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.",
"@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" }
}
]

View File

@ -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" }
}
}
}
}

View File

@ -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<OutType> 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<OutType> 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 }};
}
}

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';
// 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

View File

@ -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.`);
}
}

View File

@ -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<IdentifierGenerator>;
let webIdStore: jest.Mocked<WebIdStore>;
let webIdLinkRoute: jest.Mocked<WebIdLinkRoute>;
let podIdRoute: jest.Mocked<PodIdRoute>;
let podStore: jest.Mocked<PodStore>;
let podCreator: jest.Mocked<PodCreator>;
let handler: CreatePodHandler;
beforeEach(async(): Promise<void> => {
@ -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<WebIdStore> as any;
podCreator = {
handleSafe: jest.fn().mockResolvedValue({ podUrl, podId, webId, webIdLink }),
} satisfies Partial<PodCreator> 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<void> => {
@ -91,75 +70,25 @@ describe('A CreatePodHandler', (): void => {
it('generates a pod and WebID.', async(): Promise<void> => {
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<void> => {
json = { name, settings: { webId }};
it('generates a pod with a WebID if there is one.', async(): Promise<void> => {
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<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);
expect(podCreator.handleSafe).toHaveBeenCalledTimes(1);
expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId, name, webId, settings });
});
describe('allowing root pods', (): void => {
beforeEach(async(): Promise<void> => {
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<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 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<ResolveLoginHandler>;
let passwordHandler: jest.Mocked<JsonInteractionHandler>;
let podHandler: jest.Mocked<JsonInteractionHandler>;
let accountStore: jest.Mocked<AccountStore>;
let passwordStore: jest.Mocked<PasswordStore>;
let podCreator: jest.Mocked<PodCreator>;
let initializer: SeededAccountInitializer;
beforeEach(async(): Promise<void> => {
let count = 0;
accountHandler = {
login: jest.fn(async(): Promise<unknown> => {
accountStore = {
create: jest.fn(async(): Promise<string> => {
count += 1;
return { json: { accountId: `account${count}` }};
return `account${count}`;
}),
} as any;
} satisfies Partial<AccountStore> as any;
passwordHandler = {
handleSafe: jest.fn(),
} as any;
let pwCount = 0;
passwordStore = {
create: jest.fn(async(): Promise<string> => {
pwCount += 1;
return `password${pwCount}`;
}),
confirmVerification: jest.fn(),
} satisfies Partial<PasswordStore> as any;
podHandler = {
podCreator = {
handleSafe: jest.fn(),
} as any;
} satisfies Partial<PodCreator> 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<void> => {
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<void> => {
@ -69,24 +75,25 @@ describe('A SeededAccountInitializer', (): void => {
it('generates an account with the specified settings.', async(): Promise<void> => {
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<void> => {
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);
});
});