mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Use ETagHandler for ETag generation and comparison
This commit is contained in:
parent
5ec6eddbfa
commit
afcbfdaacf
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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" }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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" }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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",
|
||||
|
@ -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" },
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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': [
|
||||
|
@ -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}`);
|
||||
|
@ -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
|
||||
|
38
src/storage/conditions/BasicETagHandler.ts
Normal file
38
src/storage/conditions/BasicETagHandler.ts
Normal 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];
|
||||
}
|
||||
}
|
@ -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];
|
||||
}
|
||||
|
33
src/storage/conditions/ETagHandler.ts
Normal file
33
src/storage/conditions/ETagHandler.ts
Normal 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;
|
||||
}
|
@ -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' ],
|
||||
}),
|
||||
|
@ -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> => {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
56
test/unit/storage/conditions/BasicETagHandler.test.ts
Normal file
56
test/unit/storage/conditions/BasicETagHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user