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

@@ -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,13 +40,14 @@
{
"RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
},
{
"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:IdentityProviderHandler",
"@type": "UnsupportedAsyncHandler"
},
{
"@id": "urn:solid-server:default:ResourcesGenerator",
"TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"

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" }
},
{
"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:IdentityProviderHandler",
"@type": "UnsupportedAsyncHandler"
}
]
}