diff --git a/config/http/middleware/handlers/cors.json b/config/http/middleware/handlers/cors.json index 00371890a1..c0298b0260 100644 --- a/config/http/middleware/handlers/cors.json +++ b/config/http/middleware/handlers/cors.json @@ -18,6 +18,9 @@ "options_preflightContinue": true, "options_exposedHeaders": [ "Accept-Patch", + "Accept-Post", + "Accept-Put", + "Allow", "ETag", "Last-Modified", "Link", diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index 86230d1358..640dc05647 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -1,6 +1,7 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "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/link-rel.json", "files-scs:config/ldp/metadata-writer/writers/mapped.json", @@ -14,6 +15,7 @@ "@id": "urn:solid-server:default:MetadataWriter", "@type": "ParallelHandler", "handlers": [ + { "@id": "urn:solid-server:default:MetadataWriter_AllowAccept" }, { "@id": "urn:solid-server:default:MetadataWriter_Constant" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, { "@id": "urn:solid-server:default:MetadataWriter_Modified" }, diff --git a/config/ldp/metadata-writer/writers/allow-accept.json b/config/ldp/metadata-writer/writers/allow-accept.json new file mode 100644 index 0000000000..db9ffb68e7 --- /dev/null +++ b/config/ldp/metadata-writer/writers/allow-accept.json @@ -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": [ "*/*" ] + } + ] +} diff --git a/config/ldp/metadata-writer/writers/constant.json b/config/ldp/metadata-writer/writers/constant.json index 542450d908..dd89045a36 100644 --- a/config/ldp/metadata-writer/writers/constant.json +++ b/config/ldp/metadata-writer/writers/constant.json @@ -6,14 +6,6 @@ "@id": "urn:solid-server:default:MetadataWriter_Constant", "@type": "ConstantMetadataWriter", "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_value": "SPARQL" diff --git a/package-lock.json b/package-lock.json index c877ca8ba0..24640bdb97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "lodash.orderby": "^4.6.0", "marked": "^4.0.12", "mime-types": "^2.1.34", - "n3": "^1.13.0", + "n3": "^1.16.0", "nodemailer": "^6.7.2", "oidc-provider": "^7.10.6", "pump": "^3.0.0", @@ -11633,9 +11633,9 @@ "peer": true }, "node_modules/n3": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.13.0.tgz", - "integrity": "sha512-GMB4ypBfnuf6mmwbtyN6Whc8TfuVDedxc4n+3wsacQH/h0+RjaEobGMhlWrFLDsqVbT94XA6+q9yysMO5SadKA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.16.0.tgz", + "integrity": "sha512-gE5KF07yhGXhEdAVru5QUqC4fKltA4sMwgASWpOrZSwn8fi8cuLHYPjRl9pR5WhQL96lhaNMZwT8enRIayVfLg==", "dependencies": { "queue-microtask": "^1.1.2", "readable-stream": "^3.6.0" @@ -24106,9 +24106,9 @@ "peer": true }, "n3": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.13.0.tgz", - "integrity": "sha512-GMB4ypBfnuf6mmwbtyN6Whc8TfuVDedxc4n+3wsacQH/h0+RjaEobGMhlWrFLDsqVbT94XA6+q9yysMO5SadKA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.16.0.tgz", + "integrity": "sha512-gE5KF07yhGXhEdAVru5QUqC4fKltA4sMwgASWpOrZSwn8fi8cuLHYPjRl9pR5WhQL96lhaNMZwT8enRIayVfLg==", "requires": { "queue-microtask": "^1.1.2", "readable-stream": "^3.6.0" diff --git a/package.json b/package.json index d5e7896a46..9935fb8811 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "lodash.orderby": "^4.6.0", "marked": "^4.0.12", "mime-types": "^2.1.34", - "n3": "^1.13.0", + "n3": "^1.16.0", "nodemailer": "^6.7.2", "oidc-provider": "^7.10.6", "pump": "^3.0.0", diff --git a/src/http/output/metadata/AllowAcceptHeaderWriter.ts b/src/http/output/metadata/AllowAcceptHeaderWriter.ts new file mode 100644 index 0000000000..268f6aec7a --- /dev/null +++ b/src/http/output/metadata/AllowAcceptHeaderWriter.ts @@ -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 { + 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 { + 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, 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, 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(', ')); + } + } + } +} diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index a09d83cd8c..dfd9cc2156 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -257,6 +257,20 @@ export class RepresentationMetadata { 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. * @param predicate - Optional predicate to get the values for. diff --git a/src/index.ts b/src/index.ts index a7081bb40d..404eda3f86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,7 @@ export * from './http/output/error/RedirectingErrorHandler'; export * from './http/output/error/SafeErrorHandler'; // HTTP/Output/Metadata +export * from './http/output/metadata/AllowAcceptHeaderWriter'; export * from './http/output/metadata/ConstantMetadataWriter'; export * from './http/output/metadata/LinkRelMetadataWriter'; export * from './http/output/metadata/MappedMetadataWriter'; diff --git a/src/server/ParsingHttpHandler.ts b/src/server/ParsingHttpHandler.ts index 570d854f0b..ded1c64e9a 100644 --- a/src/server/ParsingHttpHandler.ts +++ b/src/server/ParsingHttpHandler.ts @@ -6,6 +6,7 @@ import type { ResponseWriter } from '../http/output/ResponseWriter'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import { getLoggerFor } from '../logging/LogUtil'; import { assertError } from '../util/errors/ErrorUtil'; +import { HttpError } from '../util/errors/HttpError'; import type { HttpHandlerInput } from './HttpHandler'; import { HttpHandler } from './HttpHandler'; import type { OperationHttpHandler } from './OperationHttpHandler'; @@ -73,6 +74,10 @@ export class ParsingHttpHandler extends HttpHandler { } catch (error: unknown) { assertError(error); 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) { diff --git a/test/integration/Middleware.test.ts b/test/integration/Middleware.test.ts index 75a2086367..f94b85b436 100644 --- a/test/integration/Middleware.test.ts +++ b/test/integration/Middleware.test.ts @@ -93,10 +93,12 @@ describe('An http server with middleware', (): void => { expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' })); }); - it('exposes the Accept-Patch header via CORS.', async(): Promise => { + it('exposes the Accept-[Method] header via CORS.', async(): Promise => { const res = await request(server).get('/').expect(200); 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-Post'); + expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Put'); }); it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise => { diff --git a/test/unit/http/output/metadata/AllowAcceptHeaderWriter.test.ts b/test/unit/http/output/metadata/AllowAcceptHeaderWriter.test.ts new file mode 100644 index 0000000000..f8e5268435 --- /dev/null +++ b/test/unit/http/output/metadata/AllowAcceptHeaderWriter.test.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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('*/*'); + }); +}); diff --git a/test/unit/http/representation/RepresentationMetadata.test.ts b/test/unit/http/representation/RepresentationMetadata.test.ts index 7ba58fc55e..710bb2d8d4 100644 --- a/test/unit/http/representation/RepresentationMetadata.test.ts +++ b/test/unit/http/representation/RepresentationMetadata.test.ts @@ -248,6 +248,13 @@ describe('A RepresentationMetadata', (): void => { ); }); + it('can check the existence of a triple.', async(): Promise => { + 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 => { expect(metadata.getAll(namedNode('has'))).toEqualRdfTermArray( [ literal('data'), literal('moreData'), literal('data') ], diff --git a/test/unit/server/ParsingHttpHandler.test.ts b/test/unit/server/ParsingHttpHandler.test.ts index 1329492226..476d7940c3 100644 --- a/test/unit/server/ParsingHttpHandler.test.ts +++ b/test/unit/server/ParsingHttpHandler.test.ts @@ -11,6 +11,7 @@ import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler'; import { ParsingHttpHandler } from '../../../src/server/ParsingHttpHandler'; +import { HttpError } from '../../../src/util/errors/HttpError'; describe('A ParsingHttpHandler', (): void => { const request: HttpRequest = {} as any; @@ -78,4 +79,17 @@ describe('A ParsingHttpHandler', (): void => { expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse }); }); + + it('adds error metadata if able.', async(): Promise => { + 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); + }); }); diff --git a/test/util/FetchUtil.ts b/test/util/FetchUtil.ts index 358470401f..cdebb265ab 100644 --- a/test/util/FetchUtil.ts +++ b/test/util/FetchUtil.ts @@ -17,7 +17,7 @@ export async function getResource(url: string, expect(response.status).toBe(200); expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`); 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'); if (isContainer) {