fix: Make allow headers more accurate

This commit is contained in:
Joachim Van Herwegen 2024-04-04 13:52:29 +02:00
parent d7078ad692
commit 5e60000681
3 changed files with 87 additions and 28 deletions

View File

@ -8,8 +8,11 @@ import { LDP, PIM, RDF, SOLID_ERROR } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter'; import { MetadataWriter } from './MetadataWriter';
// Only PUT and PATCH can be used to create a new resource enum ResourceType {
const NEW_RESOURCE_ALLOWED_METHODS = new Set([ 'PUT', 'PATCH' ]); document,
container,
unknown,
}
/** /**
* Generates Allow, Accept-Patch, Accept-Post, and Accept-Put headers. * Generates Allow, Accept-Patch, Accept-Post, and Accept-Put headers.
@ -30,8 +33,20 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> { public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const { response, metadata } = input; const { response, metadata } = input;
let resourceType: ResourceType;
if (metadata.has(RDF.terms.type, LDP.terms.Resource)) {
resourceType = isContainerPath(metadata.identifier.value) ? ResourceType.container : ResourceType.document;
} else {
const target = metadata.get(SOLID_ERROR.terms.target)?.value;
if (target) {
resourceType = isContainerPath(target) ? ResourceType.container : ResourceType.document;
} else {
resourceType = ResourceType.unknown;
}
}
// Filter out methods which are not allowed // Filter out methods which are not allowed
const allowedMethods = this.filterAllowedMethods(metadata); const allowedMethods = this.filterAllowedMethods(metadata, resourceType);
// Generate the Allow headers (if required) // Generate the Allow headers (if required)
const generateAllow = this.generateAllow(allowedMethods, response, metadata); const generateAllow = this.generateAllow(allowedMethods, response, metadata);
@ -43,29 +58,34 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
/** /**
* Starts from the stored set of methods and removes all those that are not allowed based on the 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> { private filterAllowedMethods(metadata: RepresentationMetadata, resourceType: ResourceType): Set<string> {
const disallowedMethods = new Set(metadata.getAll(SOLID_ERROR.terms.disallowedMethod) const disallowedMethods = new Set(metadata.getAll(SOLID_ERROR.terms.disallowedMethod)
.map((term): string => term.value)); .map((term): string => term.value));
const allowedMethods = new Set(this.supportedMethods.filter((method): boolean => !disallowedMethods.has(method))); const allowedMethods = new Set(this.supportedMethods.filter((method): boolean => !disallowedMethods.has(method)));
// POST is only allowed on containers. // POST is only allowed on containers.
// Metadata only has the resource URI in case it has resource metadata. // Metadata only has the resource URI in case it has resource metadata.
if (!this.isPostAllowed(metadata)) { if (!this.isPostAllowed(resourceType)) {
allowedMethods.delete('POST'); allowedMethods.delete('POST');
} }
if (!this.isPutAllowed(metadata)) { if (!this.isPutAllowed(metadata, resourceType)) {
allowedMethods.delete('PUT'); allowedMethods.delete('PUT');
} }
if (!this.isDeleteAllowed(metadata)) { if (!this.isPatchAllowed(resourceType)) {
allowedMethods.delete('PATCH');
}
if (!this.isDeleteAllowed(metadata, resourceType)) {
allowedMethods.delete('DELETE'); allowedMethods.delete('DELETE');
} }
// If we are sure the resource does not exist: only keep methods that can create a new resource. // 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)) { if (metadata.has(SOLID_ERROR.terms.errorResponse, NotFoundHttpError.uri)) {
for (const method of allowedMethods) { for (const method of allowedMethods) {
if (!NEW_RESOURCE_ALLOWED_METHODS.has(method)) { // Containers can only be created by PUT, documents also by PATCH
if (method !== 'PUT' && (method !== 'PATCH' || resourceType === ResourceType.container)) {
allowedMethods.delete(method); allowedMethods.delete(method);
} }
} }
@ -76,18 +96,23 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
/** /**
* POST is only allowed on containers. * 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 { private isPostAllowed(resourceType: ResourceType): boolean {
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || isContainerPath(metadata.identifier.value); return resourceType !== ResourceType.document;
} }
/** /**
* PUT is not allowed on existing containers. * PUT is not allowed on existing containers.
*/ */
private isPutAllowed(metadata: RepresentationMetadata): boolean { private isPutAllowed(metadata: RepresentationMetadata, resourceType: ResourceType): boolean {
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || !isContainerPath(metadata.identifier.value); return resourceType !== ResourceType.container || !metadata.has(RDF.terms.type, LDP.terms.Resource);
}
/**
* PATCH is not allowed on containers.
*/
private isPatchAllowed(resourceType: ResourceType): boolean {
return resourceType !== ResourceType.container;
} }
/** /**
@ -97,14 +122,14 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
* *
* Note that the identifier value check only works if the metadata is not about an error. * Note that the identifier value check only works if the metadata is not about an error.
*/ */
private isDeleteAllowed(metadata: RepresentationMetadata): boolean { private isDeleteAllowed(metadata: RepresentationMetadata, resourceType: ResourceType): boolean {
if (!isContainerPath(metadata.identifier.value)) { if (resourceType !== ResourceType.container) {
return true; return true;
} }
const isStorage = metadata.has(RDF.terms.type, PIM.terms.Storage); const isStorage = metadata.has(RDF.terms.type, PIM.terms.Storage);
const isEmpty = metadata.has(LDP.terms.contains); const isEmpty = !metadata.has(LDP.terms.contains);
return !isStorage && !isEmpty; return !isStorage && isEmpty;
} }
/** /**

View File

@ -30,6 +30,14 @@ describe('An AllowAcceptHeaderWriter', (): void => {
{ [RDF.type]: [ LDP.terms.Resource, LDP.terms.Container, PIM.terms.Storage ]}, { [RDF.type]: [ LDP.terms.Resource, LDP.terms.Container, PIM.terms.Storage ]},
); );
const error404 = new RepresentationMetadata({ [SOLID_ERROR.errorResponse]: NotFoundHttpError.uri }); const error404 = new RepresentationMetadata({ [SOLID_ERROR.errorResponse]: NotFoundHttpError.uri });
const error404Container = new RepresentationMetadata({
[SOLID_ERROR.errorResponse]: NotFoundHttpError.uri,
[SOLID_ERROR.target]: 'http://example.com/foo/',
});
const error404Document = new RepresentationMetadata({
[SOLID_ERROR.errorResponse]: NotFoundHttpError.uri,
[SOLID_ERROR.target]: 'http://example.com/foo/bar',
});
const error405 = new RepresentationMetadata( const error405 = new RepresentationMetadata(
{ [SOLID_ERROR.errorResponse]: MethodNotAllowedHttpError.uri, [SOLID_ERROR.disallowedMethod]: 'PUT' }, { [SOLID_ERROR.errorResponse]: MethodNotAllowedHttpError.uri, [SOLID_ERROR.disallowedMethod]: 'PUT' },
); );
@ -57,33 +65,36 @@ describe('An AllowAcceptHeaderWriter', (): void => {
expect(headers['accept-post']).toBeUndefined(); expect(headers['accept-post']).toBeUndefined();
}); });
it('returns all methods except PUT for an empty container.', async(): Promise<void> => { it('returns all methods except PUT/PATCH for an empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: emptyContainer })).resolves.toBeUndefined(); await expect(writer.handleSafe({ response, metadata: emptyContainer })).resolves.toBeUndefined();
const headers = response.getHeaders(); const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string'); expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', '))) expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH', 'DELETE' ])); .toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update'); expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
expect(headers['accept-post']).toBe('*/*'); expect(headers['accept-post']).toBe('*/*');
}); });
it('returns all methods except PUT/DELETE for a non-empty container.', async(): Promise<void> => { it('returns all methods except PUT/PATCH/DELETE for a non-empty container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: fullContainer })).resolves.toBeUndefined(); await expect(writer.handleSafe({ response, metadata: fullContainer })).resolves.toBeUndefined();
const headers = response.getHeaders(); const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string'); expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', '))) expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ])); .toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update'); expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
expect(headers['accept-post']).toBe('*/*'); expect(headers['accept-post']).toBe('*/*');
}); });
it('returns all methods except PUT/DELETE for a storage container.', async(): Promise<void> => { it('returns all methods except PUT/PATCH/DELETE for a storage container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: storageContainer })).resolves.toBeUndefined(); await expect(writer.handleSafe({ response, metadata: storageContainer })).resolves.toBeUndefined();
const headers = response.getHeaders(); const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string'); expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', '))) expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ])); .toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update'); expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
expect(headers['accept-post']).toBe('*/*'); expect(headers['accept-post']).toBe('*/*');
}); });
@ -98,6 +109,28 @@ describe('An AllowAcceptHeaderWriter', (): void => {
expect(headers['accept-post']).toBeUndefined(); expect(headers['accept-post']).toBeUndefined();
}); });
it('returns PUT if the target does not exist and is a document.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error404Document })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.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('returns PUT if the target does not exist and is a container.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error404Container })).resolves.toBeUndefined();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'PUT' ]));
expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBe('*/*');
expect(headers['accept-post']).toBeUndefined();
});
it('removes methods that are not allowed by a 405 error.', async(): Promise<void> => { it('removes methods that are not allowed by a 405 error.', async(): Promise<void> => {
await expect(writer.handleSafe({ response, metadata: error405 })).resolves.toBeUndefined(); await expect(writer.handleSafe({ response, metadata: error405 })).resolves.toBeUndefined();
const headers = response.getHeaders(); const headers = response.getHeaders();

View File

@ -19,11 +19,12 @@ export async function getResource(
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('text/n3, application/sparql-update');
if (isContainer) { if (isContainer) {
expect(response.headers.get('link')).toContain(`<${LDP.Container}>; rel="type"`); expect(response.headers.get('link')).toContain(`<${LDP.Container}>; rel="type"`);
expect(response.headers.get('link')).toContain(`<${LDP.BasicContainer}>; rel="type"`); expect(response.headers.get('link')).toContain(`<${LDP.BasicContainer}>; rel="type"`);
} else {
expect(response.headers.get('accept-patch')).toBe('text/n3, application/sparql-update');
} }
if (expected?.contentType) { if (expected?.contentType) {
expect(response.headers.get('content-type')).toBe(expected.contentType); expect(response.headers.get('content-type')).toBe(expected.contentType);