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

@@ -5,9 +5,7 @@ import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProvi
import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter';
import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/ldp/http/ErrorHandler';
import type { RequestParser } from '../../../src/ldp/http/RequestParser';
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { Operation } from '../../../src/ldp/operations/Operation';
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../src/ldp/representation/Representation';
@@ -27,32 +25,20 @@ describe('An IdentityProviderHttpHandler', (): void => {
const apiVersion = '0.2';
const baseUrl = 'http://test.com/';
const idpPath = '/idp';
let request: HttpRequest;
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let requestParser: jest.Mocked<RequestParser>;
let operation: Operation;
let providerFactory: jest.Mocked<ProviderFactory>;
let routes: Record<'response' | 'complete' | 'error', jest.Mocked<InteractionRoute>>;
let controls: Record<string, string>;
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let converter: jest.Mocked<RepresentationConverter>;
let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>;
let provider: jest.Mocked<Provider>;
let handler: IdentityProviderHttpHandler;
beforeEach(async(): Promise<void> => {
request = { url: '/idp', method: 'GET', headers: {}} as any;
requestParser = {
handleSafe: jest.fn(async(req: HttpRequest): Promise<Operation> => ({
target: { path: joinUrl(baseUrl, req.url!) },
method: req.method!,
body: req.method === 'GET' ?
undefined :
new BasicRepresentation('{}', req.headers['content-type'] ?? 'text/plain'),
preferences: { type: { 'text/html': 1 }},
})),
} as any;
operation = { method: 'GET', target: { path: 'http://test.com/idp' }, preferences: { type: { 'text/html': 1 }}};
provider = {
callback: jest.fn(),
@@ -110,41 +96,35 @@ describe('An IdentityProviderHttpHandler', (): void => {
data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`),
})) } as any;
responseWriter = { handleSafe: jest.fn() } as any;
const args: IdentityProviderHttpHandlerArgs = {
baseUrl,
idpPath,
requestParser,
providerFactory,
interactionRoutes: Object.values(routes),
converter,
interactionCompleter,
errorHandler,
responseWriter,
};
handler = new IdentityProviderHttpHandler(args);
});
it('calls the provider if there is no matching route.', async(): Promise<void> => {
request.url = 'invalid';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
operation.target.path = joinUrl(baseUrl, 'invalid');
await expect(handler.handle({ request, response, operation })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(1);
expect(provider.callback).toHaveBeenLastCalledWith(request, response);
});
it('creates Representations for InteractionResponseResults.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
operation.target.path = joinUrl(baseUrl, '/idp/routeResponse');
operation.method = 'POST';
operation.body = new BasicRepresentation('value', 'text/plain');
const result = (await handler.handle({ request, response, operation }))!;
expect(result).toBeDefined();
expect(routes.response.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(operation.body?.metadata.contentType).toBe('application/json');
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, key: 'val', authenticating: false, controls });
expect(result.statusCode).toBe(200);
@@ -153,20 +133,15 @@ describe('An IdentityProviderHttpHandler', (): void => {
});
it('creates Representations for InteractionErrorResults.', async(): Promise<void> => {
requestParser.handleSafe.mockResolvedValueOnce({
target: { path: joinUrl(baseUrl, '/idp/routeError') },
method: 'POST',
preferences: { type: { 'text/html': 1 }},
});
operation.target.path = joinUrl(baseUrl, '/idp/routeError');
operation.method = 'POST';
operation.preferences = { type: { 'text/html': 1 }};
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
const result = (await handler.handle({ request, response, operation }))!;
expect(result).toBeDefined();
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls });
expect(result.statusCode).toBe(400);
@@ -175,22 +150,17 @@ describe('An IdentityProviderHttpHandler', (): void => {
});
it('adds a prefilled field in case error requests had a body.', async(): Promise<void> => {
requestParser.handleSafe.mockResolvedValueOnce({
target: { path: joinUrl(baseUrl, '/idp/routeError') },
method: 'POST',
body: new BasicRepresentation('{ "key": "val" }', 'application/json'),
preferences: { type: { 'text/html': 1 }},
});
operation.target.path = joinUrl(baseUrl, '/idp/routeError');
operation.method = 'POST';
operation.preferences = { type: { 'text/html': 1 }};
operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json');
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
const result = (await handler.handle({ request, response, operation }))!;
expect(result).toBeDefined();
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(operation.body?.metadata.contentType).toBe('application/json');
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))).toEqual(
{ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }},
);
@@ -200,47 +170,41 @@ describe('An IdentityProviderHttpHandler', (): void => {
});
it('indicates to the templates if the request is part of an auth flow.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
operation.target.path = joinUrl(baseUrl, '/idp/routeResponse');
operation.method = 'POST';
const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any;
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
routes.response.handleOperation
.mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }});
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { result } = responseWriter.handleSafe.mock.calls[0][0];
const result = (await handler.handle({ request, response, operation }))!;
expect(result).toBeDefined();
expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls });
});
it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise<void> => {
request.url = '/idp/routeComplete';
request.method = 'POST';
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0);
expect(operation.body?.metadata.contentType).toBe('application/json');
operation.target.path = joinUrl(baseUrl, '/idp/routeComplete');
operation.method = 'POST';
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 400 }});
await expect(handler.handle({ request, response, operation })).rejects.toThrow(error);
expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the interactionCompleter for InteractionCompleteResults and redirects.', async(): Promise<void> => {
request.url = '/idp/routeComplete';
request.method = 'POST';
operation.target.path = joinUrl(baseUrl, '/idp/routeComplete');
operation.method = 'POST';
operation.body = new BasicRepresentation('value', 'text/plain');
const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any;
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
const result = (await handler.handle({ request, response, operation }))!;
expect(result).toBeDefined();
expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction);
expect(operation.body?.metadata.contentType).toBe('application/json');
@@ -248,23 +212,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' });
const location = await interactionCompleter.handleSafe.mock.results[0].value;
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const args = responseWriter.handleSafe.mock.calls[0][0];
expect(args.response).toBe(response);
expect(args.result.statusCode).toBe(302);
expect(args.result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location);
});
it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'GET';
const error = new Error('bad template');
converter.handleSafe.mockRejectedValueOnce(error);
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
expect(result.statusCode).toBe(302);
expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location);
});
});

View File

@@ -4,9 +4,7 @@ import type { Initializer } from '../../../../src/init/Initializer';
import type { SetupInput } from '../../../../src/init/setup/SetupHttpHandler';
import { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler';
import type { ErrorHandlerArgs, ErrorHandler } from '../../../../src/ldp/http/ErrorHandler';
import type { RequestParser } from '../../../../src/ldp/http/RequestParser';
import type { ResponseDescription } from '../../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../../src/ldp/http/ResponseWriter';
import type { Operation } from '../../../../src/ldp/operations/Operation';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
@@ -22,22 +20,18 @@ import type { HttpError } from '../../../../src/util/errors/HttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { joinUrl } from '../../../../src/util/PathUtil';
import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies';
describe('A SetupHttpHandler', (): void => {
const baseUrl = 'http://test.com/';
let request: HttpRequest;
let requestBody: SetupInput;
const response: HttpResponse = {} as any;
let operation: Operation;
const viewTemplate = '/templates/view';
const responseTemplate = '/templates/response';
const storageKey = 'completed';
let details: RegistrationResponse;
let requestParser: jest.Mocked<RequestParser>;
let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>;
let registrationManager: jest.Mocked<RegistrationManager>;
let initializer: jest.Mocked<Initializer>;
let converter: jest.Mocked<RepresentationConverter>;
@@ -45,27 +39,17 @@ describe('A SetupHttpHandler', (): void => {
let handler: SetupHttpHandler;
beforeEach(async(): Promise<void> => {
request = { url: '/setup', method: 'GET', headers: {}} as any;
requestBody = {};
requestParser = {
handleSafe: jest.fn(async(req: HttpRequest): Promise<Operation> => ({
target: { path: joinUrl(baseUrl, req.url!) },
method: req.method!,
body: req.method === 'GET' ?
undefined :
new BasicRepresentation(JSON.stringify(requestBody), req.headers['content-type'] ?? 'text/plain'),
preferences: { type: { 'text/html': 1 }},
})),
} as any;
operation = {
method: 'GET',
target: { path: 'http://test.com/setup' },
preferences: { type: { 'text/html': 1 }},
};
errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({
statusCode: 400,
data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`),
})) } as any;
responseWriter = { handleSafe: jest.fn() } as any;
initializer = {
handleSafe: jest.fn(),
} as any;
@@ -94,9 +78,6 @@ describe('A SetupHttpHandler', (): void => {
storage = new Map<string, any>() as any;
handler = new SetupHttpHandler({
requestParser,
errorHandler,
responseWriter,
initializer,
registrationManager,
converter,
@@ -104,23 +85,25 @@ describe('A SetupHttpHandler', (): void => {
storage,
viewTemplate,
responseTemplate,
errorHandler,
});
});
// Since all tests check similar things, the test functionality is generalized in here
async function testPost(input: SetupInput, error?: HttpError): Promise<void> {
request.method = 'POST';
operation.method = 'POST';
const initialize = Boolean(input.initialize);
const registration = Boolean(input.registration);
requestBody = { initialize, registration };
const requestBody = { initialize, registration };
if (Object.keys(input).length > 0) {
operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
}
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const result = await handler.handle({ operation, request, response });
expect(result).toBeDefined();
expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0);
expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0);
expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
let expectedResult: any = { initialize, registration };
if (error) {
expectedResult = { name: error.name, message: error.message };
@@ -139,10 +122,8 @@ describe('A SetupHttpHandler', (): void => {
}
it('returns the view template on GET requests.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
const result = await handler.handle({ operation, request, response });
expect(result).toBeDefined();
expect(JSON.parse(await readableToString(result.data!))).toEqual({});
expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html');
@@ -160,11 +141,6 @@ describe('A SetupHttpHandler', (): void => {
});
it('defaults to an empty body if there is none.', async(): Promise<void> => {
requestParser.handleSafe.mockResolvedValueOnce({
target: { path: joinUrl(baseUrl, '/randomPath') },
method: 'POST',
preferences: { type: { 'text/html': 1 }},
});
await expect(testPost({})).resolves.toBeUndefined();
});
@@ -183,18 +159,18 @@ describe('A SetupHttpHandler', (): void => {
});
it('errors on non-GET/POST requests.', async(): Promise<void> => {
request.method = 'PUT';
requestBody = { initialize: true, registration: true };
operation.method = 'PUT';
const requestBody = { initialize: true, registration: true };
operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
const error = new MethodNotAllowedHttpError();
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const result = await handler.handle({ operation, request, response });
expect(result).toBeDefined();
expect(initializer.handleSafe).toHaveBeenCalledTimes(0);
expect(registrationManager.register).toHaveBeenCalledTimes(0);
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message });
expect(result.statusCode).toBe(405);
expect(result.metadata?.contentType).toBe('text/html');
@@ -206,9 +182,7 @@ describe('A SetupHttpHandler', (): void => {
it('errors when attempting registration when no RegistrationManager is defined.', async(): Promise<void> => {
handler = new SetupHttpHandler({
requestParser,
errorHandler,
responseWriter,
initializer,
converter,
storageKey,
@@ -216,8 +190,9 @@ describe('A SetupHttpHandler', (): void => {
viewTemplate,
responseTemplate,
});
request.method = 'POST';
requestBody = { initialize: false, registration: true };
operation.method = 'POST';
const requestBody = { initialize: false, registration: true };
operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
const error = new NotImplementedHttpError('This server is not configured to support registration during setup.');
await expect(testPost({ initialize: false, registration: true }, error)).resolves.toBeUndefined();
@@ -227,9 +202,7 @@ describe('A SetupHttpHandler', (): void => {
it('errors when attempting initialization when no Initializer is defined.', async(): Promise<void> => {
handler = new SetupHttpHandler({
requestParser,
errorHandler,
responseWriter,
registrationManager,
converter,
storageKey,
@@ -237,8 +210,9 @@ describe('A SetupHttpHandler', (): void => {
viewTemplate,
responseTemplate,
});
request.method = 'POST';
requestBody = { initialize: true, registration: false };
operation.method = 'POST';
const requestBody = { initialize: true, registration: false };
operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
const error = new NotImplementedHttpError('This server is not configured with a setup initializer.');
await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined();

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();

View File

@@ -0,0 +1,78 @@
import { CredentialGroup } from '../../../src/authentication/Credentials';
import type { CredentialsExtractor } from '../../../src/authentication/CredentialsExtractor';
import type { Authorizer } from '../../../src/authorization/Authorizer';
import type { PermissionReader } from '../../../src/authorization/PermissionReader';
import type { Operation } from '../../../src/ldp/operations/Operation';
import type { ModesExtractor } from '../../../src/ldp/permissions/ModesExtractor';
import { AccessMode } from '../../../src/ldp/permissions/Permissions';
import { AuthorizingHttpHandler } from '../../../src/server/AuthorizingHttpHandler';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse';
import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler';
import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError';
describe('An AuthorizingHttpHandler', (): void => {
const credentials = { [CredentialGroup.public]: {}};
const modes = new Set([ AccessMode.read ]);
const permissionSet = { [CredentialGroup.public]: { read: true }};
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
let modesExtractor: jest.Mocked<ModesExtractor>;
let permissionReader: jest.Mocked<PermissionReader>;
let authorizer: jest.Mocked<Authorizer>;
let source: jest.Mocked<OperationHttpHandler>;
let handler: AuthorizingHttpHandler;
beforeEach(async(): Promise<void> => {
operation = {
target: { path: 'http://test.com/foo' },
method: 'GET',
preferences: {},
};
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;
source = {
handleSafe: jest.fn(),
} as any;
handler = new AuthorizingHttpHandler(
{ credentialsExtractor, modesExtractor, permissionReader, authorizer, operationHandler: source },
);
});
it('goes through all the steps and calls the source.', async(): Promise<void> => {
await expect(handler.handle({ request, response, operation })).resolves.toBeUndefined();
expect(credentialsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(credentialsExtractor.handleSafe).toHaveBeenLastCalledWith(request);
expect(modesExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(modesExtractor.handleSafe).toHaveBeenLastCalledWith(operation);
expect(permissionReader.handleSafe).toHaveBeenCalledTimes(1);
expect(permissionReader.handleSafe).toHaveBeenLastCalledWith({ credentials, identifier: operation.target });
expect(authorizer.handleSafe).toHaveBeenCalledTimes(1);
expect(authorizer.handleSafe)
.toHaveBeenLastCalledWith({ credentials, identifier: operation.target, modes, permissionSet });
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ request, response, operation });
expect(operation.permissionSet).toBe(permissionSet);
});
it('errors if authorization fails.', async(): Promise<void> => {
const error = new ForbiddenHttpError();
authorizer.handleSafe.mockRejectedValueOnce(error);
await expect(handler.handle({ request, response, operation })).rejects.toThrow(error);
expect(source.handleSafe).toHaveBeenCalledTimes(0);
});
});

View File

@@ -1,65 +1,75 @@
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
import type { RequestParser } from '../../../src/ldp/http/RequestParser';
import { OkResponseDescription } from '../../../src/ldp/http/response/OkResponseDescription';
import { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { OperationMetadataCollector } from '../../../src/ldp/operations/metadata/OperationMetadataCollector';
import type { Operation } from '../../../src/ldp/operations/Operation';
import type { BaseHttpHandlerArgs } from '../../../src/server/BaseHttpHandler';
import { BaseHttpHandler } from '../../../src/server/BaseHttpHandler';
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
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';
class DummyHttpHandler extends BaseHttpHandler {
public constructor(args: BaseHttpHandlerArgs) {
super(args);
}
public async handleOperation(): Promise<ResponseDescription | undefined> {
return undefined;
}
}
describe('A BaseHttpHandler', (): void => {
describe('A ParsingHttpHandler', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
const preferences = { type: { 'text/html': 1 }};
const operation: Operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences };
const errorResponse = new ResponseDescription(400);
let requestParser: jest.Mocked<RequestParser>;
let metadataCollector: jest.Mocked<OperationMetadataCollector>;
let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>;
let handler: jest.Mocked<DummyHttpHandler>;
let source: jest.Mocked<OperationHttpHandler>;
let handler: ParsingHttpHandler;
beforeEach(async(): Promise<void> => {
requestParser = { handleSafe: jest.fn().mockResolvedValue(operation) } as any;
metadataCollector = { handleSafe: jest.fn() } as any;
errorHandler = { handleSafe: jest.fn().mockResolvedValue(errorResponse) } as any;
responseWriter = { handleSafe: jest.fn() } as any;
handler = new DummyHttpHandler({ requestParser, errorHandler, responseWriter }) as any;
handler.handleOperation = jest.fn();
source = {
handleSafe: jest.fn(),
} as any;
handler = new ParsingHttpHandler(
{ requestParser, metadataCollector, errorHandler, responseWriter, operationHandler: source },
);
});
it('calls the handleOperation function with the generated operation.', async(): Promise<void> => {
it('calls the source with the generated operation.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(handler.handleOperation).toHaveBeenCalledTimes(1);
expect(handler.handleOperation).toHaveBeenLastCalledWith(operation, request, response);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, request, response });
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the responseWriter if there is a response.', async(): Promise<void> => {
const result = new ResponseDescription(200);
handler.handleOperation.mockResolvedValueOnce(result);
source.handleSafe.mockResolvedValueOnce(result);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(handler.handleOperation).toHaveBeenCalledTimes(1);
expect(handler.handleOperation).toHaveBeenLastCalledWith(operation, request, response);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, request, response });
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(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);
source.handleSafe.mockResolvedValueOnce(okResult);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(metadataCollector.handleSafe).toHaveBeenCalledTimes(1);
expect(metadataCollector.handleSafe).toHaveBeenLastCalledWith({ operation, metadata });
});
it('calls the error handler if something goes wrong.', async(): Promise<void> => {
const error = new Error('bad data');
handler.handleOperation.mockRejectedValueOnce(error);
source.handleSafe.mockRejectedValueOnce(error);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences });