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 { MetadataWriter } from './MetadataWriter';
// Only PUT and PATCH can be used to create a new resource
const NEW_RESOURCE_ALLOWED_METHODS = new Set([ 'PUT', 'PATCH' ]);
enum ResourceType {
document,
container,
unknown,
}
/**
* 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> {
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
const allowedMethods = this.filterAllowedMethods(metadata);
const allowedMethods = this.filterAllowedMethods(metadata, resourceType);
// Generate the Allow headers (if required)
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.
*/
private filterAllowedMethods(metadata: RepresentationMetadata): Set<string> {
private filterAllowedMethods(metadata: RepresentationMetadata, resourceType: ResourceType): 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)) {
if (!this.isPostAllowed(resourceType)) {
allowedMethods.delete('POST');
}
if (!this.isPutAllowed(metadata)) {
if (!this.isPutAllowed(metadata, resourceType)) {
allowedMethods.delete('PUT');
}
if (!this.isDeleteAllowed(metadata)) {
if (!this.isPatchAllowed(resourceType)) {
allowedMethods.delete('PATCH');
}
if (!this.isDeleteAllowed(metadata, resourceType)) {
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)) {
// Containers can only be created by PUT, documents also by PATCH
if (method !== 'PUT' && (method !== 'PATCH' || resourceType === ResourceType.container)) {
allowedMethods.delete(method);
}
}
@ -76,18 +96,23 @@ export class AllowAcceptHeaderWriter extends MetadataWriter {
/**
* 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);
private isPostAllowed(resourceType: ResourceType): boolean {
return resourceType !== ResourceType.document;
}
/**
* PUT is not allowed on existing containers.
*/
private isPutAllowed(metadata: RepresentationMetadata): boolean {
return !metadata.has(RDF.terms.type, LDP.terms.Resource) || !isContainerPath(metadata.identifier.value);
private isPutAllowed(metadata: RepresentationMetadata, resourceType: ResourceType): boolean {
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.
*/
private isDeleteAllowed(metadata: RepresentationMetadata): boolean {
if (!isContainerPath(metadata.identifier.value)) {
private isDeleteAllowed(metadata: RepresentationMetadata, resourceType: ResourceType): boolean {
if (resourceType !== ResourceType.container) {
return true;
}
const isStorage = metadata.has(RDF.terms.type, PIM.terms.Storage);
const isEmpty = metadata.has(LDP.terms.contains);
return !isStorage && !isEmpty;
const isEmpty = !metadata.has(LDP.terms.contains);
return !isStorage && isEmpty;
}
/**

View File

@ -30,6 +30,14 @@ describe('An AllowAcceptHeaderWriter', (): void => {
{ [RDF.type]: [ LDP.terms.Resource, LDP.terms.Container, PIM.terms.Storage ]},
);
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(
{ [SOLID_ERROR.errorResponse]: MethodNotAllowedHttpError.uri, [SOLID_ERROR.disallowedMethod]: 'PUT' },
);
@ -57,33 +65,36 @@ describe('An AllowAcceptHeaderWriter', (): void => {
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();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH', 'DELETE' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'DELETE' ]));
expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
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();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST' ]));
expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
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();
const headers = response.getHeaders();
expect(typeof headers.allow).toBe('string');
expect(new Set(headers.allow!.split(', ')))
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST', 'PATCH' ]));
expect(headers['accept-patch']).toBe('text/n3, application/sparql-update');
.toEqual(new Set([ 'OPTIONS', 'GET', 'HEAD', 'POST' ]));
expect(headers['accept-patch']).toBeUndefined();
expect(headers['accept-put']).toBeUndefined();
expect(headers['accept-post']).toBe('*/*');
});
@ -98,6 +109,28 @@ describe('An AllowAcceptHeaderWriter', (): void => {
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> => {
await expect(writer.handleSafe({ response, metadata: error405 })).resolves.toBeUndefined();
const headers = response.getHeaders();

View File

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