From 7b7040a1969d10c5dbe30ba70499873941d1f97a Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 5 Aug 2021 10:34:41 +0200 Subject: [PATCH] feat: Use RequestParser and ResponseWriter for IDP --- config/identity/handler/default.json | 5 +- src/identity/IdentityProviderHttpHandler.ts | 102 ++++++++++-------- .../interaction/SessionHttpHandler.ts | 4 +- .../handler/ForgotPasswordHandler.ts | 4 +- .../handler/InteractionHandler.ts | 6 +- .../email-password/handler/LoginHandler.ts | 10 +- .../handler/RegistrationHandler.ts | 11 +- .../handler/ResetPasswordHandler.ts | 6 +- src/identity/interaction/util/FormDataUtil.ts | 10 +- src/server/util/TemplateHandler.ts | 17 ++- .../IdentityProviderHttpHandler.test.ts | 67 ++++++++---- .../interaction/SessionHttpHandler.test.ts | 10 +- .../handler/ForgotPasswordHandler.test.ts | 20 ++-- .../handler/LoginHandler.test.ts | 14 +-- .../handler/RegistrationHandler.test.ts | 86 +++++++-------- .../handler/ResetPasswordHandler.test.ts | 30 +++--- .../email-password/handler/Util.ts | 16 +-- .../interaction/util/FormDataUtil.test.ts | 14 ++- test/unit/server/util/TemplateHandler.test.ts | 26 +++-- 19 files changed, 264 insertions(+), 194 deletions(-) diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 215131d81..789a889bf 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -19,10 +19,13 @@ { "@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "idpPath": "/idp", + "requestParser": { "@id": "urn:solid-server:default:RequestParser" }, "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "templateHandler": { "@type": "TemplateHandler", + "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "templateEngine": { "comment": "Renders the specific page and embeds it into the main HTML body.", "@type": "ChainedTemplateEngine", @@ -35,7 +38,7 @@ { "comment": "Will embed the result of the first engine into the main HTML template.", "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs", + "template": "@css:templates/main.html.ejs" } ] } diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index dfc65da0f..76caaff14 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,5 +1,9 @@ +import urljoin from 'url-join'; import type { ErrorHandler } from '../ldp/http/ErrorHandler'; +import type { RequestParser } from '../ldp/http/RequestParser'; import type { ResponseWriter } from '../ldp/http/ResponseWriter'; +import type { Operation } from '../ldp/operations/Operation'; +import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import { getLoggerFor } from '../logging/LogUtil'; import type { HttpHandlerInput } from '../server/HttpHandler'; import { HttpHandler } from '../server/HttpHandler'; @@ -65,7 +69,8 @@ export class InteractionRoute { export class IdentityProviderHttpHandler extends HttpHandler { protected readonly logger = getLoggerFor(this); - private readonly idpPath: string; + private readonly baseUrl: string; + private readonly requestParser: RequestParser; private readonly providerFactory: ProviderFactory; private readonly interactionRoutes: InteractionRoute[]; private readonly templateHandler: TemplateHandler; @@ -74,7 +79,9 @@ export class IdentityProviderHttpHandler extends HttpHandler { private readonly responseWriter: ResponseWriter; /** + * @param baseUrl - Base URL of the server. * @param idpPath - Relative path of the IDP entry point. + * @param requestParser - Used for parsing requests. * @param providerFactory - Used to generate the OIDC provider. * @param interactionRoutes - All routes handling the custom IDP behaviour. * @param templateHandler - Used for rendering responses. @@ -83,7 +90,9 @@ export class IdentityProviderHttpHandler extends HttpHandler { * @param responseWriter - Renders error responses. */ public constructor( + baseUrl: string, idpPath: string, + requestParser: RequestParser, providerFactory: ProviderFactory, interactionRoutes: InteractionRoute[], templateHandler: TemplateHandler, @@ -92,11 +101,9 @@ export class IdentityProviderHttpHandler extends HttpHandler { responseWriter: ResponseWriter, ) { super(); - if (!idpPath.startsWith('/')) { - throw new Error('idpPath needs to start with a /'); - } // Trimming trailing slashes so the relative URL starts with a slash after slicing this off - this.idpPath = trimTrailingSlashes(idpPath); + this.baseUrl = trimTrailingSlashes(urljoin(baseUrl, idpPath)); + this.requestParser = requestParser; this.providerFactory = providerFactory; this.interactionRoutes = interactionRoutes; this.templateHandler = templateHandler; @@ -106,20 +113,24 @@ export class IdentityProviderHttpHandler extends HttpHandler { } public async handle({ request, response }: HttpHandlerInput): Promise { + let preferences: RepresentationPreferences = { type: { 'text/plain': 1 }}; try { - await this.handleRequest(request, response); + // It is important that this RequestParser does not read out the Request body stream. + // Otherwise we can't pass it anymore to the OIDC library when needed. + const operation = await this.requestParser.handleSafe(request); + ({ preferences } = operation); + await this.handleOperation(operation, request, response); } catch (error: unknown) { assertError(error); - // Setting preferences to text/plain since we didn't parse accept headers, see #764 - const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); + const result = await this.errorHandler.handleSafe({ error, preferences }); await this.responseWriter.handleSafe({ response, result }); } } /** - * Finds the matching route and resolves the request. + * Finds the matching route and resolves the operation. */ - private async handleRequest(request: HttpRequest, response: HttpResponse): Promise { + private async handleOperation(operation: Operation, request: HttpRequest, response: HttpResponse): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; try { @@ -130,25 +141,42 @@ export class IdentityProviderHttpHandler extends HttpHandler { } // If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library - const route = await this.findRoute(request, oidcInteraction); + const route = await this.findRoute(operation, oidcInteraction); if (!route) { + // Make sure the request stream still works in case the RequestParser read it const provider = await this.providerFactory.getProvider(); this.logger.debug(`Sending request to oidc-provider: ${request.url}`); return provider.callback(request, response); } - await this.resolveRoute(request, response, route, oidcInteraction); + const { result, templateFile } = await this.resolveRoute(operation, route, oidcInteraction); + if (result.type === 'complete') { + if (!oidcInteraction) { + // Once https://github.com/solid/community-server/pull/898 is merged + // we want to assign an error code here to have a more thorough explanation + throw new BadRequestHttpError( + 'This action can only be executed as part of an authentication flow. It should not be used directly.', + ); + } + // We need the original request object for the OIDC library + return await this.interactionCompleter.handleSafe({ ...result.details, request, response }); + } + if (result.type === 'response' && templateFile) { + return await this.handleTemplateResponse(response, templateFile, result.details, oidcInteraction); + } + + throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); } /** * Finds a route that supports the given request. */ - private async findRoute(request: HttpRequest, oidcInteraction?: Interaction): Promise { - if (!request.url || !request.url.startsWith(this.idpPath)) { + private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise { + if (!operation.target.path.startsWith(this.baseUrl)) { // This is either an invalid request or a call to the .well-known configuration return; } - const pathName = request.url.slice(this.idpPath.length); + const pathName = operation.target.path.slice(this.baseUrl.length); let route = this.getRouteMatch(pathName); // In case the request targets the IDP entry point the prompt determines where to go @@ -164,42 +192,32 @@ export class IdentityProviderHttpHandler extends HttpHandler { * * GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route. */ - private async resolveRoute(request: HttpRequest, response: HttpResponse, route: InteractionRoute, - oidcInteraction?: Interaction): Promise { - if (request.method === 'GET') { - return await this.handleTemplateResponse( - response, route.viewTemplate, { errorMessage: '', prefilled: {}}, oidcInteraction, - ); + private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction): + Promise<{ result: InteractionHandlerResult; templateFile?: string }> { + if (operation.method === 'GET') { + // .ejs templates errors on undefined variables + return { + result: { type: 'response', details: { errorMessage: '', prefilled: {}}}, + templateFile: route.viewTemplate, + }; } - if (request.method === 'POST') { - let result: InteractionHandlerResult; + if (operation.method === 'POST') { try { - result = await route.handler.handleSafe({ request, oidcInteraction }); + const result = await route.handler.handleSafe({ operation, oidcInteraction }); + return { result, templateFile: route.responseTemplate }; } catch (error: unknown) { // Render error in the view const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {}; const errorMessage = createErrorMessage(error); - return await this.handleTemplateResponse( - response, route.viewTemplate, { errorMessage, prefilled }, oidcInteraction, - ); - } - - if (result.type === 'complete') { - if (!oidcInteraction) { - // Once https://github.com/solid/community-server/pull/898 is merged - // we want to assign an error code here to have a more thorough explanation - throw new BadRequestHttpError( - 'This action can only be executed as part of an authentication flow. It should not be used directly.', - ); - } - return await this.interactionCompleter.handleSafe({ ...result.details, request, response }); - } - if (result.type === 'response' && route.responseTemplate) { - return await this.handleTemplateResponse(response, route.responseTemplate, result.details, oidcInteraction); + return { + result: { type: 'response', details: { errorMessage, prefilled }}, + templateFile: route.viewTemplate, + }; } } - throw new BadRequestHttpError(`Unsupported request: ${request.method} ${request.url}`); + + throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); } private async handleTemplateResponse(response: HttpResponse, templateFile: string, data?: NodeJS.Dict, diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts index d049b8bf6..366bddaa6 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/SessionHttpHandler.ts @@ -7,12 +7,12 @@ import { getFormDataRequestBody } from './util/FormDataUtil'; * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. */ export class SessionHttpHandler extends InteractionHandler { - public async handle({ request, oidcInteraction }: InteractionHandlerInput): Promise { + public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { if (!oidcInteraction?.session) { throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); } - const { remember } = await getFormDataRequestBody(request); + const { remember } = await getFormDataRequestBody(operation); return { type: 'complete', details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }, diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 211e8800f..dd1b7df68 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -39,10 +39,10 @@ export class ForgotPasswordHandler extends InteractionHandler { this.emailSender = args.emailSender; } - public async handle({ request }: InteractionHandlerInput): Promise> { + public async handle({ operation }: InteractionHandlerInput): Promise> { try { // Validate incoming data - const { email } = await getFormDataRequestBody(request); + const { email } = await getFormDataRequestBody(operation); assert(typeof email === 'string' && email.length > 0, 'Email required'); await this.resetPassword(email); diff --git a/src/identity/interaction/email-password/handler/InteractionHandler.ts b/src/identity/interaction/email-password/handler/InteractionHandler.ts index 58a7cb2c8..7615a5f34 100644 --- a/src/identity/interaction/email-password/handler/InteractionHandler.ts +++ b/src/identity/interaction/email-password/handler/InteractionHandler.ts @@ -1,5 +1,5 @@ import type { KoaContextWithOIDC } from 'oidc-provider'; -import type { HttpRequest } from '../../../../server/HttpRequest'; +import type { Operation } from '../../../../ldp/operations/Operation'; import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; @@ -8,9 +8,9 @@ export type Interaction = KoaContextWithOIDC['oidc']['entities']['Interaction']; export interface InteractionHandlerInput { /** - * The request being made. + * The operation to execute */ - request: HttpRequest; + operation: Operation; /** * Will be defined if the OIDC library expects us to resolve an interaction it can't handle itself, * such as logging a user in. diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index a5289cb09..69d95be0e 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -1,6 +1,6 @@ import assert from 'assert'; +import type { Operation } from '../../../../ldp/operations/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { HttpRequest } from '../../../../server/HttpRequest'; import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; @@ -20,8 +20,8 @@ export class LoginHandler extends InteractionHandler { this.accountStore = accountStore; } - public async handle({ request }: InteractionHandlerInput): Promise { - const { email, password, remember } = await this.parseInput(request); + public async handle({ operation }: InteractionHandlerInput): Promise { + const { email, password, remember } = await this.parseInput(operation); try { // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -40,10 +40,10 @@ export class LoginHandler extends InteractionHandler { * Will throw an {@link IdpInteractionError} in case something is wrong. * All relevant data that was correct up to that point will be prefilled. */ - private async parseInput(request: HttpRequest): Promise<{ email: string; password: string; remember: boolean }> { + private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { const prefilled: Record = {}; try { - const { email, password, remember } = await getFormDataRequestBody(request); + const { email, password, remember } = await getFormDataRequestBody(operation); assert(typeof email === 'string' && email.length > 0, 'Email required'); prefilled.email = email; assert(typeof password === 'string' && password.length > 0, 'Password required'); diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 16b78de21..fb24e0cd0 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,11 +1,11 @@ import assert from 'assert'; import urljoin from 'url-join'; +import type { Operation } from '../../../../ldp/operations/Operation'; import type { ResourceIdentifier } from '../../../../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../../../../logging/LogUtil'; import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator'; import type { PodManager } from '../../../../pods/PodManager'; import type { PodSettings } from '../../../../pods/settings/PodSettings'; -import type { HttpRequest } from '../../../../server/HttpRequest'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; @@ -102,8 +102,9 @@ export class RegistrationHandler extends InteractionHandler { this.podManager = args.podManager; } - public async handle({ request }: InteractionHandlerInput): Promise> { - const result = await this.parseInput(request); + public async handle({ operation }: InteractionHandlerInput): + Promise> { + const result = await this.parseInput(operation); try { const details = await this.register(result); @@ -184,8 +185,8 @@ export class RegistrationHandler extends InteractionHandler { /** * Parses the input request into a `ParseResult`. */ - private async parseInput(request: HttpRequest): Promise { - const parsed = await getFormDataRequestBody(request); + private async parseInput(operation: Operation): Promise { + const parsed = await getFormDataRequestBody(operation); const prefilled: Record = {}; try { for (const [ key, value ] of Object.entries(parsed)) { diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 647149d88..340b414e3 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -20,12 +20,12 @@ export class ResetPasswordHandler extends InteractionHandler { this.accountStore = accountStore; } - public async handle({ request }: InteractionHandlerInput): Promise { + public async handle({ operation }: InteractionHandlerInput): Promise { try { // Extract record ID from request URL - const recordId = /\/([^/]+)$/u.exec(request.url!)?.[1]; + const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; // Validate input data - const { password, confirmPassword } = await getFormDataRequestBody(request); + const { password, confirmPassword } = await getFormDataRequestBody(operation); assert( typeof recordId === 'string' && recordId.length > 0, 'Invalid request. Open the link from your email again', diff --git a/src/identity/interaction/util/FormDataUtil.ts b/src/identity/interaction/util/FormDataUtil.ts index 95de98e13..75869e1da 100644 --- a/src/identity/interaction/util/FormDataUtil.ts +++ b/src/identity/interaction/util/FormDataUtil.ts @@ -1,17 +1,17 @@ import type { ParsedUrlQuery } from 'querystring'; import { parse } from 'querystring'; -import type { HttpRequest } from '../../../server/HttpRequest'; +import type { Operation } from '../../../ldp/operations/Operation'; import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../../util/ContentTypes'; import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError'; import { readableToString } from '../../../util/StreamUtil'; /** - * Takes in a request and parses its body as 'application/x-www-form-urlencoded' + * Takes in an operation and parses its body as 'application/x-www-form-urlencoded' */ -export async function getFormDataRequestBody(request: HttpRequest): Promise { - if (request.headers['content-type'] !== APPLICATION_X_WWW_FORM_URLENCODED) { +export async function getFormDataRequestBody(operation: Operation): Promise { + if (operation.body?.metadata.contentType !== APPLICATION_X_WWW_FORM_URLENCODED) { throw new UnsupportedMediaTypeHttpError(); } - const body = await readableToString(request); + const body = await readableToString(operation.body.data); return parse(body); } diff --git a/src/server/util/TemplateHandler.ts b/src/server/util/TemplateHandler.ts index a4a0a2c93..e579b91f5 100644 --- a/src/server/util/TemplateHandler.ts +++ b/src/server/util/TemplateHandler.ts @@ -1,4 +1,8 @@ +import type { ResponseDescription } from '../../ldp/http/response/ResponseDescription'; +import type { ResponseWriter } from '../../ldp/http/ResponseWriter'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import { guardedStreamFrom } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import type { HttpResponse } from '../HttpResponse'; import Dict = NodeJS.Dict; @@ -8,11 +12,13 @@ import Dict = NodeJS.Dict; */ export class TemplateHandler = Dict> extends AsyncHandler<{ response: HttpResponse; templateFile: string; contents: T }> { + private readonly responseWriter: ResponseWriter; private readonly templateEngine: TemplateEngine; private readonly contentType: string; - public constructor(templateEngine: TemplateEngine, contentType = 'text/html') { + public constructor(responseWriter: ResponseWriter, templateEngine: TemplateEngine, contentType = 'text/html') { super(); + this.responseWriter = responseWriter; this.templateEngine = templateEngine; this.contentType = contentType; } @@ -20,8 +26,11 @@ export class TemplateHandler = Dict> public async handle({ response, templateFile, contents }: { response: HttpResponse; templateFile: string; contents: T }): Promise { const rendered = await this.templateEngine.render(contents, { templateFile }); - // eslint-disable-next-line @typescript-eslint/naming-convention - response.writeHead(200, { 'Content-Type': this.contentType }); - response.end(rendered); + const result: ResponseDescription = { + statusCode: 200, + data: guardedStreamFrom(rendered), + metadata: new RepresentationMetadata(this.contentType), + }; + await this.responseWriter.handleSafe({ response, result }); } } diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index a3f3be418..77e0e89ae 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,11 +1,15 @@ import type { Provider } from 'oidc-provider'; +import urljoin from 'url-join'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import { InteractionRoute, IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; import type { InteractionHandler } from '../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { IdpInteractionError } from '../../../src/identity/interaction/util/IdpInteractionError'; import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter'; import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler'; +import type { RequestParser } from '../../../src/ldp/http/RequestParser'; 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 { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { TemplateHandler } from '../../../src/server/util/TemplateHandler'; @@ -13,9 +17,11 @@ import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpErro import { InternalServerError } from '../../../src/util/errors/InternalServerError'; describe('An IdentityProviderHttpHandler', (): void => { + const baseUrl = 'http://test.com/'; const idpPath = '/idp'; let request: HttpRequest; const response: HttpResponse = {} as any; + let requestParser: jest.Mocked; let providerFactory: jest.Mocked; let routes: { response: InteractionRoute; complete: InteractionRoute }; let interactionCompleter: jest.Mocked; @@ -26,7 +32,16 @@ describe('An IdentityProviderHttpHandler', (): void => { let handler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { - request = { url: '/idp', method: 'GET' } as any; + request = { url: '/idp', method: 'GET', headers: {}} as any; + + requestParser = { + handleSafe: jest.fn(async(req: HttpRequest): Promise => ({ + target: { path: urljoin(baseUrl, req.url!) }, + method: req.method!, + body: new BasicRepresentation('', req.headers['content-type'] ?? 'text/plain'), + preferences: { type: { 'text/html': 1 }}, + })), + } as any; provider = { callback: jest.fn(), @@ -56,7 +71,9 @@ describe('An IdentityProviderHttpHandler', (): void => { responseWriter = { handleSafe: jest.fn() } as any; handler = new IdentityProviderHttpHandler( + baseUrl, idpPath, + requestParser, providerFactory, Object.values(routes), templateHandler, @@ -66,12 +83,6 @@ describe('An IdentityProviderHttpHandler', (): void => { ); }); - it('errors if the idpPath does not start with a slash.', async(): Promise => { - expect((): any => new IdentityProviderHttpHandler( - 'idp', providerFactory, [], templateHandler, interactionCompleter, errorHandler, responseWriter, - )).toThrow('idpPath needs to start with a /'); - }); - it('calls the provider if there is no matching route.', async(): Promise => { request.url = 'invalid'; await expect(handler.handle({ request, response })).resolves.toBeUndefined(); @@ -92,8 +103,9 @@ describe('An IdentityProviderHttpHandler', (): void => { request.url = '/idp/routeResponse'; request.method = 'POST'; await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request }); + expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( { response, templateFile: routes.response.responseTemplate, contents: { key: 'val', authenticating: false }}, @@ -107,8 +119,9 @@ describe('An IdentityProviderHttpHandler', (): void => { provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); (routes.response.handler as jest.Mocked).handleSafe.mockResolvedValueOnce({ type: 'response' }); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction }); + expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( { response, templateFile: routes.response.responseTemplate, contents: { authenticating: true }}, @@ -120,15 +133,16 @@ describe('An IdentityProviderHttpHandler', (): void => { request.method = 'POST'; errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 }); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request }); + expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); const error = new BadRequestHttpError( 'This action can only be executed as part of an authentication flow. It should not be used directly.', ); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}}); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 400 }}); }); @@ -139,8 +153,9 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { session: { accountId: 'account' }} as any; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction }); + expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, response, webId: 'webId' }); }); @@ -151,9 +166,10 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { prompt: { name: 'other' }}; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction }); + expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); }); it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise => { @@ -162,8 +178,9 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { prompt: { name: 'notSupported' }}; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + const operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction }); + expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0); }); @@ -203,7 +220,7 @@ describe('An IdentityProviderHttpHandler', (): void => { 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/plain': 1 }}}); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); @@ -211,11 +228,11 @@ describe('An IdentityProviderHttpHandler', (): void => { it('can only resolve GET/POST requests.', async(): Promise => { request.url = '/idp/routeResponse'; request.method = 'DELETE'; - const error = new BadRequestHttpError('Unsupported request: DELETE /idp/routeResponse'); + const error = new BadRequestHttpError('Unsupported request: DELETE http://test.com/idp/routeResponse'); 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/plain': 1 }}}); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); @@ -224,18 +241,26 @@ describe('An IdentityProviderHttpHandler', (): void => { request.url = '/idp/routeResponse'; request.method = 'POST'; (routes.response as any).responseTemplate = undefined; - const error = new BadRequestHttpError('Unsupported request: POST /idp/routeResponse'); + const error = new BadRequestHttpError('Unsupported request: POST http://test.com/idp/routeResponse'); 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/plain': 1 }}}); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); it('errors if no route is configured for the default prompt.', async(): Promise => { handler = new IdentityProviderHttpHandler( - idpPath, providerFactory, [], templateHandler, interactionCompleter, errorHandler, responseWriter, + baseUrl, + idpPath, + requestParser, + providerFactory, + [], + templateHandler, + interactionCompleter, + errorHandler, + responseWriter, ); request.url = '/idp'; provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'other' }} as any); @@ -243,7 +268,7 @@ describe('An IdentityProviderHttpHandler', (): void => { 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/plain': 1 }}}); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}}); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts index 5696b849c..6035b4c41 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/SessionHttpHandler.test.ts @@ -1,7 +1,7 @@ import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { createPostFormRequest } from './email-password/handler/Util'; +import { createPostFormOperation } from './email-password/handler/Util'; describe('A SessionHttpHandler', (): void => { const webId = 'http://test.com/id#me'; @@ -16,14 +16,14 @@ describe('A SessionHttpHandler', (): void => { it('requires a defined oidcInteraction with a session.', async(): Promise => { oidcInteraction!.session = undefined; - await expect(handler.handle({ request: {} as any, oidcInteraction })).rejects.toThrow(NotImplementedHttpError); + await expect(handler.handle({ operation: {} as any, oidcInteraction })).rejects.toThrow(NotImplementedHttpError); - await expect(handler.handle({ request: {} as any })).rejects.toThrow(NotImplementedHttpError); + await expect(handler.handle({ operation: {} as any })).rejects.toThrow(NotImplementedHttpError); }); it('returns an InteractionCompleteResult when done.', async(): Promise => { - const request = createPostFormRequest({ remember: true }); - await expect(handler.handle({ request, oidcInteraction })).resolves.toEqual({ + const operation = createPostFormOperation({ remember: true }); + await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({ details: { webId, shouldRemember: true }, type: 'complete', }); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index e9df67902..6ed0f3d55 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -3,12 +3,12 @@ import { } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; -import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; +import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; -import { createPostFormRequest } from './Util'; +import { createPostFormOperation } from './Util'; describe('A ForgotPasswordHandler', (): void => { - let request: HttpRequest; + let operation: Operation; const email = 'test@test.email'; const recordId = '123456'; const html = `Reset Password`; @@ -20,7 +20,7 @@ describe('A ForgotPasswordHandler', (): void => { let handler: ForgotPasswordHandler; beforeEach(async(): Promise => { - request = createPostFormRequest({ email }); + operation = createPostFormOperation({ email }); accountStore = { generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), @@ -44,21 +44,21 @@ describe('A ForgotPasswordHandler', (): void => { }); it('errors on non-string emails.', async(): Promise => { - request = createPostFormRequest({}); - await expect(handler.handle({ request })).rejects.toThrow('Email required'); - request = createPostFormRequest({ email: [ 'email', 'email2' ]}); - await expect(handler.handle({ request })).rejects.toThrow('Email required'); + operation = createPostFormOperation({}); + await expect(handler.handle({ operation })).rejects.toThrow('Email required'); + operation = createPostFormOperation({ email: [ 'email', 'email2' ]}); + await expect(handler.handle({ operation })).rejects.toThrow('Email required'); }); it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); - await expect(handler.handle({ request })).resolves + await expect(handler.handle({ operation })).resolves .toEqual({ type: 'response', details: { email }}); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); }); it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { - await expect(handler.handle({ request })).resolves + await expect(handler.handle({ operation })).resolves .toEqual({ type: 'response', details: { email }}); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index 91bbb6ab4..efd0fe9dd 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -3,7 +3,7 @@ import type { } from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { createPostFormRequest } from './Util'; +import { createPostFormOperation } from './Util'; describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; @@ -23,29 +23,29 @@ describe('A LoginHandler', (): void => { }); it('errors on invalid emails.', async(): Promise => { - input.request = createPostFormRequest({}); + input.operation = createPostFormOperation({}); let prom = handler.handle(input); await expect(prom).rejects.toThrow('Email required'); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {}})); - input.request = createPostFormRequest({ email: [ 'a', 'b' ]}); + input.operation = createPostFormOperation({ email: [ 'a', 'b' ]}); prom = handler.handle(input); await expect(prom).rejects.toThrow('Email required'); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }})); }); it('errors on invalid passwords.', async(): Promise => { - input.request = createPostFormRequest({ email }); + input.operation = createPostFormOperation({ email }); let prom = handler.handle(input); await expect(prom).rejects.toThrow('Password required'); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); - input.request = createPostFormRequest({ email, password: [ 'a', 'b' ]}); + input.operation = createPostFormOperation({ email, password: [ 'a', 'b' ]}); prom = handler.handle(input); await expect(prom).rejects.toThrow('Password required'); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); }); it('throws an IdpInteractionError if there is a problem.', async(): Promise => { - input.request = createPostFormRequest({ email, password: 'password!' }); + input.operation = createPostFormOperation({ email, password: 'password!' }); (storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!')); const prom = handler.handle(input); await expect(prom).rejects.toThrow('auth failed!'); @@ -53,7 +53,7 @@ describe('A LoginHandler', (): void => { }); it('returns an InteractionCompleteResult when done.', async(): Promise => { - input.request = createPostFormRequest({ email, password: 'password!' }); + input.operation = createPostFormOperation({ email, password: 'password!' }); await expect(handler.handle(input)).resolves.toEqual({ type: 'complete', details: { webId, shouldRemember: false }, diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index 71a6511d7..caf959ce2 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -5,12 +5,12 @@ import { import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import { IdpInteractionError } from '../../../../../../src/identity/interaction/util/IdpInteractionError'; import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator'; +import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { ResourceIdentifier } from '../../../../../../src/ldp/representation/ResourceIdentifier'; import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator'; import type { PodManager } from '../../../../../../src/pods/PodManager'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; -import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; -import { createPostFormRequest } from './Util'; +import { createPostFormOperation } from './Util'; describe('A RegistrationHandler', (): void => { // "Correct" values for easy object creation @@ -25,7 +25,7 @@ describe('A RegistrationHandler', (): void => { const register = 'true'; const createPod = 'true'; - let request: HttpRequest; + let operation: Operation; const baseUrl = 'http://test.com/'; const webIdSuffix = '/profile/card'; @@ -69,80 +69,80 @@ describe('A RegistrationHandler', (): void => { describe('validating data', (): void => { it('rejects array inputs.', async(): Promise => { - request = createPostFormRequest({ mydata: [ 'a', 'b' ]}); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ mydata: [ 'a', 'b' ]}); + await expect(handler.handle({ operation })) .rejects.toThrow('Unexpected multiple values for mydata.'); }); it('errors on invalid emails.', async(): Promise => { - request = createPostFormRequest({ email: undefined }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email: undefined }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); - request = createPostFormRequest({ email: '' }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email: '' }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); - request = createPostFormRequest({ email: 'invalidEmail' }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email: 'invalidEmail' }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); }); it('errors when a required WebID is not valid.', async(): Promise => { - request = createPostFormRequest({ email, register, webId: undefined }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, register, webId: undefined }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid WebID.'); - request = createPostFormRequest({ email, register, webId: '' }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, register, webId: '' }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid WebID.'); }); it('errors on invalid passwords when registering.', async(): Promise => { - request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, webId, password, confirmPassword: 'bad', register }); + await expect(handler.handle({ operation })) .rejects.toThrow('Your password and confirmation did not match.'); }); it('errors on invalid pod names when required.', async(): Promise => { - request = createPostFormRequest({ email, webId, createPod, podName: undefined }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, webId, createPod, podName: undefined }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); - request = createPostFormRequest({ email, webId, createPod, podName: ' ' }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, webId, createPod, podName: ' ' }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); - request = createPostFormRequest({ email, webId, createWebId }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, webId, createWebId }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); }); it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise => { - request = createPostFormRequest({ email, podName, createWebId }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, podName, createWebId }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); - request = createPostFormRequest({ email, podName, createWebId, createPod }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, podName, createWebId, createPod }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); - request = createPostFormRequest({ email, podName, createWebId, createPod, register }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, podName, createWebId, createPod, register }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); }); it('errors when no option is chosen.', async(): Promise => { - request = createPostFormRequest({ email, webId }); - await expect(handler.handle({ request })) + operation = createPostFormOperation({ email, webId }); + await expect(handler.handle({ operation })) .rejects.toThrow('Please register for a WebID or create a Pod.'); }); }); describe('handling data', (): void => { it('can register a user.', async(): Promise => { - request = createPostFormRequest({ email, webId, password, confirmPassword, register }); - await expect(handler.handle({ request })).resolves.toEqual({ + operation = createPostFormOperation({ email, webId, password, confirmPassword, register }); + await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, webId, @@ -168,8 +168,8 @@ describe('A RegistrationHandler', (): void => { it('can create a pod.', async(): Promise => { const params = { email, webId, podName, createPod }; - request = createPostFormRequest(params); - await expect(handler.handle({ request })).resolves.toEqual({ + operation = createPostFormOperation(params); + await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, webId, @@ -197,8 +197,8 @@ describe('A RegistrationHandler', (): void => { it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise => { const params = { email, webId, password, confirmPassword, podName, register, createPod }; podSettings.oidcIssuer = baseUrl; - request = createPostFormRequest(params); - await expect(handler.handle({ request })).resolves.toEqual({ + operation = createPostFormOperation(params); + await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, webId, @@ -228,9 +228,9 @@ describe('A RegistrationHandler', (): void => { it('deletes the created account if pod generation fails.', async(): Promise => { const params = { email, webId, password, confirmPassword, podName, register, createPod }; podSettings.oidcIssuer = baseUrl; - request = createPostFormRequest(params); + operation = createPostFormOperation(params); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - await expect(handler.handle({ request })).rejects.toThrow('pod error'); + await expect(handler.handle({ operation })).rejects.toThrow('pod error'); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); @@ -252,8 +252,8 @@ describe('A RegistrationHandler', (): void => { podSettings.webId = generatedWebID; podSettings.oidcIssuer = baseUrl; - request = createPostFormRequest(params); - await expect(handler.handle({ request })).resolves.toEqual({ + operation = createPostFormOperation(params); + await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, webId: generatedWebID, @@ -281,9 +281,9 @@ describe('A RegistrationHandler', (): void => { it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise => { const params = { email, webId, podName, createPod }; - request = createPostFormRequest(params); + operation = createPostFormOperation(params); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - const prom = handler.handle({ request }); + const prom = handler.handle({ operation }); await expect(prom).rejects.toThrow('pod error'); await expect(prom).rejects.toThrow(IdpInteractionError); // Using the cleaned input for prefilled diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index bd7204a1f..a917eac57 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -2,11 +2,11 @@ import { ResetPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; -import { createPostFormRequest } from './Util'; +import type { Operation } from '../../../../../../src/ldp/operations/Operation'; +import { createPostFormOperation } from './Util'; describe('A ResetPasswordHandler', (): void => { - let request: HttpRequest; + let operation: Operation; const recordId = '123456'; const url = `/resetURL/${recordId}`; const email = 'alice@test.email'; @@ -25,28 +25,28 @@ describe('A ResetPasswordHandler', (): void => { it('errors for non-string recordIds.', async(): Promise => { const errorMessage = 'Invalid request. Open the link from your email again'; - request = createPostFormRequest({}); - await expect(handler.handle({ request })).rejects.toThrow(errorMessage); - request = createPostFormRequest({}, ''); - await expect(handler.handle({ request })).rejects.toThrow(errorMessage); + operation = createPostFormOperation({}); + await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); + operation = createPostFormOperation({}, ''); + await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid passwords.', async(): Promise => { const errorMessage = 'Your password and confirmation did not match.'; - request = createPostFormRequest({ password: 'password!', confirmPassword: 'otherPassword!' }, url); - await expect(handler.handle({ request })).rejects.toThrow(errorMessage); + operation = createPostFormOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); + await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid emails.', async(): Promise => { const errorMessage = 'This reset password link is no longer valid.'; - request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); - await expect(handler.handle({ request })).rejects.toThrow(errorMessage); + await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('renders a message on success.', async(): Promise => { - request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); - await expect(handler.handle({ request })).resolves.toEqual({ type: 'response' }); + operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); + await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); @@ -57,8 +57,8 @@ describe('A ResetPasswordHandler', (): void => { it('has a default error for non-native errors.', async(): Promise => { const errorMessage = 'Unknown error: not native'; - request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native'); - await expect(handler.handle({ request })).rejects.toThrow(errorMessage); + await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/Util.ts b/test/unit/identity/interaction/email-password/handler/Util.ts index 73bfdc8e6..65fb4de04 100644 --- a/test/unit/identity/interaction/email-password/handler/Util.ts +++ b/test/unit/identity/interaction/email-password/handler/Util.ts @@ -1,6 +1,6 @@ import { stringify } from 'querystring'; -import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; -import { guardedStreamFrom } from '../../../../../../src/util/StreamUtil'; +import type { Operation } from '../../../../../../src/ldp/operations/Operation'; +import { BasicRepresentation } from '../../../../../../src/ldp/representation/BasicRepresentation'; /** * Creates a mock HttpRequest which is a stream of an object encoded as application/x-www-form-urlencoded @@ -8,9 +8,11 @@ import { guardedStreamFrom } from '../../../../../../src/util/StreamUtil'; * @param data - Object to encode. * @param url - URL value of the request. */ -export function createPostFormRequest(data: NodeJS.Dict, url?: string): HttpRequest { - const request = guardedStreamFrom(stringify(data)) as HttpRequest; - request.headers = { 'content-type': 'application/x-www-form-urlencoded' }; - request.url = url; - return request; +export function createPostFormOperation(data: NodeJS.Dict, url?: string): Operation { + return { + method: 'POST', + preferences: {}, + target: { path: url ?? 'http://test.com/' }, + body: new BasicRepresentation(stringify(data), 'application/x-www-form-urlencoded'), + }; } diff --git a/test/unit/identity/interaction/util/FormDataUtil.test.ts b/test/unit/identity/interaction/util/FormDataUtil.test.ts index e6957e445..11ddf04e2 100644 --- a/test/unit/identity/interaction/util/FormDataUtil.test.ts +++ b/test/unit/identity/interaction/util/FormDataUtil.test.ts @@ -2,9 +2,9 @@ import { stringify } from 'querystring'; import { getFormDataRequestBody, } from '../../../../../src/identity/interaction/util/FormDataUtil'; -import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import type { Operation } from '../../../../../src/ldp/operations/Operation'; +import { BasicRepresentation } from '../../../../../src/ldp/representation/BasicRepresentation'; import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; -import { guardedStreamFrom } from '../../../../../src/util/StreamUtil'; describe('FormDataUtil', (): void => { describe('#getFormDataRequestBody', (): void => { @@ -15,9 +15,13 @@ describe('FormDataUtil', (): void => { it('converts the body to an object.', async(): Promise => { const data = { test: 'test!', moreTest: '!TEST!' }; - const stream = guardedStreamFrom(stringify(data)) as HttpRequest; - stream.headers = { 'content-type': 'application/x-www-form-urlencoded' }; - await expect(getFormDataRequestBody(stream)).resolves.toEqual(data); + const operation: Operation = { + method: 'GET', + preferences: {}, + target: { path: '' }, + body: new BasicRepresentation(stringify(data), 'application/x-www-form-urlencoded'), + }; + await expect(getFormDataRequestBody(operation)).resolves.toEqual(data); }); }); }); diff --git a/test/unit/server/util/TemplateHandler.test.ts b/test/unit/server/util/TemplateHandler.test.ts index b920273fd..883caab31 100644 --- a/test/unit/server/util/TemplateHandler.test.ts +++ b/test/unit/server/util/TemplateHandler.test.ts @@ -1,31 +1,39 @@ -import { createResponse } from 'node-mocks-http'; -import type { HttpResponse } from '../../../../src'; +import { readableToString } from '../../../../src'; +import type { ResponseDescription, ResponseWriter, HttpResponse } from '../../../../src'; + import { TemplateHandler } from '../../../../src/server/util/TemplateHandler'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; describe('A TemplateHandler', (): void => { const contents = { contents: 'contents' }; const templateFile = '/templates/main.html.ejs'; + let responseWriter: jest.Mocked; let templateEngine: jest.Mocked; - let response: HttpResponse; + const response: HttpResponse = {} as any; beforeEach((): void => { + responseWriter = { + handleSafe: jest.fn(), + } as any; + templateEngine = { render: jest.fn().mockResolvedValue('rendered'), }; - response = createResponse() as HttpResponse; }); it('renders the template in the response.', async(): Promise => { - const handler = new TemplateHandler(templateEngine); + const handler = new TemplateHandler(responseWriter, templateEngine); await handler.handle({ response, contents, templateFile }); expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledWith(contents, { templateFile }); - expect(response.getHeaders()).toHaveProperty('content-type', 'text/html'); - expect((response as any)._isEndCalled()).toBe(true); - expect((response as any)._getData()).toBe('rendered'); - expect((response as any)._getStatusCode()).toBe(200); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const input: { response: HttpResponse; result: ResponseDescription } = responseWriter.handleSafe.mock.calls[0][0]; + + expect(input.response).toBe(response); + expect(input.result.statusCode).toBe(200); + expect(input.result.metadata?.contentType).toBe('text/html'); + await expect(readableToString(input.result.data!)).resolves.toBe('rendered'); }); });