From 4e1a2f5981a3b902dfea40ea4e8a710ce88e9cf6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 15 Sep 2021 16:52:16 +0200 Subject: [PATCH] feat: Create SetupHttpHandler This handler allows users to set up servers with a pod and without having to enable public access first --- .../registration/route/registration.json | 15 +- .../handler/RegistrationHandler.ts | 219 +----------- .../util/RegistrationManager.ts | 245 +++++++++++++ src/index.ts | 6 + src/init/setup/SetupHttpHandler.ts | 206 +++++++++++ src/pods/GeneratedPodManager.ts | 4 +- src/pods/PodManager.ts | 3 +- .../handler/RegistrationHandler.test.ts | 300 ++-------------- .../util/RegistrationManager.test.ts | 323 ++++++++++++++++++ test/unit/init/setup/SetupHttpHandler.test.ts | 248 ++++++++++++++ test/unit/pods/GeneratedPodManager.test.ts | 14 +- 11 files changed, 1094 insertions(+), 489 deletions(-) create mode 100644 src/identity/interaction/email-password/util/RegistrationManager.ts create mode 100644 src/init/setup/SetupHttpHandler.ts create mode 100644 test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts create mode 100644 test/unit/init/setup/SetupHttpHandler.test.ts diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index 477c4dc6d..7623ae7a6 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -20,12 +20,15 @@ }, "handler": { "@type": "RegistrationHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_webIdSuffix": "/profile/card#me", - "args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, - "args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" }, - "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_podManager": { "@id": "urn:solid-server:default:PodManager" } + "registrationManager": { + "@type": "RegistrationManager", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_webIdSuffix": "/profile/card#me", + "args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, + "args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" }, + "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "args_podManager": { "@id": "urn:solid-server:default:PodManager" } + } } } ] diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 68a623b32..22985fc67 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,230 +1,27 @@ -import assert from 'assert'; -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 { joinUrl } from '../../../../util/PathUtil'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; -import { assertPassword } from '../EmailPasswordUtil'; -import type { AccountStore } from '../storage/AccountStore'; +import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler'; -const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; - -export interface RegistrationHandlerArgs { - /** - * Used to set the `oidcIssuer` value of newly registered pods. - */ - baseUrl: string; - /** - * Appended to the generated pod identifier to create the corresponding WebID. - */ - webIdSuffix: string; - /** - * Generates identifiers for new pods. - */ - identifierGenerator: IdentifierGenerator; - /** - * Verifies the user is the owner of the WebID they provide. - */ - ownershipValidator: OwnershipValidator; - /** - * Stores all the registered account information. - */ - accountStore: AccountStore; - /** - * Creates the new pods. - */ - podManager: PodManager; -} - /** - * All the parameters that will be parsed from a request. - */ -interface ParsedInput { - email: string; - webId?: string; - password?: string; - podName?: string; - template?: string; - createWebId: boolean; - register: boolean; - createPod: boolean; -} - -/** - * The results that will be applied to the response template. - */ -interface RegistrationResponse { - email: string; - webId?: string; - oidcIssuer?: string; - podBaseUrl?: string; - createWebId: boolean; - register: boolean; - createPod: boolean; -} - -/** - * This class handles the 3 potential steps of the registration process: - * 1. Generating a new WebID. - * 2. Registering a WebID with the IDP. - * 3. Creating a new pod for a given WebID. - * - * All of these steps are optional and will be determined based on the input parameters of a request, - * with the following considerations: - * * At least one option needs to be chosen. - * * In case a new WebID needs to be created, the other 2 steps are obligatory. - * * Ownership will be verified when the WebID is provided. - * * When registering and creating a pod, the base URL will be used as oidcIssuer value. + * Supports registration based on the `RegistrationManager` behaviour. */ export class RegistrationHandler extends InteractionHandler { protected readonly logger = getLoggerFor(this); - private readonly baseUrl: string; - private readonly webIdSuffix: string; - private readonly identifierGenerator: IdentifierGenerator; - private readonly ownershipValidator: OwnershipValidator; - private readonly accountStore: AccountStore; - private readonly podManager: PodManager; + private readonly registrationManager: RegistrationManager; - public constructor(args: RegistrationHandlerArgs) { + public constructor(registrationManager: RegistrationManager) { super(); - this.baseUrl = args.baseUrl; - this.webIdSuffix = args.webIdSuffix; - this.identifierGenerator = args.identifierGenerator; - this.ownershipValidator = args.ownershipValidator; - this.accountStore = args.accountStore; - this.podManager = args.podManager; + this.registrationManager = registrationManager; } public async handle({ operation }: InteractionHandlerInput): Promise> { - const result = await this.parseInput(operation); - const details = await this.register(result); + const data = await readJsonStream(operation.body!.data); + const validated = this.registrationManager.validateInput(data, false); + const details = await this.registrationManager.register(validated, false); return { type: 'response', details }; } - - /** - * Does the full registration and pod creation process, - * with the steps chosen by the values in the `ParseResult`. - */ - private async register(result: ParsedInput): Promise { - // This is only used when createWebId and/or createPod are true - let podBaseUrl: ResourceIdentifier | undefined; - if (result.createWebId || result.createPod) { - podBaseUrl = this.identifierGenerator.generate(result.podName!); - } - - // Create or verify the WebID - if (result.createWebId) { - result.webId = joinUrl(podBaseUrl!.path, this.webIdSuffix); - } else { - await this.ownershipValidator.handleSafe({ webId: result.webId! }); - } - - // Register the account - if (result.register) { - await this.accountStore.create(result.email, result.webId!, result.password!); - } - - // Create the pod - if (result.createPod) { - const podSettings: PodSettings = { - email: result.email, - webId: result.webId!, - template: result.template, - podBaseUrl: podBaseUrl!.path, - }; - - // Set the OIDC issuer to our server when registering with the IDP - if (result.register) { - podSettings.oidcIssuer = this.baseUrl; - } - - try { - await this.podManager.createPod(podBaseUrl!, podSettings); - } catch (error: unknown) { - // In case pod creation errors we don't want to keep the account - if (result.register) { - await this.accountStore.deleteAccount(result.email); - } - throw error; - } - } - - // Verify the account - if (result.register) { - // This prevents there being a small timeframe where the account can be used before the pod creation is finished. - // That timeframe could potentially be used by malicious users. - await this.accountStore.verify(result.email); - } - - return { - webId: result.webId, - email: result.email, - oidcIssuer: this.baseUrl, - podBaseUrl: podBaseUrl?.path, - createWebId: result.createWebId, - register: result.register, - createPod: result.createPod, - }; - } - - /** - * Parses the input request into a `ParseResult`. - */ - private async parseInput(operation: Operation): Promise { - const parsed = await readJsonStream(operation.body!.data); - const prefilled: Record = {}; - for (const [ key, value ] of Object.entries(parsed)) { - assert(!Array.isArray(value), `Unexpected multiple values for ${key}.`); - prefilled[key] = typeof value === 'string' ? value.trim() : value; - } - return this.validateInput(prefilled); - } - - /** - * Converts the raw input date into a `ParseResult`. - * Verifies that all the data combinations make sense. - */ - private validateInput(parsed: NodeJS.Dict): ParsedInput { - const { email, password, confirmPassword, webId, podName, register, createPod, createWebId, template } = parsed; - - // Parse email - assert(typeof email === 'string' && emailRegex.test(email), 'Please enter a valid e-mail address.'); - - const validated: ParsedInput = { - email, - template, - register: Boolean(register) || Boolean(createWebId), - createPod: Boolean(createPod) || Boolean(createWebId), - createWebId: Boolean(createWebId), - }; - assert(validated.register || validated.createPod, 'Please register for a WebID or create a Pod.'); - - // Parse WebID - if (!validated.createWebId) { - assert(typeof webId === 'string' && /^https?:\/\/[^/]+/u.test(webId), 'Please enter a valid WebID.'); - validated.webId = webId; - } - - // Parse Pod name - if (validated.createWebId || validated.createPod) { - assert(typeof podName === 'string' && podName.length > 0, 'Please specify a Pod name.'); - validated.podName = podName; - } - - // Parse account - if (validated.register) { - assertPassword(password, confirmPassword); - validated.password = password; - } - - return validated; - } } diff --git a/src/identity/interaction/email-password/util/RegistrationManager.ts b/src/identity/interaction/email-password/util/RegistrationManager.ts new file mode 100644 index 000000000..cddc73e98 --- /dev/null +++ b/src/identity/interaction/email-password/util/RegistrationManager.ts @@ -0,0 +1,245 @@ +import assert from 'assert'; +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 { joinUrl } from '../../../../util/PathUtil'; +import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; +import { assertPassword } from '../EmailPasswordUtil'; +import type { AccountStore } from '../storage/AccountStore'; + +export interface RegistrationManagerArgs { + /** + * Used to set the `oidcIssuer` value of newly registered pods. + */ + baseUrl: string; + /** + * Appended to the generated pod identifier to create the corresponding WebID. + */ + webIdSuffix: string; + /** + * Generates identifiers for new pods. + */ + identifierGenerator: IdentifierGenerator; + /** + * Verifies the user is the owner of the WebID they provide. + */ + ownershipValidator: OwnershipValidator; + /** + * Stores all the registered account information. + */ + accountStore: AccountStore; + /** + * Creates the new pods. + */ + podManager: PodManager; +} + +/** + * The parameters expected for registration. + */ +export interface RegistrationParams { + email: string; + webId?: string; + password?: string; + podName?: string; + template?: string; + createWebId: boolean; + register: boolean; + createPod: boolean; + rootPod: boolean; +} + +/** + * The result of a registration action. + */ +export interface RegistrationResponse { + email: string; + webId?: string; + oidcIssuer?: string; + podBaseUrl?: string; + createWebId: boolean; + register: boolean; + createPod: boolean; +} + +const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; + +/** + * Supports IDP registration and pod creation based on input parameters. + * + * The above behaviour is combined in the two class functions. + * `validateInput` will make sure all incoming data is correct and makes sense. + * `register` will call all the correct handlers based on the requirements of the validated parameters. + */ +export class RegistrationManager { + protected readonly logger = getLoggerFor(this); + + private readonly baseUrl: string; + private readonly webIdSuffix: string; + private readonly identifierGenerator: IdentifierGenerator; + private readonly ownershipValidator: OwnershipValidator; + private readonly accountStore: AccountStore; + private readonly podManager: PodManager; + + public constructor(args: RegistrationManagerArgs) { + this.baseUrl = args.baseUrl; + this.webIdSuffix = args.webIdSuffix; + this.identifierGenerator = args.identifierGenerator; + this.ownershipValidator = args.ownershipValidator; + this.accountStore = args.accountStore; + this.podManager = args.podManager; + } + + /** + * Trims the input if it is a string, returns `undefined` otherwise. + */ + private trimString(input: unknown): string | undefined { + if (typeof input === 'string') { + return input.trim(); + } + } + + /** + * Makes sure the input conforms to the following requirements when relevant: + * * At least one option needs to be chosen. + * * In case a new WebID needs to be created, the other 2 steps will be set to true. + * * Valid email/WebID/password/podName when required. + * * Only create a root pod when allowed. + * + * @param input - Input parameters for the registration procedure. + * @param allowRoot - If creating a pod in the root container should be allowed. + * + * @returns A cleaned up version of the input parameters. + * Only (trimmed) parameters that are relevant to the registration procedure will be retained. + */ + public validateInput(input: NodeJS.Dict, allowRoot = false): RegistrationParams { + const { + email, password, confirmPassword, webId, podName, register, createPod, createWebId, template, rootPod, + } = input; + + // Parse email + const trimmedEmail = this.trimString(email); + assert(trimmedEmail && emailRegex.test(trimmedEmail), 'Please enter a valid e-mail address.'); + + const validated: RegistrationParams = { + email: trimmedEmail, + register: Boolean(register) || Boolean(createWebId), + createPod: Boolean(createPod) || Boolean(createWebId), + createWebId: Boolean(createWebId), + rootPod: Boolean(rootPod), + }; + assert(validated.register || validated.createPod, 'Please register for a WebID or create a Pod.'); + assert(allowRoot || !validated.rootPod, 'Creating a root pod is not supported.'); + + // Parse WebID + if (!validated.createWebId) { + const trimmedWebId = this.trimString(webId); + assert(trimmedWebId && /^https?:\/\/[^/]+/u.test(trimmedWebId), 'Please enter a valid WebID.'); + validated.webId = trimmedWebId; + } + + // Parse Pod name + if (validated.createPod && !validated.rootPod) { + const trimmedPodName = this.trimString(podName); + assert(trimmedPodName && trimmedPodName.length > 0, 'Please specify a Pod name.'); + validated.podName = trimmedPodName; + } + + // Parse account + if (validated.register) { + const trimmedPassword = this.trimString(password); + const trimmedConfirmPassword = this.trimString(confirmPassword); + assertPassword(trimmedPassword, trimmedConfirmPassword); + validated.password = trimmedPassword; + } + + // Parse template if there is one + if (template) { + validated.template = this.trimString(template); + } + + return validated; + } + + /** + * Handles the 3 potential steps of the registration process: + * 1. Generating a new WebID. + * 2. Registering a WebID with the IDP. + * 3. Creating a new pod for a given WebID. + * + * All of these steps are optional and will be determined based on the input parameters. + * + * This includes the following steps: + * * Ownership will be verified when the WebID is provided. + * * When registering and creating a pod, the base URL will be used as oidcIssuer value. + */ + public async register(input: RegistrationParams, allowRoot = false): Promise { + // This is only used when createWebId and/or createPod are true + let podBaseUrl: ResourceIdentifier | undefined; + if (input.createPod) { + if (input.rootPod) { + podBaseUrl = { path: this.baseUrl }; + } else { + podBaseUrl = this.identifierGenerator.generate(input.podName!); + } + } + + // Create or verify the WebID + if (input.createWebId) { + input.webId = joinUrl(podBaseUrl!.path, this.webIdSuffix); + } else { + await this.ownershipValidator.handleSafe({ webId: input.webId! }); + } + + // Register the account + if (input.register) { + await this.accountStore.create(input.email, input.webId!, input.password!); + } + + // Create the pod + if (input.createPod) { + const podSettings: PodSettings = { + email: input.email, + webId: input.webId!, + template: input.template, + podBaseUrl: podBaseUrl!.path, + }; + + // Set the OIDC issuer to our server when registering with the IDP + if (input.register) { + podSettings.oidcIssuer = this.baseUrl; + } + + try { + // Only allow overwrite for root pods + await this.podManager.createPod(podBaseUrl!, podSettings, allowRoot); + } catch (error: unknown) { + // In case pod creation errors we don't want to keep the account + if (input.register) { + await this.accountStore.deleteAccount(input.email); + } + throw error; + } + } + + // Verify the account + if (input.register) { + // This prevents there being a small timeframe where the account can be used before the pod creation is finished. + // That timeframe could potentially be used by malicious users. + await this.accountStore.verify(input.email); + } + + return { + webId: input.webId, + email: input.email, + oidcIssuer: this.baseUrl, + podBaseUrl: podBaseUrl?.path, + createWebId: input.createWebId, + register: input.register, + createPod: input.createPod, + }; + } +} + diff --git a/src/index.ts b/src/index.ts index 964b9dbb7..82e90e6a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,9 @@ export * from './identity/interaction/email-password/handler/ResetPasswordHandle export * from './identity/interaction/email-password/storage/AccountStore'; export * from './identity/interaction/email-password/storage/BaseAccountStore'; +// Identity/Interaction/Email-Password/Util +export * from './identity/interaction/email-password/util/RegistrationManager'; + // Identity/Interaction/Email-Password export * from './identity/interaction/email-password/EmailPasswordUtil'; @@ -70,6 +73,9 @@ export * from './identity/IdentityProviderHttpHandler'; export * from './init/final/Finalizable'; export * from './init/final/ParallelFinalizer'; +// Init/Setup +export * from './init/setup/SetupHttpHandler'; + // Init export * from './init/App'; export * from './init/AppRunner'; diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts new file mode 100644 index 000000000..3261eb8c4 --- /dev/null +++ b/src/init/setup/SetupHttpHandler.ts @@ -0,0 +1,206 @@ +import type { RegistrationParams, + RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { ErrorHandler } from '../../ldp/http/ErrorHandler'; +import type { RequestParser } from '../../ldp/http/RequestParser'; +import { ResponseDescription } from '../../ldp/http/response/ResponseDescription'; +import type { ResponseWriter } from '../../ldp/http/ResponseWriter'; +import type { Operation } from '../../ldp/operations/Operation'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import { getLoggerFor } from '../../logging/LogUtil'; +import type { BaseHttpHandlerArgs } from '../../server/BaseHttpHandler'; +import { BaseHttpHandler } from '../../server/BaseHttpHandler'; +import type { 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; +} + +export interface SetupHttpHandlerArgs extends BaseHttpHandlerArgs { + // BaseHttpHandler args + requestParser: RequestParser; + errorHandler: ErrorHandler; + responseWriter: ResponseWriter; + + /** + * 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. + */ + converter: RepresentationConverter; + /** + * Key that is used to store the boolean in the storage indicating setup is finished. + */ + storageKey: string; + /** + * Used to store setup status. + */ + storage: KeyValueStorage; + /** + * Template to use for GET requests. + */ + viewTemplate: string; + /** + * Template to show when setup was completed successfully. + */ + responseTemplate: string; +} + +/** + * Handles the initial setup of a server. + * Will capture all requests until setup is finished, + * 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. + * After successfully completing a POST request this handler will disable itself and become unreachable. + * All other methods will be rejected. + */ +export class SetupHttpHandler extends BaseHttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly registrationManager?: RegistrationManager; + private readonly initializer?: Initializer; + private readonly converter: RepresentationConverter; + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + private readonly viewTemplate: string; + private readonly responseTemplate: string; + + private finished: boolean; + + public constructor(args: SetupHttpHandlerArgs) { + super(args); + this.finished = false; + + this.registrationManager = args.registrationManager; + this.initializer = args.initializer; + this.converter = args.converter; + this.storageKey = args.storageKey; + this.storage = args.storage; + this.viewTemplate = args.viewTemplate; + this.responseTemplate = args.responseTemplate; + } + + public async handleOperation(operation: Operation): 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; + } + + // 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. + */ + 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(); + } + + // Registration manager expects JSON data + let json: SetupInput = {}; + if (operation.body) { + 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)}`); + } + + // 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(); + } + + 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 }; + } +} diff --git a/src/pods/GeneratedPodManager.ts b/src/pods/GeneratedPodManager.ts index 7d9eeb556..af282c986 100644 --- a/src/pods/GeneratedPodManager.ts +++ b/src/pods/GeneratedPodManager.ts @@ -26,9 +26,9 @@ export class GeneratedPodManager implements PodManager { * Creates a new pod, pre-populating it with the resources created by the data generator. * Will throw an error if the given identifier already has a resource. */ - public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise { + public async createPod(identifier: ResourceIdentifier, settings: PodSettings, overwrite: boolean): Promise { this.logger.info(`Creating pod ${identifier.path}`); - if (await this.store.resourceExists(identifier)) { + if (!overwrite && await this.store.resourceExists(identifier)) { throw new ConflictHttpError(`There already is a resource at ${identifier.path}`); } diff --git a/src/pods/PodManager.ts b/src/pods/PodManager.ts index cd30caedf..f992a84b4 100644 --- a/src/pods/PodManager.ts +++ b/src/pods/PodManager.ts @@ -10,6 +10,7 @@ export interface PodManager { * Creates a pod for the given settings. * @param identifier - Root identifier indicating where the pod should be created. * @param settings - Settings describing the pod. + * @param overwrite - If the creation should proceed if there already is a resource there. */ - createPod: (identifier: ResourceIdentifier, settings: PodSettings) => Promise; + createPod: (identifier: ResourceIdentifier, settings: PodSettings, overwrite: boolean) => Promise; } 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 8e2ad111b..10382bb39 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -1,288 +1,54 @@ import { RegistrationHandler, } from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator'; +import type { + RegistrationManager, RegistrationParams, RegistrationResponse, +} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; 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 { joinUrl } from '../../../../../../src/util/PathUtil'; import { createPostJsonOperation } from './Util'; describe('A RegistrationHandler', (): void => { - // "Correct" values for easy object creation - const webId = 'http://alice.test.com/card#me'; - const email = 'alice@test.email'; - const password = 'superSecretPassword'; - const confirmPassword = password; - const podName = 'alice'; - const podBaseUrl = 'http://test.com/alice/'; - const createWebId = true; - const register = true; - const createPod = true; - let operation: Operation; - - const baseUrl = 'http://test.com/'; - const webIdSuffix = '/profile/card'; - let podSettings: PodSettings; - let identifierGenerator: IdentifierGenerator; - let ownershipValidator: OwnershipValidator; - let accountStore: AccountStore; - let podManager: PodManager; + let validated: RegistrationParams; + let details: RegistrationResponse; + let registrationManager: jest.Mocked; let handler: RegistrationHandler; beforeEach(async(): Promise => { - podSettings = { email, webId, podBaseUrl }; - - identifierGenerator = { - generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })), + validated = { + email: 'alice@test.email', + password: 'superSecret', + createWebId: true, + register: true, + createPod: true, + rootPod: true, + }; + details = { + email: 'alice@test.email', + createWebId: true, + register: true, + createPod: true, }; - ownershipValidator = { - handleSafe: jest.fn(), + registrationManager = { + validateInput: jest.fn().mockReturnValue(validated), + register: jest.fn().mockResolvedValue(details), } as any; - accountStore = { - create: jest.fn(), - verify: jest.fn(), - deleteAccount: jest.fn(), - } as any; - - podManager = { - createPod: jest.fn(), - }; - - handler = new RegistrationHandler({ - baseUrl, - webIdSuffix, - identifierGenerator, - accountStore, - ownershipValidator, - podManager, - }); + handler = new RegistrationHandler(registrationManager); }); - describe('validating data', (): void => { - it('rejects array inputs.', async(): Promise => { - operation = createPostJsonOperation({ mydata: [ 'a', 'b' ]}); - await expect(handler.handle({ operation })) - .rejects.toThrow('Unexpected multiple values for mydata.'); + it('converts the stream to json and sends it to the registration manager.', async(): Promise => { + const params = { email: 'alice@test.email', password: 'superSecret' }; + operation = createPostJsonOperation(params); + await expect(handler.handle({ operation })).resolves.toEqual({ + type: 'response', + details, }); - it('errors on invalid emails.', async(): Promise => { - operation = createPostJsonOperation({ email: undefined }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a valid e-mail address.'); - - operation = createPostJsonOperation({ email: '' }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a valid e-mail address.'); - - operation = createPostJsonOperation({ 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 => { - operation = createPostJsonOperation({ email, register, webId: undefined }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a valid WebID.'); - - operation = createPostJsonOperation({ email, register, webId: '' }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a valid WebID.'); - }); - - it('errors on invalid passwords when registering.', async(): Promise => { - operation = createPostJsonOperation({ 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 => { - operation = createPostJsonOperation({ email, webId, createPod, podName: undefined }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please specify a Pod name.'); - - operation = createPostJsonOperation({ email, webId, createPod, podName: ' ' }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please specify a Pod name.'); - - operation = createPostJsonOperation({ 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 => { - operation = createPostJsonOperation({ email, podName, createWebId }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a password.'); - - operation = createPostJsonOperation({ email, podName, createWebId, createPod }); - await expect(handler.handle({ operation })) - .rejects.toThrow('Please enter a password.'); - - operation = createPostJsonOperation({ 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 => { - operation = createPostJsonOperation({ 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 => { - operation = createPostJsonOperation({ email, webId, password, confirmPassword, register }); - await expect(handler.handle({ operation })).resolves.toEqual({ - details: { - email, - webId, - oidcIssuer: baseUrl, - createWebId: false, - register: true, - createPod: false, - }, - type: 'response', - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - - expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - expect(podManager.createPod).toHaveBeenCalledTimes(0); - }); - - it('can create a pod.', async(): Promise => { - const params = { email, webId, podName, createPod }; - operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - details: { - email, - webId, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: false, - register: false, - createPod: true, - }, - type: 'response', - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings); - - expect(accountStore.create).toHaveBeenCalledTimes(0); - expect(accountStore.verify).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - 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; - operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - details: { - email, - webId, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: false, - register: true, - createPod: true, - }, - type: 'response', - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('deletes the created account if pod generation fails.', async(): Promise => { - const params = { email, webId, password, confirmPassword, podName, register, createPod }; - podSettings.oidcIssuer = baseUrl; - operation = createPostJsonOperation(params); - (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - await expect(handler.handle({ operation })).rejects.toThrow('pod error'); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(1); - expect(accountStore.deleteAccount).toHaveBeenLastCalledWith(email); - - expect(accountStore.verify).toHaveBeenCalledTimes(0); - }); - - it('can create a WebID with an account and pod.', async(): Promise => { - const params = { email, password, confirmPassword, podName, createWebId, register, createPod }; - const generatedWebID = joinUrl(baseUrl, podName, webIdSuffix); - podSettings.webId = generatedWebID; - podSettings.oidcIssuer = baseUrl; - - operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - details: { - email, - webId: generatedWebID, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: true, - register: true, - createPod: true, - }, - type: 'response', - }); - - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('throws an error if something goes wrong.', async(): Promise => { - const params = { email, webId, podName, createPod }; - operation = createPostJsonOperation(params); - (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - const prom = handler.handle({ operation }); - await expect(prom).rejects.toThrow('pod error'); - }); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); + expect(registrationManager.register).toHaveBeenCalledTimes(1); + expect(registrationManager.register).toHaveBeenLastCalledWith(validated, false); }); }); diff --git a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts new file mode 100644 index 000000000..d81c97e67 --- /dev/null +++ b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts @@ -0,0 +1,323 @@ +import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { + RegistrationManager, +} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator'; +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 { joinUrl } from '../../../../../../src/util/PathUtil'; + +describe('A RegistrationManager', (): void => { + // "Correct" values for easy object creation + const webId = 'http://alice.test.com/card#me'; + const email = 'alice@test.email'; + const password = 'superSecretPassword'; + const confirmPassword = password; + const podName = 'alice'; + const podBaseUrl = 'http://test.com/alice/'; + const createWebId = true; + const register = true; + const createPod = true; + const rootPod = true; + + const baseUrl = 'http://test.com/'; + const webIdSuffix = '/profile/card'; + let podSettings: PodSettings; + let identifierGenerator: IdentifierGenerator; + let ownershipValidator: OwnershipValidator; + let accountStore: AccountStore; + let podManager: PodManager; + let manager: RegistrationManager; + + beforeEach(async(): Promise => { + podSettings = { email, webId, podBaseUrl }; + + identifierGenerator = { + generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })), + }; + + ownershipValidator = { + handleSafe: jest.fn(), + } as any; + + accountStore = { + create: jest.fn(), + verify: jest.fn(), + deleteAccount: jest.fn(), + } as any; + + podManager = { + createPod: jest.fn(), + }; + + manager = new RegistrationManager({ + baseUrl, + webIdSuffix, + identifierGenerator, + accountStore, + ownershipValidator, + podManager, + }); + }); + + describe('validating data', (): void => { + it('errors on invalid emails.', async(): Promise => { + let input: any = { email: undefined }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a valid e-mail address.'); + + input = { email: '' }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a valid e-mail address.'); + + input = { email: 'invalidEmail' }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a valid e-mail address.'); + }); + + it('errors when setting rootPod to true when not allowed.', async(): Promise => { + const input = { email, createWebId, rootPod }; + expect((): any => manager.validateInput(input)).toThrow('Creating a root pod is not supported.'); + }); + + it('errors when a required WebID is not valid.', async(): Promise => { + let input: any = { email, register, webId: undefined }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a valid WebID.'); + + input = { email, register, webId: '' }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a valid WebID.'); + }); + + it('errors on invalid passwords when registering.', async(): Promise => { + const input: any = { email, webId, password, confirmPassword: 'bad', register }; + expect((): any => manager.validateInput(input)).toThrow('Your password and confirmation did not match.'); + }); + + it('errors on invalid pod names when required.', async(): Promise => { + let input: any = { email, webId, createPod, podName: undefined }; + expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); + + input = { email, webId, createPod, podName: ' ' }; + expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); + + input = { email, webId, createWebId }; + expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); + }); + + it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise => { + let input: any = { email, podName, createWebId }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); + + input = { email, podName, createWebId, createPod }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); + + input = { email, podName, createWebId, createPod, register }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); + }); + + it('errors when no option is chosen.', async(): Promise => { + const input = { email, webId }; + expect((): any => manager.validateInput(input)).toThrow('Please register for a WebID or create a Pod.'); + }); + + it('adds the template parameter if there is one.', async(): Promise => { + const input = { email, webId, podName, template: 'template', createPod }; + expect(manager.validateInput(input)).toEqual({ + email, webId, podName, template: 'template', createWebId: false, register: false, createPod, rootPod: false, + }); + }); + + it('does not require a pod name when creating a root pod.', async(): Promise => { + const input = { email, webId, createPod, rootPod }; + expect(manager.validateInput(input, true)).toEqual({ + email, webId, createWebId: false, register: false, createPod, rootPod, + }); + }); + + it('trims input parameters.', async(): Promise => { + let input: any = { + email: ` ${email} `, + password: ` ${password} `, + confirmPassword: ` ${password} `, + podName: ` ${podName} `, + template: ' template ', + createWebId, + register, + createPod, + }; + expect(manager.validateInput(input)).toEqual({ + email, password, podName, template: 'template', createWebId, register, createPod, rootPod: false, + }); + + input = { email, webId: ` ${webId} `, password, confirmPassword, register: true }; + expect(manager.validateInput(input)).toEqual({ + email, webId, password, createWebId: false, register, createPod: false, rootPod: false, + }); + }); + }); + + describe('handling data', (): void => { + it('can register a user.', async(): Promise => { + const params: any = { email, webId, password, confirmPassword, register, createPod: false, createWebId: false }; + await expect(manager.register(params)).resolves.toEqual({ + email, + webId, + oidcIssuer: baseUrl, + createWebId: false, + register: true, + createPod: false, + }); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(accountStore.verify).toHaveBeenCalledTimes(1); + expect(accountStore.verify).toHaveBeenLastCalledWith(email); + + expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + expect(podManager.createPod).toHaveBeenCalledTimes(0); + }); + + it('can create a pod.', async(): Promise => { + const params: any = { email, webId, podName, createPod, createWebId: false, register: false }; + await expect(manager.register(params)).resolves.toEqual({ + email, + webId, + oidcIssuer: baseUrl, + podBaseUrl: `${baseUrl}${podName}/`, + createWebId: false, + register: false, + createPod: true, + }); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); + expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + + expect(accountStore.create).toHaveBeenCalledTimes(0); + expect(accountStore.verify).toHaveBeenCalledTimes(0); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + }); + + it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise => { + const params: any = { email, webId, password, confirmPassword, podName, register, createPod, createWebId: false }; + podSettings.oidcIssuer = baseUrl; + await expect(manager.register(params)).resolves.toEqual({ + email, + webId, + oidcIssuer: baseUrl, + podBaseUrl: `${baseUrl}${podName}/`, + createWebId: false, + register: true, + createPod: true, + }); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); + expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + expect(accountStore.verify).toHaveBeenCalledTimes(1); + expect(accountStore.verify).toHaveBeenLastCalledWith(email); + + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + }); + + it('deletes the created account if pod generation fails.', async(): Promise => { + const params: any = { email, webId, password, confirmPassword, podName, register, createPod }; + podSettings.oidcIssuer = baseUrl; + (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); + await expect(manager.register(params)).rejects.toThrow('pod error'); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); + expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(1); + expect(accountStore.deleteAccount).toHaveBeenLastCalledWith(email); + + expect(accountStore.verify).toHaveBeenCalledTimes(0); + }); + + it('does not try to delete an account on failure if there was no registration.', async(): Promise => { + const params: any = { email, webId, podName, createPod }; + (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); + await expect(manager.register(params)).rejects.toThrow('pod error'); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); + expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + + expect(accountStore.create).toHaveBeenCalledTimes(0); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + expect(accountStore.verify).toHaveBeenCalledTimes(0); + }); + + it('can create a WebID with an account and pod.', async(): Promise => { + const params: any = { email, password, confirmPassword, podName, createWebId, register, createPod }; + const generatedWebID = joinUrl(baseUrl, podName, webIdSuffix); + podSettings.webId = generatedWebID; + podSettings.oidcIssuer = baseUrl; + + await expect(manager.register(params)).resolves.toEqual({ + email, + webId: generatedWebID, + oidcIssuer: baseUrl, + podBaseUrl: `${baseUrl}${podName}/`, + createWebId: true, + register: true, + createPod: true, + }); + + expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); + expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password); + expect(accountStore.verify).toHaveBeenCalledTimes(1); + expect(accountStore.verify).toHaveBeenLastCalledWith(email); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + }); + + it('can create a root pod.', async(): Promise => { + const params: any = { email, webId, createPod, rootPod, createWebId: false, register: false }; + podSettings.podBaseUrl = baseUrl; + await expect(manager.register(params, true)).resolves.toEqual({ + email, + webId, + oidcIssuer: baseUrl, + podBaseUrl: baseUrl, + createWebId: false, + register: false, + createPod: true, + }); + + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(podManager.createPod).toHaveBeenCalledTimes(1); + expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true); + + expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); + expect(accountStore.create).toHaveBeenCalledTimes(0); + expect(accountStore.verify).toHaveBeenCalledTimes(0); + expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts new file mode 100644 index 000000000..11dbdf177 --- /dev/null +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -0,0 +1,248 @@ +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 { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler'; +import type { ErrorHandlerArgs, ErrorHandler } from '../../../../src/ldp/http/ErrorHandler'; +import type { RequestParser } from '../../../../src/ldp/http/RequestParser'; +import type { ResponseDescription } from '../../../../src/ldp/http/response/ResponseDescription'; +import type { ResponseWriter } from '../../../../src/ldp/http/ResponseWriter'; +import type { Operation } from '../../../../src/ldp/operations/Operation'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import { getBestPreference } from '../../../../src/storage/conversion/ConversionUtil'; +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 { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { joinUrl } from '../../../../src/util/PathUtil'; +import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; +import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies'; + +describe('A SetupHttpHandler', (): void => { + const baseUrl = 'http://test.com/'; + let request: HttpRequest; + let requestBody: SetupInput; + const response: HttpResponse = {} as any; + const viewTemplate = '/templates/view'; + const responseTemplate = '/templates/response'; + const storageKey = 'completed'; + let details: RegistrationResponse; + let requestParser: jest.Mocked; + let errorHandler: jest.Mocked; + let responseWriter: jest.Mocked; + let registrationManager: jest.Mocked; + let initializer: jest.Mocked; + let converter: jest.Mocked; + let storage: jest.Mocked>; + let handler: SetupHttpHandler; + + beforeEach(async(): Promise => { + request = { url: '/setup', method: 'GET', headers: {}} as any; + requestBody = {}; + + requestParser = { + handleSafe: jest.fn(async(req: HttpRequest): Promise => ({ + target: { path: joinUrl(baseUrl, req.url!) }, + method: req.method!, + body: req.method === 'GET' ? + undefined : + new BasicRepresentation(JSON.stringify(requestBody), req.headers['content-type'] ?? 'text/plain'), + preferences: { type: { 'text/html': 1 }}, + })), + } as any; + + errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ + statusCode: 400, + data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), + })) } as any; + + responseWriter = { handleSafe: jest.fn() } as any; + + initializer = { + handleSafe: jest.fn(), + } as any; + + 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; + + converter = { + handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { + // Just find the best match; + const type = getBestPreference(input.preferences.type!, { '*/*': 1 })!; + const metadata = new RepresentationMetadata(input.representation.metadata, { [CONTENT_TYPE]: type.value }); + return new BasicRepresentation(input.representation.data, metadata); + }), + } as any; + + storage = new Map() as any; + + handler = new SetupHttpHandler({ + requestParser, + errorHandler, + responseWriter, + initializer, + registrationManager, + converter, + storageKey, + storage, + viewTemplate, + responseTemplate, + }); + }); + + // Since all tests check similar things, the test functionality is generalized in here + async function testPost(input: SetupInput, error?: HttpError): Promise { + request.method = 'POST'; + const initialize = Boolean(input.initialize); + const registration = Boolean(input.registration); + requestBody = { initialize, registration }; + + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0); + expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + let expectedResult: any = { initialize, registration }; + if (error) { + expectedResult = { name: error.name, message: error.message }; + } 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.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 => { + 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({}); + 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(); + + // 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 => { + requestParser.handleSafe.mockResolvedValueOnce({ + target: { path: joinUrl(baseUrl, '/randomPath') }, + method: 'POST', + preferences: { type: { 'text/html': 1 }}, + }); + 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 => { + request.method = 'PUT'; + requestBody = { initialize: true, registration: true }; + const error = new MethodNotAllowedHttpError(); + + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; + expect(mockResponse).toBe(response); + expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message }); + expect(result.statusCode).toBe(405); + expect(result.metadata?.contentType).toBe('text/html'); + 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({ + requestParser, + errorHandler, + responseWriter, + initializer, + converter, + storageKey, + storage, + viewTemplate, + responseTemplate, + }); + request.method = 'POST'; + requestBody = { initialize: false, registration: true }; + 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({ + requestParser, + errorHandler, + responseWriter, + registrationManager, + converter, + storageKey, + storage, + viewTemplate, + responseTemplate, + }); + request.method = 'POST'; + requestBody = { initialize: true, registration: false }; + 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(); + }); +}); diff --git a/test/unit/pods/GeneratedPodManager.test.ts b/test/unit/pods/GeneratedPodManager.test.ts index 7aec58fa3..6d1f53b81 100644 --- a/test/unit/pods/GeneratedPodManager.test.ts +++ b/test/unit/pods/GeneratedPodManager.test.ts @@ -37,13 +37,23 @@ describe('A GeneratedPodManager', (): void => { it('throws an error if the generate identifier is not available.', async(): Promise => { store.resourceExists.mockResolvedValueOnce(true); - const result = manager.createPod({ path: `${base}user/` }, settings); + const result = manager.createPod({ path: `${base}user/` }, settings, false); await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`); await expect(result).rejects.toThrow(ConflictHttpError); }); it('generates an identifier and writes containers before writing the resources in them.', async(): Promise => { - await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings)).resolves.toBeUndefined(); + await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings, false)).resolves.toBeUndefined(); + + expect(store.setRepresentation).toHaveBeenCalledTimes(3); + expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/'); + expect(store.setRepresentation).toHaveBeenNthCalledWith(2, { path: '/path/a/' }, '/a/'); + expect(store.setRepresentation).toHaveBeenNthCalledWith(3, { path: '/path/a/b' }, '/a/b'); + }); + + it('allows overwriting when enabled.', async(): Promise => { + store.resourceExists.mockResolvedValueOnce(true); + await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings, true)).resolves.toBeUndefined(); expect(store.setRepresentation).toHaveBeenCalledTimes(3); expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/');