feat: Combine pod creation with IDP registration

This commit is contained in:
Joachim Van Herwegen 2021-05-28 16:38:35 +02:00
parent 9bb42ddf0d
commit 4d7d939dc4
18 changed files with 700 additions and 255 deletions

View File

@ -8,12 +8,13 @@
"pathName": "^/idp/register/?$",
"postHandler": {
"@type": "RegistrationHandler",
"args_ownershipValidator": {
"@type": "TokenOwnershipValidator",
"storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
},
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_webIdSuffix": "/profile/card",
"args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" },
"args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" },
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" }
"args_podManager": { "@id": "urn:solid-server:default:PodManager" },
"args_responseHandler": { "@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:RegisterRenderHandler" }
},
@ -24,6 +25,13 @@
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/register.ejs"
},
{
"comment": "Renders the successful registration page",
"@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/registerResponse.ejs"
}
]
}

View File

@ -1,83 +1,241 @@
import assert from 'assert';
import urljoin from 'url-join';
import type { ResourceIdentifier } from '../../../../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../pods/PodManager';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import type { RenderHandler } from '../../../../server/util/RenderHandler';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { InteractionCompleter } from '../../util/InteractionCompleter';
import type { OwnershipValidator } from '../../util/OwnershipValidator';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u;
interface RegistrationHandlerArgs {
export interface RegistrationHandlerArgs {
/**
* Used to set the `oidcIssuer` value of newly registered pods.
*/
baseUrl: string;
/**
* Appended to the generated pod identifier to create the corresponding WebID.
*/
webIdSuffix: string;
/**
* Generates identifiers for new pods.
*/
identifierGenerator: IdentifierGenerator;
/**
* Verifies the user is the owner of the WebID they provide.
*/
ownershipValidator: OwnershipValidator;
/**
* Stores all the registered account information.
*/
accountStore: AccountStore;
interactionCompleter: InteractionCompleter;
/**
* Creates the new pods.
*/
podManager: PodManager;
/**
* Renders the response when registration is successful.
*/
responseHandler: RenderHandler<NodeJS.Dict<any>>;
}
// Results when parsing the input form data
type ParseResult = {
/**
* All the parameters that will be parsed from a request.
* `data` contains all the raw values to potentially be used by pod templates.
*/
interface ParseResult {
email: string;
password: string;
webId: string;
remember: boolean;
};
password?: string;
podName?: string;
webId?: string;
createWebId: boolean;
register: boolean;
createPod: boolean;
data: NodeJS.Dict<string>;
}
/**
* Handles the submission of the registration form.
* Creates the user and logs them in if successful.
* This class handles the 3 potential steps of the registration process:
* 1. Generating a new WebID.
* 2. Registering a WebID with the IDP.
* 3. Creating a new pod for a given WebID.
*
* All of these steps are optional and will be determined based on the input parameters of a request,
* with the following considerations:
* * At least one option needs to be chosen.
* * In case a new WebID needs to be created, the other 2 steps are obligatory.
* * Ownership will be verified when the WebID is provided.
* * When registering and creating a pod, the base URL will be used as oidcIssuer value.
*/
export class RegistrationHandler extends InteractionHttpHandler {
export class RegistrationHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string;
private readonly webIdSuffix: string;
private readonly identifierGenerator: IdentifierGenerator;
private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore;
private readonly interactionCompleter: InteractionCompleter;
private readonly podManager: PodManager;
private readonly responseHandler: RenderHandler<NodeJS.Dict<any>>;
public constructor(args: RegistrationHandlerArgs) {
super();
this.baseUrl = args.baseUrl;
this.webIdSuffix = args.webIdSuffix;
this.identifierGenerator = args.identifierGenerator;
this.ownershipValidator = args.ownershipValidator;
this.accountStore = args.accountStore;
this.interactionCompleter = args.interactionCompleter;
this.podManager = args.podManager;
this.responseHandler = args.responseHandler;
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
const { email, webId, password, remember } = await this.parseInput(input.request);
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
const result = await this.parseInput(request);
try {
// Check if WebId contains required triples and register new account if successful
await this.ownershipValidator.handleSafe({ webId });
await this.accountStore.create(email, webId, password);
await this.interactionCompleter.handleSafe({
...input,
webId,
shouldRemember: Boolean(remember),
});
this.logger.debug(`Registering agent ${email} with WebId ${webId}`);
} catch (err: unknown) {
throwIdpInteractionError(err, { email, webId });
const props = await this.register(result);
await this.responseHandler.handleSafe({ response, props });
} catch (error: unknown) {
throwIdpInteractionError(error, result.data as Record<string, string>);
}
}
/**
* Parses and validates the input form data.
* Will throw an {@link IdpInteractionError} in case something is wrong.
* All relevant data that was correct up to that point will be prefilled.
* Does the full registration and pod creation process,
* with the steps chosen by the values in the `ParseResult`.
*/
private async register(result: ParseResult): Promise<NodeJS.Dict<any>> {
// This is only used when createWebId and/or createPod are true
let podBaseUrl: ResourceIdentifier | undefined;
// Create or verify the WebID
if (result.createWebId) {
podBaseUrl = this.identifierGenerator.generate(result.podName!);
result.webId = urljoin(podBaseUrl.path, this.webIdSuffix);
} else {
await this.ownershipValidator.handleSafe({ webId: result.webId! });
}
// Register the account
if (result.register) {
await this.accountStore.create(result.email, result.webId!, result.password!);
// Add relevant data for the templates
result.data.oidcIssuer = this.baseUrl;
}
// Create the pod
if (result.createPod) {
podBaseUrl = podBaseUrl ?? this.identifierGenerator.generate(result.podName!);
try {
await this.podManager.createPod(podBaseUrl, { ...result.data, webId: result.webId! });
} catch (error: unknown) {
// In case pod creation errors we don't want to keep the account
if (result.register) {
await this.accountStore.deleteAccount(result.email);
}
throw error;
}
}
// Verify the account
if (result.register) {
// This prevents there being a small timeframe where the account can be used before the pod creation is finished.
// That timeframe could potentially be used by malicious users.
await this.accountStore.verify(result.email);
}
return {
webId: result.webId,
email: result.email,
oidcIssuer: this.baseUrl,
podBaseUrl: podBaseUrl?.path,
createWebId: result.createWebId,
register: result.register,
createPod: result.createPod,
};
}
/**
* Parses the input request into a `ParseResult`.
*/
private async parseInput(request: HttpRequest): Promise<ParseResult> {
const prefilled: Record<string, string> = {};
const parsed = await getFormDataRequestBody(request);
let prefilled: Record<string, string> = {};
try {
const { email, webId, password, confirmPassword, remember } = await getFormDataRequestBody(request);
assert(typeof email === 'string' && email.length > 0, 'Email required');
assert(emailRegex.test(email), 'Invalid email');
prefilled.email = email;
assert(typeof webId === 'string' && webId.length > 0, 'WebId required');
prefilled.webId = webId;
assertPassword(password, confirmPassword);
return { email, password, webId, remember: Boolean(remember) };
for (const key of Object.keys(parsed)) {
if (Array.isArray(parsed[key])) {
throw new Error(`Multiple values found for key ${key}`);
}
}
prefilled = parsed as Record<string, string>;
return this.validateInput(prefilled);
} catch (err: unknown) {
throwIdpInteractionError(err, prefilled);
}
}
/**
* Converts the raw input date into a `ParseResult`.
* Verifies that all the data combinations make sense.
*/
private validateInput(parsed: NodeJS.Dict<string>): ParseResult {
const { email, password, confirmPassword, podName, webId } = parsed;
assert(typeof email === 'string' && email.length > 0 && emailRegex.test(email),
'A valid e-mail address is required');
const result: ParseResult = {
email,
createWebId: Boolean(parsed.createWebId),
register: Boolean(parsed.register),
createPod: Boolean(parsed.createPod),
data: parsed,
};
const validWebId = typeof webId === 'string' && webId.length > 0;
if (result.createWebId) {
if (validWebId) {
throw new Error('A WebID should only be provided when no new one is being created');
}
} else {
if (!validWebId) {
throw new Error('A WebID is required if no new one is being created');
}
result.webId = webId;
}
if (result.register) {
assertPassword(password, confirmPassword);
result.password = password;
} else if (typeof password === 'string' && password.length > 0) {
throw new Error('A password should only be provided when registering');
}
if (result.createWebId || result.createPod) {
assert(typeof podName === 'string' && podName.length > 0,
'A pod name is required when creating a pod and/or WebID');
result.podName = podName;
} else if (typeof podName === 'string' && podName.length > 0) {
throw new Error('A pod name should only be provided when creating a pod and/or WebID');
}
if (result.createWebId && !(result.register && result.createPod)) {
throw new Error('Creating a WebID is only possible when also registering and creating a pod');
}
if (!result.createWebId && !result.register && !result.createPod) {
throw new Error('At least one option needs to be chosen');
}
return result;
}
}

View File

@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage';
import type { ResourceStore } from '../storage/ResourceStore';
import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { PodGenerator } from './generate/PodGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager';
@ -22,27 +21,23 @@ import type { PodSettings } from './settings/PodSettings';
*/
export class ConfigPodManager implements PodManager {
protected readonly logger = getLoggerFor(this);
private readonly idGenerator: IdentifierGenerator;
private readonly podGenerator: PodGenerator;
private readonly routingStorage: KeyValueStorage<string, ResourceStore>;
private readonly resourcesGenerator: ResourcesGenerator;
/**
* @param idGenerator - Generator for the pod identifiers.
* @param podGenerator - Generator for the pod stores.
* @param resourcesGenerator - Generator for the pod resources.
* @param routingStorage - Where to store the generated pods so they can be routed to.
*/
public constructor(idGenerator: IdentifierGenerator, podGenerator: PodGenerator,
resourcesGenerator: ResourcesGenerator, routingStorage: KeyValueStorage<string, ResourceStore>) {
this.idGenerator = idGenerator;
public constructor(podGenerator: PodGenerator, resourcesGenerator: ResourcesGenerator,
routingStorage: KeyValueStorage<string, ResourceStore>) {
this.podGenerator = podGenerator;
this.routingStorage = routingStorage;
this.resourcesGenerator = resourcesGenerator;
}
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> {
const identifier = this.idGenerator.generate(settings.login);
public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
this.logger.info(`Creating pod ${identifier.path}`);
// Will error in case there already is a store for the given identifier
@ -52,7 +47,5 @@ export class ConfigPodManager implements PodManager {
this.logger.info(`Added ${count} resources to ${identifier.path}`);
await this.routingStorage.set(identifier.path, store);
return identifier;
}
}

View File

@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceStore } from '../storage/ResourceStore';
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager';
import type { PodSettings } from './settings/PodSettings';
@ -16,29 +15,24 @@ export class GeneratedPodManager implements PodManager {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
private readonly idGenerator: IdentifierGenerator;
private readonly resourcesGenerator: ResourcesGenerator;
public constructor(store: ResourceStore, idGenerator: IdentifierGenerator, resourcesGenerator: ResourcesGenerator) {
public constructor(store: ResourceStore, resourcesGenerator: ResourcesGenerator) {
this.store = store;
this.idGenerator = idGenerator;
this.resourcesGenerator = resourcesGenerator;
}
/**
* Creates a new pod, pre-populating it with the resources created by the data generator.
* Pod identifiers are created based on the identifier generator.
* Will throw an error if the given identifier already has a resource.
*/
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> {
const podIdentifier = this.idGenerator.generate(settings.login);
this.logger.info(`Creating pod ${podIdentifier.path}`);
if (await this.store.resourceExists(podIdentifier)) {
throw new ConflictHttpError(`There already is a resource at ${podIdentifier.path}`);
public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
this.logger.info(`Creating pod ${identifier.path}`);
if (await this.store.resourceExists(identifier)) {
throw new ConflictHttpError(`There already is a resource at ${identifier.path}`);
}
const count = await addGeneratedResources(podIdentifier, settings, this.resourcesGenerator, this.store);
this.logger.info(`Added ${count} resources to ${podIdentifier.path}`);
return podIdentifier;
const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, this.store);
this.logger.info(`Added ${count} resources to ${identifier.path}`);
}
}

View File

@ -8,8 +8,8 @@ import type { PodSettings } from './settings/PodSettings';
export interface PodManager {
/**
* Creates a pod for the given settings.
* @param identifier - Root identifier indicating where the pod should be created.
* @param settings - Settings describing the pod.
* @returns {@link ResourceIdentifier} of the newly created pod.
*/
createPod: (settings: PodSettings) => Promise<ResourceIdentifier>;
createPod: (identifier: ResourceIdentifier, settings: PodSettings) => Promise<void>;
}

View File

@ -4,10 +4,6 @@
* they give an indication of what is sometimes expected.
*/
export interface PodSettings extends NodeJS.Dict<string> {
/**
* The name of the pod. Will be used for generating the identifier.
*/
login: string;
/**
* The WebId of the owner of this pod.
*/
@ -29,8 +25,4 @@ export interface PodSettings extends NodeJS.Dict<string> {
* The OIDC issuer of the owner's WebId.
*/
oidcIssuer?: string;
/**
* A registration token for linking the owner's WebId to an IDP.
*/
oidcIssuerRegistrationToken?: string;
}

View File

@ -28,12 +28,7 @@
</div>
<div class="input-wrap">
<label for="webId">WebId:</label>
<input id="webId" type="text" name="webId" <% if (prefilled.webId) { %> value="<%= prefilled.webId %>" <% } %> />
</div>
<div class="input-wrap">
<label for="password">Password:</label>
<label for="password">Password (when registering with the IDP):</label>
<input id="password" type="password" name="password" />
</div>
@ -43,15 +38,28 @@
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="remember" value="yes" checked>Stay signed in</label>
<label for="podName">Pod name (when creating a pod):</label>
<input id="podName" type="text" name="podName" <% if (prefilled.podName) { %> value="<%= prefilled.podName %>" <% } %> />
</div>
<button type="submit" name="submit" class="ids-link-filled">Create Identity</button>
<hr />
<div class="space-between">
<a href="/idp/login" class="link">Sign In</a>
<div class="input-wrap">
<label for="webId">WebID (when not creating a WebID):</label>
<input id="webId" type="text" name="webId" <% if (prefilled.webId) { %> value="<%= prefilled.webId %>" <% } %> />
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="createWebId" checked>Create new WebID (also requires the other two options)</label>
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="register" checked>Register your WebID with the IDP</label>
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="createPod" checked>Create a new pod</label>
</div>
<button type="submit" name="submit" class="ids-link-filled">Submit</button>
</form>
</div>
</div>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Registration Succesful</title>
<link rel="stylesheet" href="/idp/style.css">
</head>
<body>
<div class="main-content">
<section class="main-content-section">
<div class="wrapper">
<h1 class="title--white">Registration Successful</h1>
<div class="login-panel">
<div class="panel-body">
<% if (createWebId) { %>
<p class="content__message">
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>
</p>
<% } %>
<% if (register) { %>
<p class="content__message">
You can now identify as <a href="<%= webId %>" class="link"><%= webId %></a> with our IDP using <%= email %>
</p>
<% if (!createWebId) { %>
<p class="content__message">
Make sure you add the triple
<%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%>
to your WebID profile.
</p>
<% } %>
<% } %>
<% if (createPod) { %>
<p class="content__message">
Your new pod has been created and can be found at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>
</p>
<% } %>
</div>
</div>
</div>
</section>
</div>
</body>
</html>

View File

@ -7,7 +7,6 @@
foaf:primaryTopic <{{webId}}>.
<{{webId}}>
a foaf:Person;
foaf:name "{{name}}";
solid:oidcIssuer <{{oidcIssuer}}> ;
solid:oidcIssuerRegistrationToken "{{oidcIssuerRegistrationToken}}" .
{{#if name}}foaf:name "{{name}}";{{/if}}
{{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}}
a foaf:Person.

View File

@ -1,8 +1,10 @@
import { mkdirSync } from 'fs';
import type { Server } from 'http';
import { stringify } from 'querystring';
import fetch from 'cross-fetch';
import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
import { joinFilePath } from '../../src/util/PathUtil';
import { getPort } from '../util/Util';
import { getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
@ -27,15 +29,16 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
let server: Server;
let initializer: Initializer;
let factory: HttpServerFactory;
const agent = { login: 'alice', webId: 'http://test.com/#alice', name: 'Alice Bob', template };
const podUrl = `${baseUrl}${agent.login}/`;
let expiringStorage: WrappedExpiringStorage<any, any>;
const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', template, createPod: true };
const podUrl = `${baseUrl}${settings.podName}/`;
beforeAll(async(): Promise<void> => {
const variables: Record<string, any> = {
'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
'urn:solid-server:default:variable:podConfigJson': podConfigJson,
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
};
// Need to make sure the temp folder exists so the podConfigJson can be written to it
@ -47,7 +50,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
getTestConfigPath('server-dynamic-unsafe.json'),
variables,
) as Record<string, any>;
({ factory, initializer } = instances);
({ factory, initializer, expiringStorage } = instances);
// Set up the internal store
await initializer.handleSafe();
@ -56,6 +59,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
});
afterAll(async(): Promise<void> => {
expiringStorage.finalize();
await new Promise((resolve, reject): void => {
server.close((error): void => error ? reject(error) : resolve());
});
@ -63,15 +67,13 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
});
it('creates a pod with the given config.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, {
const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(agent),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: stringify(settings),
});
expect(res.status).toBe(201);
expect(res.headers.get('location')).toBe(podUrl);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(podUrl);
});
it('can fetch the created pod.', async(): Promise<void> => {
@ -87,7 +89,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
it('should be able to read acl file with the correct credentials.', async(): Promise<void> => {
const res = await fetch(`${podUrl}.acl`, {
headers: {
authorization: `WebID ${agent.webId}`,
authorization: `WebID ${settings.webId}`,
},
});
expect(res.status).toBe(200);
@ -96,7 +98,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
it('should be able to write to the pod now as the owner.', async(): Promise<void> => {
let res = await fetch(`${podUrl}test`, {
headers: {
authorization: `WebID ${agent.webId}`,
authorization: `WebID ${settings.webId}`,
},
});
expect(res.status).toBe(404);
@ -104,7 +106,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
res = await fetch(`${podUrl}test`, {
method: 'PUT',
headers: {
authorization: `WebID ${agent.webId}`,
authorization: `WebID ${settings.webId}`,
'content-type': 'text/plain',
},
body: 'this is new data!',
@ -113,7 +115,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
res = await fetch(`${podUrl}test`, {
headers: {
authorization: `WebID ${agent.webId}`,
authorization: `WebID ${settings.webId}`,
},
});
expect(res.status).toBe(200);
@ -121,13 +123,13 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
});
it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, {
const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(agent),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: stringify(settings),
});
expect(res.status).toBe(409);
// 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(`There already is a pod at ${podUrl}`);
});
});

View File

@ -2,7 +2,9 @@ import type { Server } from 'http';
import { stringify } from 'querystring';
import { URL } from 'url';
import { load } from 'cheerio';
import type { Response } from 'cross-fetch';
import { fetch } from 'cross-fetch';
import urljoin from 'url-join';
import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
@ -24,6 +26,14 @@ jest.mock('nodemailer');
// Prevent panva/node-openid-client from emitting DraftWarning
jest.spyOn(process, 'emitWarning').mockImplementation();
async function postForm(url: string, formBody: string): Promise<Response> {
return fetch(url, {
method: 'POST',
headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
body: formBody,
});
}
// No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 .
// They will be simulated by storing the values and passing them along.
// This is why the redirects are handled manually.
@ -35,7 +45,7 @@ describe('A Solid server with IDP', (): void => {
let factory: HttpServerFactory;
const redirectUrl = 'http://mockedredirect/';
const oidcIssuer = baseUrl;
const card = new URL('profile/card', baseUrl).href;
const card = urljoin(baseUrl, 'profile/card');
const webId = `${card}#me`;
const email = 'test@test.com';
const password = 'password!';
@ -52,9 +62,7 @@ describe('A Solid server with IDP', (): void => {
'urn:solid-server:test:Instances',
getTestConfigPath('server-memory.json'),
{
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
},
) as Record<string, any>;
@ -79,27 +87,16 @@ describe('A Solid server with IDP', (): void => {
});
describe('doing registration', (): void => {
let state: IdentityTestState;
let nextUrl: string;
let formBody: string;
let registrationTriple: string;
beforeAll(async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
// We will need this twice
formBody = stringify({ email, webId, password, confirmPassword: password, remember: 'yes' });
});
it('initializes the session and finds the registration URL.', async(): Promise<void> => {
const url = await state.startSession();
const { register } = await state.parseLoginPage(url);
expect(typeof register).toBe('string');
nextUrl = (await state.extractFormUrl(register)).url;
formBody = stringify({ email, webId, password, confirmPassword: password, register: 'ok' });
});
it('sends the form once to receive the registration triple.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED);
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
// eslint-disable-next-line newline-per-chained-call
registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0];
@ -119,15 +116,14 @@ describe('A Solid server with IDP', (): void => {
expect(res.status).toBe(205);
});
it('sends the form again once the registration token was added.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED);
expect(res.status).toBe(302);
nextUrl = res.headers.get('location')!;
});
it('will be redirected internally and logged in.', async(): Promise<void> => {
await state.handleLoginRedirect(nextUrl);
expect(state.session.info?.webId).toBe(webId);
it('sends the form again to successfully register.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toMatch(new RegExp(`You can now identify as .*${webId}.*with our IDP using ${email}`, 'u'));
expect(text).toMatch(new RegExp(`Make sure you add the triple
\\s*&lt;${webId}&gt; &lt;http://www.w3.org/ns/solid/terms#oidcIssuer&gt; &lt;${baseUrl}&gt;\\.
\\s*to your WebID profile\\.`, 'mu'));
});
});
@ -193,22 +189,10 @@ describe('A Solid server with IDP', (): void => {
});
describe('resetting password', (): void => {
let state: IdentityTestState;
let nextUrl: string;
beforeAll(async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
});
it('initializes the session and finds the forgot password URL.', async(): Promise<void> => {
const url = await state.startSession();
const { forgotPassword } = await state.parseLoginPage(url);
expect(typeof forgotPassword).toBe('string');
nextUrl = (await state.extractFormUrl(forgotPassword)).url;
});
it('sends the corresponding email address through the form to get a mail.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', stringify({ email }), APPLICATION_X_WWW_FORM_URLENCODED);
const res = await postForm(`${baseUrl}idp/forgotpassword`, stringify({ email }));
expect(res.status).toBe(200);
expect(load(await res.text())('form div p').first().text().trim())
.toBe('If your account exists, an email has been sent with a link to reset your password.');
@ -222,13 +206,23 @@ describe('A Solid server with IDP', (): void => {
});
it('resets the password through the given link.', async(): Promise<void> => {
const { url, body } = await state.extractFormUrl(nextUrl);
// Extract the submit URL from the reset password form
let res = await fetch(nextUrl);
expect(res.status).toBe(200);
const text = await res.text();
const relative = load(text)('form').attr('action');
expect(typeof relative).toBe('string');
const recordId = load(body)('input[name="recordId"]').attr('value');
const recordId = load(text)('input[name="recordId"]').attr('value');
expect(typeof recordId).toBe('string');
// POST the new password
const formData = stringify({ password: password2, confirmPassword: password2, recordId });
const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
res = await fetch(new URL(relative!, baseUrl).href, {
method: 'POST',
headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
body: formData,
});
expect(res.status).toBe(200);
expect(await res.text()).toContain('Your password was successfully reset.');
});
@ -261,4 +255,97 @@ describe('A Solid server with IDP', (): void => {
expect(state.session.info?.webId).toBe(webId);
});
});
describe('creating pods without registering', (): void => {
let formBody: string;
let registrationTriple: string;
const podName = 'myPod';
beforeAll(async(): Promise<void> => {
// We will need this twice
formBody = stringify({ email, webId, podName, createPod: 'ok' });
});
it('sends the form once to receive the registration triple.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
// eslint-disable-next-line newline-per-chained-call
registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0];
expect(registrationTriple).toMatch(new RegExp(
`^<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuerRegistrationToken> "[^"]+"\\s*\\.\\s*$`,
'u',
));
});
it('updates the webId with the registration token.', async(): Promise<void> => {
const patchBody = `INSERT DATA { ${registrationTriple} }`;
const res = await fetch(webId, {
method: 'PATCH',
headers: { 'content-type': 'application/sparql-update' },
body: patchBody,
});
expect(res.status).toBe(205);
});
it('sends the form again to successfully register.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toMatch(new RegExp(`Your new pod has been created and can be found at.*${baseUrl}${podName}/`, 'u'));
});
});
describe('creating a new WebID', (): void => {
const podName = 'alice';
const newMail = 'alice@test.email';
let newWebId: string;
let podLocation: string;
let state: IdentityTestState;
const formBody = stringify({
email: newMail, password, confirmPassword: password, podName, createWebId: 'ok', register: 'ok', createPod: 'ok',
});
it('sends the form to create the WebID and register.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
const matchWebId = /Your new WebID is [^>]+>([^<]+)/u.exec(text);
expect(matchWebId).toBeDefined();
expect(matchWebId).toHaveLength(2);
newWebId = matchWebId![1];
expect(text).toMatch(new RegExp(`You can now identify as .*${newWebId}.*with our IDP using ${newMail}`, 'u'));
const matchPod = /Your new pod has been created and can be found at [^>]+>([^<]+)/u.exec(text);
expect(matchPod).toBeDefined();
expect(matchPod).toHaveLength(2);
podLocation = matchPod![1];
expect(newWebId.startsWith(podLocation)).toBe(true);
expect(podLocation.startsWith(baseUrl)).toBe(true);
});
it('initializes the session and logs in.', async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
const url = await state.startSession();
const { login } = await state.parseLoginPage(url);
expect(typeof login).toBe('string');
await state.login(login, newMail, password);
expect(state.session.info?.webId).toBe(newWebId);
});
it('can only write to the new profile when using the logged in session.', async(): Promise<void> => {
const patchOptions = {
method: 'PATCH',
headers: { 'content-type': 'application/sparql-update' },
body: `INSERT DATA { <> <http://www.w3.org/2000/01/rdf-schema#label> "A cool WebID." }`,
};
let res = await fetch(newWebId, patchOptions);
expect(res.status).toBe(401);
res = await state.session.fetch(newWebId, patchOptions);
expect(res.status).toBe(205);
});
});
});

View File

@ -1,7 +1,10 @@
import type { Server } from 'http';
import { stringify } from 'querystring';
import fetch from 'cross-fetch';
import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
import { joinFilePath } from '../../src/util/PathUtil';
import { getPort } from '../util/Util';
import { getPresetConfigPath, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
@ -25,15 +28,16 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
let server: Server;
let initializer: Initializer;
let factory: HttpServerFactory;
const settings = { login: 'alice', webId: 'http://test.com/#alice', name: 'Alice Bob' };
let expiringStorage: WrappedExpiringStorage<any, any>;
const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', createPod: true };
const podHost = `alice.localhost:${port}`;
const podUrl = `http://${podHost}/`;
beforeAll(async(): Promise<void> => {
const variables: Record<string, any> = {
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
};
// Create and initialize the server
@ -45,13 +49,14 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
],
variables,
) as Record<string, any>;
({ factory, initializer } = instances);
({ factory, initializer, expiringStorage } = instances);
await initializer.handleSafe();
server = factory.startServer(port);
});
afterAll(async(): Promise<void> => {
expiringStorage.finalize();
await new Promise((resolve, reject): void => {
server.close((error): void => error ? reject(error) : resolve());
});
@ -83,15 +88,13 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
describe('handling pods', (): void => {
it('creates pods in a subdomain.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, {
const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(settings),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: stringify(settings),
});
expect(res.status).toBe(201);
expect(res.headers.get('location')).toBe(podUrl);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(podUrl);
});
it('can fetch the created pod in a subdomain.', async(): Promise<void> => {
@ -145,14 +148,14 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
});
it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, {
const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(settings),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: stringify(settings),
});
expect(res.status).toBe(409);
// 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(`There already is a resource at ${podUrl}`);
});
});
});

View File

@ -5,6 +5,9 @@
"files-scs:config/http/middleware/no-websockets.json",
"files-scs:config/http/server-factory/no-websockets.json",
"files-scs:config/http/static/default.json",
"files-scs:config/identity/email/default.json",
"files-scs:config/identity/handler/default.json",
"files-scs:config/identity/ownership/unsafe-no-check.json",
"files-scs:config/init/handler/default.json",
"files-scs:config/ldp/authentication/debug-auth-header.json",
"files-scs:config/ldp/authorization/webacl.json",
@ -37,12 +40,13 @@
{
"RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
}
]
},
{
"@id": "urn:solid-server:default:IdentityProviderHandler",
"@type": "UnsupportedAsyncHandler"
"comment": "Timer needs to be stopped when tests are finished.",
"RecordObject:_record_key": "expiringStorage",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
}
]
},
{
"@id": "urn:solid-server:default:ResourcesGenerator",

View File

@ -54,10 +54,6 @@
"args_emailConfig_port": 587,
"args_emailConfig_auth_user": "alice@example.email",
"args_emailConfig_auth_pass": "NYEaCsqV7aVStRCbmC"
},
{
"@id": "urn:solid-server:default:ResourcesGenerator",
"TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"
}
]
}

View File

@ -5,6 +5,9 @@
"files-scs:config/http/middleware/no-websockets.json",
"files-scs:config/http/server-factory/no-websockets.json",
"files-scs:config/http/static/default.json",
"files-scs:config/identity/email/default.json",
"files-scs:config/identity/handler/default.json",
"files-scs:config/identity/ownership/unsafe-no-check.json",
"files-scs:config/init/handler/default.json",
"files-scs:config/ldp/authentication/debug-auth-header.json",
"files-scs:config/ldp/authorization/webacl.json",
@ -33,20 +36,16 @@
"RecordObject:_record_key": "initializer",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ParallelInitializer" }
},
{
"RecordObject:_record_key": "store",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
},
{
"RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
}
]
},
{
"@id": "urn:solid-server:default:IdentityProviderHandler",
"@type": "UnsupportedAsyncHandler"
"comment": "Timer needs to be stopped when tests are finished.",
"RecordObject:_record_key": "expiringStorage",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
}
]
}
]
}

View File

@ -1,92 +1,260 @@
import type { Provider } from 'oidc-provider';
import urljoin from 'url-join';
import {
RegistrationHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter';
import type { OwnershipValidator } from '../../../../../../src/identity/interaction/util/OwnershipValidator';
import { IdpInteractionError } from '../../../../../../src/identity/interaction/util/IdpInteractionError';
import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator';
import type { ResourceIdentifier } from '../../../../../../src/ldp/representation/ResourceIdentifier';
import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { RenderHandler } from '../../../../../../src/server/util/RenderHandler';
import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => {
// "Correct" values for easy object creation
const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email';
const password = 'superSecretPassword';
const confirmPassword = password;
const podName = 'alice';
// Strings instead of booleans because this is form data
const createWebId = 'true';
const register = 'true';
const createPod = 'true';
let request: HttpRequest;
const response: HttpResponse = {} as any;
const provider: Provider = {} as any;
const baseUrl = 'http://test.com/';
const webIdSuffix = '/profile/card';
let identifierGenerator: IdentifierGenerator;
let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore;
let interactionCompleter: InteractionCompleter;
let podManager: PodManager;
let responseHandler: RenderHandler<NodeJS.Dict<any>>;
let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => {
identifierGenerator = {
generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })),
};
ownershipValidator = {
handleSafe: jest.fn(),
} as any;
accountStore = {
create: jest.fn(),
verify: jest.fn(),
deleteAccount: jest.fn(),
} as any;
interactionCompleter = {
podManager = {
createPod: jest.fn(),
};
responseHandler = {
handleSafe: jest.fn(),
} as any;
handler = new RegistrationHandler({
ownershipValidator,
baseUrl,
webIdSuffix,
identifierGenerator,
accountStore,
interactionCompleter,
ownershipValidator,
podManager,
responseHandler,
});
});
it('errors on non-string emails.', async(): Promise<void> => {
request = createPostFormRequest({});
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required');
request = createPostFormRequest({ email: [ 'email', 'email2' ]});
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required');
describe('validating data', (): void => {
it('rejects array inputs.', async(): Promise<void> => {
request = createPostFormRequest({ data: [ 'a', 'b' ]});
await expect(handler.handle({ request, response })).rejects.toThrow('Multiple values found for key data');
});
it('errors on invalid emails.', async(): Promise<void> => {
request = createPostFormRequest({ email: undefined });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
request = createPostFormRequest({ email: '' });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
request = createPostFormRequest({ email: 'invalidEmail' });
const prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('Invalid email');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }}));
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
});
it('errors on non-string webIds.', async(): Promise<void> => {
request = createPostFormRequest({ email });
let prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('WebId required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
request = createPostFormRequest({ email, webId: [ 'a', 'b' ]});
prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('WebId required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
it('errors when an unnecessary WebID is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID should only be provided when no new one is being created');
});
it('errors on invalid passwords.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'bad' });
const prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('Password and confirmation do not match');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }}));
it('errors when a required WebID is not valid.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId: undefined });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID is required if no new one is being created');
request = createPostFormRequest({ email, webId: '' });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID is required if no new one is being created');
});
it('throws an IdpInteractionError if there is a problem.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' });
(accountStore.create as jest.Mock).mockRejectedValueOnce(new Error('create failed!'));
const prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('create failed!');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }}));
it('errors when an unnecessary password is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A password should only be provided when registering');
});
it('calls the OidcInteractionCompleter when done.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' });
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined();
it('errors on invalid passwords when registering.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register });
await expect(handler.handle({ request, response })).rejects.toThrow('Password and confirmation do not match');
});
it('errors when an unnecessary pod name is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, podName });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name should only be provided when creating a pod and/or WebID');
});
it('errors on invalid pod names when required.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName: undefined, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
request = createPostFormRequest({ email, webId, podName: '', createPod });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
});
it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
request = createPostFormRequest({ email, podName, password, confirmPassword, createWebId, register });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
request = createPostFormRequest({ email, podName, createWebId, createPod });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
});
it('errors when no option is chosen.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId });
await expect(handler.handle({ request, response })).rejects.toThrow('At least one option needs to be chosen');
});
});
describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword, register });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, 'password!');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe)
.toHaveBeenLastCalledWith({ request, response, provider, webId, shouldRemember: false });
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
expect(podManager.createPod).toHaveBeenCalledTimes(0);
});
it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.create).toHaveBeenCalledTimes(0);
expect(accountStore.verify).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
(params as any).oidcIssuer = baseUrl;
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('deletes the created account if pod generation fails.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod };
request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
await expect(handler.handle({ request, response })).rejects.toThrow('pod error');
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
(params as any).oidcIssuer = baseUrl;
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(1);
expect(accountStore.deleteAccount).toHaveBeenLastCalledWith(email);
expect(accountStore.verify).toHaveBeenCalledTimes(0);
});
it('can create a WebID with an account and pod.', async(): Promise<void> => {
const params = { email, password, confirmPassword, podName, createWebId, register, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const generatedWebID = urljoin(baseUrl, podName, webIdSuffix);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password);
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
const podParams = { ...params, oidcIssuer: baseUrl, webId: generatedWebID };
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podParams);
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise<void> => {
const params = { email, webId, podName, createPod };
request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
const prom = handler.handle({ request, response });
await expect(prom).rejects.toThrow('pod error');
await expect(prom).rejects.toThrow(IdpInteractionError);
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: params }));
});
});
});

View File

@ -1,6 +1,5 @@
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
import { ConfigPodManager } from '../../../src/pods/ConfigPodManager';
import type { IdentifierGenerator } from '../../../src/pods/generate/IdentifierGenerator';
import type { PodGenerator } from '../../../src/pods/generate/PodGenerator';
import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator';
import type { PodSettings } from '../../../src/pods/settings/PodSettings';
@ -10,9 +9,6 @@ import type { ResourceStore } from '../../../src/storage/ResourceStore';
describe('A ConfigPodManager', (): void => {
let settings: PodSettings;
const base = 'http://test.com/';
const idGenerator: IdentifierGenerator = {
generate: (slug: string): ResourceIdentifier => ({ path: `${base}${slug}/` }),
};
let store: ResourceStore;
let podGenerator: PodGenerator;
let routingStorage: KeyValueStorage<string, ResourceStore>;
@ -50,12 +46,12 @@ describe('A ConfigPodManager', (): void => {
set: async(key: ResourceIdentifier, value: ResourceStore): Promise<any> => map.set(key, value),
} as any;
manager = new ConfigPodManager(idGenerator, podGenerator, resourcesGenerator, routingStorage);
manager = new ConfigPodManager(podGenerator, resourcesGenerator, routingStorage);
});
it('creates a pod and returns the newly generated identifier.', async(): Promise<void> => {
const identifier = { path: `${base}alice/` };
await expect(manager.createPod(settings)).resolves.toEqual(identifier);
await expect(manager.createPod(identifier, settings)).resolves.toBeUndefined();
expect(podGenerator.generate).toHaveBeenCalledTimes(1);
expect(podGenerator.generate).toHaveBeenLastCalledWith(identifier, settings);
expect(resourcesGenerator.generate).toHaveBeenCalledTimes(1);

View File

@ -1,5 +1,3 @@
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
import type { IdentifierGenerator } from '../../../src/pods/generate/IdentifierGenerator';
import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator';
import { GeneratedPodManager } from '../../../src/pods/GeneratedPodManager';
import type { PodSettings } from '../../../src/pods/settings/PodSettings';
@ -11,9 +9,6 @@ describe('A GeneratedPodManager', (): void => {
let settings: PodSettings;
let store: jest.Mocked<ResourceStore>;
let generatorData: Resource[];
const idGenerator: IdentifierGenerator = {
generate: (slug: string): ResourceIdentifier => ({ path: `${base}${slug}/` }),
};
let resGenerator: ResourcesGenerator;
let manager: GeneratedPodManager;
@ -37,18 +32,18 @@ describe('A GeneratedPodManager', (): void => {
yield* generatorData;
}),
};
manager = new GeneratedPodManager(store, idGenerator, resGenerator);
manager = new GeneratedPodManager(store, resGenerator);
});
it('throws an error if the generate identifier is not available.', async(): Promise<void> => {
store.resourceExists.mockResolvedValueOnce(true);
const result = manager.createPod(settings);
const result = manager.createPod({ path: `${base}user/` }, settings);
await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`);
await expect(result).rejects.toThrow(ConflictHttpError);
});
it('generates an identifier and writes containers before writing the resources in them.', async(): Promise<void> => {
await expect(manager.createPod(settings)).resolves.toEqual({ path: `${base}${settings.login}/` });
await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings)).resolves.toBeUndefined();
expect(store.setRepresentation).toHaveBeenCalledTimes(3);
expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/');