import fetch from 'cross-fetch'; import { parse, splitCookiesString } from 'set-cookie-parser'; import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; import type { App } from '../../src/init/App'; import type { ResourceStore } from '../../src/storage/ResourceStore'; import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; import { joinUrl } from '../../src/util/PathUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; const port = getPort('Accounts'); const baseUrl = `http://localhost:${port}/`; // Don't send actual e-mails jest.mock('nodemailer'); describe('A server with account management', (): void => { let app: App; let store: ResourceStore; let sendMail: jest.Mock; const publicContainer = joinUrl(baseUrl, '/public/'); let cookie: string; const email = 'test@example.com'; let password = 'secret'; const indexUrl = joinUrl(baseUrl, '.account/'); let controls: { main: Record<'index' | 'logins', string>; account: Record<'create' | 'logout' | 'pod' | 'webId' | 'clientCredentials', string>; password: Record<'login' | 'forgot' | 'create', string>; }; let passwordResource: string; let pod: string; let podResource: string; let webId: string; beforeAll(async(): Promise => { // Needs to happen before Components.js instantiation sendMail = jest.fn(); const nodemailer = jest.requireMock('nodemailer'); Object.assign(nodemailer, { createTransport: (): any => ({ sendMail }) }); const instances = await instantiateFromConfig( 'urn:solid-server:test:Instances', getTestConfigPath('memory-pod.json'), getDefaultVariables(port, baseUrl), ) as Record; ({ app, store } = instances); await app.start(); // Create a public container where we can write any data await store.setRepresentation( { path: joinUrl(publicContainer, '.acl') }, new BasicRepresentation( `@prefix acl: . @prefix foaf: . <#public> a acl:Authorization; acl:agentClass foaf:Agent; acl:accessTo <./>; acl:default <./>; acl:mode acl:Read, acl:Write, acl:Control.`, 'text/turtle', ), ); controls = { main: {}, account: {}, login: {}, password: {}} as any; }); afterAll(async(): Promise => { await app.stop(); }); it('can get the general index.', async(): Promise => { const res = await fetch(indexUrl); expect(res.status).toBe(200); const json = await res.json(); expect(json.controls.main.index).toBe(indexUrl); expect(json.controls.main.logins).toBeDefined(); controls.main = json.controls.main; expect(json.controls.account.create).toBeDefined(); controls.account = json.controls.account; expect(json.controls.password.login).toBeDefined(); expect(json.controls.password.forgot).toBeDefined(); controls.password = json.controls.password; expect(json.controls.html).toBeDefined(); expect(json.controls.html.main).toBeDefined(); expect(json.controls.html.password).toBeDefined(); }); it('can create an account.', async(): Promise => { const res = await fetch(controls.account.create, { method: 'POST' }); expect(res.status).toBe(200); const json = await res.json(); expect(res.headers.get('set-cookie')).toBeDefined(); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); expect(cookies).toHaveLength(1); cookie = `${cookies[0].name}=${cookies[0].value}`; expect(json.authorization).toBe(cookies[0].value); }); it('can only access the account controls the cookie.', async(): Promise => { const res = await fetch(indexUrl, { headers: { cookie }}); expect(res.status).toBe(200); const json = await res.json(); expect(json.controls.account.logout).toBeDefined(); expect(json.controls.account.pod).toBeDefined(); expect(json.controls.account.webId).toBeDefined(); expect(json.controls.account.clientCredentials).toBeDefined(); expect((await fetch(json.controls.account.pod)).status).toBe(401); controls.account = json.controls.account; expect(json.controls.password.create).toBeDefined(); controls.password = json.controls.password; expect(json.controls.html.account).toBeDefined(); }); it('can also access the account controls using the custom authorization header.', async(): Promise => { const res = await fetch(indexUrl, { headers: { authorization: `CSS-Account-Token ${cookie.split('=')[1]}` }}); expect(res.status).toBe(200); const json = await res.json(); expect(json.controls.account.pod).toEqual(controls.account.pod); }); it('can not create a pod since the account has no login.', async(): Promise => { const res = await fetch(controls.account.pod, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'test' }), }); expect(res.status).toBe(400); expect((await res.json()).message).toBe('An account needs at least 1 login method.'); }); it('can add a password login to the account.', async(): Promise => { let res = await fetch(controls.password.create, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ email, password, }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.resource).toBeDefined(); ({ resource: passwordResource } = json); // Verify if the content was added to the account res = await fetch(controls.password.create, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).passwordLogins).toEqual({ [email]: passwordResource }); }); it('can not delete its last login method.', async(): Promise => { const res = await fetch(passwordResource, { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(400); expect((await res.json()).message).toBe('An account needs at least 1 login method.'); }); it('can not use the same email for a different account.', async(): Promise => { let res = await fetch(controls.account.create, { method: 'POST' }); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); const newCookie = `${cookies[0].name}=${cookies[0].value}`; res = await fetch(indexUrl, { headers: { cookie: newCookie }}); expect(res.status).toBe(200); const newControls: typeof controls = (await res.json()).controls; // This will fail because the email address is already used by a different account res = await fetch(newControls.password.create, { method: 'POST', headers: { cookie: newCookie, 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); expect(res.status).toBe(400); // Make sure the account still has no login method res = await fetch(newControls.password.create, { headers: { cookie: newCookie }}); expect((await res.json()).passwordLogins).toEqual({}); }); it('can log out.', async(): Promise => { const res = await fetch(controls.account.logout, { method: 'POST', headers: { cookie }}); expect(res.status).toBe(200); // Cookie doesn't work anymore expect((await fetch(controls.account.pod, { headers: { cookie }})).status).toBe(401); }); it('can login again with email/password.', async(): Promise => { const res = await fetch(controls.password.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); expect(res.status).toBe(200); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); expect(cookies).toHaveLength(1); cookie = `${cookies[0].name}=${cookies[0].value}`; // Cookie is valid again expect((await fetch(controls.account.pod, { headers: { cookie }})).status).toBe(200); }); it('can change the password.', async(): Promise => { let res = await fetch(passwordResource, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ oldPassword: password, newPassword: 'secret2', }), }); password = 'secret2'; expect(res.status).toBe(200); // Check new password res = await fetch(controls.password.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); expect(res.status).toBe(200); expect(res.headers.get('set-cookie')).toBeDefined(); }); it('can create a pod.', async(): Promise => { let res = await fetch(controls.account.pod, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'test' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.pod).toBeDefined(); expect(json.podResource).toBeDefined(); expect(json.webId).toBeDefined(); expect(json.webIdResource).toBeDefined(); ({ pod, webId, podResource } = json); // Verify if the content was added to the profile res = await fetch(controls.account.pod, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).pods[pod]).toBeDefined(); res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).webIdLinks[webId]).toBeDefined(); }); it('can not remove the last owner of a pod.', async(): Promise => { const res = await fetch(podResource, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId, remove: true }), }); expect(res.status).toBe(400); await expect(res.text()).resolves.toContain('Unable to remove the last owner of a pod.'); }); it('can add an owner to a pod.', async(): Promise => { let res = await fetch(podResource, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId: 'http://example.com/other/webID', visible: true }), }); expect(res.status).toBe(200); // Verify that the new owner was added res = await fetch(podResource, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).owners).toEqual([ { webId, visible: false }, { webId: 'http://example.com/other/webID', visible: true }, ]); // Verify only the new owner is exposed through a link header res = await fetch(pod); expect(res.status).toBe(200); const owners = res.headers.get('link')?.split(',') .filter((header): boolean => header.includes('rel="http://www.w3.org/ns/solid/terms#owner"')) .map((header): string => /<([^>]+)>/u.exec(header)![1]); expect(owners).toEqual([ 'http://example.com/other/webID' ]); }); it('can update the visibility of an existing pod owner.', async(): Promise => { let res = await fetch(podResource, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId, visible: true }), }); expect(res.status).toBe(200); // Verify that the visibility was changed res = await fetch(podResource, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).owners).toEqual([ { webId, visible: true }, { webId: 'http://example.com/other/webID', visible: true }, ]); // Verify both WebIDs are now visible res = await fetch(pod); expect(res.status).toBe(200); const owners = res.headers.get('link')?.split(',') .filter((header): boolean => header.includes('rel="http://www.w3.org/ns/solid/terms#owner"')) .map((header): string => /<([^>]+)>/u.exec(header)![1]); expect(owners).toEqual([ webId, 'http://example.com/other/webID' ]); }); it('can remove an owner from a pod.', async(): Promise => { let res = await fetch(podResource, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId: 'http://example.com/other/webID', remove: true }), }); expect(res.status).toBe(200); // Verify that the new owner was added res = await fetch(podResource, { headers: { cookie }}); expect(res.status).toBe(200); expect((await res.json()).owners).toEqual([ { webId, visible: true }, ]); }); it('does not store any data if creating a pod fails on the same account.', async(): Promise => { const oldPods = (await (await fetch(controls.account.pod, { headers: { cookie }})).json()).pods; const oldWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie }})).json()).webIdLinks; const res = await fetch(controls.account.pod, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'test' }), }); expect(res.status).toBe(400); // Verify nothing was added const newPods = (await (await fetch(controls.account.pod, { headers: { cookie }})).json()).pods; const newWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie }})).json()).webIdLinks; expect(oldPods).toEqual(newPods); expect(oldWebIdLinks).toEqual(newWebIdLinks); }); it('does not store any data if creating a pod fails on a different account.', async(): Promise => { // We have to create a new account here to try to create a pod with the same name. // Otherwise, the server will never try to write data // since it would notice the account already has a pod with that name. let res = await fetch(controls.account.create, { method: 'POST' }); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); const newCookie = `${cookies[0].name}=${cookies[0].value}`; res = await fetch(indexUrl, { headers: { cookie: newCookie }}); const json: { controls: typeof controls } = await res.json(); res = await fetch(json.controls.password.create, { method: 'POST', headers: { cookie: newCookie, 'content-type': 'application/json' }, body: JSON.stringify({ email: 'differentMail@example.com', password, }), }); expect(res.status).toBe(200); const oldPods = (await (await fetch(controls.account.pod, { headers: { cookie: newCookie }})).json()).pods; const oldWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie: newCookie }})).json()) .webIdLinks; // This will fail because there already is a pod with this name res = await fetch(json.controls.account.pod, { method: 'POST', headers: { cookie: newCookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'test' }), }); expect(res.status).toBe(400); expect((await res.json()).message).toContain('Pod creation failed'); // Make sure there is no reference in the account data const newPods = (await (await fetch(controls.account.pod, { headers: { cookie: newCookie }})).json()).pods; const newWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie: newCookie }})).json()) .webIdLinks; expect(oldPods).toEqual(newPods); expect(oldWebIdLinks).toEqual(newWebIdLinks); }); it('can remove the WebID link.', async(): Promise => { let res = await fetch(controls.account.webId, { headers: { cookie }}); const webIdResource = (await res.json()).webIdLinks[webId]; res = await fetch(webIdResource, { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(200); res = await fetch(controls.account.webId, { headers: { cookie }}); expect((await res.json()).webIdLinks[webId]).toBeUndefined(); }); it('can link the WebID again.', async(): Promise => { let res = await fetch(controls.account.webId, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId }), }); expect(res.status).toBe(200); let json = await res.json(); expect(json.resource).toBeDefined(); expect(json.oidcIssuer).toBe(baseUrl); // Verify if the content was added to the profile res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); json = await res.json(); expect(json.webIdLinks[webId]).toBeDefined(); }); it('needs to prove ownership when linking a WebID outside of a pod.', async(): Promise => { const otherWebId = joinUrl(publicContainer, 'other#me'); // Create the WebID let res = await fetch(otherWebId, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: '', }); expect(res.status).toBe(201); // Try to link the WebID res = await fetch(controls.account.webId, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId: otherWebId }), }); expect(res.status).toBe(400); let json = await res.json(); expect(json.details?.quad).toBeDefined(); const { quad } = json.details; // Update the WebID with the identifying quad await fetch(otherWebId, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: quad, }); // Try to link the WebID again res = await fetch(controls.account.webId, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ webId: otherWebId }), }); expect(res.status).toBe(200); // Verify if the content was added to the profile res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); json = await res.json(); // 2 linked WebIDs now expect(json.webIdLinks[webId]).toBeDefined(); expect(json.webIdLinks[otherWebId]).toBeDefined(); }); it('can create a client credentials token.', async(): Promise => { let res = await fetch(controls.account.clientCredentials, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'token', webId }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.id).toMatch(/^token/u); expect(json.secret).toBeDefined(); expect(json.resource).toBeDefined(); const { id, resource, secret } = json; // Verify if the content was added to the profile res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); expect(res.status).toBe(200); const { clientCredentials } = await res.json(); expect(clientCredentials[id]).toBe(resource); // Request a token const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; res = await fetch(joinUrl(baseUrl, '.oidc/token'), { method: 'POST', headers: { authorization: `Basic ${Buffer.from(authString).toString('base64')}`, 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, }, body: 'grant_type=client_credentials&scope=webid', }); expect(res.status).toBe(200); const { access_token: token } = await res.json(); expect(token).toBeDefined(); }); it('can remove registered WebIDs.', async(): Promise => { let res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); let json = await res.json(); res = await fetch(json.webIdLinks[webId], { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(200); // Make sure it's gone res = await fetch(controls.account.webId, { headers: { cookie }}); json = await res.json(); expect(json.webIdLinks[webId]).toBeUndefined(); }); it('can remove credential tokens.', async(): Promise => { let res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); expect(res.status).toBe(200); let json = await res.json(); const tokenUrl = Object.values(json.clientCredentials)[0] as string; res = await fetch(tokenUrl, { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(200); // Make sure it's gone res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); json = await res.json(); expect(Object.keys(json.clientCredentials)).toHaveLength(0); }); it('can reset a password if forgotten.', async(): Promise => { let res = await fetch(controls.password.forgot, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }), }); expect(res.status).toBe(200); expect(sendMail).toHaveBeenCalledTimes(1); // Parse reset URL out of mail const mail = sendMail.mock.calls[0][0]; expect(mail.to).toBe(email); const match = /(http:.*)$/u.exec(mail.text); expect(match).toBeDefined(); const resetUrl = match![1]; res = await fetch(resetUrl); const url = new URL(resetUrl); const recordId = url.searchParams.get('rid'); expect(recordId).toBeDefined(); // Reset the password password = 'resetSecret'; res = await fetch(resetUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ recordId, password }), }); expect(res.status).toBe(200); // Verify logging in with the new password works res = await fetch(controls.password.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); expect(res.status).toBe(200); expect(res.headers.get('set-cookie')).toBeDefined(); }); });