From 42a1ca7b645d537b5539827e44f668d46b3ede38 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 28 Sep 2023 13:58:26 +0200 Subject: [PATCH] feat: Create PodCreator class to contain most pod creation logic --- config/app/init/initializers/seeding.json | 6 +- .../interaction/routing/pod/create.json | 15 +- .../interaction/pod/CreatePodHandler.ts | 126 +++-------------- .../interaction/pod/util/BasePodCreator.ts | 132 ++++++++++++++++++ .../interaction/pod/util/PodCreator.ts | 48 +++++++ src/index.ts | 2 + src/init/SeededAccountInitializer.ts | 55 ++++---- .../interaction/pod/CreatePodHandler.test.ts | 117 +++------------- .../pod/util/BasePodCreator.test.ts | 111 +++++++++++++++ .../init/SeededAccountInitializer.test.ts | 69 +++++---- 10 files changed, 401 insertions(+), 280 deletions(-) create mode 100644 src/identity/interaction/pod/util/BasePodCreator.ts create mode 100644 src/identity/interaction/pod/util/PodCreator.ts create mode 100644 test/unit/identity/interaction/pod/util/BasePodCreator.test.ts diff --git a/config/app/init/initializers/seeding.json b/config/app/init/initializers/seeding.json index fa02a675c..6760a7be4 100644 --- a/config/app/init/initializers/seeding.json +++ b/config/app/init/initializers/seeding.json @@ -5,9 +5,9 @@ "comment": "Initializer that instantiates all the seeded accounts and pods.", "@id": "urn:solid-server:default:SeededAccountInitializer", "@type": "SeededAccountInitializer", - "accountHandler": { "@id": "urn:solid-server:default:CreateAccountHandler" }, - "passwordHandler": { "@id": "urn:solid-server:default:CreatePasswordHandler" }, - "podHandler": { "@id": "urn:solid-server:default:CreatePodHandler" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "podCreator": { "@id": "urn:solid-server:default:PodCreator" }, "configFilePath": { "@id": "urn:solid-server:default:variable:seedConfig" } } ] diff --git a/config/identity/interaction/routing/pod/create.json b/config/identity/interaction/routing/pod/create.json index 5054c26e6..7f7213587 100644 --- a/config/identity/interaction/routing/pod/create.json +++ b/config/identity/interaction/routing/pod/create.json @@ -16,14 +16,19 @@ "source": { "@id": "urn:solid-server:default:CreatePodHandler", "@type": "CreatePodHandler", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, - "relativeWebIdPath": "/profile/card#me", - "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "podStore": { "@id": "urn:solid-server:default:PodStore" }, "webIdLinkRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, "podIdRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" }, - "allowRoot": false + "allowRoot": false, + "podCreator": { + "@id": "urn:solid-server:default:PodCreator", + "@type": "BasePodCreator", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, + "relativeWebIdPath": "/profile/card#me", + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "podStore": { "@id": "urn:solid-server:default:PodStore" } + } } } } diff --git a/src/identity/interaction/pod/CreatePodHandler.ts b/src/identity/interaction/pod/CreatePodHandler.ts index 3641e4063..18b66d188 100644 --- a/src/identity/interaction/pod/CreatePodHandler.ts +++ b/src/identity/interaction/pod/CreatePodHandler.ts @@ -1,20 +1,15 @@ import type { StringSchema } from 'yup'; import { object, string } from 'yup'; -import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../../../logging/LogUtil'; -import type { IdentifierGenerator } from '../../../pods/generate/IdentifierGenerator'; -import type { PodSettings } from '../../../pods/settings/PodSettings'; -import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { joinUrl } from '../../../util/PathUtil'; import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; -import type { WebIdStore } from '../webid/util/WebIdStore'; import type { WebIdLinkRoute } from '../webid/WebIdLinkRoute'; import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; import type { PodIdRoute } from './PodIdRoute'; +import type { PodCreator } from './util/PodCreator'; import type { PodStore } from './util/PodStore'; const inSchema = object({ @@ -24,43 +19,6 @@ const inSchema = object({ }).optional(), }); -export interface CreatePodHandlerArgs { - /** - * Base URL of the server. - * Used to potentially set the `solid:oidcIssuer` triple - * and/or the pod URL if it is a root pod. - */ - baseUrl: string; - /** - * Generates the base URL of the pod based on the input `name`. - */ - identifierGenerator: IdentifierGenerator; - /** - * The path of where the WebID will be generated by the template, relative to the pod URL. - */ - relativeWebIdPath: string; - /** - * WebID data store. - */ - webIdStore: WebIdStore; - /** - * Pod data store. - */ - podStore: PodStore; - /** - * Route to generate WebID link resource URLs. - */ - webIdLinkRoute: WebIdLinkRoute; - /** - * Route to generate Pod ID resource URLs - */ - podIdRoute: PodIdRoute; - /** - * Whether it is allowed to generate a pod in the root of the server. - */ - allowRoot: boolean; -} - type OutType = { pod: string; podResource: string; @@ -70,34 +28,29 @@ type OutType = { /** * Handles the creation of pods. - * Will call the stored {@link PodStore} with the settings found in the input JSON. + * Will call the stored {@link PodCreator} with the settings found in the input JSON. */ export class CreatePodHandler extends JsonInteractionHandler implements JsonView { private readonly logger = getLoggerFor(this); - private readonly baseUrl: string; - private readonly identifierGenerator: IdentifierGenerator; - private readonly relativeWebIdPath: string; - private readonly webIdStore: WebIdStore; private readonly podStore: PodStore; + private readonly podCreator: PodCreator; private readonly webIdLinkRoute: WebIdLinkRoute; private readonly podIdRoute: PodIdRoute; private readonly inSchema: typeof inSchema; - public constructor(args: CreatePodHandlerArgs) { + public constructor(podStore: PodStore, podCreator: PodCreator, webIdLinkRoute: WebIdLinkRoute, podIdRoute: PodIdRoute, + allowRoot = false) { super(); - this.baseUrl = args.baseUrl; - this.identifierGenerator = args.identifierGenerator; - this.relativeWebIdPath = args.relativeWebIdPath; - this.webIdStore = args.webIdStore; - this.podStore = args.podStore; - this.webIdLinkRoute = args.webIdLinkRoute; - this.podIdRoute = args.podIdRoute; + this.podStore = podStore; + this.podCreator = podCreator; + this.webIdLinkRoute = webIdLinkRoute; + this.podIdRoute = podIdRoute; this.inSchema = inSchema.clone(); - if (!args.allowRoot) { + if (!allowRoot) { // Casting is necessary to prevent errors this.inSchema.fields.name = (this.inSchema.fields.name as StringSchema).required(); } @@ -117,60 +70,13 @@ export class CreatePodHandler extends JsonInteractionHandler implements const { name, settings } = await validateWithError(inSchema, json); assertAccountId(accountId); - const baseIdentifier = this.generateBaseIdentifier(name); - // Either the input WebID or the one generated in the pod - const webId = settings?.webId ?? joinUrl(baseIdentifier.path, this.relativeWebIdPath); - const linkWebId = !settings?.webId; + const result = await this.podCreator.handleSafe({ + accountId, webId: settings?.webId, name, settings, + }); - const podSettings: PodSettings = { - ...settings, - base: baseIdentifier, - webId, - }; + const webIdResource = result.webIdLink && this.webIdLinkRoute.getPath({ accountId, webIdLink: result.webIdLink }); + const podResource = this.podIdRoute.getPath({ accountId, podId: result.podId }); - // Link the WebID to the account immediately if no WebID was provided. - // This WebID will be necessary anyway to access the data in the pod, - // so might as well link it to the account immediately. - let webIdLink: string | undefined; - let webIdResource: string | undefined; - if (linkWebId) { - // It is important that this check happens here. - // Otherwise, if the account already has this WebID link, - // this link would be deleted if pod creation fails, - // since we clean up the WebID link again afterwards. - // Current implementation of the {@link WebIdStore} also has this check but better safe than sorry. - if (await this.webIdStore.isLinked(webId, accountId)) { - this.logger.warn('Trying to create pod which would generate a WebID that is already linked to this account'); - throw new BadRequestHttpError(`${webId} is already registered to this account.`); - } - - webIdLink = await this.webIdStore.create(webId, accountId); - webIdResource = this.webIdLinkRoute.getPath({ accountId, webIdLink }); - // Need to have the necessary `solid:oidcIssuer` triple if the WebID is linked - podSettings.oidcIssuer = this.baseUrl; - } - - // Create the pod - let podId: string; - try { - podId = await this.podStore.create(accountId, podSettings, !name); - } catch (error: unknown) { - // Undo the WebID linking if pod creation fails - if (webIdLink) { - await this.webIdStore.delete(webIdLink); - } - - throw error; - } - - const podResource = this.podIdRoute.getPath({ accountId, podId }); - return { json: { pod: baseIdentifier.path, webId, podResource, webIdResource }}; - } - - private generateBaseIdentifier(name?: string): ResourceIdentifier { - if (name) { - return this.identifierGenerator.generate(name); - } - return { path: this.baseUrl }; + return { json: { pod: result.podUrl, webId: result.webId, podResource, webIdResource }}; } } diff --git a/src/identity/interaction/pod/util/BasePodCreator.ts b/src/identity/interaction/pod/util/BasePodCreator.ts new file mode 100644 index 000000000..44841a70b --- /dev/null +++ b/src/identity/interaction/pod/util/BasePodCreator.ts @@ -0,0 +1,132 @@ +import type { ResourceIdentifier } from '../../../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator'; +import type { PodSettings } from '../../../../pods/settings/PodSettings'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { joinUrl } from '../../../../util/PathUtil'; +import type { WebIdStore } from '../../webid/util/WebIdStore'; +import { PodCreator } from './PodCreator'; +import type { PodCreatorInput, PodCreatorOutput } from './PodCreator'; +import type { PodStore } from './PodStore'; + +export interface BasePodCreatorArgs { + /** + * Base URL of the server. + * Used to potentially set the `solid:oidcIssuer` triple + * and/or the pod URL if it is a root pod. + */ + baseUrl: string; + /** + * Generates the base URL of the pod based on the input `name`. + */ + identifierGenerator: IdentifierGenerator; + /** + * The path of where the WebID will be generated by the template, relative to the pod URL. + */ + relativeWebIdPath: string; + /** + * WebID data store. + */ + webIdStore: WebIdStore; + /** + * Pod data store. + */ + podStore: PodStore; +} + +/** + * Handles the creation of pods. + * Will call the stored {@link PodStore} with the provided settings. + */ +export class BasePodCreator extends PodCreator { + protected readonly logger = getLoggerFor(this); + + protected readonly baseUrl: string; + protected readonly identifierGenerator: IdentifierGenerator; + protected readonly relativeWebIdPath: string; + protected readonly webIdStore: WebIdStore; + protected readonly podStore: PodStore; + + public constructor(args: BasePodCreatorArgs) { + super(); + this.baseUrl = args.baseUrl; + this.identifierGenerator = args.identifierGenerator; + this.relativeWebIdPath = args.relativeWebIdPath; + this.webIdStore = args.webIdStore; + this.podStore = args.podStore; + } + + public async handle(input: PodCreatorInput): Promise { + const baseIdentifier = this.generateBaseIdentifier(input.name); + // Either the input WebID or the one generated in the pod + const webId = input.webId ?? joinUrl(baseIdentifier.path, this.relativeWebIdPath); + + const podSettings: PodSettings = { + ...input.settings, + base: baseIdentifier, + webId, + }; + + // Link the WebID to the account immediately if no WebID was provided as this is expected behaviour. + // We do this first as we can't undo creating the pod if this would fail. + // If an external WebID is the owner we do not want to link it to the account automatically + const webIdLink = await this.handleWebId(!input.webId, webId, input.accountId, podSettings); + + // Create the pod + const podId = await this.createPod(input.accountId, podSettings, !input.name, webIdLink); + + return { + podUrl: baseIdentifier.path, + webId, + podId, + webIdLink, + }; + } + + protected generateBaseIdentifier(name?: string): ResourceIdentifier { + if (name) { + return this.identifierGenerator.generate(name); + } + return { path: this.baseUrl }; + } + + /** + * Links the WebID to the account if `linkWebId` is true. + * Also updates the `oidcIssuer` value in the settings object in that case. + */ + protected async handleWebId(linkWebId: boolean, webId: string, accountId: string, settings: PodSettings): + Promise { + if (linkWebId) { + // It is important that this check happens here. + // Otherwise, if the account already has this WebID link, + // this link would be deleted if pod creation fails, + // since we clean up the WebID link again afterwards. + // Current implementation of the {@link WebIdStore} also has this check but better safe than sorry. + if (await this.webIdStore.isLinked(webId, accountId)) { + this.logger.warn('Trying to create pod which would generate a WebID that is already linked to this account'); + throw new BadRequestHttpError(`${webId} is already registered to this account.`); + } + // Need to have the necessary `solid:oidcIssuer` triple if the WebID is linked + settings.oidcIssuer = this.baseUrl; + + return this.webIdStore.create(webId, accountId); + } + } + + /** + * Creates a pod with the given settings. + * In case pod creation fails, the given WebID link will be removed, if there is one, before throwing an error. + */ + protected async createPod(accountId: string, settings: PodSettings, overwrite: boolean, webIdLink?: string): + Promise { + try { + return await this.podStore.create(accountId, settings, overwrite); + } catch (error: unknown) { + // Undo the WebID linking if pod creation fails + if (webIdLink) { + await this.webIdStore.delete(webIdLink); + } + throw error; + } + } +} diff --git a/src/identity/interaction/pod/util/PodCreator.ts b/src/identity/interaction/pod/util/PodCreator.ts new file mode 100644 index 000000000..b6f4a5889 --- /dev/null +++ b/src/identity/interaction/pod/util/PodCreator.ts @@ -0,0 +1,48 @@ +import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; + +export interface PodCreatorInput { + /** + * The ID of the account to create the pod for. + */ + accountId: string; + /** + * The name to use when generating the base URL of the pod. + * If undefined, the pod will be created in the root of the server. + */ + name?: string; + /** + * The WebID to use for creation of the pod. + * This WebID will be used in the templates to, for example, determine who has access. + * If none is provided, the WebID generated by the creator will be used, + * in which case that WebID will also be linked to the account. + */ + webId?: string; + /** + * Additional settings to use when generating a pod. + */ + settings?: Record; +} + +export interface PodCreatorOutput { + /** + * The ID of the generated pod. + */ + podId: string; + /** + * The URl of the generated pod. + */ + podUrl: string; + /** + * The WebID that was used to generate the pod. + */ + webId: string; + /** + * The ID of the WebID link if one was generated. + */ + webIdLink?: string; +} + +/** + * Handles creating a pod and linking the created WebID. + */ +export abstract class PodCreator extends AsyncHandler {} diff --git a/src/index.ts b/src/index.ts index cd9041be7..bdc3d56d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,8 +201,10 @@ export * from './identity/interaction/password/ResetPasswordHandler'; export * from './identity/interaction/password/UpdatePasswordHandler'; // Identity/Interaction/Pod/Util +export * from './identity/interaction/pod/util/BasePodCreator'; export * from './identity/interaction/pod/util/BasePodStore'; export * from './identity/interaction/pod/util/OwnerMetadataWriter'; +export * from './identity/interaction/pod/util/PodCreator'; export * from './identity/interaction/pod/util/PodStore'; // Identity/Interaction/Pod diff --git a/src/init/SeededAccountInitializer.ts b/src/init/SeededAccountInitializer.ts index a80664bfe..19fa50481 100644 --- a/src/init/SeededAccountInitializer.ts +++ b/src/init/SeededAccountInitializer.ts @@ -1,8 +1,8 @@ import { readJson } from 'fs-extra'; import { array, object, string } from 'yup'; -import { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; -import type { JsonInteractionHandler } from '../identity/interaction/JsonInteractionHandler'; -import type { ResolveLoginHandler } from '../identity/interaction/login/ResolveLoginHandler'; +import type { AccountStore } from '../identity/interaction/account/util/AccountStore'; +import type { PasswordStore } from '../identity/interaction/password/util/PasswordStore'; +import type { PodCreator } from '../identity/interaction/pod/util/PodCreator'; import { URL_SCHEMA } from '../identity/interaction/YupUtil'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage } from '../util/errors/ErrorUtil'; @@ -24,15 +24,15 @@ export interface SeededAccountInitializerArgs { /** * Creates the accounts. */ - accountHandler: ResolveLoginHandler; + accountStore: AccountStore; /** * Adds the login methods. */ - passwordHandler: JsonInteractionHandler; + passwordStore: PasswordStore; /** * Creates the pods. */ - podHandler: JsonInteractionHandler; + podCreator: PodCreator; /** * File path of the JSON describing the accounts to seed. */ @@ -47,16 +47,16 @@ export interface SeededAccountInitializerArgs { export class SeededAccountInitializer extends Initializer { protected readonly logger = getLoggerFor(this); - private readonly accountHandler: ResolveLoginHandler; - private readonly passwordHandler: JsonInteractionHandler; - private readonly podHandler: JsonInteractionHandler; + private readonly accountStore: AccountStore; + private readonly passwordStore: PasswordStore; + private readonly podCreator: PodCreator; private readonly configFilePath?: string; public constructor(args: SeededAccountInitializerArgs) { super(); - this.accountHandler = args.accountHandler; - this.passwordHandler = args.passwordHandler; - this.podHandler = args.podHandler; + this.accountStore = args.accountStore; + this.passwordStore = args.passwordStore; + this.podCreator = args.podCreator; this.configFilePath = args.configFilePath; } @@ -76,30 +76,25 @@ export class SeededAccountInitializer extends Initializer { throw new Error(msg); } - // Dummy data for requests to all the handlers - const method = 'POST'; - const target = { path: '' }; - const metadata = new RepresentationMetadata(); - - let accounts = 0; - let pods = 0; - for await (const input of configuration) { + let accountCount = 0; + let podCount = 0; + for await (const { email, password, pods } of configuration) { try { - this.logger.info(`Creating account for ${input.email}`); - const accountResult = await this.accountHandler.login({ method, target, metadata, json: {}}); - const { accountId } = accountResult.json; - await this.passwordHandler.handleSafe({ method, target, metadata, accountId, json: input }); - accounts += 1; + this.logger.info(`Creating account for ${email}`); + const accountId = await this.accountStore.create(); + const id = await this.passwordStore.create(email, accountId, password); + await this.passwordStore.confirmVerification(id); + accountCount += 1; - for (const pod of input.pods ?? []) { - this.logger.info(`Creating pod with name ${pod.name}`); - await this.podHandler.handleSafe({ method, target, metadata, accountId, json: pod }); - pods += 1; + for (const { name, settings } of pods ?? []) { + this.logger.info(`Creating pod with name ${name}`); + await this.podCreator.handleSafe({ accountId, name, webId: settings?.webId, settings }); + podCount += 1; } } catch (error: unknown) { this.logger.warn(`Error while initializing seeded account: ${createErrorMessage(error)}`); } } - this.logger.info(`Initialized ${accounts} accounts and ${pods} pods.`); + this.logger.info(`Initialized ${accountCount} accounts and ${podCount} pods.`); } } diff --git a/test/unit/identity/interaction/pod/CreatePodHandler.test.ts b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts index 281ff02bd..495ded622 100644 --- a/test/unit/identity/interaction/pod/CreatePodHandler.test.ts +++ b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts @@ -1,9 +1,8 @@ import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler'; import type { PodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute'; +import { PodCreator } from '../../../../../src/identity/interaction/pod/util/PodCreator'; import { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore'; -import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; -import type { IdentifierGenerator } from '../../../../../src/pods/generate/IdentifierGenerator'; describe('A CreatePodHandler', (): void => { const name = 'name'; @@ -12,17 +11,13 @@ describe('A CreatePodHandler', (): void => { const podId = 'podId'; const webIdLink = 'webIdLink'; let json: unknown; - const baseUrl = 'http://example.com/'; - const relativeWebIdPath = '/profile/card#me'; const podUrl = 'http://example.com/name/'; - const generatedWebId = 'http://example.com/name/profile/card#me'; const webIdResource = 'http://example.com/.account/webID'; const podResource = 'http://example.com/.account/pod'; - let identifierGenerator: jest.Mocked; - let webIdStore: jest.Mocked; let webIdLinkRoute: jest.Mocked; let podIdRoute: jest.Mocked; let podStore: jest.Mocked; + let podCreator: jest.Mocked; let handler: CreatePodHandler; beforeEach(async(): Promise => { @@ -30,16 +25,9 @@ describe('A CreatePodHandler', (): void => { name, }; - identifierGenerator = { - generate: jest.fn().mockReturnValue({ path: podUrl }), - extractPod: jest.fn(), - }; - - webIdStore = { - isLinked: jest.fn().mockResolvedValue(false), - create: jest.fn().mockResolvedValue(webIdLink), - delete: jest.fn(), - } satisfies Partial as any; + podCreator = { + handleSafe: jest.fn().mockResolvedValue({ podUrl, podId, webId, webIdLink }), + } satisfies Partial as any; podStore = { create: jest.fn().mockResolvedValue(podId), @@ -56,16 +44,7 @@ describe('A CreatePodHandler', (): void => { matchPath: jest.fn(), }; - handler = new CreatePodHandler({ - webIdStore, - podStore, - baseUrl, - relativeWebIdPath, - identifierGenerator, - webIdLinkRoute, - podIdRoute, - allowRoot: false, - }); + handler = new CreatePodHandler(podStore, podCreator, webIdLinkRoute, podIdRoute); }); it('returns the required input fields and known pods.', async(): Promise => { @@ -91,75 +70,25 @@ describe('A CreatePodHandler', (): void => { it('generates a pod and WebID.', async(): Promise => { await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { - pod: podUrl, webId: generatedWebId, podResource, webIdResource, + pod: podUrl, webId, podResource, webIdResource, }}); - expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); - expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); - expect(webIdStore.create).toHaveBeenCalledTimes(1); - expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); - expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(accountId, { - base: { path: podUrl }, - webId: generatedWebId, - oidcIssuer: baseUrl, - }, false); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId, name, settings: {}}); }); - it('can use an external WebID for the pod generation.', async(): Promise => { - json = { name, settings: { webId }}; - + it('generates a pod with a WebID if there is one.', async(): Promise => { + const settings = { webId }; + json = { name, settings }; await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { - pod: podUrl, webId, podResource, + pod: podUrl, webId, podResource, webIdResource, }}); - expect(webIdStore.isLinked).toHaveBeenCalledTimes(0); - expect(webIdStore.create).toHaveBeenCalledTimes(0); - expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(accountId, { - base: { path: podUrl }, - webId, - }, false); - }); - - it('errors if the account is already linked to the WebID that would be generated.', async(): Promise => { - webIdStore.isLinked.mockResolvedValueOnce(true); - await expect(handler.handle({ json, accountId } as any)) - .rejects.toThrow(`${generatedWebId} is already registered to this account.`); - expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); - expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); - expect(webIdStore.create).toHaveBeenCalledTimes(0); - expect(podStore.create).toHaveBeenCalledTimes(0); - }); - - it('undoes any changes if something goes wrong creating the pod.', async(): Promise => { - const error = new Error('bad data'); - podStore.create.mockRejectedValueOnce(error); - - await expect(handler.handle({ json, accountId } as any)).rejects.toBe(error); - - expect(webIdStore.create).toHaveBeenCalledTimes(1); - expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); - expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(accountId, { - base: { path: podUrl }, - webId: generatedWebId, - oidcIssuer: baseUrl, - }, false); - expect(webIdStore.delete).toHaveBeenCalledTimes(1); - expect(webIdStore.delete).toHaveBeenLastCalledWith(webIdLink); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId, name, webId, settings }); }); describe('allowing root pods', (): void => { beforeEach(async(): Promise => { - handler = new CreatePodHandler({ - webIdStore, - podStore, - baseUrl, - relativeWebIdPath, - identifierGenerator, - webIdLinkRoute, - podIdRoute, - allowRoot: true, - }); + handler = new CreatePodHandler(podStore, podCreator, webIdLinkRoute, podIdRoute, true); }); it('does not require a name.', async(): Promise => { @@ -179,19 +108,5 @@ describe('A CreatePodHandler', (): void => { }, }); }); - - it('generates a pod and WebID in the root.', async(): Promise => { - await expect(handler.handle({ json: {}, accountId } as any)).resolves.toEqual({ json: { - pod: baseUrl, webId: `${baseUrl}profile/card#me`, podResource, webIdResource, - }}); - expect(webIdStore.create).toHaveBeenCalledTimes(1); - expect(webIdStore.create).toHaveBeenLastCalledWith(`${baseUrl}profile/card#me`, accountId); - expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(accountId, { - base: { path: baseUrl }, - webId: `${baseUrl}profile/card#me`, - oidcIssuer: baseUrl, - }, true); - }); }); }); diff --git a/test/unit/identity/interaction/pod/util/BasePodCreator.test.ts b/test/unit/identity/interaction/pod/util/BasePodCreator.test.ts new file mode 100644 index 000000000..57a4610d5 --- /dev/null +++ b/test/unit/identity/interaction/pod/util/BasePodCreator.test.ts @@ -0,0 +1,111 @@ +import { BasePodCreator } from '../../../../../../src/identity/interaction/pod/util/BasePodCreator'; +import { PodStore } from '../../../../../../src/identity/interaction/pod/util/PodStore'; +import { WebIdStore } from '../../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator'; + +describe('A BasePodCreator', (): void => { + const name = 'name'; + const webId = 'http://example.com/other/webId#me'; + const accountId = 'accountId'; + const podId = 'podId'; + const webIdLink = 'webIdLink'; + const baseUrl = 'http://example.com/'; + const relativeWebIdPath = '/profile/card#me'; + const podUrl = 'http://example.com/name/'; + const generatedWebId = 'http://example.com/name/profile/card#me'; + let identifierGenerator: jest.Mocked; + let webIdStore: jest.Mocked; + let podStore: jest.Mocked; + let creator: BasePodCreator; + + beforeEach(async(): Promise => { + identifierGenerator = { + generate: jest.fn().mockReturnValue({ path: podUrl }), + extractPod: jest.fn(), + }; + + webIdStore = { + isLinked: jest.fn().mockResolvedValue(false), + create: jest.fn().mockResolvedValue(webIdLink), + delete: jest.fn(), + } satisfies Partial as any; + + podStore = { + create: jest.fn().mockResolvedValue(podId), + findPods: jest.fn().mockResolvedValue([{ id: podId, baseUrl: podUrl }]), + } satisfies Partial as any; + + creator = new BasePodCreator({ + webIdStore, + podStore, + baseUrl, + relativeWebIdPath, + identifierGenerator, + }); + }); + + it('generates a pod and WebID.', async(): Promise => { + await expect(creator.handle({ accountId, name })).resolves + .toEqual({ podUrl, webId: generatedWebId, podId, webIdLink }); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { + base: { path: podUrl }, + webId: generatedWebId, + oidcIssuer: baseUrl, + }, false); + }); + + it('can use an external WebID for the pod generation.', async(): Promise => { + await expect(creator.handle({ accountId, name, webId })).resolves.toEqual({ podUrl, webId, podId }); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(0); + expect(webIdStore.create).toHaveBeenCalledTimes(0); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { + base: { path: podUrl }, + webId, + }, false); + }); + + it('create a root pod.', async(): Promise => { + await expect(creator.handle({ accountId, webId })).resolves.toEqual({ podUrl: baseUrl, webId, podId }); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(0); + expect(webIdStore.create).toHaveBeenCalledTimes(0); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { + base: { path: baseUrl }, + webId, + }, true); + }); + + it('errors if the account is already linked to the WebID that would be generated.', async(): Promise => { + webIdStore.isLinked.mockResolvedValueOnce(true); + await expect(creator.handle({ name, accountId })) + .rejects.toThrow(`${generatedWebId} is already registered to this account.`); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(webIdStore.create).toHaveBeenCalledTimes(0); + expect(podStore.create).toHaveBeenCalledTimes(0); + }); + + it('undoes any changes if something goes wrong creating the pod.', async(): Promise => { + const error = new Error('bad data'); + podStore.create.mockRejectedValueOnce(error); + + await expect(creator.handle({ name, accountId })).rejects.toBe(error); + + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { + base: { path: podUrl }, + webId: generatedWebId, + oidcIssuer: baseUrl, + }, false); + expect(webIdStore.delete).toHaveBeenCalledTimes(1); + expect(webIdStore.delete).toHaveBeenLastCalledWith(webIdLink); + }); +}); diff --git a/test/unit/init/SeededAccountInitializer.test.ts b/test/unit/init/SeededAccountInitializer.test.ts index 73532c98d..3c9f4ec62 100644 --- a/test/unit/init/SeededAccountInitializer.test.ts +++ b/test/unit/init/SeededAccountInitializer.test.ts @@ -1,6 +1,7 @@ import { writeJson } from 'fs-extra'; -import type { JsonInteractionHandler } from '../../../src/identity/interaction/JsonInteractionHandler'; -import type { ResolveLoginHandler } from '../../../src/identity/interaction/login/ResolveLoginHandler'; +import { AccountStore } from '../../../src/identity/interaction/account/util/AccountStore'; +import { PasswordStore } from '../../../src/identity/interaction/password/util/PasswordStore'; +import { PodCreator } from '../../../src/identity/interaction/pod/util/PodCreator'; import { SeededAccountInitializer } from '../../../src/init/SeededAccountInitializer'; import { mockFileSystem } from '../../util/Util'; @@ -25,40 +26,45 @@ describe('A SeededAccountInitializer', (): void => { }, ]; const configFilePath = './seeded-pod-config.json'; - let accountHandler: jest.Mocked; - let passwordHandler: jest.Mocked; - let podHandler: jest.Mocked; + let accountStore: jest.Mocked; + let passwordStore: jest.Mocked; + let podCreator: jest.Mocked; let initializer: SeededAccountInitializer; beforeEach(async(): Promise => { let count = 0; - accountHandler = { - login: jest.fn(async(): Promise => { + accountStore = { + create: jest.fn(async(): Promise => { count += 1; - return { json: { accountId: `account${count}` }}; + return `account${count}`; }), - } as any; + } satisfies Partial as any; - passwordHandler = { - handleSafe: jest.fn(), - } as any; + let pwCount = 0; + passwordStore = { + create: jest.fn(async(): Promise => { + pwCount += 1; + return `password${pwCount}`; + }), + confirmVerification: jest.fn(), + } satisfies Partial as any; - podHandler = { + podCreator = { handleSafe: jest.fn(), - } as any; + } satisfies Partial as any; mockFileSystem('/'); await writeJson(configFilePath, dummyConfig); initializer = new SeededAccountInitializer({ - accountHandler, passwordHandler, podHandler, configFilePath, + accountStore, passwordStore, podCreator, configFilePath, }); }); it('does not generate any accounts or pods if no config file is specified.', async(): Promise => { - await expect(new SeededAccountInitializer({ accountHandler, passwordHandler, podHandler }).handle()) + await expect(new SeededAccountInitializer({ accountStore, passwordStore, podCreator }).handle()) .resolves.toBeUndefined(); - expect(accountHandler.login).toHaveBeenCalledTimes(0); + expect(accountStore.create).toHaveBeenCalledTimes(0); }); it('errors if the seed file is invalid.', async(): Promise => { @@ -69,24 +75,25 @@ describe('A SeededAccountInitializer', (): void => { it('generates an account with the specified settings.', async(): Promise => { await expect(initializer.handleSafe()).resolves.toBeUndefined(); - expect(accountHandler.login).toHaveBeenCalledTimes(2); - expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(2); - expect(passwordHandler.handleSafe.mock.calls[0][0].json) - .toEqual(expect.objectContaining({ email: 'hello@example.com', password: 'abc123' })); - expect(passwordHandler.handleSafe.mock.calls[1][0].json) - .toEqual(expect.objectContaining({ email: 'hello2@example.com', password: '123abc' })); - expect(podHandler.handleSafe).toHaveBeenCalledTimes(3); - expect(podHandler.handleSafe.mock.calls[0][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![0])); - expect(podHandler.handleSafe.mock.calls[1][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![1])); - expect(podHandler.handleSafe.mock.calls[2][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![2])); + expect(accountStore.create).toHaveBeenCalledTimes(2); + expect(passwordStore.create).toHaveBeenCalledTimes(2); + expect(passwordStore.create).toHaveBeenNthCalledWith(1, 'hello@example.com', 'account1', 'abc123'); + expect(passwordStore.create).toHaveBeenNthCalledWith(2, 'hello2@example.com', 'account2', '123abc'); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(2); + expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(1, 'password1'); + expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(2, 'password2'); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(3); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(1, { accountId: 'account1', name: 'pod1', settings: {}}); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(2, { accountId: 'account1', name: 'pod2', settings: {}}); + expect(podCreator.handleSafe).toHaveBeenNthCalledWith(3, { accountId: 'account1', name: 'pod3', settings: {}}); }); it('does not throw exceptions when one of the steps fails.', async(): Promise => { - accountHandler.login.mockRejectedValueOnce(new Error('bad data')); + accountStore.create.mockRejectedValueOnce(new Error('bad data')); await expect(initializer.handleSafe()).resolves.toBeUndefined(); - expect(accountHandler.login).toHaveBeenCalledTimes(2); + expect(accountStore.create).toHaveBeenCalledTimes(2); // Steps for first account will be skipped due to error - expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(podHandler.handleSafe).toHaveBeenCalledTimes(0); + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(0); }); });