feat: Create PodCreator class to contain most pod creation logic

This commit is contained in:
Joachim Van Herwegen 2023-09-28 13:58:26 +02:00
parent 3bfd9e3298
commit 42a1ca7b64
10 changed files with 401 additions and 280 deletions

View File

@ -5,9 +5,9 @@
"comment": "Initializer that instantiates all the seeded accounts and pods.", "comment": "Initializer that instantiates all the seeded accounts and pods.",
"@id": "urn:solid-server:default:SeededAccountInitializer", "@id": "urn:solid-server:default:SeededAccountInitializer",
"@type": "SeededAccountInitializer", "@type": "SeededAccountInitializer",
"accountHandler": { "@id": "urn:solid-server:default:CreateAccountHandler" }, "accountStore": { "@id": "urn:solid-server:default:AccountStore" },
"passwordHandler": { "@id": "urn:solid-server:default:CreatePasswordHandler" }, "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" },
"podHandler": { "@id": "urn:solid-server:default:CreatePodHandler" }, "podCreator": { "@id": "urn:solid-server:default:PodCreator" },
"configFilePath": { "@id": "urn:solid-server:default:variable:seedConfig" } "configFilePath": { "@id": "urn:solid-server:default:variable:seedConfig" }
} }
] ]

View File

@ -16,14 +16,19 @@
"source": { "source": {
"@id": "urn:solid-server:default:CreatePodHandler", "@id": "urn:solid-server:default:CreatePodHandler",
"@type": "CreatePodHandler", "@type": "CreatePodHandler",
"podStore": { "@id": "urn:solid-server:default:PodStore" },
"webIdLinkRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" },
"podIdRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" },
"allowRoot": false,
"podCreator": {
"@id": "urn:solid-server:default:PodCreator",
"@type": "BasePodCreator",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, "identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" },
"relativeWebIdPath": "/profile/card#me", "relativeWebIdPath": "/profile/card#me",
"webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" },
"podStore": { "@id": "urn:solid-server:default:PodStore" }, "podStore": { "@id": "urn:solid-server:default:PodStore" }
"webIdLinkRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, }
"podIdRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" },
"allowRoot": false
} }
} }
} }

View File

@ -1,20 +1,15 @@
import type { StringSchema } from 'yup'; import type { StringSchema } from 'yup';
import { object, string } from 'yup'; import { object, string } from 'yup';
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../../logging/LogUtil'; 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 { assertAccountId } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonRepresentation } from '../InteractionUtil';
import { JsonInteractionHandler } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView'; import type { JsonView } from '../JsonView';
import type { WebIdStore } from '../webid/util/WebIdStore';
import type { WebIdLinkRoute } from '../webid/WebIdLinkRoute'; import type { WebIdLinkRoute } from '../webid/WebIdLinkRoute';
import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil';
import type { PodIdRoute } from './PodIdRoute'; import type { PodIdRoute } from './PodIdRoute';
import type { PodCreator } from './util/PodCreator';
import type { PodStore } from './util/PodStore'; import type { PodStore } from './util/PodStore';
const inSchema = object({ const inSchema = object({
@ -24,43 +19,6 @@ const inSchema = object({
}).optional(), }).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 = { type OutType = {
pod: string; pod: string;
podResource: string; podResource: string;
@ -70,34 +28,29 @@ type OutType = {
/** /**
* Handles the creation of pods. * 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<OutType> implements JsonView { export class CreatePodHandler extends JsonInteractionHandler<OutType> implements JsonView {
private readonly logger = getLoggerFor(this); 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 podStore: PodStore;
private readonly podCreator: PodCreator;
private readonly webIdLinkRoute: WebIdLinkRoute; private readonly webIdLinkRoute: WebIdLinkRoute;
private readonly podIdRoute: PodIdRoute; private readonly podIdRoute: PodIdRoute;
private readonly inSchema: typeof inSchema; private readonly inSchema: typeof inSchema;
public constructor(args: CreatePodHandlerArgs) { public constructor(podStore: PodStore, podCreator: PodCreator, webIdLinkRoute: WebIdLinkRoute, podIdRoute: PodIdRoute,
allowRoot = false) {
super(); super();
this.baseUrl = args.baseUrl; this.podStore = podStore;
this.identifierGenerator = args.identifierGenerator; this.podCreator = podCreator;
this.relativeWebIdPath = args.relativeWebIdPath; this.webIdLinkRoute = webIdLinkRoute;
this.webIdStore = args.webIdStore; this.podIdRoute = podIdRoute;
this.podStore = args.podStore;
this.webIdLinkRoute = args.webIdLinkRoute;
this.podIdRoute = args.podIdRoute;
this.inSchema = inSchema.clone(); this.inSchema = inSchema.clone();
if (!args.allowRoot) { if (!allowRoot) {
// Casting is necessary to prevent errors // Casting is necessary to prevent errors
this.inSchema.fields.name = (this.inSchema.fields.name as StringSchema).required(); this.inSchema.fields.name = (this.inSchema.fields.name as StringSchema).required();
} }
@ -117,60 +70,13 @@ export class CreatePodHandler extends JsonInteractionHandler<OutType> implements
const { name, settings } = await validateWithError(inSchema, json); const { name, settings } = await validateWithError(inSchema, json);
assertAccountId(accountId); assertAccountId(accountId);
const baseIdentifier = this.generateBaseIdentifier(name); const result = await this.podCreator.handleSafe({
// Either the input WebID or the one generated in the pod accountId, webId: settings?.webId, name, settings,
const webId = settings?.webId ?? joinUrl(baseIdentifier.path, this.relativeWebIdPath); });
const linkWebId = !settings?.webId;
const podSettings: PodSettings = { const webIdResource = result.webIdLink && this.webIdLinkRoute.getPath({ accountId, webIdLink: result.webIdLink });
...settings, const podResource = this.podIdRoute.getPath({ accountId, podId: result.podId });
base: baseIdentifier,
webId,
};
// Link the WebID to the account immediately if no WebID was provided. return { json: { pod: result.podUrl, webId: result.webId, podResource, webIdResource }};
// 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 };
} }
} }

View File

@ -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<PodCreatorOutput> {
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<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.`);
}
// 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<string> {
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;
}
}
}

View File

@ -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<string, unknown>;
}
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<PodCreatorInput, PodCreatorOutput> {}

View File

@ -201,8 +201,10 @@ export * from './identity/interaction/password/ResetPasswordHandler';
export * from './identity/interaction/password/UpdatePasswordHandler'; export * from './identity/interaction/password/UpdatePasswordHandler';
// Identity/Interaction/Pod/Util // Identity/Interaction/Pod/Util
export * from './identity/interaction/pod/util/BasePodCreator';
export * from './identity/interaction/pod/util/BasePodStore'; export * from './identity/interaction/pod/util/BasePodStore';
export * from './identity/interaction/pod/util/OwnerMetadataWriter'; export * from './identity/interaction/pod/util/OwnerMetadataWriter';
export * from './identity/interaction/pod/util/PodCreator';
export * from './identity/interaction/pod/util/PodStore'; export * from './identity/interaction/pod/util/PodStore';
// Identity/Interaction/Pod // Identity/Interaction/Pod

View File

@ -1,8 +1,8 @@
import { readJson } from 'fs-extra'; import { readJson } from 'fs-extra';
import { array, object, string } from 'yup'; import { array, object, string } from 'yup';
import { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; import type { AccountStore } from '../identity/interaction/account/util/AccountStore';
import type { JsonInteractionHandler } from '../identity/interaction/JsonInteractionHandler'; import type { PasswordStore } from '../identity/interaction/password/util/PasswordStore';
import type { ResolveLoginHandler } from '../identity/interaction/login/ResolveLoginHandler'; import type { PodCreator } from '../identity/interaction/pod/util/PodCreator';
import { URL_SCHEMA } from '../identity/interaction/YupUtil'; import { URL_SCHEMA } from '../identity/interaction/YupUtil';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import { createErrorMessage } from '../util/errors/ErrorUtil'; import { createErrorMessage } from '../util/errors/ErrorUtil';
@ -24,15 +24,15 @@ export interface SeededAccountInitializerArgs {
/** /**
* Creates the accounts. * Creates the accounts.
*/ */
accountHandler: ResolveLoginHandler; accountStore: AccountStore;
/** /**
* Adds the login methods. * Adds the login methods.
*/ */
passwordHandler: JsonInteractionHandler; passwordStore: PasswordStore;
/** /**
* Creates the pods. * Creates the pods.
*/ */
podHandler: JsonInteractionHandler; podCreator: PodCreator;
/** /**
* File path of the JSON describing the accounts to seed. * File path of the JSON describing the accounts to seed.
*/ */
@ -47,16 +47,16 @@ export interface SeededAccountInitializerArgs {
export class SeededAccountInitializer extends Initializer { export class SeededAccountInitializer extends Initializer {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly accountHandler: ResolveLoginHandler; private readonly accountStore: AccountStore;
private readonly passwordHandler: JsonInteractionHandler; private readonly passwordStore: PasswordStore;
private readonly podHandler: JsonInteractionHandler; private readonly podCreator: PodCreator;
private readonly configFilePath?: string; private readonly configFilePath?: string;
public constructor(args: SeededAccountInitializerArgs) { public constructor(args: SeededAccountInitializerArgs) {
super(); super();
this.accountHandler = args.accountHandler; this.accountStore = args.accountStore;
this.passwordHandler = args.passwordHandler; this.passwordStore = args.passwordStore;
this.podHandler = args.podHandler; this.podCreator = args.podCreator;
this.configFilePath = args.configFilePath; this.configFilePath = args.configFilePath;
} }
@ -76,30 +76,25 @@ export class SeededAccountInitializer extends Initializer {
throw new Error(msg); throw new Error(msg);
} }
// Dummy data for requests to all the handlers let accountCount = 0;
const method = 'POST'; let podCount = 0;
const target = { path: '' }; for await (const { email, password, pods } of configuration) {
const metadata = new RepresentationMetadata();
let accounts = 0;
let pods = 0;
for await (const input of configuration) {
try { try {
this.logger.info(`Creating account for ${input.email}`); this.logger.info(`Creating account for ${email}`);
const accountResult = await this.accountHandler.login({ method, target, metadata, json: {}}); const accountId = await this.accountStore.create();
const { accountId } = accountResult.json; const id = await this.passwordStore.create(email, accountId, password);
await this.passwordHandler.handleSafe({ method, target, metadata, accountId, json: input }); await this.passwordStore.confirmVerification(id);
accounts += 1; accountCount += 1;
for (const pod of input.pods ?? []) { for (const { name, settings } of pods ?? []) {
this.logger.info(`Creating pod with name ${pod.name}`); this.logger.info(`Creating pod with name ${name}`);
await this.podHandler.handleSafe({ method, target, metadata, accountId, json: pod }); await this.podCreator.handleSafe({ accountId, name, webId: settings?.webId, settings });
pods += 1; podCount += 1;
} }
} catch (error: unknown) { } catch (error: unknown) {
this.logger.warn(`Error while initializing seeded account: ${createErrorMessage(error)}`); 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.`);
} }
} }

View File

@ -1,9 +1,8 @@
import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler'; import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler';
import type { PodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute'; 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 { 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 { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute';
import type { IdentifierGenerator } from '../../../../../src/pods/generate/IdentifierGenerator';
describe('A CreatePodHandler', (): void => { describe('A CreatePodHandler', (): void => {
const name = 'name'; const name = 'name';
@ -12,17 +11,13 @@ describe('A CreatePodHandler', (): void => {
const podId = 'podId'; const podId = 'podId';
const webIdLink = 'webIdLink'; const webIdLink = 'webIdLink';
let json: unknown; let json: unknown;
const baseUrl = 'http://example.com/';
const relativeWebIdPath = '/profile/card#me';
const podUrl = 'http://example.com/name/'; const podUrl = 'http://example.com/name/';
const generatedWebId = 'http://example.com/name/profile/card#me';
const webIdResource = 'http://example.com/.account/webID'; const webIdResource = 'http://example.com/.account/webID';
const podResource = 'http://example.com/.account/pod'; const podResource = 'http://example.com/.account/pod';
let identifierGenerator: jest.Mocked<IdentifierGenerator>;
let webIdStore: jest.Mocked<WebIdStore>;
let webIdLinkRoute: jest.Mocked<WebIdLinkRoute>; let webIdLinkRoute: jest.Mocked<WebIdLinkRoute>;
let podIdRoute: jest.Mocked<PodIdRoute>; let podIdRoute: jest.Mocked<PodIdRoute>;
let podStore: jest.Mocked<PodStore>; let podStore: jest.Mocked<PodStore>;
let podCreator: jest.Mocked<PodCreator>;
let handler: CreatePodHandler; let handler: CreatePodHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -30,16 +25,9 @@ describe('A CreatePodHandler', (): void => {
name, name,
}; };
identifierGenerator = { podCreator = {
generate: jest.fn().mockReturnValue({ path: podUrl }), handleSafe: jest.fn().mockResolvedValue({ podUrl, podId, webId, webIdLink }),
extractPod: jest.fn(), } satisfies Partial<PodCreator> as any;
};
webIdStore = {
isLinked: jest.fn().mockResolvedValue(false),
create: jest.fn().mockResolvedValue(webIdLink),
delete: jest.fn(),
} satisfies Partial<WebIdStore> as any;
podStore = { podStore = {
create: jest.fn().mockResolvedValue(podId), create: jest.fn().mockResolvedValue(podId),
@ -56,16 +44,7 @@ describe('A CreatePodHandler', (): void => {
matchPath: jest.fn(), matchPath: jest.fn(),
}; };
handler = new CreatePodHandler({ handler = new CreatePodHandler(podStore, podCreator, webIdLinkRoute, podIdRoute);
webIdStore,
podStore,
baseUrl,
relativeWebIdPath,
identifierGenerator,
webIdLinkRoute,
podIdRoute,
allowRoot: false,
});
}); });
it('returns the required input fields and known pods.', async(): Promise<void> => { it('returns the required input fields and known pods.', async(): Promise<void> => {
@ -91,75 +70,25 @@ describe('A CreatePodHandler', (): void => {
it('generates a pod and WebID.', async(): Promise<void> => { it('generates a pod and WebID.', async(): Promise<void> => {
await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { 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(podCreator.handleSafe).toHaveBeenCalledTimes(1);
expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId, name, settings: {}});
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<void> => { it('generates a pod with a WebID if there is one.', async(): Promise<void> => {
json = { name, settings: { webId }}; const settings = { webId };
json = { name, settings };
await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { 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(podCreator.handleSafe).toHaveBeenCalledTimes(1);
expect(webIdStore.create).toHaveBeenCalledTimes(0); expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId, name, webId, settings });
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<void> => {
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<void> => {
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);
}); });
describe('allowing root pods', (): void => { describe('allowing root pods', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
handler = new CreatePodHandler({ handler = new CreatePodHandler(podStore, podCreator, webIdLinkRoute, podIdRoute, true);
webIdStore,
podStore,
baseUrl,
relativeWebIdPath,
identifierGenerator,
webIdLinkRoute,
podIdRoute,
allowRoot: true,
});
}); });
it('does not require a name.', async(): Promise<void> => { it('does not require a name.', async(): Promise<void> => {
@ -179,19 +108,5 @@ describe('A CreatePodHandler', (): void => {
}, },
}); });
}); });
it('generates a pod and WebID in the root.', async(): Promise<void> => {
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);
});
}); });
}); });

View File

@ -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<IdentifierGenerator>;
let webIdStore: jest.Mocked<WebIdStore>;
let podStore: jest.Mocked<PodStore>;
let creator: BasePodCreator;
beforeEach(async(): Promise<void> => {
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<WebIdStore> as any;
podStore = {
create: jest.fn().mockResolvedValue(podId),
findPods: jest.fn().mockResolvedValue([{ id: podId, baseUrl: podUrl }]),
} satisfies Partial<PodStore> as any;
creator = new BasePodCreator({
webIdStore,
podStore,
baseUrl,
relativeWebIdPath,
identifierGenerator,
});
});
it('generates a pod and WebID.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
});
});

View File

@ -1,6 +1,7 @@
import { writeJson } from 'fs-extra'; import { writeJson } from 'fs-extra';
import type { JsonInteractionHandler } from '../../../src/identity/interaction/JsonInteractionHandler'; import { AccountStore } from '../../../src/identity/interaction/account/util/AccountStore';
import type { ResolveLoginHandler } from '../../../src/identity/interaction/login/ResolveLoginHandler'; 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 { SeededAccountInitializer } from '../../../src/init/SeededAccountInitializer';
import { mockFileSystem } from '../../util/Util'; import { mockFileSystem } from '../../util/Util';
@ -25,40 +26,45 @@ describe('A SeededAccountInitializer', (): void => {
}, },
]; ];
const configFilePath = './seeded-pod-config.json'; const configFilePath = './seeded-pod-config.json';
let accountHandler: jest.Mocked<ResolveLoginHandler>; let accountStore: jest.Mocked<AccountStore>;
let passwordHandler: jest.Mocked<JsonInteractionHandler>; let passwordStore: jest.Mocked<PasswordStore>;
let podHandler: jest.Mocked<JsonInteractionHandler>; let podCreator: jest.Mocked<PodCreator>;
let initializer: SeededAccountInitializer; let initializer: SeededAccountInitializer;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
let count = 0; let count = 0;
accountHandler = { accountStore = {
login: jest.fn(async(): Promise<unknown> => { create: jest.fn(async(): Promise<string> => {
count += 1; count += 1;
return { json: { accountId: `account${count}` }}; return `account${count}`;
}), }),
} as any; } satisfies Partial<AccountStore> as any;
passwordHandler = { let pwCount = 0;
handleSafe: jest.fn(), passwordStore = {
} as any; create: jest.fn(async(): Promise<string> => {
pwCount += 1;
return `password${pwCount}`;
}),
confirmVerification: jest.fn(),
} satisfies Partial<PasswordStore> as any;
podHandler = { podCreator = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } satisfies Partial<PodCreator> as any;
mockFileSystem('/'); mockFileSystem('/');
await writeJson(configFilePath, dummyConfig); await writeJson(configFilePath, dummyConfig);
initializer = new SeededAccountInitializer({ 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<void> => { it('does not generate any accounts or pods if no config file is specified.', async(): Promise<void> => {
await expect(new SeededAccountInitializer({ accountHandler, passwordHandler, podHandler }).handle()) await expect(new SeededAccountInitializer({ accountStore, passwordStore, podCreator }).handle())
.resolves.toBeUndefined(); .resolves.toBeUndefined();
expect(accountHandler.login).toHaveBeenCalledTimes(0); expect(accountStore.create).toHaveBeenCalledTimes(0);
}); });
it('errors if the seed file is invalid.', async(): Promise<void> => { it('errors if the seed file is invalid.', async(): Promise<void> => {
@ -69,24 +75,25 @@ describe('A SeededAccountInitializer', (): void => {
it('generates an account with the specified settings.', async(): Promise<void> => { it('generates an account with the specified settings.', async(): Promise<void> => {
await expect(initializer.handleSafe()).resolves.toBeUndefined(); await expect(initializer.handleSafe()).resolves.toBeUndefined();
expect(accountHandler.login).toHaveBeenCalledTimes(2); expect(accountStore.create).toHaveBeenCalledTimes(2);
expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(2); expect(passwordStore.create).toHaveBeenCalledTimes(2);
expect(passwordHandler.handleSafe.mock.calls[0][0].json) expect(passwordStore.create).toHaveBeenNthCalledWith(1, 'hello@example.com', 'account1', 'abc123');
.toEqual(expect.objectContaining({ email: 'hello@example.com', password: 'abc123' })); expect(passwordStore.create).toHaveBeenNthCalledWith(2, 'hello2@example.com', 'account2', '123abc');
expect(passwordHandler.handleSafe.mock.calls[1][0].json) expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(2);
.toEqual(expect.objectContaining({ email: 'hello2@example.com', password: '123abc' })); expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(1, 'password1');
expect(podHandler.handleSafe).toHaveBeenCalledTimes(3); expect(passwordStore.confirmVerification).toHaveBeenNthCalledWith(2, 'password2');
expect(podHandler.handleSafe.mock.calls[0][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![0])); expect(podCreator.handleSafe).toHaveBeenCalledTimes(3);
expect(podHandler.handleSafe.mock.calls[1][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![1])); expect(podCreator.handleSafe).toHaveBeenNthCalledWith(1, { accountId: 'account1', name: 'pod1', settings: {}});
expect(podHandler.handleSafe.mock.calls[2][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![2])); 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<void> => { it('does not throw exceptions when one of the steps fails.', async(): Promise<void> => {
accountHandler.login.mockRejectedValueOnce(new Error('bad data')); accountStore.create.mockRejectedValueOnce(new Error('bad data'));
await expect(initializer.handleSafe()).resolves.toBeUndefined(); 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 // Steps for first account will be skipped due to error
expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(1); expect(passwordStore.create).toHaveBeenCalledTimes(1);
expect(podHandler.handleSafe).toHaveBeenCalledTimes(0); expect(podCreator.handleSafe).toHaveBeenCalledTimes(0);
}); });
}); });