feat: Use ETagHandler for ETag generation and comparison

This commit is contained in:
Joachim Van Herwegen 2023-07-27 09:38:46 +02:00
parent 5ec6eddbfa
commit afcbfdaacf
31 changed files with 367 additions and 234 deletions

View File

@ -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" }
}
]
}

View File

@ -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" }
}
},
{

View File

@ -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" }
}
},
{

View File

@ -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",

View File

@ -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" },

View File

@ -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<Conditions | undefined> {
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);
}
}

View File

@ -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<void> {
@ -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);

View File

@ -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<void> {
@ -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);

View File

@ -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';

View File

@ -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<void> {
@ -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;
}

View File

@ -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<void> {
@ -28,8 +30,7 @@ export class ActivityNotificationGenerator extends NotificationGenerator {
public async handle({ topic, activity }: NotificationHandlerInput): Promise<Notification> {
const representation = await this.store.getRepresentation(topic, {});
representation.data.destroy();
const state = getETag(representation.metadata);
const state = this.eTagHandler.getETag(representation.metadata);
return {
'@context': [

View File

@ -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<void> {
@ -30,8 +32,8 @@ export class AddRemoveNotificationGenerator extends NotificationGenerator {
public async handle({ activity, topic, metadata }: NotificationHandlerInput): Promise<Notification> {
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}`);

View File

@ -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,22 +43,13 @@ 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;
}
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
// that doesn't store the modified date.

View File

@ -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];
}
}

View File

@ -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];
}

View File

@ -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;
}

View File

@ -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' ],
}),

View File

@ -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<void> => {
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<void> => {
@ -17,28 +27,29 @@ describe('A BasicConditionsParser', (): void => {
it('parses the if-modified-since header.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
@ -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<void> => {
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<void> => {

View File

@ -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);

View File

@ -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<Conditions>;
const preferences = {};
const body = new BasicRepresentation();
let store: ResourceStore;
let eTagHandler: ETagHandler;
let handler: GetOperationHandler;
let data: Readable;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
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<Representation> =>
({ 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<void> => {
@ -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<void> => {
operation.conditions = {
matchesMetadata: (): boolean => false,
};
conditions.matchesMetadata.mockReturnValue(false);
await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
expect(data.destroy).toHaveBeenCalledTimes(1);
});

View File

@ -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<Conditions>;
const preferences = {};
const body = new BasicRepresentation();
let store: ResourceStore;
let eTagHandler: ETagHandler;
let handler: HeadOperationHandler;
let data: Readable;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
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<Representation> =>
({ 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<void> => {
@ -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<void> => {
operation.conditions = {
matchesMetadata: (): boolean => false,
};
conditions.matchesMetadata.mockReturnValue(false);
await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
expect(data.destroy).toHaveBeenCalledTimes(2);
});

View File

@ -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<ResourceStore>;
let handler: PatchOperationHandler;

View File

@ -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<ResourceStore>;
let handler: PostOperationHandler;

View File

@ -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<ResourceStore>;
let handler: PutOperationHandler;
const metaStrategy = new SimpleSuffixStrategy('.meta');

View File

@ -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<NotificationGenerator>;
let serializer: jest.Mocked<NotificationSerializer>;
let emitter: jest.Mocked<NotificationEmitter>;
let eTagHandler: jest.Mocked<ETagHandler>;
let handler: ComposedNotificationHandler;
beforeEach(async(): Promise<void> => {
@ -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<void> => {
@ -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<void> => {
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 });

View File

@ -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<ResourceStore>;
let eTagHandler: jest.Mocked<ETagHandler>;
let generator: ActivityNotificationGenerator;
beforeEach(async(): Promise<void> => {
@ -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<void> => {
@ -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,
});

View File

@ -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<ResourceStore>;
let eTagHandler: jest.Mocked<ETagHandler>;
let generator: AddRemoveNotificationGenerator;
beforeEach(async(): Promise<void> => {
@ -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<void> => {
@ -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,
});

View File

@ -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<void> => {
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<void> => {
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<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(PreconditionFailedHttpError);
});
it('throws a 412 if the conditions are not matched on resources that do not exist.', async(): Promise<void> => {
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<void> => {
const resourceID = { path: root };
const conditions = new BasicConditions({ notMatchesETag: [ '*' ]});
await expect(store.deleteResource(resourceID, conditions))
await expect(store.deleteResource(resourceID, failingConditions))
.rejects.toThrow(PreconditionFailedHttpError);
});

View File

@ -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<ETagHandler>;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
});
});

View File

@ -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<void> => {
metadata = new RepresentationMetadata();
metadata.add(DC.terms.modified, now.toISOString());
metadata.contentType = 'text/turtle';
});
it('can generate ETags.', async(): Promise<void> => {
expect(handler.getETag(metadata)).toBe(eTag);
});
it('does not generate an ETag if the last modified date is missing.', async(): Promise<void> => {
metadata.removeAll(DC.terms.modified);
expect(handler.getETag(metadata)).toBeUndefined();
});
it('does not generate an ETag if the content-type is missing.', async(): Promise<void> => {
metadata.contentType = undefined;
expect(handler.getETag(metadata)).toBeUndefined();
});
it('can validate an ETag against metadata.', async(): Promise<void> => {
expect(handler.matchesETag(metadata, eTag, true)).toBe(true);
});
it('requires a last modified date when comparing metadata with an ETag.', async(): Promise<void> => {
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<void> => {
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<void> => {
metadata.contentType = undefined;
expect(handler.matchesETag(metadata, eTag, false)).toBe(true);
});
it('can verify if 2 ETags reference the same resource state.', async(): Promise<void> => {
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);
});
});

View File

@ -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<void> => {
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<void> => {
const now = new Date();
expect(getETag(new RepresentationMetadata({ [DC.modified]: now.toISOString() }))).toBe(`"${now.getTime()}-"`);
});
it('returns undefined if no date found.', async(): Promise<void> => {
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<void> => {
expect(sameResourceState(eTag, eTag)).toBe(true);
});
it('returns true if the ETags target the same timestamp.', async(): Promise<void> => {
expect(sameResourceState(eTag, eTagJson)).toBe(true);
});
it('returns false if the timestamp differs.', async(): Promise<void> => {
expect(sameResourceState(eTag, eTagWrongTime)).toBe(false);
});
});
});