diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 281397b4d..021dc2b61 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -4,19 +4,19 @@ { "comment": "Handles all functionality on the forgot password page", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "InteractionRoute", + "@type": "BasicInteractionRoute", "route": "^/forgotpassword/?$", "viewTemplates": { - "InteractionRoute:_viewTemplates_key": "text/html", - "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" + "BasicInteractionRoute:_viewTemplates_key": "text/html", + "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" }, "responseTemplates": { - "InteractionRoute:_responseTemplates_key": "text/html", - "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" + "BasicInteractionRoute:_responseTemplates_key": "text/html", + "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" }, "controls": { - "InteractionRoute:_controls_key": "forgotPassword", - "InteractionRoute:_controls_value": "/forgotpassword" + "BasicInteractionRoute:_controls_key": "forgotPassword", + "BasicInteractionRoute:_controls_value": "/forgotpassword" }, "handler": { "@type": "ForgotPasswordHandler", diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 74a80f1fb..994e0c6cd 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -4,16 +4,16 @@ { "comment": "Handles all functionality on the Login Page", "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "InteractionRoute", + "@type": "BasicInteractionRoute", "route": "^/login/?$", "prompt": "login", "viewTemplates": { - "InteractionRoute:_viewTemplates_key": "text/html", - "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" + "BasicInteractionRoute:_viewTemplates_key": "text/html", + "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" }, "controls": { - "InteractionRoute:_controls_key": "login", - "InteractionRoute:_controls_value": "/login" + "BasicInteractionRoute:_controls_key": "login", + "BasicInteractionRoute:_controls_value": "/login" }, "handler": { "@type": "LoginHandler", diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index c047290ee..b040e5cb7 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -5,15 +5,15 @@ { "comment": "Handles the reset password page submission", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "InteractionRoute", + "@type": "BasicInteractionRoute", "route": "^/resetpassword(/[^/]*)?$", "viewTemplates": { - "InteractionRoute:_viewTemplates_key": "text/html", - "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" + "BasicInteractionRoute:_viewTemplates_key": "text/html", + "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" }, "responseTemplates": { - "InteractionRoute:_responseTemplates_key": "text/html", - "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" + "BasicInteractionRoute:_responseTemplates_key": "text/html", + "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" }, "handler": { "@type": "ResetPasswordHandler", diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json index d202d2ac9..a9c238578 100644 --- a/config/identity/handler/interaction/routes/session.json +++ b/config/identity/handler/interaction/routes/session.json @@ -4,12 +4,12 @@ { "comment": "Handles confirm requests", "@id": "urn:solid-server:auth:password:SessionRoute", - "@type": "InteractionRoute", + "@type": "BasicInteractionRoute", "route": "^/confirm/?$", "prompt": "consent", "viewTemplates": { - "InteractionRoute:_viewTemplates_key": "text/html", - "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" + "BasicInteractionRoute:_viewTemplates_key": "text/html", + "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" }, "handler": { "@type": "SessionHttpHandler" } } diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index e9446cff0..477c4dc6d 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -4,19 +4,19 @@ { "comment": "Handles all functionality on the register page", "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "InteractionRoute", + "@type": "BasicInteractionRoute", "route": "^/register/?$", "viewTemplates": { - "InteractionRoute:_viewTemplates_key": "text/html", - "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" + "BasicInteractionRoute:_viewTemplates_key": "text/html", + "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" }, "responseTemplates": { - "InteractionRoute:_responseTemplates_key": "text/html", - "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" + "BasicInteractionRoute:_responseTemplates_key": "text/html", + "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" }, "controls": { - "InteractionRoute:_controls_key": "register", - "InteractionRoute:_controls_value": "/register" + "BasicInteractionRoute:_controls_key": "register", + "BasicInteractionRoute:_controls_value": "/register" }, "handler": { "@type": "RegistrationHandler", diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index c6e2ddf8f..52094f612 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -14,59 +14,16 @@ import type { HttpResponse } from '../server/HttpResponse'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; -import { createErrorMessage } from '../util/errors/ErrorUtil'; import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; import { addTemplateMetadata } from '../util/ResourceUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { - Interaction, - InteractionHandler, - InteractionHandlerResult, - InteractionResponseResult, -} from './interaction/email-password/handler/InteractionHandler'; -import { IdpInteractionError } from './interaction/util/IdpInteractionError'; +import type { Interaction } from './interaction/email-password/handler/InteractionHandler'; +import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; // Registration is not standardized within Solid yet, so we use a custom versioned API for now const API_VERSION = '0.2'; -/** - * All the information that is required to handle a request to a custom IDP path. - */ -export class InteractionRoute { - public readonly route: RegExp; - public readonly handler: InteractionHandler; - public readonly viewTemplates: Record; - public readonly prompt?: string; - public readonly responseTemplates: Record; - public readonly controls: Record; - - /** - * @param route - Regex to match this route. - * @param viewTemplates - Templates to render on GET requests. - * Keys are content-types, values paths to a template. - * @param handler - Handler to call on POST requests. - * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. - * @param responseTemplates - Templates to render as a response to POST requests when required. - * Keys are content-types, values paths to a template. - * @param controls - Controls to add to the response JSON. - * The keys will be copied and the values will be converted to full URLs. - */ - public constructor(route: string, - viewTemplates: Record, - handler: InteractionHandler, - prompt?: string, - responseTemplates: Record = {}, - controls: Record = {}) { - this.route = new RegExp(route, 'u'); - this.viewTemplates = viewTemplates; - this.handler = handler; - this.prompt = prompt; - this.responseTemplates = responseTemplates; - this.controls = controls; - } -} - export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs { // Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73 requestParser: RequestParser; @@ -147,6 +104,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { let oidcInteraction: Interaction | undefined; try { const provider = await this.providerFactory.getProvider(); + // This being defined means we're in an OIDC session oidcInteraction = await provider.interactionDetails(request, response); } catch { // Just a regular request @@ -175,8 +133,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { operation.body = await this.converter.handleSafe(args); } - const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction); - return this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction); + const result = await route.handleOperation(operation, oidcInteraction); + return this.handleInteractionResult(operation, request, result, oidcInteraction); } /** @@ -189,57 +147,19 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { } const pathName = operation.target.path.slice(this.baseUrl.length); - // In case the request targets the IDP entry point the prompt determines where to go - const checkPrompt = oidcInteraction && trimTrailingSlashes(pathName).length === 0; - for (const route of this.interactionRoutes) { - if (checkPrompt) { - if (route.prompt === oidcInteraction!.prompt.name) { - return route; - } - } else if (route.route.test(pathName)) { + if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) { return route; } } } - /** - * Handles the behaviour of an InteractionRoute. - * Will error if the route does not support the given request. - * - * GET requests return a default response result, - * POST requests to the specific InteractionHandler of the route. - */ - private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction): - Promise<{ result: InteractionHandlerResult; templateFiles: Record }> { - if (operation.method === 'GET') { - return { result: { type: 'response' }, templateFiles: route.viewTemplates }; - } - - if (operation.method === 'POST') { - try { - const result = await route.handler.handleSafe({ operation, oidcInteraction }); - return { result, templateFiles: route.responseTemplates }; - } catch (error: unknown) { - // Render error in the view - const errorMessage = createErrorMessage(error); - const result: InteractionResponseResult = { type: 'response', details: { errorMessage }}; - if (IdpInteractionError.isInstance(error)) { - result.details!.prefilled = error.prefilled; - } - return { result, templateFiles: route.viewTemplates }; - } - } - - throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); - } - /** * Creates a ResponseDescription based on the InteractionHandlerResult. * This will either be a redirect if type is "complete" or a data stream if the type is "response". */ - private async handleInteractionResult(operation: Operation, request: HttpRequest, result: InteractionHandlerResult, - templateFiles: Record, oidcInteraction?: Interaction): Promise { + private async handleInteractionResult(operation: Operation, request: HttpRequest, + result: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise { let responseDescription: ResponseDescription | undefined; if (result.type === 'complete') { @@ -254,7 +174,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { responseDescription = new RedirectResponseDescription(location); } else { // Convert the response object to a data stream - responseDescription = await this.handleResponseResult(result, templateFiles, operation, oidcInteraction); + responseDescription = await this.handleResponseResult(result, operation, oidcInteraction); } return responseDescription; @@ -264,8 +184,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation * and applying necessary conversions. */ - private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record, - operation: Operation, oidcInteraction?: Interaction): Promise { + private async handleResponseResult(result: TemplatedInteractionResult, operation: Operation, + oidcInteraction?: Interaction): Promise { // Convert the object to a valid JSON representation const json = { apiVersion: API_VERSION, @@ -276,7 +196,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); // Template metadata is required for conversion - for (const [ type, templateFile ] of Object.entries(templateFiles)) { + for (const [ type, templateFile ] of Object.entries(result.templateFiles)) { addTemplateMetadata(representation.metadata, templateFile, type); } @@ -291,8 +211,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler { * Converts the controls object of a route to one with full URLs. */ private getRouteControls(route: InteractionRoute): Record { - return Object.fromEntries( - Object.entries(route.controls).map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]), - ); + const entries = Object.entries(route.getControls()) + .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]); + return Object.fromEntries(entries); } } diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts new file mode 100644 index 000000000..fa8180bff --- /dev/null +++ b/src/identity/interaction/routing/BasicInteractionRoute.ts @@ -0,0 +1,100 @@ +import type { Operation } from '../../../ldp/operations/Operation'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import { trimTrailingSlashes } from '../../../util/PathUtil'; +import type { + InteractionResponseResult, + InteractionHandler, + Interaction, +} from '../email-password/handler/InteractionHandler'; +import { IdpInteractionError } from '../util/IdpInteractionError'; +import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute'; + +/** + * Default implementation of the InteractionRoute. + * See function comments for specifics. + */ +export class BasicInteractionRoute implements InteractionRoute { + public readonly route: RegExp; + public readonly handler: InteractionHandler; + public readonly viewTemplates: Record; + public readonly prompt?: string; + public readonly responseTemplates: Record; + public readonly controls: Record; + + /** + * @param route - Regex to match this route. + * @param viewTemplates - Templates to render on GET requests. + * Keys are content-types, values paths to a template. + * @param handler - Handler to call on POST requests. + * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. + * @param responseTemplates - Templates to render as a response to POST requests when required. + * Keys are content-types, values paths to a template. + * @param controls - Controls to add to the response JSON. + * The keys will be copied and the values will be converted to full URLs. + */ + public constructor(route: string, + viewTemplates: Record, + handler: InteractionHandler, + prompt?: string, + responseTemplates: Record = {}, + controls: Record = {}) { + this.route = new RegExp(route, 'u'); + this.viewTemplates = viewTemplates; + this.handler = handler; + this.prompt = prompt; + this.responseTemplates = responseTemplates; + this.controls = controls; + } + + /** + * Returns the stored controls. + */ + public getControls(): Record { + return this.controls; + } + + /** + * Checks support by comparing the prompt if the path targets the base URL, + * and otherwise comparing with the stored route regular expression. + */ + public supportsPath(path: string, prompt?: string): boolean { + // In case the request targets the IDP entry point the prompt determines where to go + if (trimTrailingSlashes(path).length === 0 && prompt) { + return this.prompt === prompt; + } + return this.route.test(path); + } + + /** + * GET requests return a default response result. + * POST requests return the InteractionHandler result. + * InteractionHandler errors will be converted into response results. + * + * All results will be appended with the matching template paths. + * + * Will error for other methods + */ + public async handleOperation(operation: Operation, oidcInteraction?: Interaction): + Promise { + switch (operation.method) { + case 'GET': + return { type: 'response', templateFiles: this.viewTemplates }; + case 'POST': + try { + const result = await this.handler.handleSafe({ operation, oidcInteraction }); + return { ...result, templateFiles: this.responseTemplates }; + } catch (error: unknown) { + // Render error in the view + const errorMessage = createErrorMessage(error); + const result: InteractionResponseResult = { type: 'response', details: { errorMessage }}; + if (IdpInteractionError.isInstance(error)) { + result.details!.prefilled = error.prefilled; + } + return { ...result, templateFiles: this.viewTemplates }; + } + default: + throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); + } + } +} diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts new file mode 100644 index 000000000..f550a493d --- /dev/null +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -0,0 +1,33 @@ +import type { Operation } from '../../../ldp/operations/Operation'; +import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler'; + +export type TemplatedInteractionResult = InteractionHandlerResult & { + templateFiles: Record; +}; + +/** + * Handles the routing behaviour for IDP handlers. + */ +export interface InteractionRoute { + /** + * Returns the control fields that should be added to response objects. + * Keys are control names, values are relative URL paths. + */ + getControls: () => Record; + + /** + * If this route supports the given path. + * @param path - Relative URL path. + * @param prompt - Session prompt if there is one. + */ + supportsPath: (path: string, prompt?: string) => boolean; + + /** + * Handles the given operation. + * @param operation - Operation to handle. + * @param oidcInteraction - Interaction if there is one. + * + * @returns InteractionHandlerResult appended with relevant template files. + */ + handleOperation: (operation: Operation, oidcInteraction?: Interaction) => Promise; +} diff --git a/src/index.ts b/src/index.ts index 20d1926c5..521ed3afe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,10 @@ export * from './identity/interaction/email-password/storage/BaseAccountStore'; // Identity/Interaction/Email-Password export * from './identity/interaction/email-password/EmailPasswordUtil'; +// Identity/Interaction/Routing +export * from './identity/interaction/routing/BasicInteractionRoute'; +export * from './identity/interaction/routing/InteractionRoute'; + // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; export * from './identity/interaction/util/EmailSender'; diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index a180c6440..871cb235d 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,14 +1,8 @@ import type { Provider } from 'oidc-provider'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; -import type { - IdentityProviderHttpHandlerArgs, -} from '../../../src/identity/IdentityProviderHttpHandler'; -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 { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; +import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; +import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; 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'; @@ -24,7 +18,6 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../src/storage/conversion/RepresentationConverter'; -import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; import { joinUrl } from '../../../src/util/PathUtil'; import { readableToString } from '../../../src/util/StreamUtil'; import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; @@ -37,7 +30,7 @@ describe('An IdentityProviderHttpHandler', (): void => { const response: HttpResponse = {} as any; let requestParser: jest.Mocked; let providerFactory: jest.Mocked; - let routes: { response: InteractionRoute; complete: InteractionRoute }; + let routes: { response: jest.Mocked; complete: jest.Mocked }; let controls: Record; let interactionCompleter: jest.Mocked; let converter: jest.Mocked; @@ -69,22 +62,25 @@ describe('An IdentityProviderHttpHandler', (): void => { getProvider: jest.fn().mockResolvedValue(provider), }; - const handlers: InteractionHandler[] = [ - { handleSafe: jest.fn().mockResolvedValue({ type: 'response', details: { key: 'val' }}) } as any, - { handleSafe: jest.fn().mockResolvedValue({ type: 'complete', details: { webId: 'webId' }}) } as any, - ]; - routes = { - response: new InteractionRoute('^/routeResponse$', - { 'text/html': '/view1' }, - handlers[0], - 'login', - { 'text/html': '/response1' }, - { response: '/routeResponse' }), - complete: new InteractionRoute('^/routeComplete$', - { 'text/html': '/view2' }, - handlers[1], - 'other'), + response: { + getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }), + supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)), + handleOperation: jest.fn().mockResolvedValue({ + type: 'response', + details: { key: 'val' }, + templateFiles: { 'text/html': '/response' }, + }), + }, + complete: { + getControls: jest.fn().mockReturnValue({}), + supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)), + handleOperation: jest.fn().mockResolvedValue({ + type: 'complete', + details: { webId: 'webId' }, + templateFiles: {}, + }), + }, }; controls = { response: 'http://test.com/idp/routeResponse' }; @@ -124,27 +120,13 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(provider.callback).toHaveBeenLastCalledWith(request, response); }); - it('creates default Representations for GET requests.', async(): Promise => { - request.url = '/idp/routeResponse'; - 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); - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, authenticating: false, controls }); - expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); - }); - 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; - expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); + 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); @@ -154,7 +136,7 @@ describe('An IdentityProviderHttpHandler', (): void => { .toEqual({ apiVersion, key: 'val', authenticating: false, controls }); expect(result.statusCode).toBe(200); expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['text/html']); + expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); }); it('indicates to the templates if the request is part of an auth flow.', async(): Promise => { @@ -162,7 +144,8 @@ describe('An IdentityProviderHttpHandler', (): void => { request.method = 'POST'; const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - (routes.response.handler as jest.Mocked).handleSafe.mockResolvedValueOnce({ type: 'response' }); + routes.response.handleOperation + .mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }}); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); @@ -176,8 +159,8 @@ describe('An IdentityProviderHttpHandler', (): void => { 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.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); + 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'); @@ -198,8 +181,8 @@ describe('An IdentityProviderHttpHandler', (): void => { provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); const operation: Operation = await requestParser.handleSafe.mock.results[0].value; - expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); - expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); + expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1); + expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction); expect(operation.body?.metadata.contentType).toBe('application/json'); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); @@ -212,50 +195,6 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(args.result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); }); - it('matches paths based on prompt for requests to the root IDP.', async(): Promise => { - request.url = '/idp'; - request.method = 'POST'; - const oidcInteraction = { prompt: { name: 'other' }}; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); - await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation: 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({ operation, oidcInteraction }); - expect(operation.body?.metadata.contentType).toBe('application/json'); - }); - - it('displays a viewTemplate again in case of POST errors.', async(): Promise => { - request.url = '/idp/routeResponse'; - request.method = 'POST'; - (routes.response.handler.handleSafe as any) - .mockRejectedValueOnce(new IdpInteractionError(500, 'handle error', { name: 'name' })); - 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); - expect(JSON.parse(await readableToString(result.data!))).toEqual( - { apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false, controls }, - ); - expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); - }); - - it('defaults to an empty prefilled object in case of POST errors.', async(): Promise => { - request.url = '/idp/routeResponse'; - request.method = 'POST'; - (routes.response.handler.handleSafe as any).mockRejectedValueOnce(new Error('handle error')); - 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); - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, errorMessage: 'handle error', authenticating: false, controls }); - }); - it('calls the errorHandler if there is a problem resolving the request.', async(): Promise => { request.url = '/idp/routeResponse'; request.method = 'GET'; @@ -268,16 +207,4 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); - - it('can only resolve GET/POST requests.', async(): Promise => { - request.url = '/idp/routeResponse'; - request.method = 'DELETE'; - 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/html': 1 }}}); - expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); - expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); - }); }); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts new file mode 100644 index 000000000..8c89a3ad9 --- /dev/null +++ b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts @@ -0,0 +1,86 @@ +import type { + InteractionHandler, +} from '../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; +import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; + +describe('A BasicInteractionRoute', (): void => { + const path = '^/route$'; + const viewTemplates = { 'text/html': '/viewTemplate' }; + let handler: jest.Mocked; + const prompt = 'login'; + const responseTemplates = { 'text/html': '/responseTemplate' }; + const controls = { login: '/route' }; + const response = { type: 'response' }; + let route: BasicInteractionRoute; + + beforeEach(async(): Promise => { + handler = { + handleSafe: jest.fn().mockResolvedValue(response), + } as any; + + route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls); + }); + + it('returns its controls.', async(): Promise => { + expect(route.getControls()).toEqual(controls); + }); + + it('supports a path if it matches the stored route.', async(): Promise => { + expect(route.supportsPath('/route')).toBe(true); + expect(route.supportsPath('/notRoute')).toBe(false); + }); + + it('supports prompts when targeting the base path.', async(): Promise => { + expect(route.supportsPath('/', prompt)).toBe(true); + expect(route.supportsPath('/notRoute', prompt)).toBe(false); + expect(route.supportsPath('/', 'notPrompt')).toBe(false); + }); + + it('returns a response result on a GET request.', async(): Promise => { + await expect(route.handleOperation({ method: 'GET' } as any)) + .resolves.toEqual({ type: 'response', templateFiles: viewTemplates }); + }); + + it('returns the result of the InteractionHandler on POST requests.', async(): Promise => { + await expect(route.handleOperation({ method: 'POST' } as any)) + .resolves.toEqual({ ...response, templateFiles: responseTemplates }); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }}); + }); + + it('creates a response result in case the InteractionHandler errors.', async(): Promise => { + const error = new Error('bad data'); + handler.handleSafe.mockRejectedValueOnce(error); + await expect(route.handleOperation({ method: 'POST' } as any)) + .resolves.toEqual({ type: 'response', details: { errorMessage: 'bad data' }, templateFiles: viewTemplates }); + }); + + it('adds prefilled data in case the error is an IdpInteractionError.', async(): Promise => { + const error = new IdpInteractionError(400, 'bad data', { name: 'Alice' }); + handler.handleSafe.mockRejectedValueOnce(error); + await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ + type: 'response', + details: { errorMessage: 'bad data', prefilled: { name: 'Alice' }}, + templateFiles: viewTemplates, + }); + }); + + it('errors for non-supported operations.', async(): Promise => { + const prom = route.handleOperation({ method: 'DELETE', target: { path: '/route' }} as any); + await expect(prom).rejects.toThrow(BadRequestHttpError); + await expect(prom).rejects.toThrow('Unsupported request: DELETE /route'); + expect(handler.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('defaults to empty controls.', async(): Promise => { + route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); + expect(route.getControls()).toEqual({}); + }); + + it('defaults to empty response templates.', async(): Promise => { + route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); + await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}}); + }); +});