feat: Dynamically generate Allow and Accept-* headers

This commit is contained in:
Joachim Van Herwegen 2022-03-25 11:34:40 +01:00
parent effc20a270
commit 6e98c6aae4
15 changed files with 326 additions and 18 deletions

View File

@ -18,6 +18,9 @@
"options_preflightContinue": true, "options_preflightContinue": true,
"options_exposedHeaders": [ "options_exposedHeaders": [
"Accept-Patch", "Accept-Patch",
"Accept-Post",
"Accept-Put",
"Allow",
"ETag", "ETag",
"Last-Modified", "Last-Modified",
"Link", "Link",

View File

@ -1,6 +1,7 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"import": [ "import": [
"files-scs:config/ldp/metadata-writer/writers/allow-accept.json",
"files-scs:config/ldp/metadata-writer/writers/constant.json", "files-scs:config/ldp/metadata-writer/writers/constant.json",
"files-scs:config/ldp/metadata-writer/writers/link-rel.json", "files-scs:config/ldp/metadata-writer/writers/link-rel.json",
"files-scs:config/ldp/metadata-writer/writers/mapped.json", "files-scs:config/ldp/metadata-writer/writers/mapped.json",
@ -14,6 +15,7 @@
"@id": "urn:solid-server:default:MetadataWriter", "@id": "urn:solid-server:default:MetadataWriter",
"@type": "ParallelHandler", "@type": "ParallelHandler",
"handlers": [ "handlers": [
{ "@id": "urn:solid-server:default:MetadataWriter_AllowAccept" },
{ "@id": "urn:solid-server:default:MetadataWriter_Constant" }, { "@id": "urn:solid-server:default:MetadataWriter_Constant" },
{ "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" },
{ "@id": "urn:solid-server:default:MetadataWriter_Modified" }, { "@id": "urn:solid-server:default:MetadataWriter_Modified" },

View File

@ -0,0 +1,14 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Adds Allow and Accept-[Method] headers.",
"@id": "urn:solid-server:default:MetadataWriter_AllowAccept",
"@type": "AllowAcceptHeaderWriter",
"supportedMethods": [ "OPTIONS", "HEAD", "GET", "PATCH", "POST", "PUT", "DELETE" ],
"acceptTypes_patch": [ "text/n3", "application/sparql-update" ],
"acceptTypes_put": [ "*/*" ],
"acceptTypes_post": [ "*/*" ]
}
]
}

View File

@ -6,14 +6,6 @@
"@id": "urn:solid-server:default:MetadataWriter_Constant", "@id": "urn:solid-server:default:MetadataWriter_Constant",
"@type": "ConstantMetadataWriter", "@type": "ConstantMetadataWriter",
"headers": [ "headers": [
{
"ConstantMetadataWriter:_headers_key": "Accept-Patch",
"ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3"
},
{
"ConstantMetadataWriter:_headers_key": "Allow",
"ConstantMetadataWriter:_headers_value": "OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE"
},
{ {
"ConstantMetadataWriter:_headers_key": "MS-Author-Via", "ConstantMetadataWriter:_headers_key": "MS-Author-Via",
"ConstantMetadataWriter:_headers_value": "SPARQL" "ConstantMetadataWriter:_headers_value": "SPARQL"

14
package-lock.json generated
View File

@ -50,7 +50,7 @@
"lodash.orderby": "^4.6.0", "lodash.orderby": "^4.6.0",
"marked": "^4.0.12", "marked": "^4.0.12",
"mime-types": "^2.1.34", "mime-types": "^2.1.34",
"n3": "^1.13.0", "n3": "^1.16.0",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"oidc-provider": "^7.10.6", "oidc-provider": "^7.10.6",
"pump": "^3.0.0", "pump": "^3.0.0",
@ -11633,9 +11633,9 @@
"peer": true "peer": true
}, },
"node_modules/n3": { "node_modules/n3": {
"version": "1.13.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/n3/-/n3-1.13.0.tgz", "resolved": "https://registry.npmjs.org/n3/-/n3-1.16.0.tgz",
"integrity": "sha512-GMB4ypBfnuf6mmwbtyN6Whc8TfuVDedxc4n+3wsacQH/h0+RjaEobGMhlWrFLDsqVbT94XA6+q9yysMO5SadKA==", "integrity": "sha512-gE5KF07yhGXhEdAVru5QUqC4fKltA4sMwgASWpOrZSwn8fi8cuLHYPjRl9pR5WhQL96lhaNMZwT8enRIayVfLg==",
"dependencies": { "dependencies": {
"queue-microtask": "^1.1.2", "queue-microtask": "^1.1.2",
"readable-stream": "^3.6.0" "readable-stream": "^3.6.0"
@ -24106,9 +24106,9 @@
"peer": true "peer": true
}, },
"n3": { "n3": {
"version": "1.13.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/n3/-/n3-1.13.0.tgz", "resolved": "https://registry.npmjs.org/n3/-/n3-1.16.0.tgz",
"integrity": "sha512-GMB4ypBfnuf6mmwbtyN6Whc8TfuVDedxc4n+3wsacQH/h0+RjaEobGMhlWrFLDsqVbT94XA6+q9yysMO5SadKA==", "integrity": "sha512-gE5KF07yhGXhEdAVru5QUqC4fKltA4sMwgASWpOrZSwn8fi8cuLHYPjRl9pR5WhQL96lhaNMZwT8enRIayVfLg==",
"requires": { "requires": {
"queue-microtask": "^1.1.2", "queue-microtask": "^1.1.2",
"readable-stream": "^3.6.0" "readable-stream": "^3.6.0"

View File

@ -116,7 +116,7 @@
"lodash.orderby": "^4.6.0", "lodash.orderby": "^4.6.0",
"marked": "^4.0.12", "marked": "^4.0.12",
"mime-types": "^2.1.34", "mime-types": "^2.1.34",
"n3": "^1.13.0", "n3": "^1.16.0",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"oidc-provider": "^7.10.6", "oidc-provider": "^7.10.6",
"pump": "^3.0.0", "pump": "^3.0.0",

View File

@ -0,0 +1,139 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { MethodNotAllowedHttpError } from '../../../util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError';
import { addHeader } from '../../../util/HeaderUtil';
import { isContainerPath } from '../../../util/PathUtil';
import { LDP, PIM, RDF, SOLID_ERROR } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
// Only PUT and PATCH can be used to create a new resource
const NEW_RESOURCE_ALLOWED_METHODS = new Set([ 'PUT', 'PATCH' ]);
/**
* Generates Allow, Accept-Patch, Accept-Post, and Accept-Put headers.
* The resulting values depend on the choses input methods and types.
* The input metadata also gets used to remove methods from that list
* if they are not valid in the given situation.
*/
export class AllowAcceptHeaderWriter extends MetadataWriter {
private readonly supportedMethods: string[];
private readonly acceptTypes: { patch: string[]; post: string[]; put: string[] };
public constructor(supportedMethods: string[], acceptTypes: { patch?: string[]; post?: string[]; put?: string[] }) {
super();
this.supportedMethods = supportedMethods;
this.acceptTypes = { patch: [], post: [], put: [], ...acceptTypes };
}
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const { response, metadata } = input;
// Filter out methods which are not allowed
const allowedMethods = this.filterAllowedMethods(metadata);
// Generate the Allow headers (if required)
const generateAllow = this.generateAllow(allowedMethods, response, metadata);
// Generate Accept-[Method] headers (if required)
this.generateAccept(allowedMethods, generateAllow, response, metadata);
}
/**
* Starts from the stored set of methods and removes all those that are not allowed based on the metadata.
*/
private filterAllowedMethods(metadata: RepresentationMetadata): Set<string> {
const disallowedMethods = new Set(metadata.getAll(SOLID_ERROR.terms.disallowedMethod)
.map((term): string => term.value));
const allowedMethods = new Set(this.supportedMethods.filter((method): boolean => !disallowedMethods.has(method)));
// POST is only allowed on containers.
// Metadata only has the resource URI in case it has resource metadata.
if (this.isPostAllowed(metadata)) {
allowedMethods.delete('POST');
}
if (!this.isDeleteAllowed(metadata)) {
allowedMethods.delete('DELETE');
}
// If we are sure the resource does not exist: only keep methods that can create a new resource.
if (metadata.has(SOLID_ERROR.terms.errorResponse, NotFoundHttpError.uri)) {
for (const method of allowedMethods) {
if (!NEW_RESOURCE_ALLOWED_METHODS.has(method)) {
allowedMethods.delete(method);
}
}
}
return allowedMethods;
}
/**
* POST is only allowed on containers.
* The metadata URI is only valid in case there is resource metadata,
* otherwise it is just a blank node.
*/
private isPostAllowed(metadata: RepresentationMetadata): boolean {
return metadata.has(RDF.terms.type, LDP.terms.Resource) && !isContainerPath(metadata.identifier.value);
}
/**
* DELETE is allowed if the target exists,
* is not a container,
* or is an empty container that isn't a storage.
*
* Note that the identifier value check only works if the metadata is not about an error.
*/
private isDeleteAllowed(metadata: RepresentationMetadata): boolean {
if (!isContainerPath(metadata.identifier.value)) {
return true;
}
const isStorage = metadata.has(RDF.terms.type, PIM.terms.Storage);
const isEmpty = metadata.has(LDP.terms.contains);
return !isStorage && !isEmpty;
}
/**
* Generates the Allow header if required.
* It only needs to get added for successful GET/HEAD requests, 404s, or 405s.
* The spec only requires it for GET/HEAD requests and 405s.
* In the case of other error messages we can't deduce what the request method was,
* so we do not add the header as we don't have enough information.
*/
private generateAllow(methods: Set<string>, response: HttpResponse, metadata: RepresentationMetadata): boolean {
const methodDisallowed = metadata.has(SOLID_ERROR.terms.errorResponse, MethodNotAllowedHttpError.uri);
// 405s indicate the target resource exists.
// This is a heuristic, but one that should always be correct in our case.
const resourceExists = methodDisallowed || metadata.has(RDF.terms.type, LDP.terms.Resource);
const generateAllow = resourceExists || metadata.has(SOLID_ERROR.terms.errorResponse, NotFoundHttpError.uri);
if (generateAllow) {
addHeader(response, 'Allow', [ ...methods ].join(', '));
}
return generateAllow;
}
/**
* Generates the Accept-[Method] headers if required.
* Will be added if the Allow header was added, or in case of a 415 error.
* Specific Accept-[Method] headers will only be added if the method is in the `methods` set.
*/
private generateAccept(methods: Set<string>, generateAllow: boolean, response: HttpResponse,
metadata: RepresentationMetadata): void {
const typeWasUnsupported = metadata.has(SOLID_ERROR.terms.errorResponse, UnsupportedMediaTypeHttpError.uri);
const generateAccept = generateAllow || typeWasUnsupported;
if (generateAccept) {
if (methods.has('PATCH')) {
addHeader(response, 'Accept-Patch', this.acceptTypes.patch.join(', '));
}
if (methods.has('POST')) {
addHeader(response, 'Accept-Post', this.acceptTypes.post.join(', '));
}
if (methods.has('PUT')) {
addHeader(response, 'Accept-Put', this.acceptTypes.put.join(', '));
}
}
}
}

View File

@ -257,6 +257,20 @@ export class RepresentationMetadata {
return this; return this;
} }
/**
* Verifies if a specific triple can be found in the metadata.
* Undefined parameters are interpreted as wildcards.
*/
public has(
predicate: NamedNode | string | null = null,
object: NamedNode | BlankNode | Literal | string | null = null,
graph: MetadataGraph | null = null,
): boolean {
// This works with N3.js but at the time of writing the typings have not been updated yet.
// If you see this line of code check if the typings are already correct and update this if so.
return (this.store.has as any)(this.id, predicate, object, graph);
}
/** /**
* Finds all object values matching the given predicate and/or graph. * Finds all object values matching the given predicate and/or graph.
* @param predicate - Optional predicate to get the values for. * @param predicate - Optional predicate to get the values for.

View File

@ -94,6 +94,7 @@ export * from './http/output/error/RedirectingErrorHandler';
export * from './http/output/error/SafeErrorHandler'; export * from './http/output/error/SafeErrorHandler';
// HTTP/Output/Metadata // HTTP/Output/Metadata
export * from './http/output/metadata/AllowAcceptHeaderWriter';
export * from './http/output/metadata/ConstantMetadataWriter'; export * from './http/output/metadata/ConstantMetadataWriter';
export * from './http/output/metadata/LinkRelMetadataWriter'; export * from './http/output/metadata/LinkRelMetadataWriter';
export * from './http/output/metadata/MappedMetadataWriter'; export * from './http/output/metadata/MappedMetadataWriter';

View File

@ -6,6 +6,7 @@ import type { ResponseWriter } from '../http/output/ResponseWriter';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { assertError } from '../util/errors/ErrorUtil'; import { assertError } from '../util/errors/ErrorUtil';
import { HttpError } from '../util/errors/HttpError';
import type { HttpHandlerInput } from './HttpHandler'; import type { HttpHandlerInput } from './HttpHandler';
import { HttpHandler } from './HttpHandler'; import { HttpHandler } from './HttpHandler';
import type { OperationHttpHandler } from './OperationHttpHandler'; import type { OperationHttpHandler } from './OperationHttpHandler';
@ -73,6 +74,10 @@ export class ParsingHttpHandler extends HttpHandler {
} catch (error: unknown) { } catch (error: unknown) {
assertError(error); assertError(error);
result = await this.errorHandler.handleSafe({ error, preferences }); result = await this.errorHandler.handleSafe({ error, preferences });
if (HttpError.isInstance(error) && result.metadata) {
const quads = error.generateMetadata(result.metadata.identifier);
result.metadata.addQuads(quads);
}
} }
if (result) { if (result) {

View File

@ -93,10 +93,12 @@ describe('An http server with middleware', (): void => {
expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' })); expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
}); });
it('exposes the Accept-Patch header via CORS.', async(): Promise<void> => { it('exposes the Accept-[Method] header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200); const res = await request(server).get('/').expect(200);
const exposed = res.header['access-control-expose-headers']; const exposed = res.header['access-control-expose-headers'];
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Patch'); expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Patch');
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Post');
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Put');
}); });
it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise<void> => { it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise<void> => {

View File

@ -0,0 +1,115 @@
import { createResponse } from 'node-mocks-http';
import { AllowAcceptHeaderWriter } from '../../../../../src/http/output/metadata/AllowAcceptHeaderWriter';
import type { MetadataRecord } from '../../../../../src/http/representation/RepresentationMetadata';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
import { MethodNotAllowedHttpError } from '../../../../../src/util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError';
import { LDP, PIM, RDF, SOLID_ERROR } from '../../../../../src/util/Vocabularies';
describe('An AllowAcceptHeaderWriter', (): void => {
const document = new RepresentationMetadata({ path: 'http://example.com/foo/bar' },
{ [RDF.type]: LDP.terms.Resource });
const emptyContainer = new RepresentationMetadata({ path: 'http://example.com/foo/' },
{ [RDF.type]: [ LDP.terms.Resource, LDP.terms.Container ]});
const fullContainer = new RepresentationMetadata({ path: 'http://example.com/foo/' },
{
[RDF.type]: [ LDP.terms.Resource, LDP.terms.Container ],
[LDP.contains]: [ document.identifier ],
// Typescript doesn't find the correct constructor without the cast
} as MetadataRecord);
const storageContainer = new RepresentationMetadata({ path: 'http://example.com/foo/' },
{ [RDF.type]: [ LDP.terms.Resource, LDP.terms.Container, PIM.terms.Storage ]});
const error404 = new RepresentationMetadata({ [SOLID_ERROR.errorResponse]: NotFoundHttpError.uri });
const error405 = new RepresentationMetadata(
{ [SOLID_ERROR.errorResponse]: MethodNotAllowedHttpError.uri, [SOLID_ERROR.disallowedMethod]: 'PUT' },
);
const error415 = new RepresentationMetadata({ [SOLID_ERROR.errorResponse]: UnsupportedMediaTypeHttpError.uri });
let response: HttpResponse;
let writer: AllowAcceptHeaderWriter;
beforeEach(async(): Promise<void> => {
response = createResponse();
writer = new AllowAcceptHeaderWriter(
[ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE' ],
{ patch: [ 'text/n3', 'application/sparql-update' ], post: [ '*/*' ], put: [ '*/*' ]},
);
});
it('returns all methods except POST for a document.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: document })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'PATCH', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBeUndefined();
});
it('returns all methods for an empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: emptyContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
it('returns all methods except DELETE for a non-empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: fullContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
it('returns all methods except DELETE for a storage container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: storageContainer })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'PUT', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
it('returns PATCH and PUT if the target does not exist.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error404 })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'PUT', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBeUndefined();
});
it('removes methods that are not allowed by a 405 error.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error405 })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set((headers.allow as string).split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBeUndefined();
expect(headers['accept-post']).toBe('*/*');
});
it('only returns Accept- headers in case of a 415.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error415 })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(headers.allow).toBeUndefined();
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBe('*/*');
});
});

View File

@ -248,6 +248,13 @@ describe('A RepresentationMetadata', (): void => {
); );
}); });
it('can check the existence of a triple.', async(): Promise<void> => {
expect(metadata.has(namedNode('has'), literal('data'))).toBe(true);
expect(metadata.has(namedNode('has'))).toBe(true);
expect(metadata.has(undefined, literal('data'))).toBe(true);
expect(metadata.has(namedNode('has'), literal('wrongData'))).toBe(false);
});
it('can get all values for a predicate.', async(): Promise<void> => { it('can get all values for a predicate.', async(): Promise<void> => {
expect(metadata.getAll(namedNode('has'))).toEqualRdfTermArray( expect(metadata.getAll(namedNode('has'))).toEqualRdfTermArray(
[ literal('data'), literal('moreData'), literal('data') ], [ literal('data'), literal('moreData'), literal('data') ],

View File

@ -11,6 +11,7 @@ import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../src/server/HttpResponse';
import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler'; import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler';
import { ParsingHttpHandler } from '../../../src/server/ParsingHttpHandler'; import { ParsingHttpHandler } from '../../../src/server/ParsingHttpHandler';
import { HttpError } from '../../../src/util/errors/HttpError';
describe('A ParsingHttpHandler', (): void => { describe('A ParsingHttpHandler', (): void => {
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
@ -78,4 +79,17 @@ describe('A ParsingHttpHandler', (): void => {
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse }); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse });
}); });
it('adds error metadata if able.', async(): Promise<void> => {
const error = new HttpError(0, 'error');
source.handleSafe.mockRejectedValueOnce(error);
const metaResponse = new ResponseDescription(0, new RepresentationMetadata());
errorHandler.handleSafe.mockResolvedValueOnce(metaResponse);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences });
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: metaResponse });
expect(metaResponse.metadata?.quads()).toHaveLength(1);
});
}); });

View File

@ -17,7 +17,7 @@ export async function getResource(url: string,
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`); expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`);
expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`); expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`);
expect(response.headers.get('accept-patch')).toBe('application/sparql-update, text/n3'); expect(response.headers.get('accept-patch')).toBe('text/n3, application/sparql-update');
expect(response.headers.get('ms-author-via')).toBe('SPARQL'); expect(response.headers.get('ms-author-via')).toBe('SPARQL');
if (isContainer) { if (isContainer) {