refactor: Split HttpHandler behaviour over multiple classes

This allows easier reuse of certain reoccurring behaviours,
such as authorization.
The AuthenticatedLdpHandler is no longer required
since it is a combination of parsing and authorization.
This did require a small change to the OperationHandler interface.
This commit is contained in:
Joachim Van Herwegen
2021-09-24 15:49:56 +02:00
parent 8f5d61911d
commit bb7e88b137
28 changed files with 483 additions and 574 deletions

View File

@@ -1,133 +0,0 @@
import { CredentialGroup } from '../../../src/authentication/Credentials';
import type { CredentialSet } from '../../../src/authentication/Credentials';
import type { AuthenticatedLdpHandlerArgs } from '../../../src/ldp/AuthenticatedLdpHandler';
import { AuthenticatedLdpHandler } from '../../../src/ldp/AuthenticatedLdpHandler';
import { OkResponseDescription } from '../../../src/ldp/http/response/OkResponseDescription';
import { ResetResponseDescription } from '../../../src/ldp/http/response/ResetResponseDescription';
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { Operation } from '../../../src/ldp/operations/Operation';
import type { PermissionSet } from '../../../src/ldp/permissions/Permissions';
import { AccessMode } from '../../../src/ldp/permissions/Permissions';
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import type { RepresentationPreferences } from '../../../src/ldp/representation/RepresentationPreferences';
import * as LogUtil from '../../../src/logging/LogUtil';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse';
describe('An AuthenticatedLdpHandler', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
const preferences: RepresentationPreferences = { type: { 'text/turtle': 0.9 }};
let operation: Operation;
const credentials: CredentialSet = {};
const modes: Set<AccessMode> = new Set([ AccessMode.read ]);
const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }};
const result: ResponseDescription = new ResetResponseDescription();
const errorResult: ResponseDescription = { statusCode: 500 };
let args: AuthenticatedLdpHandlerArgs;
let handler: AuthenticatedLdpHandler;
beforeEach(async(): Promise<void> => {
operation = { target: { path: 'identifier' }, method: 'GET', preferences };
args = {
requestParser: {
canHandle: jest.fn(),
handleSafe: jest.fn().mockResolvedValue(operation),
} as any,
credentialsExtractor: { handleSafe: jest.fn().mockResolvedValue(credentials) } as any,
modesExtractor: { handleSafe: jest.fn().mockResolvedValue(modes) } as any,
permissionReader: { handleSafe: jest.fn().mockResolvedValue(permissionSet) } as any,
authorizer: { handleSafe: jest.fn() } as any,
operationHandler: { handleSafe: jest.fn().mockResolvedValue(result) } as any,
operationMetadataCollector: { handleSafe: jest.fn() } as any,
errorHandler: { handleSafe: jest.fn().mockResolvedValue(errorResult) } as any,
responseWriter: { handleSafe: jest.fn() } as any,
};
handler = new AuthenticatedLdpHandler(args);
});
it('can be created.', async(): Promise<void> => {
expect(new AuthenticatedLdpHandler(args)).toBeInstanceOf(AuthenticatedLdpHandler);
});
it('can not handle the input if the RequestParser rejects it.', async(): Promise<void> => {
(args.requestParser.canHandle as jest.Mock).mockRejectedValueOnce(new Error('bad data!'));
await expect(handler.canHandle({ request, response })).rejects.toThrow('bad data!');
expect(args.requestParser.canHandle).toHaveBeenLastCalledWith(request);
});
it('can handle the input if the RequestParser can handle it.', async(): Promise<void> => {
await expect(handler.canHandle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.canHandle).toHaveBeenCalledTimes(1);
expect(args.requestParser.canHandle).toHaveBeenLastCalledWith(request);
});
it('can handle input.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.requestParser.handleSafe).toHaveBeenLastCalledWith(request);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenLastCalledWith(request);
expect(args.modesExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.modesExtractor.handleSafe).toHaveBeenLastCalledWith(operation);
expect(args.permissionReader.handleSafe).toHaveBeenCalledTimes(1);
expect(args.permissionReader.handleSafe).toHaveBeenLastCalledWith({ credentials, identifier: operation.target });
expect(args.authorizer.handleSafe).toHaveBeenCalledTimes(1);
expect(args.authorizer.handleSafe)
.toHaveBeenLastCalledWith({ credentials, identifier: { path: 'identifier' }, modes, permissionSet });
expect(args.operationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.operationHandler.handleSafe).toHaveBeenLastCalledWith(operation);
expect(args.operationMetadataCollector.handleSafe).toHaveBeenCalledTimes(0);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result });
});
it('calls the operation metadata collector if there is response metadata.', async(): Promise<void> => {
const metadata = new RepresentationMetadata();
const okResult = new OkResponseDescription(metadata);
(args.operationHandler.handleSafe as jest.Mock).mockResolvedValueOnce(okResult);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.operationMetadataCollector.handleSafe).toHaveBeenCalledTimes(1);
expect(args.operationMetadataCollector.handleSafe).toHaveBeenLastCalledWith({ operation, metadata });
});
it('sets preferences to text/plain in case of an error during request parsing.', async(): Promise<void> => {
const error = new Error('bad request!');
(args.requestParser.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad request!'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(0);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResult });
});
it('sets preferences to the request preferences if they were parsed before the error.', async(): Promise<void> => {
const error = new Error('bad request!');
(args.credentialsExtractor.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad request!'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.authorizer.handleSafe).toHaveBeenCalledTimes(0);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences });
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResult });
});
it('logs an error if authorization failed.', async(): Promise<void> => {
const logger = { verbose: jest.fn() };
const mock = jest.spyOn(LogUtil, 'getLoggerFor');
mock.mockReturnValueOnce(logger as any);
handler = new AuthenticatedLdpHandler(args);
(args.authorizer.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad auth!'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(logger.verbose).toHaveBeenLastCalledWith('Authorization failed: bad auth!');
mock.mockRestore();
});
});

View File

@@ -5,22 +5,25 @@ 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 store = {} as unknown as ResourceStore;
const handler = new DeleteOperationHandler(store);
beforeEach(async(): Promise<void> => {
operation = { method: 'DELETE', target: { path: 'http://test.com/foo' }, preferences: {}, conditions };
store.deleteResource = jest.fn(async(): Promise<any> => undefined);
});
it('only supports DELETE operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'DELETE' } as Operation)).resolves.toBeUndefined();
await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'GET';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }, conditions } as Operation);
const result = await handler.handle({ operation });
expect(store.deleteResource).toHaveBeenCalledTimes(1);
expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' }, conditions);
expect(store.deleteResource).toHaveBeenLastCalledWith(operation.target, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined();

View File

@@ -6,12 +6,14 @@ import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A GetOperationHandler', (): void => {
let operation: Operation;
const conditions = new BasicConditions({});
const preferences = {};
let store: ResourceStore;
let handler: GetOperationHandler;
beforeEach(async(): Promise<void> => {
operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences, conditions };
store = {
getRepresentation: jest.fn(async(): Promise<Representation> =>
({ binary: false, data: 'data', metadata: 'metadata' } as any)),
@@ -21,16 +23,17 @@ describe('A GetOperationHandler', (): void => {
});
it('only supports GET operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined();
await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'POST';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('returns the representation from the store with the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation);
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(200);
expect(result.metadata).toBe('metadata');
expect(result.data).toBe('data');
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
});

View File

@@ -7,6 +7,7 @@ import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A HeadOperationHandler', (): void => {
let operation: Operation;
const conditions = new BasicConditions({});
const preferences = {};
let store: ResourceStore;
@@ -14,6 +15,7 @@ describe('A HeadOperationHandler', (): void => {
let data: Readable;
beforeEach(async(): Promise<void> => {
operation = { method: 'HEAD', target: { path: 'http://test.com/foo' }, preferences, conditions };
data = { destroy: jest.fn() } as any;
store = {
getRepresentation: jest.fn(async(): Promise<Representation> =>
@@ -24,18 +26,20 @@ describe('A HeadOperationHandler', (): void => {
});
it('only supports HEAD operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'HEAD' } as Operation)).resolves.toBeUndefined();
await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'GET';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
operation.method = 'POST';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('returns the representation from the store with the correct response.', async(): Promise<void> => {
const result = await handler.handle({ target: { path: 'url' }, preferences, conditions } as Operation);
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(200);
expect(result.metadata).toBe('metadata');
expect(result.data).toBeUndefined();
expect(data.destroy).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, preferences, conditions);
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
});

View File

@@ -1,35 +1,41 @@
import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PatchOperationHandler } from '../../../../src/ldp/operations/PatchOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PatchOperationHandler', (): void => {
let operation: Operation;
let body: Representation;
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore;
const handler = new PatchOperationHandler(store);
beforeEach(async(): Promise<void> => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'PATCH', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
store.modifyResource = jest.fn(async(): Promise<any> => undefined);
});
it('only supports PATCH operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'PATCH' } as Operation)).resolves.toBeUndefined();
await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'GET';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('errors if there is no body or content-type.', async(): Promise<void> => {
await expect(handler.handle({ } as Operation)).rejects.toThrow(BadRequestHttpError);
await expect(handler.handle({ body: { metadata: new RepresentationMetadata() }} as Operation))
.rejects.toThrow(BadRequestHttpError);
operation.body!.metadata.contentType = undefined;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
delete operation.body;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
});
it('deletes the resource from the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation);
const result = await handler.handle({ operation });
expect(store.modifyResource).toHaveBeenCalledTimes(1);
expect(store.modifyResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(store.modifyResource).toHaveBeenLastCalledWith(operation.target, body, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined();

View File

@@ -1,5 +1,7 @@
import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PostOperationHandler } from '../../../../src/ldp/operations/PostOperationHandler';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
@@ -9,11 +11,15 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => {
let operation: Operation;
let body: Representation;
const conditions = new BasicConditions({});
let store: ResourceStore;
let handler: PostOperationHandler;
beforeEach(async(): Promise<void> => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
store = {
addResource: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier)),
} as unknown as ResourceStore;
@@ -21,28 +27,25 @@ describe('A PostOperationHandler', (): void => {
});
it('only supports POST operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'POST', body: { }} as Operation))
.resolves.toBeUndefined();
await expect(handler.canHandle({ method: 'GET', body: { }} as Operation))
.rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'GET';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('errors if there is no body or content-type.', async(): Promise<void> => {
await expect(handler.handle({ } as Operation)).rejects.toThrow(BadRequestHttpError);
await expect(handler.handle({ body: { metadata: new RepresentationMetadata() }} as Operation))
.rejects.toThrow(BadRequestHttpError);
operation.body!.metadata.contentType = undefined;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
delete operation.body;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
});
it('adds the given representation to the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle(
{ method: 'POST', target: { path: 'url' }, body: { metadata }, conditions } as Operation,
);
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(201);
expect(result.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath');
expect(result.data).toBeUndefined();
expect(store.addResource).toHaveBeenCalledTimes(1);
expect(store.addResource).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions);
});
});

View File

@@ -1,36 +1,42 @@
import type { Operation } from '../../../../src/ldp/operations/Operation';
import { PutOperationHandler } from '../../../../src/ldp/operations/PutOperationHandler';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A PutOperationHandler', (): void => {
let operation: Operation;
let body: Representation;
const conditions = new BasicConditions({});
const store = {} as unknown as ResourceStore;
const handler = new PutOperationHandler(store);
beforeEach(async(): Promise<void> => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'PUT', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
// eslint-disable-next-line @typescript-eslint/no-empty-function
store.setRepresentation = jest.fn(async(): Promise<any> => {});
});
it('only supports PUT operations.', async(): Promise<void> => {
await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ method: 'PUT' } as Operation)).resolves.toBeUndefined();
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'GET';
await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
});
it('errors if there is no body or content-type.', async(): Promise<void> => {
await expect(handler.handle({ } as Operation)).rejects.toThrow(BadRequestHttpError);
await expect(handler.handle({ body: { metadata: new RepresentationMetadata() }} as Operation))
.rejects.toThrow(BadRequestHttpError);
operation.body!.metadata.contentType = undefined;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
delete operation.body;
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
});
it('sets the representation in the store and returns the correct response.', async(): Promise<void> => {
const metadata = new RepresentationMetadata('text/turtle');
const result = await handler.handle({ target: { path: 'url' }, body: { metadata }, conditions } as Operation);
const result = await handler.handle({ operation });
expect(store.setRepresentation).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenLastCalledWith({ path: 'url' }, { metadata }, conditions);
expect(store.setRepresentation).toHaveBeenLastCalledWith(operation.target, body, conditions);
expect(result.statusCode).toBe(205);
expect(result.metadata).toBeUndefined();
expect(result.data).toBeUndefined();