diff --git a/config/http/notifications/base/handler.json b/config/http/notifications/base/handler.json index 6f49f120e..8fdd2e38a 100644 --- a/config/http/notifications/base/handler.json +++ b/config/http/notifications/base/handler.json @@ -15,15 +15,13 @@ { "@type": "DeleteNotificationGenerator" }, { "@type": "AddRemoveNotificationGenerator", - "store": { - "@id": "urn:solid-server:default:ResourceStore" - } + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } }, { "@type": "ActivityNotificationGenerator", - "store": { - "@id": "urn:solid-server:default:ResourceStore" - } + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } } ] } diff --git a/config/http/notifications/webhooks/handler.json b/config/http/notifications/webhooks/handler.json index ff864ba4b..2685d44f7 100644 --- a/config/http/notifications/webhooks/handler.json +++ b/config/http/notifications/webhooks/handler.json @@ -10,7 +10,8 @@ "@type": "ComposedNotificationHandler", "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, "serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" }, - "emitter": { "@id": "urn:solid-server:default:WebHookEmitter" } + "emitter": { "@id": "urn:solid-server:default:WebHookEmitter" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } } }, { diff --git a/config/http/notifications/websockets/handler.json b/config/http/notifications/websockets/handler.json index 09cdfdc71..99476dac3 100644 --- a/config/http/notifications/websockets/handler.json +++ b/config/http/notifications/websockets/handler.json @@ -10,7 +10,8 @@ "@type": "ComposedNotificationHandler", "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, "serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" }, - "emitter": { "@id": "urn:solid-server:default:WebSocket2023Emitter" } + "emitter": { "@id": "urn:solid-server:default:WebSocket2023Emitter" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } } }, { diff --git a/config/ldp/handler/components/operation-handler.json b/config/ldp/handler/components/operation-handler.json index fcf6d265c..df9b675f9 100644 --- a/config/ldp/handler/components/operation-handler.json +++ b/config/ldp/handler/components/operation-handler.json @@ -7,7 +7,8 @@ "handlers": [ { "@type": "GetOperationHandler", - "store": { "@id": "urn:solid-server:default:ResourceStore" } + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } }, { "@type": "PostOperationHandler", @@ -24,7 +25,8 @@ }, { "@type": "HeadOperationHandler", - "store": { "@id": "urn:solid-server:default:ResourceStore" } + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "eTagHandler": { "@id": "urn:solid-server:default:ETagHandler" } }, { "@type": "PatchOperationHandler", diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index 5fa635b35..54904a668 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -5,16 +5,22 @@ "comment": "Handles everything related to parsing a Request.", "@id": "urn:solid-server:default:RequestParser", "@type": "BasicRequestParser", - "args_targetExtractor": { + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor", "@type": "OriginalUrlExtractor", - "args_identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, - "args_includeQueryString": false + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "includeQueryString": false }, - "args_preferenceParser": { "@id": "urn:solid-server:default:PreferenceParser" }, - "args_metadataParser": { "@id": "urn:solid-server:default:MetadataParser" }, - "args_conditionsParser": { "@type": "BasicConditionsParser" }, - "args_bodyParser": { + "preferenceParser": { "@id": "urn:solid-server:default:PreferenceParser" }, + "metadataParser": { "@id": "urn:solid-server:default:MetadataParser" }, + "conditionsParser": { + "@type": "BasicConditionsParser", + "eTagHandler": { + "@id": "urn:solid-server:default:ETagHandler", + "@type": "BasicETagHandler" + } + }, + "bodyParser": { "@type": "WaterfallHandler", "handlers": [ { "@id": "urn:solid-server:default:PatchBodyParser" }, diff --git a/src/http/input/conditions/BasicConditionsParser.ts b/src/http/input/conditions/BasicConditionsParser.ts index 54fa8a4fc..ef976c5d3 100644 --- a/src/http/input/conditions/BasicConditionsParser.ts +++ b/src/http/input/conditions/BasicConditionsParser.ts @@ -3,11 +3,12 @@ import type { HttpRequest } from '../../../server/HttpRequest'; import type { BasicConditionsOptions } from '../../../storage/conditions/BasicConditions'; import { BasicConditions } from '../../../storage/conditions/BasicConditions'; import type { Conditions } from '../../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../../storage/conditions/ETagHandler'; import { splitCommaSeparated } from '../../../util/StringUtil'; import { ConditionsParser } from './ConditionsParser'; /** - * Creates a Conditions object based on the the following headers: + * Creates a Conditions object based on the following headers: * - If-Modified-Since * - If-Unmodified-Since * - If-Match @@ -18,6 +19,13 @@ import { ConditionsParser } from './ConditionsParser'; export class BasicConditionsParser extends ConditionsParser { protected readonly logger = getLoggerFor(this); + protected readonly eTagHandler: ETagHandler; + + public constructor(eTagHandler: ETagHandler) { + super(); + this.eTagHandler = eTagHandler; + } + public async handle(request: HttpRequest): Promise { const options: BasicConditionsOptions = { matchesETag: this.parseTagHeader(request, 'if-match'), @@ -38,7 +46,7 @@ export class BasicConditionsParser extends ConditionsParser { // 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); + return new BasicConditions(this.eTagHandler, options); } } diff --git a/src/http/ldp/GetOperationHandler.ts b/src/http/ldp/GetOperationHandler.ts index 8c1760d45..4e5b5c417 100644 --- a/src/http/ldp/GetOperationHandler.ts +++ b/src/http/ldp/GetOperationHandler.ts @@ -1,4 +1,4 @@ -import { getETag } from '../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../storage/conditions/ETagHandler'; import type { ResourceStore } from '../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { assertReadConditions } from '../../util/ResourceUtil'; @@ -14,10 +14,12 @@ import { OperationHandler } from './OperationHandler'; */ export class GetOperationHandler extends OperationHandler { private readonly store: ResourceStore; + private readonly eTagHandler: ETagHandler; - public constructor(store: ResourceStore) { + public constructor(store: ResourceStore, eTagHandler: ETagHandler) { super(); this.store = store; + this.eTagHandler = eTagHandler; } public async canHandle({ operation }: OperationHandlerInput): Promise { @@ -33,7 +35,7 @@ export class GetOperationHandler extends OperationHandler { assertReadConditions(body, operation.conditions); // Add the ETag of the returned representation - const etag = getETag(body.metadata); + const etag = this.eTagHandler.getETag(body.metadata); body.metadata.set(HH.terms.etag, etag); return new OkResponseDescription(body.metadata, body.data); diff --git a/src/http/ldp/HeadOperationHandler.ts b/src/http/ldp/HeadOperationHandler.ts index 76ec1d746..c15aa87df 100644 --- a/src/http/ldp/HeadOperationHandler.ts +++ b/src/http/ldp/HeadOperationHandler.ts @@ -1,4 +1,4 @@ -import { getETag } from '../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../storage/conditions/ETagHandler'; import type { ResourceStore } from '../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { assertReadConditions } from '../../util/ResourceUtil'; @@ -14,10 +14,12 @@ import { OperationHandler } from './OperationHandler'; */ export class HeadOperationHandler extends OperationHandler { private readonly store: ResourceStore; + private readonly eTagHandler: ETagHandler; - public constructor(store: ResourceStore) { + public constructor(store: ResourceStore, eTagHandler: ETagHandler) { super(); this.store = store; + this.eTagHandler = eTagHandler; } public async canHandle({ operation }: OperationHandlerInput): Promise { @@ -37,7 +39,7 @@ export class HeadOperationHandler extends OperationHandler { assertReadConditions(body, operation.conditions); // Add the ETag of the returned representation - const etag = getETag(body.metadata); + const etag = this.eTagHandler.getETag(body.metadata); body.metadata.set(HH.terms.etag, etag); return new OkResponseDescription(body.metadata); diff --git a/src/index.ts b/src/index.ts index 02f241ecb..94297f88a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -374,7 +374,9 @@ export * from './storage/accessors/ValidatingDataAccessor'; // Storage/Conditions export * from './storage/conditions/BasicConditions'; +export * from './storage/conditions/BasicETagHandler'; export * from './storage/conditions/Conditions'; +export * from './storage/conditions/ETagHandler'; // Storage/Conversion export * from './storage/conversion/BaseTypedRepresentationConverter'; diff --git a/src/server/notifications/ComposedNotificationHandler.ts b/src/server/notifications/ComposedNotificationHandler.ts index 86ec3370f..ce3288019 100644 --- a/src/server/notifications/ComposedNotificationHandler.ts +++ b/src/server/notifications/ComposedNotificationHandler.ts @@ -1,4 +1,4 @@ -import { sameResourceState } from '../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../storage/conditions/ETagHandler'; import type { NotificationGenerator } from './generate/NotificationGenerator'; import type { NotificationEmitter } from './NotificationEmitter'; import type { NotificationHandlerInput } from './NotificationHandler'; @@ -9,6 +9,7 @@ export interface ComposedNotificationHandlerArgs { generator: NotificationGenerator; serializer: NotificationSerializer; emitter: NotificationEmitter; + eTagHandler: ETagHandler; } /** @@ -21,12 +22,14 @@ export class ComposedNotificationHandler extends NotificationHandler { private readonly generator: NotificationGenerator; private readonly serializer: NotificationSerializer; private readonly emitter: NotificationEmitter; + private readonly eTagHandler: ETagHandler; public constructor(args: ComposedNotificationHandlerArgs) { super(); this.generator = args.generator; this.serializer = args.serializer; this.emitter = args.emitter; + this.eTagHandler = args.eTagHandler; } public async canHandle(input: NotificationHandlerInput): Promise { @@ -38,7 +41,8 @@ export class ComposedNotificationHandler extends NotificationHandler { const { state } = input.channel; // In case the state matches there is no need to send the notification - if (typeof state === 'string' && notification.state && sameResourceState(state, notification.state)) { + if (typeof state === 'string' && notification.state && + this.eTagHandler.sameResourceState(state, notification.state)) { return; } diff --git a/src/server/notifications/generate/ActivityNotificationGenerator.ts b/src/server/notifications/generate/ActivityNotificationGenerator.ts index f0cb65880..5d875944d 100644 --- a/src/server/notifications/generate/ActivityNotificationGenerator.ts +++ b/src/server/notifications/generate/ActivityNotificationGenerator.ts @@ -1,4 +1,4 @@ -import { getETag } from '../../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../../storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError'; import { AS } from '../../../util/Vocabularies'; @@ -13,10 +13,12 @@ import { NotificationGenerator } from './NotificationGenerator'; */ export class ActivityNotificationGenerator extends NotificationGenerator { private readonly store: ResourceStore; + private readonly eTagHandler: ETagHandler; - public constructor(store: ResourceStore) { + public constructor(store: ResourceStore, eTagHandler: ETagHandler) { super(); this.store = store; + this.eTagHandler = eTagHandler; } public async canHandle({ activity }: NotificationHandlerInput): Promise { @@ -28,8 +30,7 @@ export class ActivityNotificationGenerator extends NotificationGenerator { public async handle({ topic, activity }: NotificationHandlerInput): Promise { const representation = await this.store.getRepresentation(topic, {}); representation.data.destroy(); - - const state = getETag(representation.metadata); + const state = this.eTagHandler.getETag(representation.metadata); return { '@context': [ diff --git a/src/server/notifications/generate/AddRemoveNotificationGenerator.ts b/src/server/notifications/generate/AddRemoveNotificationGenerator.ts index 8db5c3f88..49a16a5b3 100644 --- a/src/server/notifications/generate/AddRemoveNotificationGenerator.ts +++ b/src/server/notifications/generate/AddRemoveNotificationGenerator.ts @@ -1,4 +1,4 @@ -import { getETag } from '../../../storage/conditions/Conditions'; +import type { ETagHandler } from '../../../storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../storage/ResourceStore'; import { InternalServerError } from '../../../util/errors/InternalServerError'; import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError'; @@ -15,10 +15,12 @@ import { NotificationGenerator } from './NotificationGenerator'; */ export class AddRemoveNotificationGenerator extends NotificationGenerator { private readonly store: ResourceStore; + private readonly eTagHandler: ETagHandler; - public constructor(store: ResourceStore) { + public constructor(store: ResourceStore, eTagHandler: ETagHandler) { super(); this.store = store; + this.eTagHandler = eTagHandler; } public async canHandle({ activity }: NotificationHandlerInput): Promise { @@ -30,8 +32,8 @@ export class AddRemoveNotificationGenerator extends NotificationGenerator { public async handle({ activity, topic, metadata }: NotificationHandlerInput): Promise { const representation = await this.store.getRepresentation(topic, {}); representation.data.destroy(); + const state = this.eTagHandler.getETag(representation.metadata); - const state = getETag(representation.metadata); const objects = metadata?.getAll(AS.terms.object); if (!objects || objects.length === 0) { throw new InternalServerError(`Missing as:object metadata for ${activity?.value} activity on ${topic.path}`); diff --git a/src/storage/conditions/BasicConditions.ts b/src/storage/conditions/BasicConditions.ts index aa45a4268..6c9d8323e 100644 --- a/src/storage/conditions/BasicConditions.ts +++ b/src/storage/conditions/BasicConditions.ts @@ -1,7 +1,7 @@ import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; import { DC } from '../../util/Vocabularies'; -import { getETag, sameResourceState } from './Conditions'; import type { Conditions } from './Conditions'; +import type { ETagHandler } from './ETagHandler'; export interface BasicConditionsOptions { matchesETag?: string[]; @@ -14,12 +14,16 @@ export interface BasicConditionsOptions { * Stores all the relevant Conditions values and matches them based on RFC7232. */ export class BasicConditions implements Conditions { + protected readonly eTagHandler: ETagHandler; + public readonly matchesETag?: string[]; public readonly notMatchesETag?: string[]; public readonly modifiedSince?: Date; public readonly unmodifiedSince?: Date; - public constructor(options: BasicConditionsOptions) { + public constructor(eTagHandler: ETagHandler, options: BasicConditionsOptions) { + this.eTagHandler = eTagHandler; + this.matchesETag = options.matchesETag; this.notMatchesETag = options.notMatchesETag; this.modifiedSince = options.modifiedSince; @@ -39,21 +43,12 @@ export class BasicConditions implements Conditions { return false; } - const eTag = getETag(metadata); - if (eTag) { - // Helper function to see if an ETag matches the provided metadata - // eslint-disable-next-line func-style - let eTagMatches = (tag: string): boolean => sameResourceState(tag, eTag); - if (strict) { - eTagMatches = (tag: string): boolean => tag === eTag; - } - - if (this.matchesETag && !this.matchesETag.includes('*') && !this.matchesETag.some(eTagMatches)) { - return false; - } - if (this.notMatchesETag?.some(eTagMatches)) { - return false; - } + const eTagMatches = (tag: string): boolean => this.eTagHandler.matchesETag(metadata, tag, Boolean(strict)); + if (this.matchesETag && !this.matchesETag.includes('*') && !this.matchesETag.some(eTagMatches)) { + return false; + } + if (this.notMatchesETag?.some(eTagMatches)) { + return false; } // In practice, this will only be undefined on a backend diff --git a/src/storage/conditions/BasicETagHandler.ts b/src/storage/conditions/BasicETagHandler.ts new file mode 100644 index 000000000..dde82fbbb --- /dev/null +++ b/src/storage/conditions/BasicETagHandler.ts @@ -0,0 +1,38 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import { DC } from '../../util/Vocabularies'; +import type { ETagHandler } from './ETagHandler'; + +/** + * Standard implementation of {@link ETagHandler}. + * ETags are constructed by combining the last modified date with the content type of the representation. + */ +export class BasicETagHandler implements ETagHandler { + public getETag(metadata: RepresentationMetadata): string | undefined { + const modified = metadata.get(DC.terms.modified); + const { contentType } = metadata; + if (modified && contentType) { + const date = new Date(modified.value); + return `"${date.getTime()}-${contentType}"`; + } + } + + public matchesETag(metadata: RepresentationMetadata, eTag: string, strict: boolean): boolean { + const modified = metadata.get(DC.terms.modified); + if (!modified) { + return false; + } + const date = new Date(modified.value); + const { contentType } = metadata; + + // Slicing of the double quotes + const [ eTagTimestamp, eTagContentType ] = eTag.slice(1, -1).split('-'); + + return eTagTimestamp === `${date.getTime()}` && (!strict || eTagContentType === contentType); + } + + public sameResourceState(eTag1: string, eTag2: string): boolean { + // Since we base the ETag on the last modified date, + // we know the ETags match as long as the date part is the same. + return eTag1.split('-')[0] === eTag2.split('-')[0]; + } +} diff --git a/src/storage/conditions/Conditions.ts b/src/storage/conditions/Conditions.ts index 5178262ea..067eaccb7 100644 --- a/src/storage/conditions/Conditions.ts +++ b/src/storage/conditions/Conditions.ts @@ -1,5 +1,4 @@ import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; -import { DC } from '../../util/Vocabularies'; /** * The conditions of an HTTP conditional request. @@ -26,37 +25,8 @@ export interface Conditions { * Checks validity based on the given metadata. * @param metadata - Metadata of the representation. Undefined if the resource does not exist. * @param strict - How to compare the ETag related headers. - * If true, exact string matching will be used to compare with the ETag for the given metadata. - * If false, it will take into account that content negotiation might still happen - * which can change the ETag. + * If true, the comparison will happen on representation level. + * If false, the comparison happens on resource level, ignoring the content-type. */ matchesMetadata: (metadata?: RepresentationMetadata, strict?: boolean) => boolean; } - -/** - * Generates an ETag based on the last modified date of a resource. - * @param metadata - Metadata of the resource. - * - * @returns the generated ETag. Undefined if no last modified date was found. - */ -export function getETag(metadata: RepresentationMetadata): string | undefined { - const modified = metadata.get(DC.terms.modified); - const { contentType } = metadata; - if (modified) { - const date = new Date(modified.value); - // It is possible for the content type to be undefined, - // such as when only the metadata returned by a `DataAccessor` is used. - return `"${date.getTime()}-${contentType ?? ''}"`; - } -} - -/** - * Validates whether 2 ETags correspond to the same state of a resource, - * independent of the representation the ETags correspond to. - * Assumes ETags are made with the {@link getETag} function. - */ -export function sameResourceState(eTag1: string, eTag2: string): boolean { - // Since we base the ETag on the last modified date, - // we know the ETags match as long as the date part is the same. - return eTag1.split('-')[0] === eTag2.split('-')[0]; -} diff --git a/src/storage/conditions/ETagHandler.ts b/src/storage/conditions/ETagHandler.ts new file mode 100644 index 000000000..c4c26ddf0 --- /dev/null +++ b/src/storage/conditions/ETagHandler.ts @@ -0,0 +1,33 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; + +/** + * Responsible for everything related to ETag generation and comparison. + * ETags are constructed in such a way they can both be used for the standard ETag usage of comparing representations, + * but also to see if two ETags of different representations correspond to the same resource state. + */ +export interface ETagHandler { + /** + * Generates an ETag for the given metadata. Returns undefined if no ETag could be generated. + * + * @param metadata - Metadata of the representation to generate an ETag for. + */ + getETag: (metadata: RepresentationMetadata) => string | undefined; + + /** + * Validates whether the given metadata corresponds to the given ETag. + * + * @param metadata - Metadata of the resource. + * @param eTag - ETag to compare to. + * @param strict - True if the comparison needs to be on representation level. + * False if it is on resource level and the content-type doesn't matter. + */ + matchesETag: (metadata: RepresentationMetadata, eTag: string, strict: boolean) => boolean; + + /** + * Validates whether 2 ETags correspond to the same state of a resource, + * independent of the representation the ETags correspond to. + * @param eTag1 - First ETag to compare. + * @param eTag2 - Second ETag to compare. + */ + sameResourceState: (eTag1: string, eTag2: string) => boolean; +} diff --git a/test/integration/RequestParser.test.ts b/test/integration/RequestParser.test.ts index 1c862806e..a9923c25d 100644 --- a/test/integration/RequestParser.test.ts +++ b/test/integration/RequestParser.test.ts @@ -1,6 +1,6 @@ import { Readable } from 'stream'; import arrayifyStream from 'arrayify-stream'; -import { SingleRootIdentifierStrategy } from '../../src'; +import { BasicETagHandler, SingleRootIdentifierStrategy } from '../../src'; import { BasicRequestParser } from '../../src/http/input/BasicRequestParser'; import { RawBodyParser } from '../../src/http/input/body/RawBodyParser'; import { BasicConditionsParser } from '../../src/http/input/conditions/BasicConditionsParser'; @@ -9,7 +9,6 @@ import { ContentTypeParser } from '../../src/http/input/metadata/ContentTypePars import { AcceptPreferenceParser } from '../../src/http/input/preferences/AcceptPreferenceParser'; import { RepresentationMetadata } from '../../src/http/representation/RepresentationMetadata'; import type { HttpRequest } from '../../src/server/HttpRequest'; -import { BasicConditions } from '../../src/storage/conditions/BasicConditions'; import { guardedStreamFrom } from '../../src/util/StreamUtil'; describe('A BasicRequestParser with simple input parsers', (): void => { @@ -17,7 +16,7 @@ describe('A BasicRequestParser with simple input parsers', (): void => { const targetExtractor = new OriginalUrlExtractor({ identifierStrategy }); const preferenceParser = new AcceptPreferenceParser(); const metadataParser = new ContentTypeParser(); - const conditionsParser = new BasicConditionsParser(); + const conditionsParser = new BasicConditionsParser(new BasicETagHandler()); const bodyParser = new RawBodyParser(); const requestParser = new BasicRequestParser( { targetExtractor, preferenceParser, metadataParser, conditionsParser, bodyParser }, @@ -45,7 +44,7 @@ describe('A BasicRequestParser with simple input parsers', (): void => { type: { 'text/turtle': 0.8 }, language: { 'en-gb': 1, en: 0.5 }, }, - conditions: new BasicConditions({ + conditions: expect.objectContaining({ unmodifiedSince: new Date('2015-10-21T07:28:00.000Z'), notMatchesETag: [ '12345' ], }), diff --git a/test/unit/http/input/conditions/BasicConditionsParser.test.ts b/test/unit/http/input/conditions/BasicConditionsParser.test.ts index b8f1f63b6..f6f503678 100644 --- a/test/unit/http/input/conditions/BasicConditionsParser.test.ts +++ b/test/unit/http/input/conditions/BasicConditionsParser.test.ts @@ -1,14 +1,24 @@ import { BasicConditionsParser } from '../../../../../src/http/input/conditions/BasicConditionsParser'; import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { ETagHandler } from '../../../../../src/storage/conditions/ETagHandler'; 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(); + let eTagHandler: ETagHandler; + let parser: BasicConditionsParser; beforeEach(async(): Promise => { request = { headers: {}, method: 'GET' } as HttpRequest; + + eTagHandler = { + getETag: jest.fn(), + matchesETag: jest.fn(), + sameResourceState: jest.fn(), + }; + + parser = new BasicConditionsParser(eTagHandler); }); it('returns undefined if there are no relevant headers.', async(): Promise => { @@ -17,28 +27,29 @@ describe('A BasicConditionsParser', (): void => { it('parses the if-modified-since header.', async(): Promise => { request.headers['if-modified-since'] = dateString; - await expect(parser.handleSafe(request)).resolves.toEqual({ modifiedSince: date }); + await expect(parser.handleSafe(request)).resolves.toEqual({ eTagHandler, 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 }); + await expect(parser.handleSafe(request)).resolves.toEqual({ eTagHandler, 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"' ]}); + await expect(parser.handleSafe(request)).resolves + .toEqual({ eTagHandler, 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: [ '*' ]}); + await expect(parser.handleSafe(request)).resolves.toEqual({ eTagHandler, 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: [ '*' ]}); + await expect(parser.handleSafe(request)).resolves.toEqual({ eTagHandler, notMatchesETag: [ '*' ]}); }); it('only parses the if-modified-since header for GET and HEAD requests.', async(): Promise => { @@ -50,7 +61,7 @@ describe('A BasicConditionsParser', (): void => { 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: [ '*' ]}); + await expect(parser.handleSafe(request)).resolves.toEqual({ eTagHandler, matchesETag: [ '*' ]}); }); it('ignores invalid dates.', async(): Promise => { diff --git a/test/unit/http/ldp/DeleteOperationHandler.test.ts b/test/unit/http/ldp/DeleteOperationHandler.test.ts index 7ef83b474..993ba9ed4 100644 --- a/test/unit/http/ldp/DeleteOperationHandler.test.ts +++ b/test/unit/http/ldp/DeleteOperationHandler.test.ts @@ -1,13 +1,13 @@ import { DeleteOperationHandler } from '../../../../src/http/ldp/DeleteOperationHandler'; import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A DeleteOperationHandler', (): void => { let operation: Operation; - const conditions = new BasicConditions({}); + const conditions: Conditions = { matchesMetadata: jest.fn() }; const body = new BasicRepresentation(); const store = {} as unknown as ResourceStore; const handler = new DeleteOperationHandler(store); diff --git a/test/unit/http/ldp/GetOperationHandler.test.ts b/test/unit/http/ldp/GetOperationHandler.test.ts index 6764fa366..d226fa578 100644 --- a/test/unit/http/ldp/GetOperationHandler.test.ts +++ b/test/unit/http/ldp/GetOperationHandler.test.ts @@ -4,8 +4,8 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; -import { getETag } from '../../../../src/storage/conditions/Conditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; +import type { ETagHandler } from '../../../../src/storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError'; @@ -14,25 +14,37 @@ import { CONTENT_TYPE, HH } from '../../../../src/util/Vocabularies'; describe('A GetOperationHandler', (): void => { let operation: Operation; - const conditions = new BasicConditions({}); + let conditions: jest.Mocked; const preferences = {}; const body = new BasicRepresentation(); let store: ResourceStore; + let eTagHandler: ETagHandler; let handler: GetOperationHandler; let data: Readable; let metadata: RepresentationMetadata; beforeEach(async(): Promise => { + conditions = { + matchesMetadata: jest.fn().mockReturnValue(true), + }; + operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences, conditions, body }; data = { destroy: jest.fn() } as any; metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }); updateModifiedDate(metadata); + store = { getRepresentation: jest.fn(async(): Promise => ({ binary: false, data, metadata } as any)), } as unknown as ResourceStore; - handler = new GetOperationHandler(store); + eTagHandler = { + getETag: jest.fn().mockReturnValue('ETag'), + matchesETag: jest.fn(), + sameResourceState: jest.fn(), + }; + + handler = new GetOperationHandler(store, eTagHandler); }); it('only supports GET operations.', async(): Promise => { @@ -45,16 +57,14 @@ describe('A GetOperationHandler', (): void => { const result = await handler.handle({ operation }); expect(result.statusCode).toBe(200); expect(result.metadata).toBe(metadata); - expect(metadata.get(HH.terms.etag)?.value).toBe(getETag(metadata)); + expect(metadata.get(HH.terms.etag)?.value).toBe('ETag'); expect(result.data).toBe(data); expect(store.getRepresentation).toHaveBeenCalledTimes(1); expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions); }); it('returns a 304 if the conditions do not match.', async(): Promise => { - operation.conditions = { - matchesMetadata: (): boolean => false, - }; + conditions.matchesMetadata.mockReturnValue(false); await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError); expect(data.destroy).toHaveBeenCalledTimes(1); }); diff --git a/test/unit/http/ldp/HeadOperationHandler.test.ts b/test/unit/http/ldp/HeadOperationHandler.test.ts index e7250f4fc..bf8d6c4dd 100644 --- a/test/unit/http/ldp/HeadOperationHandler.test.ts +++ b/test/unit/http/ldp/HeadOperationHandler.test.ts @@ -4,8 +4,8 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; -import { getETag } from '../../../../src/storage/conditions/Conditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; +import type { ETagHandler } from '../../../../src/storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError'; @@ -14,25 +14,37 @@ import { CONTENT_TYPE, HH } from '../../../../src/util/Vocabularies'; describe('A HeadOperationHandler', (): void => { let operation: Operation; - const conditions = new BasicConditions({}); + let conditions: jest.Mocked; const preferences = {}; const body = new BasicRepresentation(); let store: ResourceStore; + let eTagHandler: ETagHandler; let handler: HeadOperationHandler; let data: Readable; let metadata: RepresentationMetadata; beforeEach(async(): Promise => { + conditions = { + matchesMetadata: jest.fn().mockReturnValue(true), + }; + operation = { method: 'HEAD', target: { path: 'http://test.com/foo' }, preferences, conditions, body }; data = { destroy: jest.fn() } as any; metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }); updateModifiedDate(metadata); + store = { getRepresentation: jest.fn(async(): Promise => ({ binary: false, data, metadata } as any)), } as any; - handler = new HeadOperationHandler(store); + eTagHandler = { + getETag: jest.fn().mockReturnValue('ETag'), + matchesETag: jest.fn(), + sameResourceState: jest.fn(), + }; + + handler = new HeadOperationHandler(store, eTagHandler); }); it('only supports HEAD operations.', async(): Promise => { @@ -47,7 +59,7 @@ describe('A HeadOperationHandler', (): void => { const result = await handler.handle({ operation }); expect(result.statusCode).toBe(200); expect(result.metadata).toBe(metadata); - expect(metadata.get(HH.terms.etag)?.value).toBe(getETag(metadata)); + expect(metadata.get(HH.terms.etag)?.value).toBe('ETag'); expect(result.data).toBeUndefined(); expect(data.destroy).toHaveBeenCalledTimes(1); expect(store.getRepresentation).toHaveBeenCalledTimes(1); @@ -55,9 +67,7 @@ describe('A HeadOperationHandler', (): void => { }); it('returns a 304 if the conditions do not match.', async(): Promise => { - operation.conditions = { - matchesMetadata: (): boolean => false, - }; + conditions.matchesMetadata.mockReturnValue(false); await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError); expect(data.destroy).toHaveBeenCalledTimes(2); }); diff --git a/test/unit/http/ldp/PatchOperationHandler.test.ts b/test/unit/http/ldp/PatchOperationHandler.test.ts index 6723b28ae..870eb1370 100644 --- a/test/unit/http/ldp/PatchOperationHandler.test.ts +++ b/test/unit/http/ldp/PatchOperationHandler.test.ts @@ -2,7 +2,7 @@ import { PatchOperationHandler } from '../../../../src/http/ldp/PatchOperationHa import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; @@ -11,7 +11,7 @@ import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; describe('A PatchOperationHandler', (): void => { let operation: Operation; let body: Representation; - const conditions = new BasicConditions({}); + const conditions: Conditions = { matchesMetadata: jest.fn() }; let store: jest.Mocked; let handler: PatchOperationHandler; diff --git a/test/unit/http/ldp/PostOperationHandler.test.ts b/test/unit/http/ldp/PostOperationHandler.test.ts index 29a938894..77c3c86de 100644 --- a/test/unit/http/ldp/PostOperationHandler.test.ts +++ b/test/unit/http/ldp/PostOperationHandler.test.ts @@ -3,7 +3,7 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; @@ -14,7 +14,7 @@ import { AS, LDP, RDF, SOLID_AS, SOLID_HTTP } from '../../../../src/util/Vocabul describe('A PostOperationHandler', (): void => { let operation: Operation; let body: Representation; - const conditions = new BasicConditions({}); + const conditions: Conditions = { matchesMetadata: jest.fn() }; let store: jest.Mocked; let handler: PostOperationHandler; diff --git a/test/unit/http/ldp/PutOperationHandler.test.ts b/test/unit/http/ldp/PutOperationHandler.test.ts index 8c82b1807..d31c7ec6e 100644 --- a/test/unit/http/ldp/PutOperationHandler.test.ts +++ b/test/unit/http/ldp/PutOperationHandler.test.ts @@ -2,7 +2,7 @@ import { PutOperationHandler } from '../../../../src/http/ldp/PutOperationHandle import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; -import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; +import type { Conditions } from '../../../../src/storage/conditions/Conditions'; import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; @@ -13,7 +13,7 @@ import { SimpleSuffixStrategy } from '../../../util/SimpleSuffixStrategy'; describe('A PutOperationHandler', (): void => { let operation: Operation; let body: Representation; - const conditions = new BasicConditions({}); + const conditions: Conditions = { matchesMetadata: jest.fn() }; let store: jest.Mocked; let handler: PutOperationHandler; const metaStrategy = new SimpleSuffixStrategy('.meta'); diff --git a/test/unit/server/notifications/ComposedNotificationHandler.test.ts b/test/unit/server/notifications/ComposedNotificationHandler.test.ts index 1c2584c07..804849792 100644 --- a/test/unit/server/notifications/ComposedNotificationHandler.test.ts +++ b/test/unit/server/notifications/ComposedNotificationHandler.test.ts @@ -6,6 +6,7 @@ import type { Notification } from '../../../../src/server/notifications/Notifica import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel'; import type { NotificationEmitter } from '../../../../src/server/notifications/NotificationEmitter'; import type { NotificationSerializer } from '../../../../src/server/notifications/serialize/NotificationSerializer'; +import type { ETagHandler } from '../../../../src/storage/conditions/ETagHandler'; describe('A ComposedNotificationHandler', (): void => { const topic: ResourceIdentifier = { path: 'http://example.com/foo' }; @@ -25,6 +26,7 @@ describe('A ComposedNotificationHandler', (): void => { let generator: jest.Mocked; let serializer: jest.Mocked; let emitter: jest.Mocked; + let eTagHandler: jest.Mocked; let handler: ComposedNotificationHandler; beforeEach(async(): Promise => { @@ -47,7 +49,13 @@ describe('A ComposedNotificationHandler', (): void => { handleSafe: jest.fn(), } as any; - handler = new ComposedNotificationHandler({ generator, serializer, emitter }); + eTagHandler = { + getETag: jest.fn(), + matchesETag: jest.fn(), + sameResourceState: jest.fn().mockReturnValue(false), + }; + + handler = new ComposedNotificationHandler({ generator, serializer, emitter, eTagHandler }); }); it('can only handle input supported by the generator.', async(): Promise => { @@ -68,6 +76,7 @@ describe('A ComposedNotificationHandler', (): void => { it('does not emit the notification if it has the same resource state as the channel.', async(): Promise => { channel.state = '"123456-application/ld+json"'; + eTagHandler.sameResourceState.mockReturnValue(true); await expect(handler.handle({ channel, topic })).resolves.toBeUndefined(); expect(generator.handle).toHaveBeenCalledTimes(1); expect(generator.handle).toHaveBeenLastCalledWith({ channel, topic }); diff --git a/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts b/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts index 3c18c9faa..e76e9d507 100644 --- a/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts +++ b/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts @@ -5,6 +5,7 @@ import { ActivityNotificationGenerator, } from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator'; import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; +import type { ETagHandler } from '../../../../../src/storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../../../src/storage/ResourceStore'; import { AS, CONTENT_TYPE, DC, LDP, RDF } from '../../../../../src/util/Vocabularies'; @@ -23,6 +24,7 @@ describe('An ActivityNotificationGenerator', (): void => { [CONTENT_TYPE]: 'text/turtle', }); let store: jest.Mocked; + let eTagHandler: jest.Mocked; let generator: ActivityNotificationGenerator; beforeEach(async(): Promise => { @@ -30,7 +32,13 @@ describe('An ActivityNotificationGenerator', (): void => { getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', metadata)), } as any; - generator = new ActivityNotificationGenerator(store); + eTagHandler = { + getETag: jest.fn().mockReturnValue('ETag'), + matchesETag: jest.fn(), + sameResourceState: jest.fn(), + }; + + generator = new ActivityNotificationGenerator(store, eTagHandler); }); it('only handles defined activities.', async(): Promise => { @@ -52,7 +60,7 @@ describe('An ActivityNotificationGenerator', (): void => { id: `urn:${ms}:http://example.com/foo`, type: 'Update', object: 'http://example.com/foo', - state: expect.stringMatching(/"\d+-text\/turtle"/u), + state: 'ETag', published: date, }); diff --git a/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts index d12e4e0b7..b6c1ecc9f 100644 --- a/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts +++ b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts @@ -5,6 +5,7 @@ import { AddRemoveNotificationGenerator, } from '../../../../../src/server/notifications/generate/AddRemoveNotificationGenerator'; import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; +import type { ETagHandler } from '../../../../../src/storage/conditions/ETagHandler'; import type { ResourceStore } from '../../../../../src/storage/ResourceStore'; import { AS, CONTENT_TYPE, DC, LDP, RDF } from '../../../../../src/util/Vocabularies'; @@ -18,6 +19,7 @@ describe('An AddRemoveNotificationGenerator', (): void => { }; let metadata: RepresentationMetadata; let store: jest.Mocked; + let eTagHandler: jest.Mocked; let generator: AddRemoveNotificationGenerator; beforeEach(async(): Promise => { @@ -33,7 +35,13 @@ describe('An AddRemoveNotificationGenerator', (): void => { getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', responseMetadata)), } as any; - generator = new AddRemoveNotificationGenerator(store); + eTagHandler = { + getETag: jest.fn().mockReturnValue('ETag'), + matchesETag: jest.fn(), + sameResourceState: jest.fn(), + }; + + generator = new AddRemoveNotificationGenerator(store, eTagHandler); }); it('only handles Add/Remove activities.', async(): Promise => { @@ -73,7 +81,7 @@ describe('An AddRemoveNotificationGenerator', (): void => { type: 'Add', object: 'http://example.com/foo', target: 'http://example.com/', - state: expect.stringMatching(/"\d+-text\/turtle"/u), + state: 'ETag', published: date, }); diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index 8bc2eb52a..3467b8997 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -2,6 +2,7 @@ import 'jest-rdf'; import type { Readable } from 'stream'; import arrayifyStream from 'arrayify-stream'; import { DataFactory, Store } from 'n3'; +import type { Conditions } from '../../../src'; import { CONTENT_TYPE_TERM } from '../../../src'; import type { AuxiliaryStrategy } from '../../../src/http/auxiliary/AuxiliaryStrategy'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; @@ -9,7 +10,6 @@ import type { Representation } from '../../../src/http/representation/Representa import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor'; -import { BasicConditions } from '../../../src/storage/conditions/BasicConditions'; import { DataAccessorBasedStore } from '../../../src/storage/DataAccessorBasedStore'; import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; @@ -103,6 +103,7 @@ describe('A DataAccessorBasedStore', (): void => { let auxiliaryStrategy: AuxiliaryStrategy; let containerMetadata: RepresentationMetadata; let representation: Representation; + const failingConditions: Conditions = { matchesMetadata: (): boolean => false }; const resourceData = 'text'; const metadataStrategy = new SimpleSuffixStrategy('.meta'); @@ -256,8 +257,7 @@ describe('A DataAccessorBasedStore', (): void => { it('throws a 412 if the conditions are not matched.', async(): Promise => { const resourceID = { path: root }; - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); - await expect(store.addResource(resourceID, representation, conditions)) + await expect(store.addResource(resourceID, representation, failingConditions)) .rejects.toThrow(PreconditionFailedHttpError); }); @@ -417,8 +417,7 @@ describe('A DataAccessorBasedStore', (): void => { it('throws a 412 if the conditions are not matched.', async(): Promise => { const resourceID = { path: `${root}resource` }; await store.setRepresentation(resourceID, representation); - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); - await expect(store.setRepresentation(resourceID, representation, conditions)) + await expect(store.setRepresentation(resourceID, representation, failingConditions)) .rejects.toThrow(PreconditionFailedHttpError); }); @@ -697,15 +696,13 @@ describe('A DataAccessorBasedStore', (): void => { describe('modifying a Representation', (): void => { it('throws a 412 if the conditions are not matched.', async(): Promise => { const resourceID = { path: root }; - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); - await expect(store.modifyResource(resourceID, representation, conditions)) + await expect(store.modifyResource(resourceID, representation, failingConditions)) .rejects.toThrow(PreconditionFailedHttpError); }); it('throws a 412 if the conditions are not matched on resources that do not exist.', async(): Promise => { const resourceID = { path: `${root}notHere` }; - const conditions = new BasicConditions({ matchesETag: [ '*' ]}); - await expect(store.modifyResource(resourceID, representation, conditions)) + await expect(store.modifyResource(resourceID, representation, failingConditions)) .rejects.toThrow(PreconditionFailedHttpError); }); @@ -715,8 +712,7 @@ describe('A DataAccessorBasedStore', (): void => { }); const resourceID = { path: root }; - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); - await expect(store.modifyResource(resourceID, representation, conditions)) + await expect(store.modifyResource(resourceID, representation, failingConditions)) .rejects.toThrow('error'); }); @@ -764,8 +760,7 @@ describe('A DataAccessorBasedStore', (): void => { it('throws a 412 if the conditions are not matched.', async(): Promise => { const resourceID = { path: root }; - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); - await expect(store.deleteResource(resourceID, conditions)) + await expect(store.deleteResource(resourceID, failingConditions)) .rejects.toThrow(PreconditionFailedHttpError); }); diff --git a/test/unit/storage/conditions/BasicConditions.test.ts b/test/unit/storage/conditions/BasicConditions.test.ts index d5b3cfa06..826f37633 100644 --- a/test/unit/storage/conditions/BasicConditions.test.ts +++ b/test/unit/storage/conditions/BasicConditions.test.ts @@ -1,89 +1,95 @@ import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import { BasicConditions } from '../../../../src/storage/conditions/BasicConditions'; -import { getETag } from '../../../../src/storage/conditions/Conditions'; -import { CONTENT_TYPE, DC } from '../../../../src/util/Vocabularies'; - -function getMetadata(modified: Date, type = 'application/ld+json'): RepresentationMetadata { - return new RepresentationMetadata({ - [DC.modified]: `${modified.toISOString()}`, - [CONTENT_TYPE]: type, - }); -} +import type { ETagHandler } from '../../../../src/storage/conditions/ETagHandler'; +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 turtleTag = getETag(getMetadata(now, 'text/turtle'))!; - const jsonLdTag = getETag(getMetadata(now))!; + const eTag = `"${now.getTime()}-text/turtle"`; + let eTagHandler: jest.Mocked; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + eTagHandler = { + getETag: jest.fn(), + matchesETag: jest.fn().mockReturnValue(true), + sameResourceState: jest.fn(), + }; + + metadata = new RepresentationMetadata({ [DC.modified]: `${now.toISOString()}` }); + }); it('copies the input parameters.', async(): Promise => { const eTags = [ '123456', 'abcdefg' ]; const options = { matchesETag: eTags, notMatchesETag: eTags, modifiedSince: now, unmodifiedSince: now }; - expect(new BasicConditions(options)).toMatchObject(options); + expect(new BasicConditions(eTagHandler, options)).toMatchObject(options); }); it('always returns false if notMatchesETag contains *.', async(): Promise => { - const conditions = new BasicConditions({ notMatchesETag: [ '*' ]}); + const conditions = new BasicConditions(eTagHandler, { notMatchesETag: [ '*' ]}); expect(conditions.matchesMetadata(new RepresentationMetadata())).toBe(false); }); - it('requires matchesETag to match the provided ETag timestamp.', async(): Promise => { - const conditions = new BasicConditions({ matchesETag: [ turtleTag ]}); - expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(false); - expect(conditions.matchesMetadata(getMetadata(now))).toBe(true); + it('requires matchesETag to match the provided ETag with the metadata.', async(): Promise => { + const conditions = new BasicConditions(eTagHandler, { matchesETag: [ eTag ]}); + expect(conditions.matchesMetadata(metadata)).toBe(true); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(1); + expect(eTagHandler.matchesETag).toHaveBeenLastCalledWith(metadata, eTag, false); + + eTagHandler.matchesETag.mockReturnValue(false); + expect(conditions.matchesMetadata(metadata)).toBe(false); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(2); + expect(eTagHandler.matchesETag).toHaveBeenLastCalledWith(metadata, eTag, false); }); - it('requires matchesETag to match the exact provided ETag in strict mode.', async(): Promise => { - const turtleConditions = new BasicConditions({ matchesETag: [ turtleTag ]}); - const jsonLdConditions = new BasicConditions({ matchesETag: [ jsonLdTag ]}); - expect(turtleConditions.matchesMetadata(getMetadata(now), true)).toBe(false); - expect(jsonLdConditions.matchesMetadata(getMetadata(now), true)).toBe(true); + it('calls the ETagHandler in strict mode if required.', async(): Promise => { + const conditions = new BasicConditions(eTagHandler, { matchesETag: [ eTag ]}); + expect(conditions.matchesMetadata(metadata, true)).toBe(true); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(1); + expect(eTagHandler.matchesETag).toHaveBeenLastCalledWith(metadata, eTag, true); }); it('supports all ETags if matchesETag contains *.', async(): Promise => { - const conditions = new BasicConditions({ matchesETag: [ '*' ]}); - expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true); - expect(conditions.matchesMetadata(getMetadata(now))).toBe(true); + eTagHandler.matchesETag.mockReturnValue(false); + const conditions = new BasicConditions(eTagHandler, { matchesETag: [ '*' ]}); + expect(conditions.matchesMetadata(metadata, true)).toBe(true); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(0); }); - it('requires notMatchesETag to not match the provided ETag timestamp.', async(): Promise => { - const conditions = new BasicConditions({ notMatchesETag: [ turtleTag ]}); - expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true); - expect(conditions.matchesMetadata(getMetadata(now))).toBe(false); - }); + it('requires notMatchesETag to not match the provided ETag with the metadata.', async(): Promise => { + const conditions = new BasicConditions(eTagHandler, { notMatchesETag: [ eTag ]}); + expect(conditions.matchesMetadata(metadata)).toBe(false); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(1); + expect(eTagHandler.matchesETag).toHaveBeenLastCalledWith(metadata, eTag, false); - it('requires notMatchesETag to not match the exact provided ETag in strict mode.', async(): Promise => { - const turtleConditions = new BasicConditions({ notMatchesETag: [ turtleTag ]}); - const jsonLdConditions = new BasicConditions({ notMatchesETag: [ jsonLdTag ]}); - expect(turtleConditions.matchesMetadata(getMetadata(now), true)).toBe(true); - expect(jsonLdConditions.matchesMetadata(getMetadata(now), true)).toBe(false); + eTagHandler.matchesETag.mockReturnValue(false); + expect(conditions.matchesMetadata(metadata)).toBe(true); + expect(eTagHandler.matchesETag).toHaveBeenCalledTimes(2); + expect(eTagHandler.matchesETag).toHaveBeenLastCalledWith(metadata, eTag, false); }); it('requires lastModified to be after modifiedSince.', async(): Promise => { - const conditions = new BasicConditions({ modifiedSince: now }); - expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(false); - expect(conditions.matchesMetadata(getMetadata(tomorrow))).toBe(true); - }); + const conditions = new BasicConditions(eTagHandler, { modifiedSince: now }); + metadata.set(DC.terms.modified, yesterday.toISOString()); + expect(conditions.matchesMetadata(metadata)).toBe(false); - it('requires lastModified to be before unmodifiedSince.', async(): Promise => { - const conditions = new BasicConditions({ unmodifiedSince: now }); - expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true); - expect(conditions.matchesMetadata(getMetadata(tomorrow))).toBe(false); - }); - - it('matches if no date is found in the metadata.', async(): Promise => { - const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }); - const conditions = new BasicConditions({ - modifiedSince: yesterday, - unmodifiedSince: tomorrow, - notMatchesETag: [ '123456' ], - }); + metadata.set(DC.terms.modified, tomorrow.toISOString()); expect(conditions.matchesMetadata(metadata)).toBe(true); }); + it('requires lastModified to be before unmodifiedSince.', async(): Promise => { + const conditions = new BasicConditions(eTagHandler, { unmodifiedSince: now }); + metadata.set(DC.terms.modified, yesterday.toISOString()); + expect(conditions.matchesMetadata(metadata)).toBe(true); + + metadata.set(DC.terms.modified, tomorrow.toISOString()); + expect(conditions.matchesMetadata(metadata)).toBe(false); + }); + 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); + expect(new BasicConditions(eTagHandler, { matchesETag: [ '*' ]}).matchesMetadata()).toBe(false); + expect(new BasicConditions(eTagHandler, {}).matchesMetadata()).toBe(true); }); }); diff --git a/test/unit/storage/conditions/BasicETagHandler.test.ts b/test/unit/storage/conditions/BasicETagHandler.test.ts new file mode 100644 index 000000000..3b557447b --- /dev/null +++ b/test/unit/storage/conditions/BasicETagHandler.test.ts @@ -0,0 +1,56 @@ +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import { BasicETagHandler } from '../../../../src/storage/conditions/BasicETagHandler'; +import { DC } from '../../../../src/util/Vocabularies'; + +describe('A BasicETagHandler', (): void => { + const now = new Date(); + const contentType = 'text/turtle'; + const eTag = `"${now.getTime()}-${contentType}"`; + let metadata: RepresentationMetadata; + const handler = new BasicETagHandler(); + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata(); + metadata.add(DC.terms.modified, now.toISOString()); + metadata.contentType = 'text/turtle'; + }); + + it('can generate ETags.', async(): Promise => { + expect(handler.getETag(metadata)).toBe(eTag); + }); + + it('does not generate an ETag if the last modified date is missing.', async(): Promise => { + metadata.removeAll(DC.terms.modified); + expect(handler.getETag(metadata)).toBeUndefined(); + }); + + it('does not generate an ETag if the content-type is missing.', async(): Promise => { + metadata.contentType = undefined; + expect(handler.getETag(metadata)).toBeUndefined(); + }); + + it('can validate an ETag against metadata.', async(): Promise => { + expect(handler.matchesETag(metadata, eTag, true)).toBe(true); + }); + + it('requires a last modified date when comparing metadata with an ETag.', async(): Promise => { + metadata.removeAll(DC.terms.modified); + expect(handler.matchesETag(metadata, eTag, true)).toBe(false); + }); + + it('requires a content type when comparing metadata with an ETag.', async(): Promise => { + metadata.contentType = undefined; + expect(handler.matchesETag(metadata, eTag, true)).toBe(false); + }); + + it('does not require a content type when comparing metadata with an ETag.', async(): Promise => { + metadata.contentType = undefined; + expect(handler.matchesETag(metadata, eTag, false)).toBe(true); + }); + + it('can verify if 2 ETags reference the same resource state.', async(): Promise => { + expect(handler.sameResourceState(eTag, eTag)).toBe(true); + expect(handler.sameResourceState(eTag, `"${now.getTime()}-text/plain"`)).toBe(true); + expect(handler.sameResourceState(eTag, `"${now.getTime() + 1}-${contentType}"`)).toBe(false); + }); +}); diff --git a/test/unit/storage/conditions/Conditions.test.ts b/test/unit/storage/conditions/Conditions.test.ts deleted file mode 100644 index d11043968..000000000 --- a/test/unit/storage/conditions/Conditions.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import { getETag, sameResourceState } from '../../../../src/storage/conditions/Conditions'; -import { CONTENT_TYPE, DC } from '../../../../src/util/Vocabularies'; - -describe('Conditions', (): void => { - describe('#getETag', (): void => { - it('creates an ETag based on the date last modified and content-type.', async(): Promise => { - const now = new Date(); - const metadata = new RepresentationMetadata({ - [DC.modified]: now.toISOString(), - [CONTENT_TYPE]: 'text/turtle', - }); - expect(getETag(metadata)).toBe(`"${now.getTime()}-text/turtle"`); - }); - - it('creates a simpler ETag if no content type was found.', async(): Promise => { - const now = new Date(); - expect(getETag(new RepresentationMetadata({ [DC.modified]: now.toISOString() }))).toBe(`"${now.getTime()}-"`); - }); - - it('returns undefined if no date found.', async(): Promise => { - expect(getETag(new RepresentationMetadata())).toBeUndefined(); - expect(getETag(new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }))).toBeUndefined(); - }); - }); - - describe('sameResourceState', (): void => { - const eTag = '"123456-text/turtle"'; - const eTagJson = '"123456-application/ld+json"'; - const eTagWrongTime = '"654321-text/turtle"'; - - it('returns true if the ETags are the same.', async(): Promise => { - expect(sameResourceState(eTag, eTag)).toBe(true); - }); - - it('returns true if the ETags target the same timestamp.', async(): Promise => { - expect(sameResourceState(eTag, eTagJson)).toBe(true); - }); - - it('returns false if the timestamp differs.', async(): Promise => { - expect(sameResourceState(eTag, eTagWrongTime)).toBe(false); - }); - }); -});