diff --git a/.componentsignore b/.componentsignore index f74666e5e..f3993b3bb 100644 --- a/.componentsignore +++ b/.componentsignore @@ -1,5 +1,6 @@ [ "Adapter", + "BasicConditions", "BasicRepresentation", "Error", "EventEmitter", diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index a97b407d1..62131b3d2 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -11,6 +11,7 @@ }, "args_preferenceParser": { "@type": "AcceptPreferenceParser" }, "args_metadataParser": { "@id": "urn:solid-server:default:MetadataParser" }, + "args_conditionsParser": { "@type": "BasicConditionsParser" }, "args_bodyParser": { "@type": "WaterfallHandler", "handlers": [ diff --git a/src/index.ts b/src/index.ts index 692d5a18d..e890e2e06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,10 @@ export * from './ldp/auxiliary/RoutingAuxiliaryStrategy'; export * from './ldp/auxiliary/SuffixAuxiliaryIdentifierStrategy'; export * from './ldp/auxiliary/Validator'; +// LDP/HTTP/Conditions +export * from './ldp/http/conditions/BasicConditionsParser'; +export * from './ldp/http/conditions/ConditionsParser'; + // LDP/HTTP/Metadata export * from './ldp/http/metadata/ConstantMetadataWriter'; export * from './ldp/http/metadata/ContentTypeParser'; @@ -258,6 +262,7 @@ export * from './storage/routing/RouterRule'; // Storage export * from './storage/AtomicResourceStore'; export * from './storage/BaseResourceStore'; +export * from './storage/BasicConditions'; export * from './storage/Conditions'; export * from './storage/DataAccessorBasedStore'; export * from './storage/IndexRepresentationStore'; diff --git a/src/ldp/http/BasicRequestParser.ts b/src/ldp/http/BasicRequestParser.ts index 8965f8890..ca31f1b66 100644 --- a/src/ldp/http/BasicRequestParser.ts +++ b/src/ldp/http/BasicRequestParser.ts @@ -3,6 +3,7 @@ import { InternalServerError } from '../../util/errors/InternalServerError'; import type { Operation } from '../operations/Operation'; import { RepresentationMetadata } from '../representation/RepresentationMetadata'; import type { BodyParser } from './BodyParser'; +import type { ConditionsParser } from './conditions/ConditionsParser'; import type { MetadataParser } from './metadata/MetadataParser'; import type { PreferenceParser } from './PreferenceParser'; import { RequestParser } from './RequestParser'; @@ -15,17 +16,20 @@ export interface BasicRequestParserArgs { targetExtractor: TargetExtractor; preferenceParser: PreferenceParser; metadataParser: MetadataParser; + conditionsParser: ConditionsParser; bodyParser: BodyParser; } /** * Creates an {@link Operation} from an incoming {@link HttpRequest} by aggregating the results - * of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser}, and {@link BodyParser}. + * of a {@link TargetExtractor}, {@link PreferenceParser}, {@link MetadataParser}, + * {@link ConditionsParser} and {@link BodyParser}. */ export class BasicRequestParser extends RequestParser { private readonly targetExtractor!: TargetExtractor; private readonly preferenceParser!: PreferenceParser; private readonly metadataParser!: MetadataParser; + private readonly conditionsParser!: ConditionsParser; private readonly bodyParser!: BodyParser; public constructor(args: BasicRequestParserArgs) { @@ -42,8 +46,9 @@ export class BasicRequestParser extends RequestParser { const preferences = await this.preferenceParser.handleSafe({ request }); const metadata = new RepresentationMetadata(target); await this.metadataParser.handleSafe({ request, metadata }); + const conditions = await this.conditionsParser.handleSafe(request); const body = await this.bodyParser.handleSafe({ request, metadata }); - return { method, target, preferences, body }; + return { method, target, preferences, conditions, body }; } } diff --git a/src/ldp/http/conditions/BasicConditionsParser.ts b/src/ldp/http/conditions/BasicConditionsParser.ts new file mode 100644 index 000000000..17e643e35 --- /dev/null +++ b/src/ldp/http/conditions/BasicConditionsParser.ts @@ -0,0 +1,63 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import type { BasicConditionsOptions } from '../../../storage/BasicConditions'; +import { BasicConditions } from '../../../storage/BasicConditions'; +import type { Conditions } from '../../../storage/Conditions'; +import { ConditionsParser } from './ConditionsParser'; + +/** + * Creates a Conditions object based on the the following headers: + * - If-Modified-Since + * - If-Unmodified-Since + * - If-Match + * - If-None-Match + * + * Implementation based on RFC7232 + */ +export class BasicConditionsParser extends ConditionsParser { + protected readonly logger = getLoggerFor(this); + + public async handle(request: HttpRequest): Promise { + const options: BasicConditionsOptions = { + matchesETag: this.parseTagHeader(request, 'if-match'), + notMatchesETag: this.parseTagHeader(request, 'if-none-match'), + }; + + // A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field + // A recipient MUST ignore the If-Modified-Since header field ... if the request method is neither GET nor HEAD. + if (!options.notMatchesETag && (request.method === 'GET' || request.method === 'HEAD')) { + options.modifiedSince = this.parseDateHeader(request, 'if-modified-since'); + } + + // A recipient MUST ignore If-Unmodified-Since if the request contains an If-Match header field + if (!options.matchesETag) { + options.unmodifiedSince = this.parseDateHeader(request, 'if-unmodified-since'); + } + + // Only return a Conditions object if there is at least one condition; undefined otherwise + this.logger.debug(`Found the following conditions: ${JSON.stringify(options)}`); + if (Object.values(options).some((val): boolean => typeof val !== 'undefined')) { + return new BasicConditions(options); + } + } + + /** + * Converts a request header containing a datetime string to an actual Date object. + * Undefined if there is no value for the given header name. + */ + private parseDateHeader(request: HttpRequest, header: 'if-modified-since' | 'if-unmodified-since'): Date | undefined { + const headerVal = request.headers[header]; + if (headerVal) { + const timestamp = Date.parse(headerVal); + return Number.isNaN(timestamp) ? undefined : new Date(timestamp); + } + } + + /** + * Converts a request header containing ETags to an array of ETags. + * Undefined if there is no value for the given header name. + */ + private parseTagHeader(request: HttpRequest, header: 'if-match' | 'if-none-match'): string[] | undefined { + return request.headers[header]?.trim().split(/\s*,\s*/u); + } +} diff --git a/src/ldp/http/conditions/ConditionsParser.ts b/src/ldp/http/conditions/ConditionsParser.ts new file mode 100644 index 000000000..3970635bf --- /dev/null +++ b/src/ldp/http/conditions/ConditionsParser.ts @@ -0,0 +1,8 @@ +import type { HttpRequest } from '../../../server/HttpRequest'; +import type { Conditions } from '../../../storage/Conditions'; +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; + +/** + * Creates a Conditions object based on the input HttpRequest. + */ +export abstract class ConditionsParser extends AsyncHandler {} diff --git a/src/ldp/operations/DeleteOperationHandler.ts b/src/ldp/operations/DeleteOperationHandler.ts index 03685c73e..19a70d16b 100644 --- a/src/ldp/operations/DeleteOperationHandler.ts +++ b/src/ldp/operations/DeleteOperationHandler.ts @@ -24,7 +24,7 @@ export class DeleteOperationHandler extends OperationHandler { } public async handle(input: Operation): Promise { - await this.store.deleteResource(input.target); + await this.store.deleteResource(input.target, input.conditions); return new ResetResponseDescription(); } } diff --git a/src/ldp/operations/GetOperationHandler.ts b/src/ldp/operations/GetOperationHandler.ts index 13a2123bb..e2c1dfc7f 100644 --- a/src/ldp/operations/GetOperationHandler.ts +++ b/src/ldp/operations/GetOperationHandler.ts @@ -24,7 +24,7 @@ export class GetOperationHandler extends OperationHandler { } public async handle(input: Operation): Promise { - const body = await this.store.getRepresentation(input.target, input.preferences); + const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions); input.authorization?.addMetadata(body.metadata); diff --git a/src/ldp/operations/HeadOperationHandler.ts b/src/ldp/operations/HeadOperationHandler.ts index 1a29fbaa8..3133fb2d9 100644 --- a/src/ldp/operations/HeadOperationHandler.ts +++ b/src/ldp/operations/HeadOperationHandler.ts @@ -24,7 +24,7 @@ export class HeadOperationHandler extends OperationHandler { } public async handle(input: Operation): Promise { - const body = await this.store.getRepresentation(input.target, input.preferences); + const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions); // Close the Readable as we will not return it. body.data.destroy(); diff --git a/src/ldp/operations/Operation.ts b/src/ldp/operations/Operation.ts index 75b7ed26b..d03091b51 100644 --- a/src/ldp/operations/Operation.ts +++ b/src/ldp/operations/Operation.ts @@ -1,4 +1,5 @@ import type { Authorization } from '../../authorization/Authorization'; +import type { Conditions } from '../../storage/Conditions'; import type { Representation } from '../representation/Representation'; import type { RepresentationPreferences } from '../representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; @@ -19,6 +20,10 @@ export interface Operation { * Representation preferences of the response. Will be empty if there are none. */ preferences: RepresentationPreferences; + /** + * Conditions the resource must fulfill for a valid operation. + */ + conditions?: Conditions; /** * This value will be set if the Operation was authorized by an Authorizer. */ diff --git a/src/ldp/operations/PatchOperationHandler.ts b/src/ldp/operations/PatchOperationHandler.ts index 2b047483c..ee80c677d 100644 --- a/src/ldp/operations/PatchOperationHandler.ts +++ b/src/ldp/operations/PatchOperationHandler.ts @@ -36,7 +36,7 @@ export class PatchOperationHandler extends OperationHandler { this.logger.warn('No Content-Type header specified on PATCH request'); throw new BadRequestHttpError('No Content-Type header specified on PATCH request'); } - await this.store.modifyResource(input.target, input.body as Patch); + await this.store.modifyResource(input.target, input.body as Patch, input.conditions); return new ResetResponseDescription(); } } diff --git a/src/ldp/operations/PostOperationHandler.ts b/src/ldp/operations/PostOperationHandler.ts index 1687849cd..adee00a90 100644 --- a/src/ldp/operations/PostOperationHandler.ts +++ b/src/ldp/operations/PostOperationHandler.ts @@ -35,7 +35,7 @@ export class PostOperationHandler extends OperationHandler { this.logger.warn('No Content-Type header specified on POST request'); throw new BadRequestHttpError('No Content-Type header specified on POST request'); } - const identifier = await this.store.addResource(input.target, input.body); + const identifier = await this.store.addResource(input.target, input.body, input.conditions); return new CreatedResponseDescription(identifier); } } diff --git a/src/ldp/operations/PutOperationHandler.ts b/src/ldp/operations/PutOperationHandler.ts index 94ec7a469..db7da593d 100644 --- a/src/ldp/operations/PutOperationHandler.ts +++ b/src/ldp/operations/PutOperationHandler.ts @@ -35,7 +35,7 @@ export class PutOperationHandler extends OperationHandler { this.logger.warn('No Content-Type header specified on PUT request'); throw new BadRequestHttpError('No Content-Type header specified on PUT request'); } - await this.store.setRepresentation(input.target, input.body); + await this.store.setRepresentation(input.target, input.body, input.conditions); return new ResetResponseDescription(); } } diff --git a/src/storage/BasicConditions.ts b/src/storage/BasicConditions.ts new file mode 100644 index 000000000..6bf186f85 --- /dev/null +++ b/src/storage/BasicConditions.ts @@ -0,0 +1,69 @@ +import type { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; +import { DC } from '../util/Vocabularies'; +import { getETag } from './Conditions'; +import type { Conditions } from './Conditions'; + +export interface BasicConditionsOptions { + matchesETag?: string[]; + notMatchesETag?: string[]; + modifiedSince?: Date; + unmodifiedSince?: Date; +} + +/** + * Stores all the relevant Conditions values and matches them based on RFC7232. + */ +export class BasicConditions implements Conditions { + public readonly matchesETag?: string[]; + public readonly notMatchesETag?: string[]; + public readonly modifiedSince?: Date; + public readonly unmodifiedSince?: Date; + + public constructor(options: BasicConditionsOptions) { + this.matchesETag = options.matchesETag; + this.notMatchesETag = options.notMatchesETag; + this.modifiedSince = options.modifiedSince; + this.unmodifiedSince = options.unmodifiedSince; + } + + public matchesMetadata(metadata?: RepresentationMetadata): boolean { + if (!metadata) { + // RFC7232: ...If-Match... If the field-value is "*", the condition is false if the origin server + // does not have a current representation for the target resource. + return !this.matchesETag?.includes('*'); + } + + const modified = metadata.get(DC.terms.modified); + const modifiedDate = modified ? new Date(modified.value) : undefined; + const etag = getETag(metadata); + return this.matches(etag, modifiedDate); + } + + public matches(eTag?: string, lastModified?: Date): boolean { + // RFC7232: ...If-None-Match... If the field-value is "*", the condition is false if the origin server + // has a current representation for the target resource. + if (this.notMatchesETag?.includes('*')) { + return false; + } + + if (eTag) { + if (this.matchesETag && !this.matchesETag.includes(eTag) && !this.matchesETag.includes('*')) { + return false; + } + if (this.notMatchesETag?.includes(eTag)) { + return false; + } + } + + if (lastModified) { + if (this.modifiedSince && lastModified < this.modifiedSince) { + return false; + } + if (this.unmodifiedSince && lastModified > this.unmodifiedSince) { + return false; + } + } + + return true; + } +} diff --git a/src/storage/Conditions.ts b/src/storage/Conditions.ts index af2b57980..6a1721568 100644 --- a/src/storage/Conditions.ts +++ b/src/storage/Conditions.ts @@ -24,11 +24,13 @@ export interface Conditions { /** * Checks validity based on the given metadata. - * @param metadata - Metadata of the representation. + * @param metadata - Metadata of the representation. Undefined if the resource does not exist. */ - matchesMetadata: (metadata: RepresentationMetadata) => boolean; + matchesMetadata: (metadata?: RepresentationMetadata) => boolean; /** * Checks validity based on the given ETag and/or date. + * This function assumes the resource being checked exists. + * If not, the `matchesMetadata` function should be used. * @param eTag - Condition based on ETag. * @param lastModified - Condition based on last modified date. */ diff --git a/test/integration/RequestParser.test.ts b/test/integration/RequestParser.test.ts index 118005654..bf68904a3 100644 --- a/test/integration/RequestParser.test.ts +++ b/test/integration/RequestParser.test.ts @@ -2,19 +2,24 @@ import { Readable } from 'stream'; import arrayifyStream from 'arrayify-stream'; import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser'; import { BasicRequestParser } from '../../src/ldp/http/BasicRequestParser'; +import { BasicConditionsParser } from '../../src/ldp/http/conditions/BasicConditionsParser'; import { ContentTypeParser } from '../../src/ldp/http/metadata/ContentTypeParser'; import { OriginalUrlExtractor } from '../../src/ldp/http/OriginalUrlExtractor'; import { RawBodyParser } from '../../src/ldp/http/RawBodyParser'; import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../src/server/HttpRequest'; +import { BasicConditions } from '../../src/storage/BasicConditions'; import { guardedStreamFrom } from '../../src/util/StreamUtil'; describe('A BasicRequestParser with simple input parsers', (): void => { const targetExtractor = new OriginalUrlExtractor(); const preferenceParser = new AcceptPreferenceParser(); const metadataParser = new ContentTypeParser(); + const conditionsParser = new BasicConditionsParser(); const bodyParser = new RawBodyParser(); - const requestParser = new BasicRequestParser({ targetExtractor, preferenceParser, metadataParser, bodyParser }); + const requestParser = new BasicRequestParser( + { targetExtractor, preferenceParser, metadataParser, conditionsParser, bodyParser }, + ); it('can parse an incoming request.', async(): Promise => { const request = guardedStreamFrom([ ' .' ]) as HttpRequest; @@ -24,6 +29,8 @@ describe('A BasicRequestParser with simple input parsers', (): void => { accept: 'text/turtle; q=0.8', 'accept-language': 'en-gb, en;q=0.5', 'content-type': 'text/turtle', + 'if-unmodified-since': 'Wed, 21 Oct 2015 07:28:00 UTC', + 'if-none-match': '12345', 'transfer-encoding': 'chunked', host: 'test.com', }; @@ -36,6 +43,10 @@ describe('A BasicRequestParser with simple input parsers', (): void => { type: { 'text/turtle': 0.8 }, language: { 'en-gb': 1, en: 0.5 }, }, + conditions: new BasicConditions({ + unmodifiedSince: new Date('2015-10-21T07:28:00.000Z'), + notMatchesETag: [ '12345' ], + }), body: { data: expect.any(Readable), binary: true, diff --git a/test/unit/ldp/http/BasicRequestParser.test.ts b/test/unit/ldp/http/BasicRequestParser.test.ts index 6aa020afd..8066165d6 100644 --- a/test/unit/ldp/http/BasicRequestParser.test.ts +++ b/test/unit/ldp/http/BasicRequestParser.test.ts @@ -1,5 +1,6 @@ import { BasicRequestParser } from '../../../../src/ldp/http/BasicRequestParser'; import type { BodyParser } from '../../../../src/ldp/http/BodyParser'; +import type { ConditionsParser } from '../../../../src/ldp/http/conditions/ConditionsParser'; import type { MetadataParser } from '../../../../src/ldp/http/metadata/MetadataParser'; import type { PreferenceParser } from '../../../../src/ldp/http/PreferenceParser'; import type { TargetExtractor } from '../../../../src/ldp/http/TargetExtractor'; @@ -10,6 +11,7 @@ describe('A BasicRequestParser', (): void => { let targetExtractor: TargetExtractor; let preferenceParser: PreferenceParser; let metadataParser: MetadataParser; + let conditionsParser: ConditionsParser; let bodyParser: BodyParser; let requestParser: BasicRequestParser; @@ -17,8 +19,11 @@ describe('A BasicRequestParser', (): void => { targetExtractor = new StaticAsyncHandler(true, 'target' as any); preferenceParser = new StaticAsyncHandler(true, 'preference' as any); metadataParser = new StaticAsyncHandler(true, undefined); + conditionsParser = new StaticAsyncHandler(true, 'conditions' as any); bodyParser = new StaticAsyncHandler(true, 'body' as any); - requestParser = new BasicRequestParser({ targetExtractor, preferenceParser, metadataParser, bodyParser }); + requestParser = new BasicRequestParser( + { targetExtractor, preferenceParser, metadataParser, conditionsParser, bodyParser }, + ); }); it('can handle any input.', async(): Promise => { @@ -36,6 +41,7 @@ describe('A BasicRequestParser', (): void => { method: 'GET', target: 'target', preferences: 'preference', + conditions: 'conditions', body: { data: 'body', metadata: new RepresentationMetadata('target') }, }); }); diff --git a/test/unit/ldp/http/conditions/BasicConditionsParser.test.ts b/test/unit/ldp/http/conditions/BasicConditionsParser.test.ts new file mode 100644 index 000000000..aa6e120ce --- /dev/null +++ b/test/unit/ldp/http/conditions/BasicConditionsParser.test.ts @@ -0,0 +1,60 @@ +import { BasicConditionsParser } from '../../../../../src/ldp/http/conditions/BasicConditionsParser'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; + +describe('A BasicConditionsParser', (): void => { + const dateString = 'Wed, 21 Oct 2015 07:28:00 UTC'; + const date = new Date('2015-10-21T07:28:00.000Z'); + let request: HttpRequest; + const parser = new BasicConditionsParser(); + + beforeEach(async(): Promise => { + request = { headers: {}, method: 'GET' } as HttpRequest; + }); + + it('returns undefined if there are no relevant headers.', async(): Promise => { + await expect(parser.handleSafe(request)).resolves.toBeUndefined(); + }); + + it('parses the if-modified-since header.', async(): Promise => { + request.headers['if-modified-since'] = dateString; + await expect(parser.handleSafe(request)).resolves.toEqual({ modifiedSince: date }); + }); + + it('parses the if-unmodified-since header.', async(): Promise => { + request.headers['if-unmodified-since'] = dateString; + await expect(parser.handleSafe(request)).resolves.toEqual({ unmodifiedSince: date }); + }); + + it('parses the if-match header.', async(): Promise => { + request.headers['if-match'] = '"1234567", "abcdefg"'; + await expect(parser.handleSafe(request)).resolves.toEqual({ matchesETag: [ '"1234567"', '"abcdefg"' ]}); + }); + + it('parses the if-none-match header.', async(): Promise => { + request.headers['if-none-match'] = '*'; + await expect(parser.handleSafe(request)).resolves.toEqual({ notMatchesETag: [ '*' ]}); + }); + + it('does not parse the if-modified-since header if there is an if-none-match header.', async(): Promise => { + request.headers['if-modified-since'] = dateString; + request.headers['if-none-match'] = '*'; + await expect(parser.handleSafe(request)).resolves.toEqual({ notMatchesETag: [ '*' ]}); + }); + + it('only parses the if-modified-since header for GET and HEAD requests.', async(): Promise => { + request.headers['if-modified-since'] = dateString; + request.method = 'PUT'; + await expect(parser.handleSafe(request)).resolves.toBeUndefined(); + }); + + it('does not parse the if-unmodified-since header if there is an if-match header.', async(): Promise => { + request.headers['if-unmodified-since'] = dateString; + request.headers['if-match'] = '*'; + await expect(parser.handleSafe(request)).resolves.toEqual({ matchesETag: [ '*' ]}); + }); + + it('ignores invalid dates.', async(): Promise => { + request.headers['if-modified-since'] = 'notADate'; + await expect(parser.handleSafe(request)).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/ldp/operations/DeleteOperationHandler.test.ts b/test/unit/ldp/operations/DeleteOperationHandler.test.ts index cbb272278..dd7cf09df 100644 --- a/test/unit/ldp/operations/DeleteOperationHandler.test.ts +++ b/test/unit/ldp/operations/DeleteOperationHandler.test.ts @@ -1,9 +1,11 @@ import { DeleteOperationHandler } from '../../../../src/ldp/operations/DeleteOperationHandler'; import type { Operation } from '../../../../src/ldp/operations/Operation'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A DeleteOperationHandler', (): void => { + const conditions = new BasicConditions({}); const store = {} as unknown as ResourceStore; const handler = new DeleteOperationHandler(store); beforeEach(async(): Promise => { @@ -16,9 +18,9 @@ describe('A DeleteOperationHandler', (): void => { }); it('deletes the resource from the store and returns the correct response.', async(): Promise => { - const result = await handler.handle({ target: { path: 'url' }} as Operation); + const result = await handler.handle({ target: { path: 'url' }, conditions } as Operation); expect(store.deleteResource).toHaveBeenCalledTimes(1); - expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }); + expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }, conditions); expect(result.statusCode).toBe(205); expect(result.metadata).toBeUndefined(); expect(result.data).toBeUndefined(); diff --git a/test/unit/ldp/operations/GetOperationHandler.test.ts b/test/unit/ldp/operations/GetOperationHandler.test.ts index 92409489f..8fe20a9bc 100644 --- a/test/unit/ldp/operations/GetOperationHandler.test.ts +++ b/test/unit/ldp/operations/GetOperationHandler.test.ts @@ -2,15 +2,24 @@ import type { Authorization } from '../../../../src/authorization/Authorization' import { GetOperationHandler } from '../../../../src/ldp/operations/GetOperationHandler'; import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A GetOperationHandler', (): void => { - const store = { - getRepresentation: async(): Promise => - ({ binary: false, data: 'data', metadata: 'metadata' } as any), - } as unknown as ResourceStore; - const handler = new GetOperationHandler(store); + const conditions = new BasicConditions({}); + const preferences = {}; + let store: ResourceStore; + let handler: GetOperationHandler; + + beforeEach(async(): Promise => { + store = { + getRepresentation: jest.fn(async(): Promise => + ({ binary: false, data: 'data', metadata: 'metadata' } as any)), + } as unknown as ResourceStore; + + handler = new GetOperationHandler(store); + }); it('only supports GET operations.', async(): Promise => { await expect(handler.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined(); @@ -18,16 +27,22 @@ describe('A GetOperationHandler', (): void => { }); it('returns the representation from the store with the correct response.', async(): Promise => { - const result = await handler.handle({ target: { path: 'url' }} as Operation); + const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation); expect(result.statusCode).toBe(200); expect(result.metadata).toBe('metadata'); expect(result.data).toBe('data'); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions); }); it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise => { const authorization: Authorization = { addMetadata: jest.fn() }; - const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); + const result = await handler.handle( + { target: { path: 'url' }, preferences, conditions, authorization } as Operation, + ); expect(result.statusCode).toBe(200); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions); expect(authorization.addMetadata).toHaveBeenCalledTimes(1); expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); }); diff --git a/test/unit/ldp/operations/HeadOperationHandler.test.ts b/test/unit/ldp/operations/HeadOperationHandler.test.ts index cd4343dbf..63d4b9f8d 100644 --- a/test/unit/ldp/operations/HeadOperationHandler.test.ts +++ b/test/unit/ldp/operations/HeadOperationHandler.test.ts @@ -3,10 +3,13 @@ import type { Authorization } from '../../../../src/authorization/Authorization' import { HeadOperationHandler } from '../../../../src/ldp/operations/HeadOperationHandler'; import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A HeadOperationHandler', (): void => { + const conditions = new BasicConditions({}); + const preferences = {}; let store: ResourceStore; let handler: HeadOperationHandler; let data: Readable; @@ -14,8 +17,8 @@ describe('A HeadOperationHandler', (): void => { beforeEach(async(): Promise => { data = { destroy: jest.fn() } as any; store = { - getRepresentation: async(): Promise => - ({ binary: false, data, metadata: 'metadata' } as any), + getRepresentation: jest.fn(async(): Promise => + ({ binary: false, data, metadata: 'metadata' } as any)), } as any; handler = new HeadOperationHandler(store); }); @@ -27,17 +30,23 @@ describe('A HeadOperationHandler', (): void => { }); it('returns the representation from the store with the correct response.', async(): Promise => { - const result = await handler.handle({ target: { path: 'url' }} as Operation); + const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation); expect(result.statusCode).toBe(200); expect(result.metadata).toBe('metadata'); expect(result.data).toBeUndefined(); expect(data.destroy).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions); }); it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise => { const authorization: Authorization = { addMetadata: jest.fn() }; - const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); + const result = await handler.handle( + { target: { path: 'url' }, preferences, conditions, authorization } as Operation, + ); expect(result.statusCode).toBe(200); + expect(store.getRepresentation).toHaveBeenCalledTimes(1); + expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions); expect(authorization.addMetadata).toHaveBeenCalledTimes(1); expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); }); diff --git a/test/unit/ldp/operations/PatchOperationHandler.test.ts b/test/unit/ldp/operations/PatchOperationHandler.test.ts index aed83c541..9050453c5 100644 --- a/test/unit/ldp/operations/PatchOperationHandler.test.ts +++ b/test/unit/ldp/operations/PatchOperationHandler.test.ts @@ -1,11 +1,13 @@ import type { Operation } from '../../../../src/ldp/operations/Operation'; import { PatchOperationHandler } from '../../../../src/ldp/operations/PatchOperationHandler'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A PatchOperationHandler', (): void => { + const conditions = new BasicConditions({}); const store = {} as unknown as ResourceStore; const handler = new PatchOperationHandler(store); beforeEach(async(): Promise => { @@ -25,9 +27,9 @@ describe('A PatchOperationHandler', (): void => { it('deletes the resource from the store and returns the correct response.', async(): Promise => { const metadata = new RepresentationMetadata('text/turtle'); - const result = await handler.handle({ target: { path: 'url' }, body: { metadata }} as Operation); + const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation); expect(store.modifyResource).toHaveBeenCalledTimes(1); - expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }); + expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions); expect(result.statusCode).toBe(205); expect(result.metadata).toBeUndefined(); expect(result.data).toBeUndefined(); diff --git a/test/unit/ldp/operations/PostOperationHandler.test.ts b/test/unit/ldp/operations/PostOperationHandler.test.ts index 92b718121..6c66ac760 100644 --- a/test/unit/ldp/operations/PostOperationHandler.test.ts +++ b/test/unit/ldp/operations/PostOperationHandler.test.ts @@ -2,16 +2,23 @@ import type { Operation } from '../../../../src/ldp/operations/Operation'; import { PostOperationHandler } from '../../../../src/ldp/operations/PostOperationHandler'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; describe('A PostOperationHandler', (): void => { - const store = { - addResource: async(): Promise => ({ path: 'newPath' } as ResourceIdentifier), - } as unknown as ResourceStore; - const handler = new PostOperationHandler(store); + const conditions = new BasicConditions({}); + let store: ResourceStore; + let handler: PostOperationHandler; + + beforeEach(async(): Promise => { + store = { + addResource: jest.fn(async(): Promise => ({ path: 'newPath' } as ResourceIdentifier)), + } as unknown as ResourceStore; + handler = new PostOperationHandler(store); + }); it('only supports POST operations.', async(): Promise => { await expect(handler.canHandle({ method: 'POST', body: { }} as Operation)) @@ -28,10 +35,14 @@ describe('A PostOperationHandler', (): void => { it('adds the given representation to the store and returns the correct response.', async(): Promise => { const metadata = new RepresentationMetadata('text/turtle'); - const result = await handler.handle({ method: 'POST', body: { metadata }} as Operation); + const result = await handler.handle( + { method: 'POST', target: { path: 'url' }, body: { metadata }, conditions } as Operation, + ); expect(result.statusCode).toBe(201); expect(result.metadata).toBeInstanceOf(RepresentationMetadata); expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath'); expect(result.data).toBeUndefined(); + expect(store.addResource).toHaveBeenCalledTimes(1); + expect(store.addResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions); }); }); diff --git a/test/unit/ldp/operations/PutOperationHandler.test.ts b/test/unit/ldp/operations/PutOperationHandler.test.ts index de62efa23..4a7ffe369 100644 --- a/test/unit/ldp/operations/PutOperationHandler.test.ts +++ b/test/unit/ldp/operations/PutOperationHandler.test.ts @@ -1,11 +1,13 @@ import type { Operation } from '../../../../src/ldp/operations/Operation'; import { PutOperationHandler } from '../../../../src/ldp/operations/PutOperationHandler'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import { BasicConditions } from '../../../../src/storage/BasicConditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A PutOperationHandler', (): void => { + const conditions = new BasicConditions({}); const store = {} as unknown as ResourceStore; const handler = new PutOperationHandler(store); beforeEach(async(): Promise => { @@ -26,9 +28,9 @@ describe('A PutOperationHandler', (): void => { it('sets the representation in the store and returns the correct response.', async(): Promise => { const metadata = new RepresentationMetadata('text/turtle'); - const result = await handler.handle({ target: { path: 'url' }, body: { metadata }} as Operation); + const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation); expect(store.setRepresentation).toHaveBeenCalledTimes(1); - expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }); + expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions); expect(result.statusCode).toBe(205); expect(result.metadata).toBeUndefined(); expect(result.data).toBeUndefined(); diff --git a/test/unit/storage/BasicConditions.test.ts b/test/unit/storage/BasicConditions.test.ts new file mode 100644 index 000000000..91c1e1ee5 --- /dev/null +++ b/test/unit/storage/BasicConditions.test.ts @@ -0,0 +1,78 @@ +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import { BasicConditions } from '../../../src/storage/BasicConditions'; +import { getETag } from '../../../src/storage/Conditions'; +import { DC } from '../../../src/util/Vocabularies'; + +describe('A BasicConditions', (): void => { + const now = new Date(2020, 10, 20); + const tomorrow = new Date(2020, 10, 21); + const yesterday = new Date(2020, 10, 19); + const eTags = [ '123456', 'abcdefg' ]; + + it('copies the input parameters.', async(): Promise => { + const options = { matchesETag: eTags, notMatchesETag: eTags, modifiedSince: now, unmodifiedSince: now }; + expect(new BasicConditions(options)).toMatchObject(options); + }); + + it('always returns false if notMatchesETag contains *.', async(): Promise => { + const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); + expect(conditions.matches()).toBe(false); + }); + + it('requires matchesETag to contain the provided ETag.', async(): Promise => { + const conditions = new BasicConditions({ matchesETag: [ '1234' ]}); + expect(conditions.matches('abcd')).toBe(false); + expect(conditions.matches('1234')).toBe(true); + }); + + it('supports all ETags if matchesETag contains *.', async(): Promise => { + const conditions = new BasicConditions({ matchesETag: [ '*' ]}); + expect(conditions.matches('abcd')).toBe(true); + expect(conditions.matches('1234')).toBe(true); + }); + + it('requires notMatchesETag to not contain the provided ETag.', async(): Promise => { + const conditions = new BasicConditions({ notMatchesETag: [ '1234' ]}); + expect(conditions.matches('1234')).toBe(false); + expect(conditions.matches('abcd')).toBe(true); + }); + + it('requires lastModified to be after modifiedSince.', async(): Promise => { + const conditions = new BasicConditions({ modifiedSince: now }); + expect(conditions.matches(undefined, yesterday)).toBe(false); + expect(conditions.matches(undefined, tomorrow)).toBe(true); + }); + + it('requires lastModified to be before unmodifiedSince.', async(): Promise => { + const conditions = new BasicConditions({ unmodifiedSince: now }); + expect(conditions.matches(undefined, tomorrow)).toBe(false); + expect(conditions.matches(undefined, yesterday)).toBe(true); + }); + + it('can match based on the last modified date in the metadata.', async(): Promise => { + const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() }); + const conditions = new BasicConditions({ + modifiedSince: yesterday, + unmodifiedSince: tomorrow, + matchesETag: [ getETag(metadata)! ], + notMatchesETag: [ '123456' ], + }); + expect(conditions.matchesMetadata(metadata)).toBe(true); + }); + + it('matches if no date is found in the metadata.', async(): Promise => { + const metadata = new RepresentationMetadata(); + const conditions = new BasicConditions({ + modifiedSince: yesterday, + unmodifiedSince: tomorrow, + matchesETag: [ getETag(metadata)! ], + notMatchesETag: [ '123456' ], + }); + expect(conditions.matchesMetadata(metadata)).toBe(true); + }); + + it('checks if matchesETag contains * for resources that do not exist.', async(): Promise => { + expect(new BasicConditions({ matchesETag: [ '*' ]}).matchesMetadata()).toBe(false); + expect(new BasicConditions({}).matchesMetadata()).toBe(true); + }); +}); diff --git a/test/unit/storage/Conditions.test.ts b/test/unit/storage/Conditions.test.ts new file mode 100644 index 000000000..67d3e426f --- /dev/null +++ b/test/unit/storage/Conditions.test.ts @@ -0,0 +1,17 @@ +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import { getETag } from '../../../src/storage/Conditions'; +import { DC } from '../../../src/util/Vocabularies'; + +describe('Conditions', (): void => { + describe('#getETag', (): void => { + it('creates an ETag based on the date last modified.', async(): Promise => { + const now = new Date(); + const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() }); + expect(getETag(metadata)).toBe(`"${now.getTime()}"`); + }); + + it('returns undefined if no date was found.', async(): Promise => { + expect(getETag(new RepresentationMetadata())).toBeUndefined(); + }); + }); +});