From 95777914729890debe0d4815c084029864afaf23 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 7 Dec 2021 16:03:03 +0100 Subject: [PATCH] feat: Simplify setup to be more in line with IDP behaviour --- config/app/setup/handlers/setup.json | 27 ++- src/index.ts | 1 + src/init/setup/SetupHandler.ts | 83 +++++++ src/init/setup/SetupHttpHandler.ts | 179 ++++----------- templates/setup/index.html.ejs | 105 ++++----- templates/setup/input-partial.html.ejs | 69 ++++++ templates/setup/response.html.ejs | 54 ----- test/integration/Setup.test.ts | 33 ++- test/unit/init/setup/SetupHandler.test.ts | 88 +++++++ test/unit/init/setup/SetupHttpHandler.test.ts | 214 +++++------------- 10 files changed, 420 insertions(+), 433 deletions(-) create mode 100644 src/init/setup/SetupHandler.ts create mode 100644 templates/setup/input-partial.html.ejs delete mode 100644 templates/setup/response.html.ejs create mode 100644 test/unit/init/setup/SetupHandler.test.ts diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index fd0bbd140..ea279ffd3 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -14,14 +14,31 @@ "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "args_operationHandler": { "@type": "SetupHttpHandler", - "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, - "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }, + "args_handler": { + "@type": "SetupHandler", + "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, + "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" } + }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "args_storageKey": "setupCompleted-2.0", "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }, - "args_viewTemplate": "@css:templates/setup/index.html.ejs", - "args_responseTemplate": "@css:templates/setup/response.html.ejs", - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Renders the main setup template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/setup/index.html.ejs" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + } } }, { diff --git a/src/index.ts b/src/index.ts index 4b056a28e..2d68312c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,6 +185,7 @@ export * from './init/final/Finalizable'; export * from './init/final/ParallelFinalizer'; // Init/Setup +export * from './init/setup/SetupHandler'; export * from './init/setup/SetupHttpHandler'; // Init/Cli diff --git a/src/init/setup/SetupHandler.ts b/src/init/setup/SetupHandler.ts new file mode 100644 index 000000000..9d7f798f4 --- /dev/null +++ b/src/init/setup/SetupHandler.ts @@ -0,0 +1,83 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { BaseInteractionHandler } from '../../identity/interaction/BaseInteractionHandler'; +import type { RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandlerInput } from '../../identity/interaction/InteractionHandler'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { Initializer } from '../Initializer'; + +export interface SetupHandlerArgs { + /** + * Used for registering a pod during setup. + */ + registrationManager?: RegistrationManager; + /** + * Initializer to call in case no registration procedure needs to happen. + * This Initializer should make sure the necessary resources are there so the server can work correctly. + */ + initializer?: Initializer; +} + +/** + * On POST requests, runs an initializer and/or performs a registration step, both optional. + */ +export class SetupHandler extends BaseInteractionHandler { + protected readonly logger = getLoggerFor(this); + + private readonly registrationManager?: RegistrationManager; + private readonly initializer?: Initializer; + + public constructor(args: SetupHandlerArgs) { + super({}); + this.registrationManager = args.registrationManager; + this.initializer = args.initializer; + } + + protected async handlePost({ operation }: InteractionHandlerInput): Promise { + const json = operation.body.isEmpty ? {} : await readJsonStream(operation.body.data); + + const output: Record = { initialize: false, registration: false }; + if (json.registration) { + Object.assign(output, await this.register(json)); + output.registration = true; + } else if (json.initialize) { + // We only want to initialize if no registration happened + await this.initialize(); + output.initialize = true; + } + + this.logger.debug(`Output: ${JSON.stringify(output)}`); + + return new BasicRepresentation(JSON.stringify(output), APPLICATION_JSON); + } + + /** + * Call the initializer. + * Errors if no initializer was defined. + */ + private async initialize(): Promise { + if (!this.initializer) { + throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); + } + await this.initializer.handleSafe(); + } + + /** + * Register a user based on the given input. + * Errors if no registration manager is defined. + */ + private async register(json: NodeJS.Dict): Promise> { + if (!this.registrationManager) { + throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); + } + // Validate the input JSON + const validated = this.registrationManager.validateInput(json, true); + this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); + + // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. + return this.registrationManager.register(validated, true); + } +} diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts index ffb3a1c79..ebc7730d9 100644 --- a/src/init/setup/SetupHttpHandler.ts +++ b/src/init/setup/SetupHttpHandler.ts @@ -1,55 +1,26 @@ import type { Operation } from '../../http/Operation'; -import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; -import { ResponseDescription } from '../../http/output/response/ResponseDescription'; +import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { RegistrationParams, - RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandler } from '../../identity/interaction/InteractionHandler'; import { getLoggerFor } from '../../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../../server/OperationHttpHandler'; import { OperationHttpHandler } from '../../server/OperationHttpHandler'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; -import { createErrorMessage } from '../../util/errors/ErrorUtil'; -import { HttpError } from '../../util/errors/HttpError'; -import { InternalServerError } from '../../util/errors/InternalServerError'; import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { addTemplateMetadata } from '../../util/ResourceUtil'; -import { readJsonStream } from '../../util/StreamUtil'; -import type { Initializer } from '../Initializer'; - -/** - * Input parameters expected in calls to the handler. - * Will be sent to the RegistrationManager for validation and registration. - * The reason this is a flat object and does not have a specific field for all the registration parameters - * is so we can also support form data. - */ -export interface SetupInput extends Record{ - /** - * Indicates if the initializer should be executed. Ignored if `registration` is true. - */ - initialize?: boolean; - /** - * Indicates if the registration procedure should be done for IDP registration and/or pod provisioning. - */ - registration?: boolean; -} +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; export interface SetupHttpHandlerArgs { /** - * Used for registering a pod during setup. - */ - registrationManager?: RegistrationManager; - /** - * Initializer to call in case no registration procedure needs to happen. - * This Initializer should make sure the necessary resources are there so the server can work correctly. - */ - initializer?: Initializer; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; + /** + * Handles the requests. + */ + handler: InteractionHandler; /** * Key that is used to store the boolean in the storage indicating setup is finished. */ @@ -59,17 +30,9 @@ export interface SetupHttpHandlerArgs { */ storage: KeyValueStorage; /** - * Template to use for GET requests. + * Renders the main view. */ - viewTemplate: string; - /** - * Template to show when setup was completed successfully. - */ - responseTemplate: string; - /** - * Used for converting output errors. - */ - errorHandler: ErrorHandler; + templateEngine: TemplateEngine; } /** @@ -78,128 +41,68 @@ export interface SetupHttpHandlerArgs { * this to prevent accidentally running unsafe servers. * * GET requests will return the view template which should contain the setup information for the user. - * POST requests will run an initializer and/or perform a registration step, both optional. + * POST requests will be sent to the InteractionHandler. * After successfully completing a POST request this handler will disable itself and become unreachable. * All other methods will be rejected. */ export class SetupHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly registrationManager?: RegistrationManager; - private readonly initializer?: Initializer; + private readonly handler: InteractionHandler; private readonly converter: RepresentationConverter; private readonly storageKey: string; private readonly storage: KeyValueStorage; - private readonly viewTemplate: string; - private readonly responseTemplate: string; - private readonly errorHandler: ErrorHandler; - - private finished: boolean; + private readonly templateEngine: TemplateEngine; public constructor(args: SetupHttpHandlerArgs) { super(); - this.finished = false; - this.registrationManager = args.registrationManager; - this.initializer = args.initializer; + this.handler = args.handler; this.converter = args.converter; this.storageKey = args.storageKey; this.storage = args.storage; - this.viewTemplate = args.viewTemplate; - this.responseTemplate = args.responseTemplate; - this.errorHandler = args.errorHandler; + this.templateEngine = args.templateEngine; } public async handle({ operation }: OperationHttpHandlerInput): Promise { - let json: Record; - let template: string; - let success = false; - let statusCode = 200; - try { - ({ json, template } = await this.getJsonResult(operation)); - success = true; - } catch (err: unknown) { - // We want to show the errors on the original page in case of HTML interactions, so we can't just throw them here - const error = HttpError.isInstance(err) ? err : new InternalServerError(createErrorMessage(err)); - ({ statusCode } = error); - this.logger.warn(error.message); - const response = await this.errorHandler.handleSafe({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); - json = await readJsonStream(response.data!); - template = this.viewTemplate; + switch (operation.method) { + case 'GET': return this.handleGet(operation); + case 'POST': return this.handlePost(operation); + default: throw new MethodNotAllowedHttpError(); } - - // Convert the response JSON to the required format - const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - addTemplateMetadata(representation.metadata, template, TEXT_HTML); - const result = await this.converter.handleSafe( - { representation, identifier: operation.target, preferences: operation.preferences }, - ); - - // Make sure this setup handler is never used again after a successful POST request - if (success && operation.method === 'POST') { - this.finished = true; - await this.storage.set(this.storageKey, true); - } - - return new ResponseDescription(statusCode, result.metadata, result.data); } /** - * Creates a JSON object representing the result of executing the given operation, - * together with the template it should be applied to. + * Returns the HTML representation of the setup page. */ - private async getJsonResult(operation: Operation): Promise<{ json: Record; template: string }> { - if (operation.method === 'GET') { - // Return the initial setup page - return { json: {}, template: this.viewTemplate }; - } - if (operation.method !== 'POST') { - throw new MethodNotAllowedHttpError(); - } + private async handleGet(operation: Operation): Promise { + const result = await this.templateEngine.render({}); + const representation = new BasicRepresentation(result, operation.target, TEXT_HTML); + return new OkResponseDescription(representation.metadata, representation.data); + } - // Registration manager expects JSON data - let json: SetupInput = {}; - if (!operation.body.isEmpty) { + /** + * Converts the input data to JSON and calls the setup handler. + * On success `true` will be written to the storage key. + */ + private async handlePost(operation: Operation): Promise { + // Convert input data to JSON + // Allows us to still support form data + if (operation.body.metadata.contentType) { const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - const converted = await this.converter.handleSafe(args); - json = await readJsonStream(converted.data); - this.logger.debug(`Input JSON: ${JSON.stringify(json)}`); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - // We want to initialize after the input has been validated, but before (potentially) writing a pod - // since that might overwrite the initializer result - if (json.initialize && !json.registration) { - if (!this.initializer) { - throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); - } - await this.initializer.handleSafe(); - } + const representation = await this.handler.handleSafe({ operation }); + await this.storage.set(this.storageKey, true); - let output: Record = {}; - // We only call the RegistrationManager when getting registration input. - // This way it is also possible to set up a server without requiring registration parameters. - let validated: RegistrationParams | undefined; - if (json.registration) { - if (!this.registrationManager) { - throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); - } - // Validate the input JSON - validated = this.registrationManager.validateInput(json, true); - this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); - - // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. - output = await this.registrationManager.register(validated, true); - } - - // Add extra setup metadata - output.initialize = Boolean(json.initialize); - output.registration = Boolean(json.registration); - this.logger.debug(`Output: ${JSON.stringify(output)}`); - - return { json: output, template: this.responseTemplate }; + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/templates/setup/index.html.ejs b/templates/setup/index.html.ejs index acdb998b8..b5b9923fb 100644 --- a/templates/setup/index.html.ejs +++ b/templates/setup/index.html.ejs @@ -1,72 +1,45 @@ -

Set up your Solid server

-

- Your Solid server needs a one-time setup - so it acts exactly the way you want. -

+
+ <%- include('./input-partial.html.ejs') %> +
+
+

Server setup complete

+

+ Congratulations! + Your Solid server is now ready to use. +
+ You can now visit its homepage. +

-
- <% const safePrefilled = locals.prefilled || {}; %> +
+

Root Pod

+

+ Warning: the root Pod is publicly accessible. +
+ Prevent public write and control access to the root + by modifying its ACL document. +

+
- <% if (locals.message) { %> -

<%= message %>

- <% } %> -
- Accounts on this server -
    -
  1. - -

    - You can disable account registration - by changing the configuration. -

    -
  2. -
  3. - -

    - Any existing root Pod will be disabled. -

    -
  4. -
  5. - -

    - By default, the public has read and write access to the root Pod. -
    - You typically only want to choose this - for rapid testing and development. -

    -
  6. -
-
+
+ <%- include('../identity/email-password/register-response-partial.html.ejs', { idpIndex: '' }) %> +
+
-
- Sign up - <%- - include('../identity/email-password/register-partial.html.ejs', { - allowRoot: true, - }) - %> -
- -

- - - diff --git a/templates/setup/input-partial.html.ejs b/templates/setup/input-partial.html.ejs new file mode 100644 index 000000000..bf9eb785f --- /dev/null +++ b/templates/setup/input-partial.html.ejs @@ -0,0 +1,69 @@ +

Set up your Solid server

+

+ Your Solid server needs a one-time setup + so it acts exactly the way you want. +

+ +
+

+ +
+ Accounts on this server +
    +
  1. + +

    + You can disable account registration + by changing the configuration. +

    +
  2. +
  3. + +

    + Any existing root Pod will be disabled. +

    +
  4. +
  5. + +

    + By default, the public has read and write access to the root Pod. +
    + You typically only want to choose this + for rapid testing and development. +

    +
  6. +
+
+ +
+ Sign up + <%- + include('../identity/email-password/register-partial.html.ejs', { + allowRoot: true, + }) + %> +
+ +

+
+ + + diff --git a/templates/setup/response.html.ejs b/templates/setup/response.html.ejs deleted file mode 100644 index fd0764d55..000000000 --- a/templates/setup/response.html.ejs +++ /dev/null @@ -1,54 +0,0 @@ -

Server setup complete

-

- Congratulations! - Your Solid server is now ready to use. -
- You can now visit its homepage. -

- -<% if (initialize && !registration) { %> -

Root Pod

-

- Warning: the root Pod is publicly accessible. -
- Prevent public write and control access to the root - by modifying its ACL document. -

-<% } %> - -<% if (registration) { %> - <% if (createPod) { %> -

Your new Pod

-

- Your new Pod is located at <%= podBaseUrl %>. -
- You can store your documents and data there. -

- <% } %> - - <% if (createWebId) { %> -

Your new WebID

-

- Your new WebID is <%= webId %>. -
- You can use this identifier to interact with Solid pods and apps. -

- <% } %> - - <% if (register) { %> -

Your new account

-

- Via your email address <%= email %>, - this server lets you log in to Solid apps - with your WebID <%= webId %> -

- <% if (!createWebId) { %> -

- You will need to add the triple - <%= `<${webId}> <${oidcIssuer}>.`%> - to your existing WebID document <%= webId %> - to indicate that you trust this server as a login provider. -

- <% } %> - <% } %> -<% } %> diff --git a/test/integration/Setup.test.ts b/test/integration/Setup.test.ts index b4be03ef3..f8976e975 100644 --- a/test/integration/Setup.test.ts +++ b/test/integration/Setup.test.ts @@ -33,21 +33,24 @@ describe('A Solid server with setup', (): void => { it('catches all requests.', async(): Promise => { let res = await fetch(baseUrl, { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); - res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT', headers: { accept: 'text/html' }}); + res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT' }); expect(res.status).toBe(405); - await expect(res.text()).resolves.toContain('Set up your Solid server'); + expect(res.url).toBe(setupUrl); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ name: 'MethodNotAllowedHttpError' })); }); it('can create a server that disables root but allows registration.', async(): Promise => { - let res = await fetch(setupUrl, { method: 'POST', headers: { accept: 'text/html' }}); + let res = await fetch(setupUrl, { method: 'POST' }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: false, registration: false }); // Root access disabled res = await fetch(baseUrl); @@ -57,7 +60,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(200); @@ -70,11 +73,11 @@ describe('A Solid server with setup', (): void => { it('can create a server with a public root.', async(): Promise => { let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ initialize: true }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: true, registration: false }); // Root access enabled res = await fetch(baseUrl); @@ -85,7 +88,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(500); @@ -95,11 +98,19 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ registration: true, initialize: true, ...registerParams }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + const json = await res.json(); + expect(json).toEqual(expect.objectContaining({ + registration: true, + initialize: false, + oidcIssuer: baseUrl, + webId: `${baseUrl}profile/card#me`, + email, + podBaseUrl: baseUrl, + })); // Root profile created res = await fetch(joinUrl(baseUrl, '/profile/card')); @@ -109,7 +120,7 @@ describe('A Solid server with setup', (): void => { // Pod root is not accessible even though initialize was set to true res = await fetch(joinUrl(baseUrl, 'resource'), { method: 'PUT', - headers: { accept: 'text/html', 'content-type': 'text/plain' }, + headers: { 'content-type': 'text/plain' }, body: 'random data', }); expect(res.status).toBe(401); diff --git a/test/unit/init/setup/SetupHandler.test.ts b/test/unit/init/setup/SetupHandler.test.ts new file mode 100644 index 000000000..e703e273b --- /dev/null +++ b/test/unit/init/setup/SetupHandler.test.ts @@ -0,0 +1,88 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { RegistrationResponse, + RegistrationManager } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import type { Initializer } from '../../../../src/init/Initializer'; +import { SetupHandler } from '../../../../src/init/setup/SetupHandler'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A SetupHandler', (): void => { + let operation: Operation; + let details: RegistrationResponse; + let registrationManager: jest.Mocked; + let initializer: jest.Mocked; + let handler: SetupHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + + initializer = { + handleSafe: jest.fn(), + } as any; + + details = { + email: 'alice@test.email', + createWebId: true, + register: true, + createPod: true, + }; + + registrationManager = { + validateInput: jest.fn((input): any => input), + register: jest.fn().mockResolvedValue(details), + } as any; + + handler = new SetupHandler({ registrationManager, initializer }); + }); + + it('error if no Initializer is defined and initialization is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('error if no RegistrationManager is defined and registration is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ registration: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('calls the Initializer when requested.', async(): Promise => { + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: true, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); + + it('calls the RegistrationManager when requested.', async(): Promise => { + const body = { registration: true, email: 'test@example.com' }; + operation.body = new BasicRepresentation(JSON.stringify(body), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: true, ...details }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); + expect(registrationManager.register).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenLastCalledWith(body, true); + expect(registrationManager.register).toHaveBeenLastCalledWith(body, true); + }); + + it('defaults to an empty JSON body if no data is provided.', async(): Promise => { + operation.body = new BasicRepresentation(); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts index d58bcf510..b1cf83b33 100644 --- a/test/unit/init/setup/SetupHttpHandler.test.ts +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -1,13 +1,8 @@ import type { Operation } from '../../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import type { RegistrationManager, - RegistrationResponse } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; -import type { Initializer } from '../../../../src/init/Initializer'; -import type { SetupInput } from '../../../../src/init/setup/SetupHttpHandler'; +import type { InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; @@ -15,25 +10,20 @@ import { getBestPreference } from '../../../../src/storage/conversion/Conversion import type { RepresentationConverterArgs, RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; -import type { HttpError } from '../../../../src/util/errors/HttpError'; -import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../../src/util/ContentTypes'; import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; +import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; describe('A SetupHttpHandler', (): void => { - let request: HttpRequest; + const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; let operation: Operation; - const viewTemplate = '/templates/view'; - const responseTemplate = '/templates/response'; const storageKey = 'completed'; - let details: RegistrationResponse; - let errorHandler: jest.Mocked; - let registrationManager: jest.Mocked; - let initializer: jest.Mocked; + let representation: Representation; + let interactionHandler: jest.Mocked; + let templateEngine: jest.Mocked; let converter: jest.Mocked; let storage: jest.Mocked>; let handler: SetupHttpHandler; @@ -41,32 +31,15 @@ describe('A SetupHttpHandler', (): void => { beforeEach(async(): Promise => { operation = { method: 'GET', - target: { path: 'http://test.com/setup' }, - preferences: { type: { 'text/html': 1 }}, + target: { path: 'http://example.com/setup' }, + preferences: {}, body: new BasicRepresentation(), }; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; - - initializer = { - handleSafe: jest.fn(), - } as any; - - details = { - email: 'alice@test.email', - createWebId: true, - register: true, - createPod: true, + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), }; - registrationManager = { - validateInput: jest.fn((input): any => input), - register: jest.fn().mockResolvedValue(details), - } as any; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -76,148 +49,71 @@ describe('A SetupHttpHandler', (): void => { }), } as any; + representation = new BasicRepresentation(); + interactionHandler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; + storage = new Map() as any; handler = new SetupHttpHandler({ - initializer, - registrationManager, converter, storageKey, storage, - viewTemplate, - responseTemplate, - errorHandler, + handler: interactionHandler, + templateEngine, }); }); - // Since all tests check similar things, the test functionality is generalized in here - async function testPost(input: SetupInput, error?: HttpError): Promise { - operation.method = 'POST'; - const initialize = Boolean(input.initialize); - const registration = Boolean(input.registration); - const requestBody = { initialize, registration }; - if (Object.keys(input).length > 0) { - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - } + it('only accepts GET and POST operations.', async(): Promise => { + operation = { + method: 'DELETE', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + await expect(handler.handle({ operation, request, response })).rejects.toThrow(MethodNotAllowedHttpError); + }); + it('calls the template engine for GET requests.', async(): Promise => { const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0); - expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - let expectedResult: any = { initialize, registration }; - if (error) { - expectedResult = { name: error.name, message: error.message }; - } else if (registration) { - Object.assign(expectedResult, details); - } - expect(JSON.parse(await readableToString(result.data!))).toEqual(expectedResult); - expect(result.statusCode).toBe(error?.statusCode ?? 200); + expect(result.data).toBeDefined(); + await expect(readableToString(result.data!)).resolves.toBe(''); expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(error ? viewTemplate : responseTemplate); - - if (!error && registration) { - expect(registrationManager.validateInput).toHaveBeenLastCalledWith(requestBody, true); - expect(registrationManager.register).toHaveBeenLastCalledWith(requestBody, true); - } - } - - it('returns the view template on GET requests.', async(): Promise => { - const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({}); - expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); // Setup is still enabled since this was a GET request expect(storage.get(storageKey)).toBeUndefined(); }); - it('simply disables the handler if no setup is requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: false })).resolves.toBeUndefined(); + it('returns the handler result as 200 response.', async(): Promise => { + operation.method = 'POST'; + const result = await handler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith({ operation }); // Handler is now disabled due to successful POST expect(storage.get(storageKey)).toBe(true); }); - it('defaults to an empty body if there is none.', async(): Promise => { - await expect(testPost({})).resolves.toBeUndefined(); - }); - - it('calls the initializer when requested.', async(): Promise => { - await expect(testPost({ initialize: true, registration: false })).resolves.toBeUndefined(); - }); - - it('calls the registrationManager when requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: true })).resolves.toBeUndefined(); - }); - - it('converts non-HTTP errors to internal errors.', async(): Promise => { - converter.handleSafe.mockRejectedValueOnce(new Error('bad data')); - const error = new InternalServerError('bad data'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - }); - - it('errors on non-GET/POST requests.', async(): Promise => { - operation.method = 'PUT'; - const requestBody = { initialize: true, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new MethodNotAllowedHttpError(); - + it('converts input bodies to JSON.', async(): Promise => { + operation.method = 'POST'; + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(0); - expect(registrationManager.register).toHaveBeenCalledTimes(0); - expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation) }, + ); + expect(interactionHandler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message }); - expect(result.statusCode).toBe(405); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting registration when no RegistrationManager is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - initializer, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: false, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured to support registration during setup.'); - await expect(testPost({ initialize: false, registration: true }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting initialization when no Initializer is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - registrationManager, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: true, registration: false }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured with a setup initializer.'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); + // Handler is now disabled due to successful POST + expect(storage.get(storageKey)).toBe(true); }); });