From bb7e88b1370bd86775ea08d3f76a5e267c41fba3 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 24 Sep 2021 15:49:56 +0200 Subject: [PATCH] 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. --- config/app/setup/handlers/setup.json | 21 +-- config/identity/handler/default.json | 32 +++-- config/ldp/handler/default.json | 17 ++- src/identity/IdentityProviderHttpHandler.ts | 25 ++-- src/index.ts | 7 +- src/init/setup/SetupHttpHandler.ts | 25 ++-- src/ldp/AuthenticatedLdpHandler.ts | 120 ---------------- src/ldp/operations/DeleteOperationHandler.ts | 10 +- src/ldp/operations/GetOperationHandler.ts | 10 +- src/ldp/operations/HeadOperationHandler.ts | 10 +- src/ldp/operations/OperationHandler.ts | 6 +- src/ldp/operations/PatchOperationHandler.ts | 12 +- src/ldp/operations/PostOperationHandler.ts | 12 +- src/ldp/operations/PutOperationHandler.ts | 12 +- src/server/AuthorizingHttpHandler.ts | 84 +++++++++++ src/server/OperationHttpHandler.ts | 16 +++ ...seHttpHandler.ts => ParsingHttpHandler.ts} | 48 ++++--- .../IdentityProviderHttpHandler.test.ts | 130 +++++------------ test/unit/init/setup/SetupHttpHandler.test.ts | 82 ++++------- test/unit/ldp/AuthenticatedLdpHandler.test.ts | 133 ------------------ .../operations/DeleteOperationHandler.test.ts | 11 +- .../operations/GetOperationHandler.test.ts | 11 +- .../operations/HeadOperationHandler.test.ts | 14 +- .../operations/PatchOperationHandler.test.ts | 24 ++-- .../operations/PostOperationHandler.test.ts | 27 ++-- .../operations/PutOperationHandler.test.ts | 24 ++-- .../server/AuthorizingHttpHandler.test.ts | 78 ++++++++++ ...ler.test.ts => ParsingHttpHandler.test.ts} | 56 +++++--- 28 files changed, 483 insertions(+), 574 deletions(-) delete mode 100644 src/ldp/AuthenticatedLdpHandler.ts create mode 100644 src/server/AuthorizingHttpHandler.ts create mode 100644 src/server/OperationHttpHandler.ts rename src/server/{BaseHttpHandler.ts => ParsingHttpHandler.ts} (56%) delete mode 100644 test/unit/ldp/AuthenticatedLdpHandler.test.ts create mode 100644 test/unit/server/AuthorizingHttpHandler.test.ts rename test/unit/server/{BaseHttpHandler.test.ts => ParsingHttpHandler.test.ts} (55%) diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index a1c82a25e..79743640f 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -7,17 +7,22 @@ { "comment": "Handles everything related to the first-time server setup.", "@id": "urn:solid-server:default:SetupHttpHandler", - "@type": "SetupHttpHandler", + "@type": "ParsingHttpHandler", "args_requestParser": { "@id": "urn:solid-server:default:RequestParser" }, + "args_metadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, - "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, - "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }, - "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_storageKey": "setupCompleted-1.0", - "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }, - "args_viewTemplate": "@css:templates/setup/index.html.ejs", - "args_responseTemplate": "@css:templates/setup/response.html.ejs" + "args_operationHandler": { + "@type": "SetupHttpHandler", + "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, + "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }, + "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, + "args_storageKey": "setupCompleted-1.0", + "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }, + "args_viewTemplate": "@css:templates/setup/index.html.ejs", + "args_responseTemplate": "@css:templates/setup/response.html.ejs", + "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + } }, { "comment": "Separate manager from the RegistrationHandler in case registration is disabled.", diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index b6f59b964..39bab35c0 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -15,23 +15,29 @@ "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "^/idp/.*", "^/\\.well-known/openid-configuration" ], - "args_handler": { "@id": "urn:solid-server:default:IdentityProviderHttpHandler" } + "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } }, { - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "@type": "IdentityProviderHttpHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", + "@id": "urn:solid-server:default:IdentityProviderParsingHandler", + "@type": "ParsingHttpHandler", "args_requestParser": { "@id": "urn:solid-server:default:RequestParser" }, - "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, - "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_interactionCompleter": { - "comment": "Responsible for finishing OIDC interactions.", - "@type": "InteractionCompleter", - "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } - }, + "args_metadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, - "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" } + "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, + "args_operationHandler": { + "@id": "urn:solid-server:default:IdentityProviderHttpHandler", + "@type": "IdentityProviderHttpHandler", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_idpPath": "/idp", + "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, + "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, + "args_interactionCompleter": { + "comment": "Responsible for finishing OIDC interactions.", + "@type": "InteractionCompleter", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + }, + "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + } } ] } diff --git a/config/ldp/handler/default.json b/config/ldp/handler/default.json index 968da17c1..0063ebb1f 100644 --- a/config/ldp/handler/default.json +++ b/config/ldp/handler/default.json @@ -12,16 +12,19 @@ { "comment": "The main entry point into the main Solid behaviour.", "@id": "urn:solid-server:default:LdpHandler", - "@type": "AuthenticatedLdpHandler", + "@type": "ParsingHttpHandler", "args_requestParser": { "@id": "urn:solid-server:default:RequestParser" }, - "args_credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, - "args_modesExtractor": { "@id": "urn:solid-server:default:ModesExtractor" }, - "args_permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, - "args_authorizer": { "@id": "urn:solid-server:default:Authorizer" }, - "args_operationHandler": { "@id": "urn:solid-server:default:OperationHandler" }, + "args_metadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, - "args_operationMetadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" } + "args_operationHandler": { + "@type": "AuthorizingHttpHandler", + "args_credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, + "args_modesExtractor": { "@id": "urn:solid-server:default:ModesExtractor" }, + "args_permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, + "args_authorizer": { "@id": "urn:solid-server:default:Authorizer" }, + "args_operationHandler": { "@id": "urn:solid-server:default:OperationHandler" } + } } ] } diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index c41859d0b..3fc521c01 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,16 +1,13 @@ import type { ErrorHandler } from '../ldp/http/ErrorHandler'; -import type { RequestParser } from '../ldp/http/RequestParser'; import { RedirectResponseDescription } from '../ldp/http/response/RedirectResponseDescription'; import { ResponseDescription } from '../ldp/http/response/ResponseDescription'; -import type { ResponseWriter } from '../ldp/http/ResponseWriter'; import type { Operation } from '../ldp/operations/Operation'; import { BasicRepresentation } from '../ldp/representation/BasicRepresentation'; import type { Representation } from '../ldp/representation/Representation'; import { getLoggerFor } from '../logging/LogUtil'; -import { BaseHttpHandler } from '../server/BaseHttpHandler'; -import type { BaseHttpHandlerArgs } from '../server/BaseHttpHandler'; import type { HttpRequest } from '../server/HttpRequest'; -import type { HttpResponse } from '../server/HttpResponse'; +import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; +import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; @@ -25,11 +22,7 @@ import type { InteractionCompleter } from './interaction/util/InteractionComplet // Registration is not standardized within Solid yet, so we use a custom versioned API for now const API_VERSION = '0.2'; -export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs { - // Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73 - requestParser: RequestParser; - errorHandler: ErrorHandler; - responseWriter: ResponseWriter; +export interface IdentityProviderHttpHandlerArgs { /** * Base URL of the server. */ @@ -54,6 +47,10 @@ export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs { * Used for POST requests that need to be handled by the OIDC library. */ interactionCompleter: InteractionCompleter; + /** + * Used for converting output errors. + */ + errorHandler: ErrorHandler; } /** @@ -68,7 +65,7 @@ export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs { * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. * A {@link RouterHandler} should be used to filter out other requests. */ -export class IdentityProviderHttpHandler extends BaseHttpHandler { +export class IdentityProviderHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); private readonly baseUrl: string; @@ -76,19 +73,21 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { private readonly interactionRoutes: InteractionRoute[]; private readonly converter: RepresentationConverter; private readonly interactionCompleter: InteractionCompleter; + private readonly errorHandler: ErrorHandler; private readonly controls: Record; public constructor(args: IdentityProviderHttpHandlerArgs) { // It is important that the RequestParser does not read out the Request body stream. // Otherwise we can't pass it anymore to the OIDC library when needed. - super(args); + super(); // Trimming trailing slashes so the relative URL starts with a slash after slicing this off this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath)); this.providerFactory = args.providerFactory; this.interactionRoutes = args.interactionRoutes; this.converter = args.converter; this.interactionCompleter = args.interactionCompleter; + this.errorHandler = args.errorHandler; this.controls = Object.assign( {}, @@ -99,7 +98,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { /** * Finds the matching route and resolves the operation. */ - protected async handleOperation(operation: Operation, request: HttpRequest, response: HttpResponse): + public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; diff --git a/src/index.ts b/src/index.ts index d9940540e..8d24f7425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,7 +168,6 @@ export * from './ldp/representation/RepresentationPreferences'; export * from './ldp/representation/ResourceIdentifier'; // LDP -export * from './ldp/AuthenticatedLdpHandler'; export * from './ldp/UnsecureWebSocketsProtocol'; // Logging @@ -211,14 +210,16 @@ export * from './pods/GeneratedPodManager'; export * from './pods/PodManager'; // Server -export * from './server/BaseHttpHandler'; +export * from './server/AuthorizingHttpHandler'; export * from './server/BaseHttpServerFactory'; export * from './server/HttpHandler'; export * from './server/HttpRequest'; export * from './server/HttpResponse'; export * from './server/HttpServerFactory'; -export * from './server/WebSocketServerFactory'; +export * from './server/OperationHttpHandler'; +export * from './server/ParsingHttpHandler'; export * from './server/WebSocketHandler'; +export * from './server/WebSocketServerFactory'; // Server/Middleware export * from './server/middleware/CorsHandler'; diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts index 3261eb8c4..20c6f6fae 100644 --- a/src/init/setup/SetupHttpHandler.ts +++ b/src/init/setup/SetupHttpHandler.ts @@ -1,14 +1,12 @@ import type { RegistrationParams, RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; import type { ErrorHandler } from '../../ldp/http/ErrorHandler'; -import type { RequestParser } from '../../ldp/http/RequestParser'; import { ResponseDescription } from '../../ldp/http/response/ResponseDescription'; -import type { ResponseWriter } from '../../ldp/http/ResponseWriter'; import type { Operation } from '../../ldp/operations/Operation'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import { getLoggerFor } from '../../logging/LogUtil'; -import type { BaseHttpHandlerArgs } from '../../server/BaseHttpHandler'; -import { BaseHttpHandler } from '../../server/BaseHttpHandler'; +import type { OperationHttpHandlerInput } from '../../server/OperationHttpHandler'; +import { OperationHttpHandler } from '../../server/OperationHttpHandler'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; @@ -38,12 +36,7 @@ export interface SetupInput extends Record{ registration?: boolean; } -export interface SetupHttpHandlerArgs extends BaseHttpHandlerArgs { - // BaseHttpHandler args - requestParser: RequestParser; - errorHandler: ErrorHandler; - responseWriter: ResponseWriter; - +export interface SetupHttpHandlerArgs { /** * Used for registering a pod during setup. */ @@ -73,6 +66,10 @@ export interface SetupHttpHandlerArgs extends BaseHttpHandlerArgs { * Template to show when setup was completed successfully. */ responseTemplate: string; + /** + * Used for converting output errors. + */ + errorHandler: ErrorHandler; } /** @@ -85,7 +82,7 @@ export interface SetupHttpHandlerArgs extends BaseHttpHandlerArgs { * After successfully completing a POST request this handler will disable itself and become unreachable. * All other methods will be rejected. */ -export class SetupHttpHandler extends BaseHttpHandler { +export class SetupHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); private readonly registrationManager?: RegistrationManager; @@ -95,11 +92,12 @@ export class SetupHttpHandler extends BaseHttpHandler { private readonly storage: KeyValueStorage; private readonly viewTemplate: string; private readonly responseTemplate: string; + private readonly errorHandler: ErrorHandler; private finished: boolean; public constructor(args: SetupHttpHandlerArgs) { - super(args); + super(); this.finished = false; this.registrationManager = args.registrationManager; @@ -109,9 +107,10 @@ export class SetupHttpHandler extends BaseHttpHandler { this.storage = args.storage; this.viewTemplate = args.viewTemplate; this.responseTemplate = args.responseTemplate; + this.errorHandler = args.errorHandler; } - public async handleOperation(operation: Operation): Promise { + public async handle({ operation }: OperationHttpHandlerInput): Promise { let json: Record; let template: string; let success = false; diff --git a/src/ldp/AuthenticatedLdpHandler.ts b/src/ldp/AuthenticatedLdpHandler.ts deleted file mode 100644 index abd6ff8e3..000000000 --- a/src/ldp/AuthenticatedLdpHandler.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { CredentialSet } from '../authentication/Credentials'; -import type { CredentialsExtractor } from '../authentication/CredentialsExtractor'; -import type { Authorizer } from '../authorization/Authorizer'; -import type { PermissionReader } from '../authorization/PermissionReader'; -import { BaseHttpHandler } from '../server/BaseHttpHandler'; -import type { BaseHttpHandlerArgs } from '../server/BaseHttpHandler'; -import type { HttpHandlerInput } from '../server/HttpHandler'; -import type { HttpRequest } from '../server/HttpRequest'; -import type { ErrorHandler } from './http/ErrorHandler'; -import type { RequestParser } from './http/RequestParser'; -import type { ResponseDescription } from './http/response/ResponseDescription'; -import type { ResponseWriter } from './http/ResponseWriter'; -import type { OperationMetadataCollector } from './operations/metadata/OperationMetadataCollector'; -import type { Operation } from './operations/Operation'; -import type { OperationHandler } from './operations/OperationHandler'; -import type { ModesExtractor } from './permissions/ModesExtractor'; - -export interface AuthenticatedLdpHandlerArgs extends BaseHttpHandlerArgs { - // Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73 - requestParser: RequestParser; - errorHandler: ErrorHandler; - responseWriter: ResponseWriter; - /** - * Extracts the credentials from the incoming request. - */ - credentialsExtractor: CredentialsExtractor; - /** - * Extracts the required modes from the generated Operation. - */ - modesExtractor: ModesExtractor; - /** - * Reads the permissions available for the Operation. - */ - permissionReader: PermissionReader; - /** - * Verifies if the requested operation is allowed. - */ - authorizer: Authorizer; - /** - * Executed the operation. - */ - operationHandler: OperationHandler; - /** - * Generates generic operation metadata that is required for a response. - */ - operationMetadataCollector: OperationMetadataCollector; -} - -/** - * The central manager that connects all the necessary handlers to go from an incoming request to an executed operation. - */ -export class AuthenticatedLdpHandler extends BaseHttpHandler { - private readonly credentialsExtractor: CredentialsExtractor; - private readonly modesExtractor: ModesExtractor; - private readonly permissionReader: PermissionReader; - private readonly authorizer: Authorizer; - private readonly operationHandler: OperationHandler; - private readonly operationMetadataCollector: OperationMetadataCollector; - - /** - * Creates the handler. - * @param args - The handlers required. None of them are optional. - */ - public constructor(args: AuthenticatedLdpHandlerArgs) { - super(args); - this.credentialsExtractor = args.credentialsExtractor; - this.modesExtractor = args.modesExtractor; - this.permissionReader = args.permissionReader; - this.authorizer = args.authorizer; - this.operationHandler = args.operationHandler; - this.operationMetadataCollector = args.operationMetadataCollector; - } - - /** - * Checks if the incoming request can be handled. The check is very non-restrictive and will usually be true. - * It is based on whether the incoming request can be parsed to an operation. - * @param input - Incoming request and response. Only the request will be used. - * - * @returns A promise resolving if this request can be handled, otherwise rejecting with an Error. - */ - public async canHandle(input: HttpHandlerInput): Promise { - return this.requestParser.canHandle(input.request); - } - - /** - * Handles the incoming operation and generates a response. - * This includes the following steps: - * - Extracting credentials from the request. - * - Extracting the required permissions. - * - Validating if this operation is allowed. - * - Executing the operation. - */ - protected async handleOperation(operation: Operation, request: HttpRequest): Promise { - const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request); - this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`); - - const modes = await this.modesExtractor.handleSafe(operation); - this.logger.verbose(`Required modes are read: ${[ ...modes ].join(',')}`); - - const permissionSet = await this.permissionReader.handleSafe({ credentials, identifier: operation.target }); - this.logger.verbose(`Available permissions are ${JSON.stringify(permissionSet)}`); - - try { - await this.authorizer.handleSafe({ credentials, identifier: operation.target, modes, permissionSet }); - operation.permissionSet = permissionSet; - } catch (error: unknown) { - this.logger.verbose(`Authorization failed: ${(error as any).message}`); - throw error; - } - - this.logger.verbose(`Authorization succeeded, performing operation`); - const response = await this.operationHandler.handleSafe(operation); - - if (response.metadata) { - await this.operationMetadataCollector.handleSafe({ operation, metadata: response.metadata }); - } - - return response; - } -} diff --git a/src/ldp/operations/DeleteOperationHandler.ts b/src/ldp/operations/DeleteOperationHandler.ts index 19a70d16b..8f73fdef8 100644 --- a/src/ldp/operations/DeleteOperationHandler.ts +++ b/src/ldp/operations/DeleteOperationHandler.ts @@ -2,7 +2,7 @@ import type { ResourceStore } from '../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { ResetResponseDescription } from '../http/response/ResetResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -17,14 +17,14 @@ export class DeleteOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'DELETE') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'DELETE') { throw new NotImplementedHttpError('This handler only supports DELETE operations'); } } - public async handle(input: Operation): Promise { - await this.store.deleteResource(input.target, input.conditions); + public async handle({ operation }: OperationHandlerInput): Promise { + await this.store.deleteResource(operation.target, operation.conditions); return new ResetResponseDescription(); } } diff --git a/src/ldp/operations/GetOperationHandler.ts b/src/ldp/operations/GetOperationHandler.ts index 8c9f5642d..d561aba73 100644 --- a/src/ldp/operations/GetOperationHandler.ts +++ b/src/ldp/operations/GetOperationHandler.ts @@ -2,7 +2,7 @@ import type { ResourceStore } from '../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { OkResponseDescription } from '../http/response/OkResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -17,14 +17,14 @@ export class GetOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'GET') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'GET') { throw new NotImplementedHttpError('This handler only supports GET operations'); } } - public async handle(input: Operation): Promise { - const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions); + public async handle({ operation }: OperationHandlerInput): Promise { + const body = await this.store.getRepresentation(operation.target, operation.preferences, operation.conditions); return new OkResponseDescription(body.metadata, body.data); } diff --git a/src/ldp/operations/HeadOperationHandler.ts b/src/ldp/operations/HeadOperationHandler.ts index 3933f3b84..9234a5189 100644 --- a/src/ldp/operations/HeadOperationHandler.ts +++ b/src/ldp/operations/HeadOperationHandler.ts @@ -2,7 +2,7 @@ import type { ResourceStore } from '../../storage/ResourceStore'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { OkResponseDescription } from '../http/response/OkResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -17,14 +17,14 @@ export class HeadOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'HEAD') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'HEAD') { throw new NotImplementedHttpError('This handler only supports HEAD operations'); } } - public async handle(input: Operation): Promise { - const body = await this.store.getRepresentation(input.target, input.preferences, input.conditions); + public async handle({ operation }: OperationHandlerInput): Promise { + const body = await this.store.getRepresentation(operation.target, operation.preferences, operation.conditions); // Close the Readable as we will not return it. body.data.destroy(); diff --git a/src/ldp/operations/OperationHandler.ts b/src/ldp/operations/OperationHandler.ts index 724cd3d3e..7758e7571 100644 --- a/src/ldp/operations/OperationHandler.ts +++ b/src/ldp/operations/OperationHandler.ts @@ -2,7 +2,11 @@ import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { ResponseDescription } from '../http/response/ResponseDescription'; import type { Operation } from './Operation'; +export interface OperationHandlerInput { + operation: Operation; +} + /** * Handler for a specific operation type. */ -export abstract class OperationHandler extends AsyncHandler {} +export abstract class OperationHandler extends AsyncHandler {} diff --git a/src/ldp/operations/PatchOperationHandler.ts b/src/ldp/operations/PatchOperationHandler.ts index ee80c677d..d5ef4eab5 100644 --- a/src/ldp/operations/PatchOperationHandler.ts +++ b/src/ldp/operations/PatchOperationHandler.ts @@ -5,7 +5,7 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpErr import type { Patch } from '../http/Patch'; import { ResetResponseDescription } from '../http/response/ResetResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -22,21 +22,21 @@ export class PatchOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'PATCH') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'PATCH') { throw new NotImplementedHttpError('This handler only supports PATCH operations.'); } } - public async handle(input: Operation): Promise { + public async handle({ operation }: OperationHandlerInput): Promise { // Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests // without the Content-Type header with a status code of 400." // https://solid.github.io/specification/protocol#http-server - if (!input.body?.metadata.contentType) { + if (!operation.body?.metadata.contentType) { this.logger.warn('No Content-Type header specified on PATCH request'); throw new BadRequestHttpError('No Content-Type header specified on PATCH request'); } - await this.store.modifyResource(input.target, input.body as Patch, input.conditions); + await this.store.modifyResource(operation.target, operation.body as Patch, operation.conditions); return new ResetResponseDescription(); } } diff --git a/src/ldp/operations/PostOperationHandler.ts b/src/ldp/operations/PostOperationHandler.ts index adee00a90..e77df7d54 100644 --- a/src/ldp/operations/PostOperationHandler.ts +++ b/src/ldp/operations/PostOperationHandler.ts @@ -4,7 +4,7 @@ import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { CreatedResponseDescription } from '../http/response/CreatedResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -21,21 +21,21 @@ export class PostOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'POST') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'POST') { throw new NotImplementedHttpError('This handler only supports POST operations'); } } - public async handle(input: Operation): Promise { + public async handle({ operation }: OperationHandlerInput): Promise { // Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests // without the Content-Type header with a status code of 400." // https://solid.github.io/specification/protocol#http-server - if (!input.body?.metadata.contentType) { + if (!operation.body?.metadata.contentType) { this.logger.warn('No Content-Type header specified on POST request'); throw new BadRequestHttpError('No Content-Type header specified on POST request'); } - const identifier = await this.store.addResource(input.target, input.body, input.conditions); + const identifier = await this.store.addResource(operation.target, operation.body, operation.conditions); return new CreatedResponseDescription(identifier); } } diff --git a/src/ldp/operations/PutOperationHandler.ts b/src/ldp/operations/PutOperationHandler.ts index db7da593d..a2ca7e8e3 100644 --- a/src/ldp/operations/PutOperationHandler.ts +++ b/src/ldp/operations/PutOperationHandler.ts @@ -4,7 +4,7 @@ import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { ResetResponseDescription } from '../http/response/ResetResponseDescription'; import type { ResponseDescription } from '../http/response/ResponseDescription'; -import type { Operation } from './Operation'; +import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; /** @@ -21,21 +21,21 @@ export class PutOperationHandler extends OperationHandler { this.store = store; } - public async canHandle(input: Operation): Promise { - if (input.method !== 'PUT') { + public async canHandle({ operation }: OperationHandlerInput): Promise { + if (operation.method !== 'PUT') { throw new NotImplementedHttpError('This handler only supports PUT operations'); } } - public async handle(input: Operation): Promise { + public async handle({ operation }: OperationHandlerInput): Promise { // Solid, §2.1: "A Solid server MUST reject PUT, POST and PATCH requests // without the Content-Type header with a status code of 400." // https://solid.github.io/specification/protocol#http-server - if (!input.body?.metadata.contentType) { + if (!operation.body?.metadata.contentType) { this.logger.warn('No Content-Type header specified on PUT request'); throw new BadRequestHttpError('No Content-Type header specified on PUT request'); } - await this.store.setRepresentation(input.target, input.body, input.conditions); + await this.store.setRepresentation(operation.target, operation.body, operation.conditions); return new ResetResponseDescription(); } } diff --git a/src/server/AuthorizingHttpHandler.ts b/src/server/AuthorizingHttpHandler.ts new file mode 100644 index 000000000..f0b1a2d72 --- /dev/null +++ b/src/server/AuthorizingHttpHandler.ts @@ -0,0 +1,84 @@ +import type { CredentialSet } from '../authentication/Credentials'; +import type { CredentialsExtractor } from '../authentication/CredentialsExtractor'; +import type { Authorizer } from '../authorization/Authorizer'; +import type { PermissionReader } from '../authorization/PermissionReader'; +import type { ResponseDescription } from '../ldp/http/response/ResponseDescription'; +import type { ModesExtractor } from '../ldp/permissions/ModesExtractor'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { OperationHttpHandlerInput } from './OperationHttpHandler'; +import { OperationHttpHandler } from './OperationHttpHandler'; + +export interface AuthorizingHttpHandlerArgs { + /** + * Extracts the credentials from the incoming request. + */ + credentialsExtractor: CredentialsExtractor; + /** + * Extracts the required modes from the generated Operation. + */ + modesExtractor: ModesExtractor; + /** + * Reads the permissions available for the Operation. + */ + permissionReader: PermissionReader; + /** + * Verifies if the requested operation is allowed. + */ + authorizer: Authorizer; + /** + * Handler to call if the operation is authorized. + */ + operationHandler: OperationHttpHandler; +} + +/** + * Handles all the necessary steps for an authorization. + * Errors if authorization fails, otherwise passes the parameter to the operationHandler handler. + * The following steps are executed: + * - Extracting credentials from the request. + * - Extracting the required permissions. + * - Reading the allowed permissions for the credentials. + * - Validating if this operation is allowed. + */ +export class AuthorizingHttpHandler extends OperationHttpHandler { + private readonly logger = getLoggerFor(this); + + private readonly credentialsExtractor: CredentialsExtractor; + private readonly modesExtractor: ModesExtractor; + private readonly permissionReader: PermissionReader; + private readonly authorizer: Authorizer; + private readonly operationHandler: OperationHttpHandler; + + public constructor(args: AuthorizingHttpHandlerArgs) { + super(); + this.credentialsExtractor = args.credentialsExtractor; + this.modesExtractor = args.modesExtractor; + this.permissionReader = args.permissionReader; + this.authorizer = args.authorizer; + this.operationHandler = args.operationHandler; + } + + public async handle(input: OperationHttpHandlerInput): Promise { + const { request, operation } = input; + const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request); + this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`); + + const modes = await this.modesExtractor.handleSafe(operation); + this.logger.verbose(`Required modes are read: ${[ ...modes ].join(',')}`); + + const permissionSet = await this.permissionReader.handleSafe({ credentials, identifier: operation.target }); + this.logger.verbose(`Available permissions are ${JSON.stringify(permissionSet)}`); + + try { + await this.authorizer.handleSafe({ credentials, identifier: operation.target, modes, permissionSet }); + operation.permissionSet = permissionSet; + } catch (error: unknown) { + this.logger.verbose(`Authorization failed: ${(error as any).message}`); + throw error; + } + + this.logger.verbose(`Authorization succeeded, calling source handler`); + + return this.operationHandler.handleSafe(input); + } +} diff --git a/src/server/OperationHttpHandler.ts b/src/server/OperationHttpHandler.ts new file mode 100644 index 000000000..71d05665e --- /dev/null +++ b/src/server/OperationHttpHandler.ts @@ -0,0 +1,16 @@ +import type { ResponseDescription } from '../ldp/http/response/ResponseDescription'; +import type { Operation } from '../ldp/operations/Operation'; +import { AsyncHandler } from '../util/handlers/AsyncHandler'; +import type { HttpHandlerInput } from './HttpHandler'; + +export interface OperationHttpHandlerInput extends HttpHandlerInput { + operation: Operation; +} + +/** + * An HTTP handler that makes use of an already parsed Operation. + * Can either return a ResponseDescription to be resolved by the calling class, + * or undefined if this class handles the response itself. + */ +export abstract class OperationHttpHandler + extends AsyncHandler {} diff --git a/src/server/BaseHttpHandler.ts b/src/server/ParsingHttpHandler.ts similarity index 56% rename from src/server/BaseHttpHandler.ts rename to src/server/ParsingHttpHandler.ts index 13255c725..2b1910c5e 100644 --- a/src/server/BaseHttpHandler.ts +++ b/src/server/ParsingHttpHandler.ts @@ -2,20 +2,23 @@ import type { ErrorHandler } from '../ldp/http/ErrorHandler'; import type { RequestParser } from '../ldp/http/RequestParser'; import type { ResponseDescription } from '../ldp/http/response/ResponseDescription'; import type { ResponseWriter } from '../ldp/http/ResponseWriter'; -import type { Operation } from '../ldp/operations/Operation'; +import type { OperationMetadataCollector } from '../ldp/operations/metadata/OperationMetadataCollector'; import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import { getLoggerFor } from '../logging/LogUtil'; import { assertError } from '../util/errors/ErrorUtil'; import type { HttpHandlerInput } from './HttpHandler'; import { HttpHandler } from './HttpHandler'; -import type { HttpRequest } from './HttpRequest'; -import type { HttpResponse } from './HttpResponse'; +import type { OperationHttpHandler } from './OperationHttpHandler'; -export interface BaseHttpHandlerArgs { +export interface ParsingHttpHandlerArgs { /** * Parses the incoming requests. */ requestParser: RequestParser; + /** + * Generates generic operation metadata that is required for a response. + */ + metadataCollector: OperationMetadataCollector; /** * Converts errors to a serializable format. */ @@ -24,25 +27,33 @@ export interface BaseHttpHandlerArgs { * Writes out the response of the operation. */ responseWriter: ResponseWriter; + /** + * Handler to send the operation to. + */ + operationHandler: OperationHttpHandler; } /** - * Parses requests and sends the resulting Operation to the abstract `handleOperation` function. + * Parses requests and sends the resulting Operation to wrapped operationHandler. * Errors are caught and handled by the Errorhandler. - * In case the `handleOperation` function returns a result it will be sent to the ResponseWriter. + * In case the operationHandler returns a result it will be sent to the ResponseWriter. */ -export abstract class BaseHttpHandler extends HttpHandler { - protected readonly logger = getLoggerFor(this); +export class ParsingHttpHandler extends HttpHandler { + private readonly logger = getLoggerFor(this); - protected readonly requestParser: RequestParser; - protected readonly errorHandler: ErrorHandler; - protected readonly responseWriter: ResponseWriter; + private readonly requestParser: RequestParser; + private readonly errorHandler: ErrorHandler; + private readonly responseWriter: ResponseWriter; + private readonly metadataCollector: OperationMetadataCollector; + private readonly operationHandler: OperationHttpHandler; - protected constructor(args: BaseHttpHandlerArgs) { + public constructor(args: ParsingHttpHandlerArgs) { super(); this.requestParser = args.requestParser; this.errorHandler = args.errorHandler; this.responseWriter = args.responseWriter; + this.metadataCollector = args.metadataCollector; + this.operationHandler = args.operationHandler; } public async handle({ request, response }: HttpHandlerInput): Promise { @@ -52,7 +63,12 @@ export abstract class BaseHttpHandler extends HttpHandler { try { const operation = await this.requestParser.handleSafe(request); ({ preferences } = operation); - result = await this.handleOperation(operation, request, response); + result = await this.operationHandler.handleSafe({ operation, request, response }); + + if (result?.metadata) { + await this.metadataCollector.handleSafe({ operation, metadata: result.metadata }); + } + this.logger.verbose(`Parsed ${operation.method} operation on ${operation.target.path}`); } catch (error: unknown) { assertError(error); @@ -63,10 +79,4 @@ export abstract class BaseHttpHandler extends HttpHandler { await this.responseWriter.handleSafe({ response, result }); } } - - /** - * Handles the operation. Should return a ResponseDescription if it does not handle the response itself. - */ - protected abstract handleOperation(operation: Operation, request: HttpRequest, response: HttpResponse): - Promise; } diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index e5b2020c6..dc1ff4590 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -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; + let operation: Operation; let providerFactory: jest.Mocked; let routes: Record<'response' | 'complete' | 'error', jest.Mocked>; let controls: Record; let interactionCompleter: jest.Mocked; let converter: jest.Mocked; let errorHandler: jest.Mocked; - let responseWriter: jest.Mocked; let provider: jest.Mocked; let handler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { - request = { url: '/idp', method: 'GET', headers: {}} as any; - - requestParser = { - handleSafe: jest.fn(async(req: HttpRequest): Promise => ({ - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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); }); }); diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts index 11dbdf177..0ef6ffd58 100644 --- a/test/unit/init/setup/SetupHttpHandler.test.ts +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -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; let errorHandler: jest.Mocked; - let responseWriter: jest.Mocked; let registrationManager: jest.Mocked; let initializer: jest.Mocked; let converter: jest.Mocked; @@ -45,27 +39,17 @@ describe('A SetupHttpHandler', (): void => { let handler: SetupHttpHandler; beforeEach(async(): Promise => { - request = { url: '/setup', method: 'GET', headers: {}} as any; - requestBody = {}; - - requestParser = { - handleSafe: jest.fn(async(req: HttpRequest): Promise => ({ - 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() 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 { - 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 => { - 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 => { - 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 => { - 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 => { 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 => { 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(); diff --git a/test/unit/ldp/AuthenticatedLdpHandler.test.ts b/test/unit/ldp/AuthenticatedLdpHandler.test.ts deleted file mode 100644 index d1488f3ec..000000000 --- a/test/unit/ldp/AuthenticatedLdpHandler.test.ts +++ /dev/null @@ -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 = 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 => { - 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 => { - expect(new AuthenticatedLdpHandler(args)).toBeInstanceOf(AuthenticatedLdpHandler); - }); - - it('can not handle the input if the RequestParser rejects it.', async(): Promise => { - (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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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(); - }); -}); diff --git a/test/unit/ldp/operations/DeleteOperationHandler.test.ts b/test/unit/ldp/operations/DeleteOperationHandler.test.ts index dd7cf09df..7ee99fe1c 100644 --- a/test/unit/ldp/operations/DeleteOperationHandler.test.ts +++ b/test/unit/ldp/operations/DeleteOperationHandler.test.ts @@ -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 => { + operation = { method: 'DELETE', target: { path: 'http://test.com/foo' }, preferences: {}, conditions }; store.deleteResource = jest.fn(async(): Promise => undefined); }); it('only supports DELETE operations.', async(): Promise => { - 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 => { - 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(); diff --git a/test/unit/ldp/operations/GetOperationHandler.test.ts b/test/unit/ldp/operations/GetOperationHandler.test.ts index bb74da84b..b5c7a6074 100644 --- a/test/unit/ldp/operations/GetOperationHandler.test.ts +++ b/test/unit/ldp/operations/GetOperationHandler.test.ts @@ -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 => { + operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences, conditions }; store = { getRepresentation: jest.fn(async(): Promise => ({ binary: false, data: 'data', metadata: 'metadata' } as any)), @@ -21,16 +23,17 @@ describe('A GetOperationHandler', (): void => { }); it('only supports GET operations.', async(): Promise => { - 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 => { - 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); }); }); diff --git a/test/unit/ldp/operations/HeadOperationHandler.test.ts b/test/unit/ldp/operations/HeadOperationHandler.test.ts index c8bcf8198..e30620560 100644 --- a/test/unit/ldp/operations/HeadOperationHandler.test.ts +++ b/test/unit/ldp/operations/HeadOperationHandler.test.ts @@ -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 => { + operation = { method: 'HEAD', target: { path: 'http://test.com/foo' }, preferences, conditions }; data = { destroy: jest.fn() } as any; store = { getRepresentation: jest.fn(async(): Promise => @@ -24,18 +26,20 @@ describe('A HeadOperationHandler', (): void => { }); it('only supports HEAD operations.', async(): Promise => { - 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 => { - 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); }); }); diff --git a/test/unit/ldp/operations/PatchOperationHandler.test.ts b/test/unit/ldp/operations/PatchOperationHandler.test.ts index 9050453c5..a7601f8c0 100644 --- a/test/unit/ldp/operations/PatchOperationHandler.test.ts +++ b/test/unit/ldp/operations/PatchOperationHandler.test.ts @@ -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 => { + body = new BasicRepresentation('', 'text/turtle'); + operation = { method: 'PATCH', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}}; store.modifyResource = jest.fn(async(): Promise => undefined); }); it('only supports PATCH operations.', async(): Promise => { - 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 => { - 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 => { - 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(); diff --git a/test/unit/ldp/operations/PostOperationHandler.test.ts b/test/unit/ldp/operations/PostOperationHandler.test.ts index 6c66ac760..c7e2a41c7 100644 --- a/test/unit/ldp/operations/PostOperationHandler.test.ts +++ b/test/unit/ldp/operations/PostOperationHandler.test.ts @@ -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 => { + body = new BasicRepresentation('', 'text/turtle'); + operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}}; store = { addResource: jest.fn(async(): Promise => ({ path: 'newPath' } as ResourceIdentifier)), } as unknown as ResourceStore; @@ -21,28 +27,25 @@ describe('A PostOperationHandler', (): void => { }); it('only supports POST operations.', async(): Promise => { - 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 => { - 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 => { - 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); }); }); diff --git a/test/unit/ldp/operations/PutOperationHandler.test.ts b/test/unit/ldp/operations/PutOperationHandler.test.ts index 4a7ffe369..4f1293bc4 100644 --- a/test/unit/ldp/operations/PutOperationHandler.test.ts +++ b/test/unit/ldp/operations/PutOperationHandler.test.ts @@ -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 => { + 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 => {}); }); it('only supports PUT operations.', async(): Promise => { - 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 => { - 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 => { - 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(); diff --git a/test/unit/server/AuthorizingHttpHandler.test.ts b/test/unit/server/AuthorizingHttpHandler.test.ts new file mode 100644 index 000000000..5db56cb79 --- /dev/null +++ b/test/unit/server/AuthorizingHttpHandler.test.ts @@ -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; + let modesExtractor: jest.Mocked; + let permissionReader: jest.Mocked; + let authorizer: jest.Mocked; + let source: jest.Mocked; + let handler: AuthorizingHttpHandler; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + const error = new ForbiddenHttpError(); + authorizer.handleSafe.mockRejectedValueOnce(error); + await expect(handler.handle({ request, response, operation })).rejects.toThrow(error); + expect(source.handleSafe).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/server/BaseHttpHandler.test.ts b/test/unit/server/ParsingHttpHandler.test.ts similarity index 55% rename from test/unit/server/BaseHttpHandler.test.ts rename to test/unit/server/ParsingHttpHandler.test.ts index 8e5e07a2a..1f5a3c344 100644 --- a/test/unit/server/BaseHttpHandler.test.ts +++ b/test/unit/server/ParsingHttpHandler.test.ts @@ -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 { - 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; + let metadataCollector: jest.Mocked; let errorHandler: jest.Mocked; let responseWriter: jest.Mocked; - let handler: jest.Mocked; + let source: jest.Mocked; + let handler: ParsingHttpHandler; beforeEach(async(): Promise => { 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 => { + it('calls the source with the generated operation.', async(): Promise => { 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 => { 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 => { + 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 => { 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 });