feat: Full rework of account management

Complete rewrite of the account management and related systems.
Makes the architecture more modular,
allowing for easier extensions and configurations.
This commit is contained in:
Joachim Van Herwegen
2022-03-16 10:12:13 +01:00
parent ade977bb4f
commit a47f5236ef
366 changed files with 12345 additions and 5111 deletions

View File

@@ -1,70 +0,0 @@
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler';
import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler';
import { APPLICATION_JSON } from '../../../../src/util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
class DummyBaseInteractionHandler extends BaseInteractionHandler {
public constructor() {
super({ view: 'view' });
}
public async handlePost(input: InteractionHandlerInput): Promise<Representation> {
return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON);
}
}
describe('A BaseInteractionHandler', (): void => {
const handler = new DummyBaseInteractionHandler();
it('can only handle GET and POST requests.', async(): Promise<void> => {
const operation: Operation = {
method: 'DELETE',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: {},
};
await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
operation.method = 'GET';
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
operation.method = 'POST';
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
});
it('returns the view on GET requests.', async(): Promise<void> => {
const operation: Operation = {
method: 'GET',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: {},
};
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' });
});
it('calls the handlePost function on POST requests.', async(): Promise<void> => {
const operation: Operation = {
method: 'POST',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: {},
};
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' });
});
it('rejects other methods.', async(): Promise<void> => {
const operation: Operation = {
method: 'DELETE',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: {},
};
await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
});
});

View File

@@ -0,0 +1,134 @@
import { ControlHandler } from '../../../../src/identity/interaction/ControlHandler';
import type {
JsonInteractionHandler,
JsonInteractionHandlerInput,
} from '../../../../src/identity/interaction/JsonInteractionHandler';
import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute';
describe('A ControlHandler', (): void => {
const input: JsonInteractionHandlerInput = {} as any;
let controls: Record<string, InteractionRoute | JsonInteractionHandler>;
let source: jest.Mocked<JsonInteractionHandler>;
let handler: ControlHandler;
beforeEach(async(): Promise<void> => {
controls = {
login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any,
register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any,
};
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue({ json: { data: 'data' }}),
} as any;
handler = new ControlHandler(controls, source);
});
it('can handle any input its source can handle if there is one.', async(): Promise<void> => {
await expect(handler.canHandle(input)).resolves.toBeUndefined();
source.canHandle.mockRejectedValue(new Error('bad data'));
await expect(handler.canHandle(input)).rejects.toThrow('bad data');
handler = new ControlHandler(controls);
await expect(handler.canHandle(input)).resolves.toBeUndefined();
});
it('adds controls to the source response in the key field.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
login: 'http://example.com/login/',
register: 'http://example.com/register/',
}});
});
it('can have handlers instead of routes as control values.', async(): Promise<void> => {
controls.handler = {
handleSafe: jest.fn().mockResolvedValue({ json: {
key1: 'path1',
key2: 'path2',
}}),
} as any;
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
login: 'http://example.com/login/',
handler: {
key1: 'path1',
key2: 'path2',
},
register: 'http://example.com/register/',
}});
});
it('does not add route results if getting the path fails.', async(): Promise<void> => {
controls.account = {
getPath: jest.fn((): never => {
throw new Error('missing account ID');
}),
} as any;
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
login: 'http://example.com/login/',
register: 'http://example.com/register/',
}});
});
it('does not add handler results if it returns an empty array.', async(): Promise<void> => {
controls.array = {
handleSafe: jest.fn().mockResolvedValue({ json: []}),
} as any;
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
login: 'http://example.com/login/',
register: 'http://example.com/register/',
}});
});
it('does not add handler results if it returns an empty object.', async(): Promise<void> => {
controls.object = {
handleSafe: jest.fn().mockResolvedValue({ json: {}}),
} as any;
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
login: 'http://example.com/login/',
register: 'http://example.com/register/',
}});
});
it('merges results with controls.', async(): Promise<void> => {
source.handle.mockResolvedValueOnce({ json: {
data: 'data1',
arr: [ 'arr1' ],
arr2: [ 'arr1' ],
obj: {
key1: 'val1',
},
obj2: {
key1: 'val1',
},
}});
controls = {
data: { getPath: jest.fn().mockReturnValue('data2') } as any,
arr: { getPath: jest.fn().mockReturnValue([ 'arr2' ]) } as any,
arr2: { getPath: jest.fn().mockReturnValue({ key2: 'val2' }) } as any,
obj: { getPath: jest.fn().mockReturnValue({ key2: 'val2' }) } as any,
obj2: { getPath: jest.fn().mockReturnValue([ 'moreData2' ]) } as any,
};
handler = new ControlHandler(controls, source);
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data1',
arr: [ 'arr1', 'arr2' ],
arr2: [ 'arr1' ],
obj: {
key1: 'val1',
key2: 'val2',
},
obj2: {
key1: 'val1',
},
}});
});
});

View File

@@ -0,0 +1,161 @@
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../src/identity/interaction/account/util/Account';
import type { Account } from '../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../src/identity/interaction/account/util/AccountStore';
import type { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore';
import { CookieInteractionHandler } from '../../../../src/identity/interaction/CookieInteractionHandler';
import type { JsonRepresentation } from '../../../../src/identity/interaction/InteractionUtil';
import type {
JsonInteractionHandler,
JsonInteractionHandlerInput,
} from '../../../../src/identity/interaction/JsonInteractionHandler';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
import { createAccount, mockAccountStore } from '../../../util/AccountUtil';
describe('A CookieInteractionHandler', (): void => {
const date = new Date();
const accountId = 'accountId';
const cookie = 'cookie';
const target: ResourceIdentifier = { path: 'http://example.com/foo' };
let input: JsonInteractionHandlerInput;
let output: JsonRepresentation;
let account: Account;
let source: jest.Mocked<JsonInteractionHandler>;
let accountStore: jest.Mocked<AccountStore>;
let cookieStore: jest.Mocked<CookieStore>;
let handler: CookieInteractionHandler;
beforeEach(async(): Promise<void> => {
input = {
method: 'GET',
json: {},
metadata: new RepresentationMetadata({ [SOLID_HTTP.accountCookie]: cookie }),
target,
};
output = {
json: {},
metadata: new RepresentationMetadata(),
};
account = createAccount(accountId);
account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = true;
accountStore = mockAccountStore(account);
cookieStore = {
get: jest.fn().mockResolvedValue(account.id),
refresh: jest.fn().mockResolvedValue(date),
} as any;
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(output),
} as any;
handler = new CookieInteractionHandler(source, accountStore, cookieStore);
});
it('can handle input its source can handle.', async(): Promise<void> => {
await expect(handler.canHandle(input)).resolves.toBeUndefined();
expect(source.canHandle).toHaveBeenCalledTimes(1);
expect(source.canHandle).toHaveBeenLastCalledWith(input);
source.canHandle.mockRejectedValueOnce(new Error('bad data'));
await expect(handler.canHandle(input)).rejects.toThrow('bad data');
expect(source.canHandle).toHaveBeenCalledTimes(2);
expect(source.canHandle).toHaveBeenLastCalledWith(input);
});
it('refreshes the cookie and sets its expiration metadata if required.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual(output);
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
expect(cookieStore.get).toHaveBeenCalledTimes(1);
expect(cookieStore.get).toHaveBeenLastCalledWith(cookie);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.refresh).toHaveBeenCalledTimes(1);
expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString());
});
it('creates a new metadata output object if there was none.', async(): Promise<void> => {
delete output.metadata;
await expect(handler.handle(input)).resolves.toEqual(output);
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
expect(cookieStore.get).toHaveBeenCalledTimes(1);
expect(cookieStore.get).toHaveBeenLastCalledWith(cookie);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.refresh).toHaveBeenCalledTimes(1);
expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie);
// Typescript things the typing of this is `never` since we deleted it above
expect((output.metadata as any).get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie);
expect((output.metadata as any).get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString());
});
it('uses the output cookie over the input cookie if there is one.', async(): Promise<void> => {
output.metadata!.set(SOLID_HTTP.terms.accountCookie, 'other-cookie');
await expect(handler.handle(input)).resolves.toEqual(output);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe('other-cookie');
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString());
});
it('adds no cookie metadata if there is no cookie.', async(): Promise<void> => {
input.metadata.removeAll(SOLID_HTTP.terms.accountCookie);
await expect(handler.handle(input)).resolves.toEqual(output);
expect(cookieStore.get).toHaveBeenCalledTimes(0);
expect(accountStore.get).toHaveBeenCalledTimes(0);
expect(cookieStore.refresh).toHaveBeenCalledTimes(0);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined();
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined();
});
it('adds no cookie metadata if the output metadata already has expiration metadata.', async(): Promise<void> => {
output.metadata!.set(SOLID_HTTP.terms.accountCookie, 'other-cookie');
output.metadata!.set(SOLID_HTTP.terms.accountCookieExpiration, date.toISOString());
await expect(handler.handle(input)).resolves.toEqual(output);
expect(cookieStore.get).toHaveBeenCalledTimes(0);
expect(accountStore.get).toHaveBeenCalledTimes(0);
expect(cookieStore.refresh).toHaveBeenCalledTimes(0);
});
it('adds no cookie metadata if no account ID was found.', async(): Promise<void> => {
cookieStore.get.mockResolvedValue(undefined);
await expect(handler.handle(input)).resolves.toEqual(output);
expect(cookieStore.get).toHaveBeenCalledTimes(1);
expect(cookieStore.get).toHaveBeenLastCalledWith(cookie);
expect(accountStore.get).toHaveBeenCalledTimes(0);
expect(cookieStore.refresh).toHaveBeenCalledTimes(0);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined();
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined();
});
it('adds no cookie metadata if the account does not want to be remembered.', async(): Promise<void> => {
account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = false;
await expect(handler.handle(input)).resolves.toEqual(output);
expect(cookieStore.get).toHaveBeenCalledTimes(1);
expect(cookieStore.get).toHaveBeenLastCalledWith(cookie);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.refresh).toHaveBeenCalledTimes(0);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined();
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined();
});
it('adds no cookie metadata if the refresh action returns no value.', async(): Promise<void> => {
cookieStore.refresh.mockResolvedValue(undefined);
await expect(handler.handle(input)).resolves.toEqual(output);
expect(cookieStore.get).toHaveBeenCalledTimes(1);
expect(cookieStore.get).toHaveBeenLastCalledWith(cookie);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.refresh).toHaveBeenCalledTimes(1);
expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie);
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined();
expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined();
});
});

View File

@@ -1,15 +0,0 @@
import type { Operation } from '../../../../src/http/Operation';
import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('A FixedInteractionHandler', (): void => {
const json = { data: 'data' };
const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
const handler = new FixedInteractionHandler(json);
it('returns the given JSON as response.', async(): Promise<void> => {
const response = await handler.handle({ operation });
await expect(readJsonStream(response.data)).resolves.toEqual(json);
expect(response.metadata.contentType).toBe('application/json');
});
});

View File

@@ -1,6 +1,6 @@
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler';
import { HtmlViewEntry, HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler';
import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute';
import { TEXT_HTML } from '../../../../src/util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
@@ -13,14 +13,15 @@ describe('An HtmlViewHandler', (): void => {
const idpIndex = 'http://example.com/idp/';
let index: InteractionRoute;
let operation: Operation;
let templates: Record<string, jest.Mocked<InteractionRoute>>;
let templates: HtmlViewEntry[];
let templateEngine: TemplateEngine;
let handler: HtmlViewHandler;
beforeEach(async(): Promise<void> => {
index = {
getPath: jest.fn().mockReturnValue(idpIndex),
} as any;
matchPath: jest.fn().mockReturnValue({}),
};
operation = {
method: 'GET',
@@ -29,10 +30,16 @@ describe('An HtmlViewHandler', (): void => {
body: new BasicRepresentation(),
};
templates = {
'/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any,
'/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any,
};
templates = [
new HtmlViewEntry({
getPath: jest.fn().mockReturnValue('http://example.com/idp/login/'),
matchPath: jest.fn().mockReturnValue({}),
}, '/templates/login.html.ejs'),
new HtmlViewEntry({
getPath: jest.fn().mockReturnValue('http://example.com/idp/register/'),
matchPath: jest.fn().mockReturnValue({}),
}, '/templates/register.html.ejs'),
];
templateEngine = {
handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
@@ -47,7 +54,9 @@ describe('An HtmlViewHandler', (): void => {
});
it('rejects unsupported paths.', async(): Promise<void> => {
operation.target.path = 'http://example.com/idp/otherPath/';
for (const template of templates) {
(template.route as jest.Mocked<InteractionRoute>).matchPath.mockReturnValue(undefined);
}
await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError);
});

View File

@@ -1,28 +0,0 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import {
InteractionHandler,
} from '../../../../src/identity/interaction/InteractionHandler';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
class SimpleInteractionHandler extends InteractionHandler {
public async handle(): Promise<Representation> {
return new BasicRepresentation();
}
}
describe('An InteractionHandler', (): void => {
const handler = new SimpleInteractionHandler();
it('only supports JSON data or empty bodies.', async(): Promise<void> => {
let representation = new BasicRepresentation('{}', 'application/json');
await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
representation = new BasicRepresentation('', 'application/x-www-form-urlencoded');
await expect(handler.canHandle({ operation: { body: representation }} as any))
.rejects.toThrow(NotImplementedHttpError);
representation = new BasicRepresentation();
await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,97 @@
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import type { AccountInteractionResults } from '../../../../src/identity/interaction/InteractionUtil';
import {
assertOidcInteraction, finishInteraction, forgetWebId,
} from '../../../../src/identity/interaction/InteractionUtil';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import type Provider from '../../../../templates/types/oidc-provider';
jest.useFakeTimers();
jest.setSystemTime();
describe('InteractionUtil', (): void => {
let oidcInteraction: Interaction;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
lastSubmission: {
login: {
accountId: 'http://example.com/card#me',
},
},
session: {
cookie: 'cookie',
},
exp: (Date.now() / 1000) + 1234,
returnTo: 'returnTo',
persist: jest.fn(),
} as any;
});
describe('#assertOidcInteraction', (): void => {
it('does nothing if the interaction is defined.', async(): Promise<void> => {
expect(assertOidcInteraction(oidcInteraction)).toBeUndefined();
});
it('throws an error if there is no interaction.', async(): Promise<void> => {
try {
assertOidcInteraction();
// Make sure the function always errors
expect(true).toBe(false);
} catch (error: unknown) {
/* eslint-disable jest/no-conditional-expect */
expect(BadRequestHttpError.isInstance(error)).toBe(true);
expect((error as BadRequestHttpError).message)
.toBe('This action can only be performed as part of an OIDC authentication flow.');
expect((error as BadRequestHttpError).errorCode).toBe('E0002');
/* eslint-enable jest/no-conditional-expect */
}
});
});
describe('#finishInteraction', (): void => {
const result: AccountInteractionResults = {
account: 'accountId',
};
it('updates the interaction.', async(): Promise<void> => {
await expect(finishInteraction(oidcInteraction, result, false)).resolves.toBe('returnTo');
expect(oidcInteraction.result).toBe(result);
expect(oidcInteraction.persist).toHaveBeenCalledTimes(1);
});
it('can merge the result into the interaction.', async(): Promise<void> => {
await expect(finishInteraction(oidcInteraction, result, true)).resolves.toBe('returnTo');
expect(oidcInteraction.result).toEqual({
account: 'accountId',
login: {
accountId: 'http://example.com/card#me',
},
});
expect(oidcInteraction.persist).toHaveBeenCalledTimes(1);
});
});
describe('#forgetWebId', (): void => {
let provider: jest.Mocked<Provider>;
beforeEach(async(): Promise<void> => {
provider = {
// eslint-disable-next-line @typescript-eslint/naming-convention
Session: {
find: jest.fn().mockResolvedValue({
accountId: 'accountId',
persist: jest.fn(),
}),
},
} as any;
});
it('removes the accountId from the session.', async(): Promise<void> => {
await expect(forgetWebId(provider, oidcInteraction)).resolves.toBeUndefined();
const session = await (provider.Session.find as jest.Mock).mock.results[0].value;
expect(session.accountId).toBeUndefined();
expect(session.persist).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,99 @@
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import { JsonConversionHandler } from '../../../../src/identity/interaction/JsonConversionHandler';
import type { JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler';
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../../../../src/util/ContentTypes';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('A JsonConversionHandler', (): void => {
const accountId = 'accountId';
const oidcInteraction: Interaction = { returnTo: 'returnTo' } as any;
let convertedRepresentation: Representation;
let operation: Operation;
let source: jest.Mocked<JsonInteractionHandler>;
let converter: jest.Mocked<RepresentationConverter>;
let handler: JsonConversionHandler;
beforeEach(async(): Promise<void> => {
operation = {
method: 'GET',
target: { path: 'http://test.com/idp' },
preferences: { type: { 'application/json': 1 }},
body: new BasicRepresentation('{ "input": "data" }', 'application/json'),
};
source = {
canHandle: jest.fn(),
handle: jest.fn(),
handleSafe: jest.fn().mockResolvedValue({ json: { output: 'data' }}),
};
converter = {
canHandle: jest.fn(),
handle: jest.fn(async(input): Promise<Representation> => {
convertedRepresentation = new BasicRepresentation(input.representation.data, 'application/json');
return convertedRepresentation;
}),
handleSafe: jest.fn(),
};
handler = new JsonConversionHandler(source, converter);
});
it('only handle representations its converter can handle.', async(): Promise<void> => {
await expect(handler.canHandle({ operation, accountId, oidcInteraction })).resolves.toBeUndefined();
const error = new Error('bad data');
converter.canHandle.mockRejectedValueOnce(error);
await expect(handler.canHandle({ operation, accountId, oidcInteraction })).rejects.toThrow(error);
});
it('can always handle empty bodies.', async(): Promise<void> => {
operation.body = new BasicRepresentation();
const error = new Error('bad data');
converter.canHandle.mockRejectedValueOnce(error);
await expect(handler.canHandle({ operation, accountId, oidcInteraction })).resolves.toBeUndefined();
});
it('calls the source with the generated JSON and converts the output back.', async(): Promise<void> => {
const output = await handler.handle({ operation, accountId, oidcInteraction });
expect(output).toBeDefined();
await expect(readJsonStream(output.data)).resolves.toEqual({ output: 'data' });
expect(output.metadata.contentType).toBe('application/json');
expect(converter.handle).toHaveBeenCalledTimes(1);
expect(converter.handle).toHaveBeenLastCalledWith({
identifier: operation.target,
preferences: { type: { [APPLICATION_JSON]: 1 }},
representation: operation.body,
});
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({
method: operation.method,
target: operation.target,
metadata: convertedRepresentation.metadata,
json: { input: 'data' },
oidcInteraction,
accountId,
});
});
it('does not call the converter if the body is empty.', async(): Promise<void> => {
operation.body = new BasicRepresentation();
const output = await handler.handle({ operation, accountId, oidcInteraction });
expect(output).toBeDefined();
await expect(readJsonStream(output.data)).resolves.toEqual({ output: 'data' });
expect(output.metadata.contentType).toBe('application/json');
expect(converter.handle).toHaveBeenCalledTimes(0);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({
method: operation.method,
target: operation.target,
metadata: operation.body.metadata,
json: {},
oidcInteraction,
accountId,
});
});
});

View File

@@ -1,24 +1,22 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type {
InteractionHandler,
InteractionHandlerInput,
} from '../../../../src/identity/interaction/InteractionHandler';
JsonInteractionHandler,
JsonInteractionHandlerInput,
} from '../../../../src/identity/interaction/JsonInteractionHandler';
import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('A LocationInteractionHandler', (): void => {
const representation = new BasicRepresentation();
const input: InteractionHandlerInput = {
operation: {
target: { path: 'http://example.com/target' },
preferences: {},
method: 'GET',
body: new BasicRepresentation(),
},
const input: JsonInteractionHandlerInput = {
target: { path: 'http://example.com/target' },
method: 'GET',
json: { input: 'data' },
metadata: new RepresentationMetadata(),
};
let source: jest.Mocked<InteractionHandler>;
let source: jest.Mocked<JsonInteractionHandler>;
let handler: LocationInteractionHandler;
beforeEach(async(): Promise<void> => {
@@ -50,8 +48,8 @@ describe('A LocationInteractionHandler', (): void => {
source.handle.mockRejectedValueOnce(new FoundHttpError(location));
const response = await handler.handle(input);
expect(response.metadata.identifier.value).toEqual(input.operation.target.path);
await expect(readJsonStream(response.data)).resolves.toEqual({ location });
expect(response.metadata?.identifier.value).toEqual(input.target.path);
expect(response.json).toEqual({ location });
});
it('rethrows non-redirect errors.', async(): Promise<void> => {

View File

@@ -0,0 +1,81 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { AccountIdRoute } from '../../../../src/identity/interaction/account/AccountIdRoute';
import type { InteractionHandlerInput,
InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler';
import { LockingInteractionHandler } from '../../../../src/identity/interaction/LockingInteractionHandler';
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
describe('A LockingInteractionHandler', (): void => {
const accountId = 'accountId';
let input: InteractionHandlerInput;
let locker: jest.Mocked<ReadWriteLocker>;
let route: jest.Mocked<AccountIdRoute>;
let source: jest.Mocked<InteractionHandler>;
let handler: LockingInteractionHandler;
beforeEach(async(): Promise<void> => {
input = {
operation: {
method: 'GET',
target: { path: 'http://example.com/foo' },
preferences: {},
body: new BasicRepresentation(),
},
accountId,
};
locker = {
withReadLock: jest.fn(async(id, fn): Promise<any> => fn()),
withWriteLock: jest.fn(async(id, fn): Promise<any> => fn()),
};
route = {
matchPath: jest.fn(),
getPath: jest.fn().mockReturnValue('http://example.com/accountId'),
};
source = {
handleSafe: jest.fn(),
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue('response'),
};
handler = new LockingInteractionHandler(locker, route, source);
});
it('can handle input its source can handle.', async(): Promise<void> => {
await expect(handler.canHandle(input)).resolves.toBeUndefined();
expect(source.canHandle).toHaveBeenCalledTimes(1);
expect(source.canHandle).toHaveBeenLastCalledWith(input);
const error = new Error('bad data');
source.canHandle.mockRejectedValueOnce(error);
await expect(handler.canHandle(input)).rejects.toThrow(error);
});
it('does not create a lock if there is no account ID.', async(): Promise<void> => {
delete input.accountId;
await expect(handler.handle(input)).resolves.toBe('response');
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
expect(locker.withReadLock).toHaveBeenCalledTimes(0);
expect(locker.withWriteLock).toHaveBeenCalledTimes(0);
});
it('creates a read lock for read operations.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toBe('response');
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
expect(locker.withReadLock).toHaveBeenCalledTimes(1);
expect(locker.withWriteLock).toHaveBeenCalledTimes(0);
});
it('creates a write lock for write operations.', async(): Promise<void> => {
input.operation.method = 'PUT';
await expect(handler.handle(input)).resolves.toBe('response');
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
expect(locker.withReadLock).toHaveBeenCalledTimes(0);
expect(locker.withWriteLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,17 @@
import { OidcControlHandler } from '../../../../src/identity/interaction/OidcControlHandler';
describe('An OidcControlHandler', (): void => {
const handler = new OidcControlHandler({ key: {
getPath: jest.fn().mockReturnValue('http://example.com/foo/'),
matchPath: jest.fn().mockReturnValue(true),
}});
it('returns results if there is an OIDC interaction.', async(): Promise<void> => {
await expect(handler.handle({ oidcInteraction: {}} as any))
.resolves.toEqual({ json: { key: 'http://example.com/foo/' }});
});
it('returns an empty object if there is no OIDC interaction.', async(): Promise<void> => {
await expect(handler.handle({ } as any)).resolves.toEqual({ json: { }});
});
});

View File

@@ -1,37 +0,0 @@
import type { Operation } from '../../../../src/http/Operation';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler';
import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
describe('A PromptHandler', (): void => {
const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
let oidcInteraction: Interaction;
let promptRoutes: Record<string, jest.Mocked<InteractionRoute>>;
let handler: PromptHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = { prompt: { name: 'login' }} as any;
promptRoutes = {
login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any,
};
handler = new PromptHandler(promptRoutes);
});
it('errors if there is no interaction.', async(): Promise<void> => {
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
});
it('errors if the prompt is unsupported.', async(): Promise<void> => {
oidcInteraction.prompt.name = 'unsupported';
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError);
});
it('throws a redirect error with the correct location.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 302,
location: 'http://example.com/idp/login/',
});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error);
});
});

View File

@@ -0,0 +1,10 @@
import { StaticInteractionHandler } from '../../../../src/identity/interaction/StaticInteractionHandler';
describe('A FixedInteractionHandler', (): void => {
const json = { data: 'data' };
const handler = new StaticInteractionHandler(json);
it('returns the given JSON as response.', async(): Promise<void> => {
await expect(handler.handle()).resolves.toEqual({ json });
});
});

View File

@@ -0,0 +1,31 @@
import type { JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler';
import { VersionHandler } from '../../../../src/identity/interaction/VersionHandler';
describe('A VersionHandler', (): void => {
let source: jest.Mocked<JsonInteractionHandler>;
let handler: VersionHandler;
beforeEach(async(): Promise<void> => {
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue({ json: { data: 'data' }}),
} as any;
handler = new VersionHandler(source);
});
it('can handle input its source can handle.', async(): Promise<void> => {
await expect(handler.canHandle({} as any)).resolves.toBeUndefined();
const error = new Error('bad data');
source.canHandle.mockRejectedValueOnce(error);
await expect(handler.canHandle({} as any)).rejects.toThrow(error);
});
it('adds the API version to the output.', async(): Promise<void> => {
await expect(handler.handle({} as any)).resolves.toEqual({ json: {
data: 'data',
version: '0.5',
}});
});
});

View File

@@ -0,0 +1,56 @@
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { JsonInteractionHandlerInput,
JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler';
import type { JsonView } from '../../../../src/identity/interaction/JsonView';
import { ViewInteractionHandler } from '../../../../src/identity/interaction/ViewInteractionHandler';
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
describe('A BaseInteractionHandler', (): void => {
let input: JsonInteractionHandlerInput;
let source: jest.Mocked<JsonInteractionHandler & JsonView>;
let handler: ViewInteractionHandler;
beforeEach(async(): Promise<void> => {
input = {
method: 'GET',
target: { path: 'target' },
json: { input: 'data' },
metadata: new RepresentationMetadata(),
};
source = {
getView: jest.fn().mockResolvedValue('view'),
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue('response'),
handleSafe: jest.fn(),
};
handler = new ViewInteractionHandler(source);
});
it('can only handle GET and POST requests.', async(): Promise<void> => {
input.method = 'DELETE';
await expect(handler.canHandle(input)).rejects.toThrow(MethodNotAllowedHttpError);
input.method = 'GET';
await expect(handler.canHandle(input)).resolves.toBeUndefined();
input.method = 'POST';
await expect(handler.canHandle(input)).resolves.toBeUndefined();
});
it('returns the view on GET requests.', async(): Promise<void> => {
input.method = 'GET';
await expect(handler.handle(input)).resolves.toBe('view');
expect(source.getView).toHaveBeenCalledTimes(1);
expect(source.getView).toHaveBeenLastCalledWith(input);
});
it('calls the handlePost function on POST requests.', async(): Promise<void> => {
input.method = 'POST';
await expect(handler.handle(input)).resolves.toBe('response');
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
});
});

View File

@@ -0,0 +1,57 @@
import { boolean, number, object, string } from 'yup';
import { parseSchema, URL_SCHEMA, validateWithError } from '../../../../src/identity/interaction/YupUtil';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
describe('YupUtil', (): void => {
describe('#URL_SCHEMA', (): void => {
it('validates URLs.', async(): Promise<void> => {
await expect(URL_SCHEMA.isValid('https://example.com/foo')).resolves.toBe(true);
await expect(URL_SCHEMA.isValid('http://localhost:3000/foo')).resolves.toBe(true);
await expect(URL_SCHEMA.isValid('apple')).resolves.toBe(false);
await expect(URL_SCHEMA.isValid('mail@example.com')).resolves.toBe(false);
await expect(URL_SCHEMA.isValid('')).resolves.toBe(true);
await expect(URL_SCHEMA.isValid(null)).resolves.toBe(false);
});
});
describe('#parseSchema', (): void => {
it('creates representations for yup schemas.', async(): Promise<void> => {
const schema = object({
optStr: string(),
reqStr: string().required(),
numb: number(),
bool: boolean(),
obj: object({
key: string().required(),
obj2: object({
nested: number(),
}),
}).required(),
});
expect(parseSchema(schema)).toEqual({ fields: {
optStr: { type: 'string', required: false },
reqStr: { type: 'string', required: true },
numb: { type: 'number', required: false },
bool: { type: 'boolean', required: false },
obj: { type: 'object',
required: true,
fields: {
key: { type: 'string', required: true },
obj2: { type: 'object',
required: false,
fields: {
nested: { type: 'number', required: false },
}},
}},
}});
});
});
describe('#validateWithError', (): void => {
it('throws a BadRequestHttpError if there is an error.', async(): Promise<void> => {
const schema = object({});
await expect(validateWithError(schema, { test: 'data' })).resolves.toEqual({ test: 'data' });
await expect(validateWithError(schema, 'test')).rejects.toThrow(BadRequestHttpError);
});
});
});

View File

@@ -0,0 +1,20 @@
import { AccountDetailsHandler } from '../../../../../src/identity/interaction/account/AccountDetailsHandler';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('An AccountDetailsHandler', (): void => {
const accountId = 'id';
const account = createAccount();
let accountStore: jest.Mocked<AccountStore>;
let handler: AccountDetailsHandler;
beforeEach(async(): Promise<void> => {
accountStore = mockAccountStore(account);
handler = new AccountDetailsHandler(accountStore);
});
it('returns a JSON representation of the account.', async(): Promise<void> => {
await expect(handler.handle({ accountId } as any)).resolves.toEqual({ json: account });
});
});

View File

@@ -0,0 +1,11 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
describe('A BaseAccountIdRoute', (): void => {
it('uses the Account ID key.', async(): Promise<void> => {
const accountIdRoute = new BaseAccountIdRoute(new AbsolutePathInteractionRoute('http://example.com/'));
expect(accountIdRoute.matchPath('http://example.com/123/')).toEqual({ accountId: '123' });
});
});

View File

@@ -0,0 +1,23 @@
import { CreateAccountHandler } from '../../../../../src/identity/interaction/account/CreateAccountHandler';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A CreateAccountHandler', (): void => {
let accountStore: jest.Mocked<AccountStore>;
let handler: CreateAccountHandler;
beforeEach(async(): Promise<void> => {
accountStore = mockAccountStore();
handler = new CreateAccountHandler(accountStore, {} as any, {} as any);
});
it('has no requirements.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({ json: {}});
});
it('returns the identifier of the newly created account.', async(): Promise<void> => {
const account = createAccount('custom');
accountStore.create.mockResolvedValueOnce(account);
await expect(handler.login()).resolves.toEqual({ json: { accountId: 'custom' }});
});
});

View File

@@ -0,0 +1,114 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore';
import {
addLoginEntry,
ensureResource,
getRequiredAccount,
safeUpdate,
} from '../../../../../../src/identity/interaction/account/util/AccountUtil';
import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil';
describe('AccountUtil', (): void => {
const resource = 'http://example.com/.account/link';
let account: Account;
beforeEach(async(): Promise<void> => {
account = createAccount();
});
describe('#getRequiredAccount', (): void => {
let accountStore: jest.Mocked<AccountStore>;
beforeEach(async(): Promise<void> => {
accountStore = mockAccountStore(account);
});
it('returns the found account.', async(): Promise<void> => {
await expect(getRequiredAccount(accountStore, 'id')).resolves.toBe(account);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('id');
});
it('throws an error if no account was found.', async(): Promise<void> => {
accountStore.get.mockResolvedValueOnce(undefined);
await expect(getRequiredAccount(accountStore)).rejects.toThrow(NotFoundHttpError);
});
});
describe('#ensureResource', (): void => {
const data = {
'http://example.com/pod/': resource,
'http://example.com/other-pod/': 'http://example.com/.account/other-link',
};
it('returns the matching key.', async(): Promise<void> => {
expect(ensureResource(data, resource)).toBe('http://example.com/pod/');
});
it('throws a 404 if there is no input.', async(): Promise<void> => {
expect((): any => ensureResource(undefined, resource)).toThrow(NotFoundHttpError);
expect((): any => ensureResource(data)).toThrow(NotFoundHttpError);
});
it('throws a 404 if there is no match.', async(): Promise<void> => {
expect((): any => ensureResource(data, 'http://example.com/unknown/')).toThrow(NotFoundHttpError);
});
});
describe('#addLoginEntry', (): void => {
it('adds the login entry.', async(): Promise<void> => {
addLoginEntry(account, 'method', 'key', 'resource');
expect(account.logins?.method?.key).toBe('resource');
});
it('does not overwrite existing entries.', async(): Promise<void> => {
account.logins.method = { key: 'resource' };
addLoginEntry(account, 'method', 'key2', 'resource2');
expect(account.logins?.method).toEqual({ key: 'resource', key2: 'resource2' });
});
});
describe('#safeUpdate', (): void => {
const oldAccount: Account = createAccount();
let accountStore: jest.Mocked<AccountStore>;
let operation: jest.Mock<Promise<string>, []>;
beforeEach(async(): Promise<void> => {
accountStore = mockAccountStore(oldAccount);
operation = jest.fn().mockResolvedValue('response');
});
it('updates the account and calls the operation function.', async(): Promise<void> => {
account.pods['http://example.com.pod'] = resource;
await expect(safeUpdate(account, accountStore, operation)).resolves.toBe('response');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(operation).toHaveBeenCalledTimes(1);
expect(account.pods['http://example.com.pod']).toBe(resource);
});
it('resets the account data if an error occurs.', async(): Promise<void> => {
const error = new Error('bad data');
operation.mockRejectedValueOnce(error);
await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(error);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenNthCalledWith(1, account);
expect(accountStore.update).toHaveBeenNthCalledWith(2, oldAccount);
expect(operation).toHaveBeenCalledTimes(1);
expect(account.pods).toEqual({});
});
it('throws a 404 if the account is unknown.', async(): Promise<void> => {
accountStore.get.mockResolvedValueOnce(undefined);
await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.update).toHaveBeenCalledTimes(0);
expect(operation).toHaveBeenCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,54 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import { BaseAccountStore } from '../../../../../../src/identity/interaction/account/util/BaseAccountStore';
import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage';
import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError';
import { createAccount } from '../../../../../util/AccountUtil';
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A BaseAccountStore', (): void => {
let account: Account;
let storage: jest.Mocked<ExpiringStorage<string, Account>>;
let store: BaseAccountStore;
beforeEach(async(): Promise<void> => {
account = createAccount('4c9b88c1-7502-4107-bb79-2a3a590c7aa3');
storage = {
get: jest.fn().mockResolvedValue(account),
set: jest.fn(),
} as any;
store = new BaseAccountStore(storage);
});
it('creates an empty account.', async(): Promise<void> => {
await expect(store.create()).resolves.toEqual(account);
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(account.id, account, 30 * 60 * 1000);
});
it('stores the new data when updating.', async(): Promise<void> => {
// This line is here just for 100% coverage
account.logins.empty = undefined;
account.logins.method = { key: 'value' };
await expect(store.update(account)).resolves.toBeUndefined();
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(account.id, account);
});
it('errors when trying to update without login methods.', async(): Promise<void> => {
await expect(store.update(account)).rejects.toThrow('An account needs at least 1 login method.');
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(account.id);
expect(storage.set).toHaveBeenCalledTimes(0);
});
it('throws a 404 if the account is not known when updating.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce(undefined);
await expect(store.update(account)).rejects.toThrow(NotFoundHttpError);
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(account.id);
expect(storage.set).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,59 @@
import { BaseCookieStore } from '../../../../../../src/identity/interaction/account/util/BaseCookieStore';
import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage';
const cookie = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3';
jest.mock('uuid', (): any => ({ v4: (): string => cookie }));
const now = new Date();
jest.useFakeTimers();
jest.setSystemTime(now);
describe('A BaseCookieStore', (): void => {
const accountId = 'id';
let storage: jest.Mocked<ExpiringStorage<string, string>>;
let store: BaseCookieStore;
beforeEach(async(): Promise<void> => {
storage = {
get: jest.fn().mockResolvedValue(accountId),
set: jest.fn(),
delete: jest.fn(),
} as any;
store = new BaseCookieStore(storage);
});
it('can create new cookies.', async(): Promise<void> => {
await expect(store.generate(accountId)).resolves.toBe(cookie);
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(cookie, accountId, 14 * 24 * 60 * 60 * 1000);
});
it('can return the matching account ID.', async(): Promise<void> => {
await expect(store.get(cookie)).resolves.toBe(accountId);
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(cookie);
});
it('can refresh the expiration timer.', async(): Promise<void> => {
await expect(store.refresh(cookie)).resolves.toEqual(new Date(now.getTime() + (14 * 24 * 60 * 60 * 1000)));
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(cookie);
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(cookie, accountId, 14 * 24 * 60 * 60 * 1000);
});
it('does not reset the timer if there is no match.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce(undefined);
await expect(store.refresh(cookie)).resolves.toBeUndefined();
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(cookie);
expect(storage.set).toHaveBeenCalledTimes(0);
});
it('can delete cookies.', async(): Promise<void> => {
await expect(store.delete(cookie)).resolves.toBeUndefined();
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith(cookie);
});
});

View File

@@ -0,0 +1,113 @@
import type { Adapter } from 'oidc-provider';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
ClientCredentialsAdapter, ClientCredentialsAdapterFactory,
} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import type { AdapterFactory } from '../../../../../src/identity/storage/AdapterFactory';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A ClientCredentialsAdapterFactory', (): void => {
let credentialsStore: jest.Mocked<ClientCredentialsStore>;
let accountStore: jest.Mocked<AccountStore>;
let sourceAdapter: jest.Mocked<Adapter>;
let sourceFactory: jest.Mocked<AdapterFactory>;
let adapter: ClientCredentialsAdapter;
let factory: ClientCredentialsAdapterFactory;
beforeEach(async(): Promise<void> => {
sourceAdapter = {
find: jest.fn(),
} as any;
sourceFactory = {
createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter),
};
accountStore = mockAccountStore();
credentialsStore = {
get: jest.fn(),
delete: jest.fn(),
} as any;
adapter = new ClientCredentialsAdapter('Client', sourceAdapter, accountStore, credentialsStore);
factory = new ClientCredentialsAdapterFactory(sourceFactory, accountStore, credentialsStore);
});
it('calls the source factory when creating a new Adapter.', async(): Promise<void> => {
expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter);
expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name');
});
it('returns the result from the source.', async(): Promise<void> => {
sourceAdapter.find.mockResolvedValue({ payload: 'payload' });
await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' });
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(0);
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('tries to find a matching client credentials token if no result was found.', async(): Promise<void> => {
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('returns no result if there is no matching account.', async(): Promise<void> => {
accountStore.get.mockResolvedValueOnce(undefined);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' });
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
});
it('returns no result if the WebID is not linked to the account and deletes the token.', async(): Promise<void> => {
const account = createAccount();
accountStore.get.mockResolvedValueOnce(account);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' });
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
expect(credentialsStore.delete).toHaveBeenCalledTimes(1);
expect(credentialsStore.delete).toHaveBeenLastCalledWith('id', account);
});
it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise<void> => {
const webId = 'http://example.com/foo#me';
const account = createAccount();
account.webIds[webId] = 'resource';
accountStore.get.mockResolvedValueOnce(account);
credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId, accountId: 'accountId' });
/* eslint-disable @typescript-eslint/naming-convention */
await expect(adapter.find('id')).resolves.toEqual({
client_id: 'id',
client_secret: 'super_secret',
grant_types: [ 'client_credentials' ],
redirect_uris: [],
response_types: [],
});
/* eslint-enable @typescript-eslint/naming-convention */
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(credentialsStore.get).toHaveBeenCalledTimes(1);
expect(credentialsStore.get).toHaveBeenLastCalledWith('id');
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith('accountId');
});
});

View File

@@ -0,0 +1,59 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
ClientCredentialsDetailsHandler,
} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A ClientCredentialsDetailsHandler', (): void => {
const webId = 'http://example.com/card#me';
const id = 'token_id';
const target = { path: 'http://example.com/.account/my_token' };
let account: Account;
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: ClientCredentialsDetailsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.clientCredentials[id] = target.path;
accountStore = mockAccountStore(account);
clientCredentialsStore = {
get: jest.fn().mockResolvedValue({ webId, accountId: account.id, secret: 'ssh!' }),
} as any;
handler = new ClientCredentialsDetailsHandler(accountStore, clientCredentialsStore);
});
it('returns the necessary information.', async(): Promise<void> => {
await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: { id, webId }});
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id);
});
it('throws a 404 if there is no such token.', async(): Promise<void> => {
delete account.clientCredentials[id];
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(0);
});
it('throws an error if there is a data mismatch.', async(): Promise<void> => {
clientCredentialsStore.get.mockResolvedValueOnce(undefined);
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(InternalServerError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id);
});
});

View File

@@ -0,0 +1,17 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import {
BaseClientCredentialsIdRoute,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
describe('A BaseClientCredentialsIdRoute', (): void => {
it('uses the Credentials ID key.', async(): Promise<void> => {
const credentialsIdRoute = new BaseClientCredentialsIdRoute(new BaseAccountIdRoute(
new AbsolutePathInteractionRoute('http://example.com/'),
));
expect(credentialsIdRoute.matchPath('http://example.com/123/456/'))
.toEqual({ accountId: '123', clientCredentialsId: '456' });
});
});

View File

@@ -0,0 +1,64 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
CreateClientCredentialsHandler,
} from '../../../../../src/identity/interaction/client-credentials/CreateClientCredentialsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A CreateClientCredentialsHandler', (): void => {
let account: Account;
const json = {
webId: 'http://example.com/foo#me',
name: 'token',
};
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: CreateClientCredentialsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
accountStore = mockAccountStore(account);
clientCredentialsStore = {
add: jest.fn().mockReturnValue({ secret: 'secret', resource: 'resource' }),
} as any;
handler = new CreateClientCredentialsHandler(accountStore, clientCredentialsStore);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
name: {
required: false,
type: 'string',
},
webId: {
required: true,
type: 'string',
},
},
},
});
});
it('creates a new token based on the provided settings.', async(): Promise<void> => {
await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({
json: { id: 'token_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' },
});
});
it('allows token names to be empty.', async(): Promise<void> => {
await expect(handler.handle({ accountId: account.id, json: { webId: 'http://example.com/foo#me' }} as any))
.resolves.toEqual({
json: { id: '_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' },
});
});
});

View File

@@ -0,0 +1,48 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import {
DeleteClientCredentialsHandler,
} from '../../../../../src/identity/interaction/client-credentials/DeleteClientCredentialsHandler';
import type {
ClientCredentialsStore,
} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A DeleteClientCredentialsHandler', (): void => {
let account: Account;
const id = 'token_id';
const target = { path: 'http://example.com/.account/my_token' };
let accountStore: jest.Mocked<AccountStore>;
let clientCredentialsStore: jest.Mocked<ClientCredentialsStore>;
let handler: DeleteClientCredentialsHandler;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.clientCredentials[id] = target.path;
accountStore = mockAccountStore(account);
clientCredentialsStore = {
delete: jest.fn(),
} as any;
handler = new DeleteClientCredentialsHandler(accountStore, clientCredentialsStore);
});
it('deletes the token.', async(): Promise<void> => {
await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: {}});
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(1);
expect(clientCredentialsStore.delete).toHaveBeenLastCalledWith(id, account);
});
it('throws a 404 if there is no such token.', async(): Promise<void> => {
delete account.clientCredentials[id];
await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(account.id);
expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,91 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore';
import {
BaseClientCredentialsStore,
} from '../../../../../../src/identity/interaction/client-credentials/util/BaseClientCredentialsStore';
import type {
ClientCredentialsIdRoute,
} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute';
import type {
ClientCredentials,
} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil';
const secret = 'verylongstringof64bytes';
jest.mock('crypto', (): any => ({ randomBytes: (): string => secret }));
describe('A BaseClientCredentialsStore', (): void => {
const webId = 'http://example.com/card#me';
let account: Account;
const route: ClientCredentialsIdRoute = {
getPath: (): string => 'http://example.com/.account/resource',
matchPath: (): any => ({}),
};
let accountStore: jest.Mocked<AccountStore>;
let storage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let store: BaseClientCredentialsStore;
beforeEach(async(): Promise<void> => {
account = createAccount();
account.webIds[webId] = 'resource';
// Different account object so `safeUpdate` can be tested correctly
const oldAccount = createAccount();
oldAccount.webIds[webId] = 'resource';
accountStore = mockAccountStore(oldAccount);
storage = {
get: jest.fn().mockResolvedValue({ accountId: account.id, webId, secret: 'secret' }),
set: jest.fn(),
delete: jest.fn(),
} as any;
store = new BaseClientCredentialsStore(route, accountStore, storage);
});
it('returns the token it finds.', async(): Promise<void> => {
await expect(store.get('credentialsId')).resolves.toEqual({ accountId: account.id, webId, secret: 'secret' });
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith('credentialsId');
});
it('creates a new token and adds it to the account.', async(): Promise<void> => {
await expect(store.add('credentialsId', webId, account)).resolves
.toEqual({ secret, resource: 'http://example.com/.account/resource' });
expect(account.clientCredentials.credentialsId).toBe('http://example.com/.account/resource');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId });
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
});
it('errors if the WebID is not registered to the account.', async(): Promise<void> => {
delete account.webIds[webId];
await expect(store.add('credentialsId', webId, account)).rejects.toThrow(BadRequestHttpError);
expect(storage.set).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
expect(account.clientCredentials).toEqual({});
});
it('does not update the account if something goes wrong.', async(): Promise<void> => {
storage.set.mockRejectedValueOnce(new Error('bad data'));
await expect(store.add('credentialsId', webId, account)).rejects.toThrow('bad data');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId });
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.clientCredentials).toEqual({});
});
it('can delete tokens.', async(): Promise<void> => {
account.clientCredentials.credentialsId = 'resource';
await expect(store.delete('credentialsId', account)).resolves.toBeUndefined();
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith('credentialsId');
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.clientCredentials).toEqual({});
});
});

View File

@@ -1,16 +0,0 @@
import {
assertPassword,
} from '../../../../../src/identity/interaction/email-password/EmailPasswordUtil';
describe('EmailPasswordUtil', (): void => {
describe('#assertPassword', (): void => {
it('validates the password against the confirmPassword.', async(): Promise<void> => {
expect((): void => assertPassword(undefined, undefined)).toThrow('Please enter a password.');
expect((): void => assertPassword([], undefined)).toThrow('Please enter a password.');
expect((): void => assertPassword('password', undefined)).toThrow('Please confirm your password.');
expect((): void => assertPassword('password', [])).toThrow('Please confirm your password.');
expect((): void => assertPassword('password', 'other')).toThrow('Your password and confirmation did not match');
expect(assertPassword('password', 'password')).toBeUndefined();
});
});
});

View File

@@ -1,74 +0,0 @@
import {
ClientCredentialsAdapter,
ClientCredentialsAdapterFactory,
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import type {
ClientCredentials,
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import type { AdapterFactory } from '../../../../../../src/identity/storage/AdapterFactory';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import type { Adapter } from '../../../../../../templates/types/oidc-provider';
describe('A ClientCredentialsAdapterFactory', (): void => {
let storage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let sourceAdapter: jest.Mocked<Adapter>;
let sourceFactory: jest.Mocked<AdapterFactory>;
let adapter: ClientCredentialsAdapter;
let factory: ClientCredentialsAdapterFactory;
beforeEach(async(): Promise<void> => {
storage = {
get: jest.fn(),
} as any;
sourceAdapter = {
find: jest.fn(),
} as any;
sourceFactory = {
createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter),
};
adapter = new ClientCredentialsAdapter('Client', sourceAdapter, storage);
factory = new ClientCredentialsAdapterFactory(sourceFactory, storage);
});
it('calls the source factory when creating a new Adapter.', async(): Promise<void> => {
expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter);
expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name');
});
it('returns the result from the source.', async(): Promise<void> => {
sourceAdapter.find.mockResolvedValue({ payload: 'payload' });
await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' });
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(storage.get).toHaveBeenCalledTimes(0);
});
it('tries to find a matching client credentials token if no result was found.', async(): Promise<void> => {
await expect(adapter.find('id')).resolves.toBeUndefined();
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith('id');
});
it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise<void> => {
storage.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me' });
/* eslint-disable @typescript-eslint/naming-convention */
await expect(adapter.find('id')).resolves.toEqual({
client_id: 'id',
client_secret: 'super_secret',
grant_types: [ 'client_credentials' ],
redirect_uris: [],
response_types: [],
});
/* eslint-enable @typescript-eslint/naming-convention */
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith('id');
});
});

View File

@@ -1,91 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
import type {
ClientCredentials,
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import {
CreateCredentialsHandler,
} from '../../../../../../src/identity/interaction/email-password/credentials/CreateCredentialsHandler';
import type {
CredentialsHandlerBody,
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
describe('A CreateCredentialsHandler', (): void => {
let operation: Operation;
let body: CredentialsHandlerBody;
let accountStore: jest.Mocked<AccountStore>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let handler: CreateCredentialsHandler;
beforeEach(async(): Promise<void> => {
operation = {
method: 'POST',
body: new BasicRepresentation(),
target: { path: 'http://example.com/foo' },
preferences: {},
};
body = {
email: 'test@example.com',
webId: 'http://example.com/foo#me',
name: 'token',
};
accountStore = {
getSettings: jest.fn().mockResolvedValue({ useIdp: true, clientCredentials: []}),
updateSettings: jest.fn(),
} as any;
credentialStorage = {
set: jest.fn(),
} as any;
handler = new CreateCredentialsHandler(accountStore, credentialStorage);
});
it('only supports bodies with a name entry.', async(): Promise<void> => {
await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined();
delete body.name;
await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError);
});
it('rejects requests for accounts not using the IDP.', async(): Promise<void> => {
accountStore.getSettings.mockResolvedValue({ useIdp: false });
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
});
it('creates a new credential token.', async(): Promise<void> => {
const response = await handler.handle({ operation, body });
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
const { id, secret } = await readJsonStream(response.data);
expect(id).toMatch(/^token_/u);
expect(credentialStorage.set).toHaveBeenCalledTimes(1);
expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret });
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
expect(accountStore.updateSettings)
.toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]});
});
it('can handle account settings with undefined client credentials.', async(): Promise<void> => {
accountStore.getSettings.mockResolvedValue({ useIdp: true });
const response = await handler.handle({ operation, body });
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
const { id, secret } = await readJsonStream(response.data);
expect(id).toMatch(/^token_/u);
expect(credentialStorage.set).toHaveBeenCalledTimes(1);
expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret });
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
expect(accountStore.updateSettings)
.toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]});
});
});

View File

@@ -1,76 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
import type {
ClientCredentials,
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import type {
CredentialsHandlerBody,
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
import {
DeleteCredentialsHandler,
} from '../../../../../../src/identity/interaction/email-password/credentials/DeleteCredentialsHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError';
describe('A DeleteCredentialsHandler', (): void => {
let operation: Operation;
const id = 'token_id';
let body: CredentialsHandlerBody;
let accountStore: jest.Mocked<AccountStore>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let handler: DeleteCredentialsHandler;
beforeEach(async(): Promise<void> => {
operation = {
method: 'POST',
body: new BasicRepresentation(),
target: { path: 'http://example.com/foo' },
preferences: {},
};
body = {
email: 'test@example.com',
webId: 'http://example.com/foo#me',
delete: id,
};
accountStore = {
getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}),
updateSettings: jest.fn(),
} as any;
credentialStorage = {
delete: jest.fn(),
} as any;
handler = new DeleteCredentialsHandler(accountStore, credentialStorage);
});
it('only supports bodies with a delete entry.', async(): Promise<void> => {
await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined();
delete body.delete;
await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError);
});
it('deletes the token.', async(): Promise<void> => {
const response = await handler.handle({ operation, body });
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
expect(credentialStorage.delete).toHaveBeenCalledTimes(1);
expect(credentialStorage.delete).toHaveBeenLastCalledWith(id);
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
expect(accountStore.updateSettings).toHaveBeenLastCalledWith(body.webId, { clientCredentials: []});
});
it('errors if the account has no such token.', async(): Promise<void> => {
accountStore.getSettings.mockResolvedValue({ useIdp: true, clientCredentials: []});
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
accountStore.getSettings.mockResolvedValue({ useIdp: true });
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
});
});

View File

@@ -1,57 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../../../src/http/representation/Representation';
import type {
CredentialsHandler,
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
import {
EmailPasswordAuthorizer,
} from '../../../../../../src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
import { MethodNotAllowedHttpError } from '../../../../../../src/util/errors/MethodNotAllowedHttpError';
describe('An EmailPasswordAuthorizer', (): void => {
const email = 'test@example.com';
const password = 'super_secret';
const webId = 'http://example.com/profile#me';
let operation: Operation;
let response: Representation;
let accountStore: jest.Mocked<AccountStore>;
let source: jest.Mocked<CredentialsHandler>;
let handler: EmailPasswordAuthorizer;
beforeEach(async(): Promise<void> => {
operation = {
method: 'POST',
body: new BasicRepresentation(JSON.stringify({ email, password }), APPLICATION_JSON),
target: { path: 'http://example.com/foo' },
preferences: {},
};
response = new BasicRepresentation();
accountStore = {
authenticate: jest.fn().mockResolvedValue(webId),
} as any;
source = {
handleSafe: jest.fn().mockResolvedValue(response),
} as any;
handler = new EmailPasswordAuthorizer(accountStore, source);
});
it('requires POST methods.', async(): Promise<void> => {
operation.method = 'GET';
await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
});
it('calls the source after validation.', async(): Promise<void> => {
await expect(handler.handle({ operation })).resolves.toBe(response);
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, password);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, body: { email, webId }});
});
});

View File

@@ -1,58 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
import type {
CredentialsHandlerBody,
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
import {
ListCredentialsHandler,
} from '../../../../../../src/identity/interaction/email-password/credentials/ListCredentialsHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
describe('A ListCredentialsHandler', (): void => {
let operation: Operation;
const id = 'token_id';
let body: CredentialsHandlerBody;
let accountStore: jest.Mocked<AccountStore>;
let handler: ListCredentialsHandler;
beforeEach(async(): Promise<void> => {
operation = {
method: 'POST',
body: new BasicRepresentation(),
target: { path: 'http://example.com/foo' },
preferences: {},
};
body = {
email: 'test@example.com',
webId: 'http://example.com/foo#me',
delete: id,
};
accountStore = {
getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}),
updateSettings: jest.fn(),
} as any;
handler = new ListCredentialsHandler(accountStore);
});
it('lists all tokens.', async(): Promise<void> => {
const response = await handler.handle({ operation, body });
expect(response).toBeDefined();
expect(response.metadata.contentType).toEqual(APPLICATION_JSON);
const list = await readJsonStream(response.data);
expect(list).toEqual([ id ]);
});
it('returns an empty array if there are no tokens.', async(): Promise<void> => {
accountStore.getSettings.mockResolvedValue({ useIdp: true });
const response = await handler.handle({ operation, body });
expect(response).toBeDefined();
expect(response.metadata.contentType).toEqual(APPLICATION_JSON);
const list = await readJsonStream(response.data);
expect(list).toEqual([]);
});
});

View File

@@ -1,76 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import {
ForgotPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/email-password/util/EmailSender';
import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostJsonOperation } from './Util';
describe('A ForgotPasswordHandler', (): void => {
let operation: Operation;
const email = 'test@test.email';
const recordId = '123456';
const html = `<a href="/base/idp/resetpassword/?rid=${recordId}">Reset Password</a>`;
let accountStore: AccountStore;
let templateEngine: TemplateEngine<{ resetLink: string }>;
let resetRoute: jest.Mocked<InteractionRoute>;
let emailSender: EmailSender;
let handler: ForgotPasswordHandler;
beforeEach(async(): Promise<void> => {
operation = createPostJsonOperation({ email });
accountStore = {
generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId),
} as any;
templateEngine = {
handleSafe: jest.fn().mockResolvedValue(html),
} as any;
resetRoute = {
getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'),
} as any;
emailSender = {
handleSafe: jest.fn(),
} as any;
handler = new ForgotPasswordHandler({
accountStore,
templateEngine,
emailSender,
resetRoute,
});
});
it('errors on non-string emails.', async(): Promise<void> => {
operation = createPostJsonOperation({});
await expect(handler.handle({ operation })).rejects.toThrow('Email required');
operation = createPostJsonOperation({ email: [ 'email', 'email2' ]});
await expect(handler.handle({ operation })).rejects.toThrow('Email required');
});
it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
(accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error');
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({ email });
expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
});
it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({ email });
expect(result.metadata.contentType).toBe('application/json');
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
recipient: email,
subject: 'Reset your password',
text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`,
html,
});
});
});

View File

@@ -1,81 +0,0 @@
import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type {
Interaction,
InteractionHandlerInput,
} from '../../../../../../src/identity/interaction/InteractionHandler';
import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
import { createPostJsonOperation } from './Util';
describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email';
let oidcInteraction: jest.Mocked<Interaction>;
let input: Required<InteractionHandlerInput>;
let accountStore: jest.Mocked<AccountStore>;
let handler: LoginHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
exp: 123456,
save: jest.fn(),
} as any;
input = { oidcInteraction } as any;
accountStore = {
authenticate: jest.fn().mockResolvedValue(webId),
getSettings: jest.fn().mockResolvedValue({ useIdp: true }),
} as any;
handler = new LoginHandler(accountStore);
});
it('errors if no oidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error);
await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction }))
.resolves.toBeUndefined();
});
it('errors on invalid emails.', async(): Promise<void> => {
input.operation = createPostJsonOperation({});
await expect(handler.handle(input)).rejects.toThrow('Email required');
input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]});
await expect(handler.handle(input)).rejects.toThrow('Email required');
});
it('errors on invalid passwords.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email });
await expect(handler.handle(input)).rejects.toThrow('Password required');
input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]});
await expect(handler.handle(input)).rejects.toThrow('Password required');
});
it('throws an error if there is a problem.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' });
accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!'));
await expect(handler.handle(input)).rejects.toThrow('auth failed!');
});
it('throws an error if the account does not have the correct settings.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' });
accountStore.getSettings.mockResolvedValueOnce({ useIdp: false, clientCredentials: []});
await expect(handler.handle(input))
.rejects.toThrow('This server is not an identity provider for this account.');
});
it('returns the generated redirect URL.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' });
await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
expect(oidcInteraction.save).toHaveBeenCalledTimes(1);
expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }});
});
});

View File

@@ -1,54 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import {
RegistrationHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler';
import type {
RegistrationManager, RegistrationParams, RegistrationResponse,
} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './Util';
describe('A RegistrationHandler', (): void => {
let operation: Operation;
let validated: RegistrationParams;
let details: RegistrationResponse;
let registrationManager: jest.Mocked<RegistrationManager>;
let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => {
validated = {
email: 'alice@test.email',
password: 'superSecret',
createWebId: true,
register: true,
createPod: true,
rootPod: true,
};
details = {
email: 'alice@test.email',
createWebId: true,
register: true,
createPod: true,
};
registrationManager = {
validateInput: jest.fn().mockReturnValue(validated),
register: jest.fn().mockResolvedValue(details),
} as any;
handler = new RegistrationHandler(registrationManager);
});
it('converts the stream to json and sends it to the registration manager.', async(): Promise<void> => {
const params = { email: 'alice@test.email', password: 'superSecret' };
operation = createPostJsonOperation(params);
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual(details);
expect(result.metadata.contentType).toBe('application/json');
expect(registrationManager.validateInput).toHaveBeenCalledTimes(1);
expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false);
expect(registrationManager.register).toHaveBeenCalledTimes(1);
expect(registrationManager.register).toHaveBeenLastCalledWith(validated, false);
});
});

View File

@@ -1,60 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import {
ResetPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './Util';
describe('A ResetPasswordHandler', (): void => {
let operation: Operation;
const recordId = '123456';
const url = `/resetURL/${recordId}`;
const email = 'alice@test.email';
let accountStore: AccountStore;
let handler: ResetPasswordHandler;
beforeEach(async(): Promise<void> => {
accountStore = {
getForgotPasswordRecord: jest.fn().mockResolvedValue(email),
deleteForgotPasswordRecord: jest.fn(),
changePassword: jest.fn(),
} as any;
handler = new ResetPasswordHandler(accountStore);
});
it('errors for non-string recordIds.', async(): Promise<void> => {
const errorMessage = 'Invalid request. Open the link from your email again';
operation = createPostJsonOperation({});
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
operation = createPostJsonOperation({ recordId: 5 });
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
});
it('errors for invalid passwords.', async(): Promise<void> => {
const errorMessage = 'Your password and confirmation did not match.';
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url);
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
});
it('errors for invalid emails.', async(): Promise<void> => {
const errorMessage = 'This reset password link is no longer valid.';
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url);
(accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
});
it('renders a message on success.', async(): Promise<void> => {
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url);
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({});
expect(result.metadata.contentType).toBe('application/json');
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.changePassword).toHaveBeenCalledTimes(1);
expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!');
});
});

View File

@@ -1,17 +0,0 @@
import type { Operation } from '../../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
/**
* Creates a mock HttpRequest which is a stream of an object encoded as application/json
* and a matching content-type header.
* @param data - Object to encode.
* @param url - URL value of the request.
*/
export function createPostJsonOperation(data: NodeJS.Dict<any>, url?: string): Operation {
return {
method: 'POST',
preferences: {},
target: { path: url ?? 'http://test.com/' },
body: new BasicRepresentation(JSON.stringify(data), 'application/json'),
};
}

View File

@@ -1,139 +0,0 @@
import type { AccountSettings } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type {
EmailPasswordData,
} from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore';
import { BaseAccountStore } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore';
import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
describe('A BaseAccountStore', (): void => {
let storage: KeyValueStorage<string, EmailPasswordData>;
let forgotPasswordStorage: ExpiringStorage<string, EmailPasswordData>;
const saltRounds = 11;
let store: BaseAccountStore;
const email = 'test@test.com';
const webId = 'http://test.com/#webId';
const password = 'password!';
const settings: AccountSettings = { useIdp: true, clientCredentials: []};
beforeEach(async(): Promise<void> => {
const map = new Map();
storage = {
get: jest.fn((id: string): any => map.get(id)),
set: jest.fn((id: string, value: any): any => map.set(id, value)),
delete: jest.fn((id: string): any => map.delete(id)),
} as any;
forgotPasswordStorage = {
get: jest.fn((id: string): any => map.get(id)),
set: jest.fn((id: string, value: any): any => map.set(id, value)),
delete: jest.fn((id: string): any => map.delete(id)),
} as any;
store = new BaseAccountStore(storage, forgotPasswordStorage, saltRounds);
});
it('can create accounts.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
});
it('errors when creating a second account for an email.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.create(email, webId, 'diffPass', settings)).rejects.toThrow('Account already exists');
});
it('errors when creating a second account for a WebID.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.create('bob@test.email', webId, 'diffPass', settings))
.rejects.toThrow('There already is an account for this WebID');
});
it('errors when authenticating a non-existent account.', async(): Promise<void> => {
await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist');
});
it('errors when authenticating an unverified account.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Account still needs to be verified');
});
it('errors when verifying a non-existent account.', async(): Promise<void> => {
await expect(store.verify(email)).rejects.toThrow('Account does not exist');
});
it('errors when authenticating with the wrong password.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.verify(email)).resolves.toBeUndefined();
await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password');
});
it('can authenticate.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.verify(email)).resolves.toBeUndefined();
await expect(store.authenticate(email, password)).resolves.toBe(webId);
});
it('errors when changing the password of a non-existent account.', async(): Promise<void> => {
await expect(store.changePassword(email, password)).rejects.toThrow('Account does not exist');
});
it('can change the password.', async(): Promise<void> => {
const newPassword = 'newPassword!';
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.verify(email)).resolves.toBeUndefined();
await expect(store.changePassword(email, newPassword)).resolves.toBeUndefined();
await expect(store.authenticate(email, newPassword)).resolves.toBe(webId);
});
it('can get the settings.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.verify(email)).resolves.toBeUndefined();
await expect(store.getSettings(webId)).resolves.toBe(settings);
});
it('can update the settings.', async(): Promise<void> => {
const newSettings = { webId, useIdp: false, clientCredentials: []};
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.verify(email)).resolves.toBeUndefined();
await expect(store.updateSettings(webId, newSettings)).resolves.toBeUndefined();
await expect(store.getSettings(webId)).resolves.toBe(newSettings);
});
it('can delete an account.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
await expect(store.deleteAccount(email)).resolves.toBeUndefined();
await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist');
await expect(store.getSettings(webId)).rejects.toThrow('Account does not exist');
});
it('does nothing when deleting non-existent accounts.', async(): Promise<void> => {
await expect(store.deleteAccount(email)).resolves.toBeUndefined();
});
it('errors when forgetting the password of an account that does not exist.', async(): Promise<void> => {
await expect(store.generateForgotPasswordRecord(email)).rejects.toThrow('Account does not exist');
});
it('generates a recordId when a password was forgotten.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
const recordId = await store.generateForgotPasswordRecord(email);
expect(typeof recordId).toBe('string');
});
it('returns undefined if there is no matching record to retrieve.', async(): Promise<void> => {
await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined();
});
it('returns the email matching the forgotten password record.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
const recordId = await store.generateForgotPasswordRecord(email);
await expect(store.getForgotPasswordRecord(recordId)).resolves.toBe(email);
});
it('can delete stored forgotten password records.', async(): Promise<void> => {
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
const recordId = await store.generateForgotPasswordRecord(email);
await expect(store.deleteForgotPasswordRecord(recordId)).resolves.toBeUndefined();
await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined();
});
});

View File

@@ -1,321 +0,0 @@
import type { ResourceIdentifier } from '../../../../../../src/http/representation/ResourceIdentifier';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import {
RegistrationManager,
} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager';
import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator';
import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings';
import { joinUrl } from '../../../../../../src/util/PathUtil';
describe('A RegistrationManager', (): 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';
const podBaseUrl = 'http://test.com/alice/';
const createWebId = true;
const register = true;
const createPod = true;
const rootPod = true;
const baseUrl = 'http://test.com/';
const webIdSuffix = '/profile/card';
let podSettings: PodSettings;
let identifierGenerator: IdentifierGenerator;
let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore;
let podManager: PodManager;
let manager: RegistrationManager;
beforeEach(async(): Promise<void> => {
podSettings = { email, webId, podBaseUrl };
identifierGenerator = {
generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })),
extractPod: jest.fn(),
};
ownershipValidator = {
handleSafe: jest.fn(),
} as any;
accountStore = {
create: jest.fn(),
verify: jest.fn(),
deleteAccount: jest.fn(),
} as any;
podManager = {
createPod: jest.fn(),
};
manager = new RegistrationManager({
baseUrl,
webIdSuffix,
identifierGenerator,
accountStore,
ownershipValidator,
podManager,
});
});
describe('validating data', (): void => {
it('errors on invalid emails.', async(): Promise<void> => {
let input: any = { email: undefined };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.');
input = { email: '' };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.');
input = { email: 'invalidEmail' };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.');
});
it('errors on invalid passwords.', async(): Promise<void> => {
const input: any = { email, webId, password, confirmPassword: 'bad' };
expect((): any => manager.validateInput(input, false)).toThrow('Your password and confirmation did not match.');
});
it('errors on missing passwords.', async(): Promise<void> => {
const input: any = { email, webId };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a password.');
});
it('errors when setting rootPod to true when not allowed.', async(): Promise<void> => {
const input = { email, password, confirmPassword, createWebId, rootPod };
expect((): any => manager.validateInput(input, false)).toThrow('Creating a root pod is not supported.');
});
it('errors when a required WebID is not valid.', async(): Promise<void> => {
let input: any = { email, password, confirmPassword, register, webId: undefined };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid WebID.');
input = { email, password, confirmPassword, register, webId: '' };
expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid WebID.');
});
it('errors on invalid pod names when required.', async(): Promise<void> => {
let input: any = { email, webId, password, confirmPassword, createPod, podName: undefined };
expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.');
input = { email, webId, password, confirmPassword, createPod, podName: ' ' };
expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.');
input = { email, webId, password, confirmPassword, createWebId };
expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.');
});
it('errors when no option is chosen.', async(): Promise<void> => {
const input = { email, webId, password, confirmPassword };
expect((): any => manager.validateInput(input, false)).toThrow('Please register for a WebID or create a Pod.');
});
it('adds the template parameter if there is one.', async(): Promise<void> => {
const input = { email, webId, password, confirmPassword, podName, template: 'template', createPod };
expect(manager.validateInput(input, false)).toEqual({
email,
webId,
password,
podName,
template: 'template',
createWebId: false,
register: false,
createPod,
rootPod: false,
});
});
it('does not require a pod name when creating a root pod.', async(): Promise<void> => {
const input = { email, password, confirmPassword, webId, createPod, rootPod };
expect(manager.validateInput(input, true)).toEqual({
email, password, webId, createWebId: false, register: false, createPod, rootPod,
});
});
it('trims non-password input parameters.', async(): Promise<void> => {
let input: any = {
email: ` ${email} `,
password: ' a ',
confirmPassword: ' a ',
podName: ` ${podName} `,
template: ' template ',
createWebId,
register,
createPod,
};
expect(manager.validateInput(input, false)).toEqual({
email, password: ' a ', podName, template: 'template', createWebId, register, createPod, rootPod: false,
});
input = { email, webId: ` ${webId} `, password: ' a ', confirmPassword: ' a ', register: true };
expect(manager.validateInput(input, false)).toEqual({
email, webId, password: ' a ', createWebId: false, register, createPod: false, rootPod: false,
});
});
});
describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => {
const params: any = { email, webId, password, register, createPod: false, createWebId: false };
await expect(manager.register(params, false)).resolves.toEqual({
email,
webId,
oidcIssuer: baseUrl,
createWebId: false,
register: true,
createPod: false,
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create)
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, clientCredentials: []});
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: any = { email, webId, password, podName, createPod, createWebId: false, register: false };
await expect(manager.register(params, false)).resolves.toEqual({
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: false,
register: false,
createPod: true,
});
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}/` }, podSettings, false);
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create)
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl, clientCredentials: []});
expect(accountStore.verify).toHaveBeenCalledTimes(1);
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: any = { email, webId, password, confirmPassword, podName, register, createPod, createWebId: false };
podSettings.oidcIssuer = baseUrl;
await expect(manager.register(params, false)).resolves.toEqual({
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: false,
register: true,
createPod: true,
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create)
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []});
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
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: any = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl;
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
await expect(manager.register(params, false)).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, { useIdp: true, podBaseUrl, clientCredentials: []});
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
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: any = { email, password, confirmPassword, podName, createWebId, register, createPod };
const generatedWebID = joinUrl(baseUrl, podName, webIdSuffix);
podSettings.webId = generatedWebID;
podSettings.oidcIssuer = baseUrl;
await expect(manager.register(params, false)).resolves.toEqual({
email,
webId: generatedWebID,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: true,
register: true,
createPod: true,
});
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email,
generatedWebID,
password,
{ useIdp: true, podBaseUrl, clientCredentials: []});
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('can create a root pod.', async(): Promise<void> => {
const params: any = { email, webId, password, createPod, rootPod, createWebId: false, register: false };
podSettings.podBaseUrl = baseUrl;
await expect(manager.register(params, true)).resolves.toEqual({
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: baseUrl,
createWebId: false,
register: false,
createPod: true,
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true);
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email,
webId,
password,
{ useIdp: false, podBaseUrl: baseUrl, clientCredentials: []});
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,46 @@
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore';
import { LogoutHandler } from '../../../../../src/identity/interaction/login/LogoutHandler';
import { SOLID_HTTP } from '../../../../../src/util/Vocabularies';
describe('A LogoutHandler', (): void => {
const accountId = 'accountId';
const cookie = 'cookie';
let metadata: RepresentationMetadata;
let cookieStore: jest.Mocked<CookieStore>;
let handler: LogoutHandler;
beforeEach(async(): Promise<void> => {
metadata = new RepresentationMetadata({ [SOLID_HTTP.accountCookie]: cookie });
cookieStore = {
get: jest.fn().mockResolvedValue(accountId),
delete: jest.fn(),
} as any;
handler = new LogoutHandler(cookieStore);
});
it('removes the cookie and sets the relevant metadata.', async(): Promise<void> => {
const { json, metadata: outputMetadata } = await handler.handle({ metadata, accountId } as any);
expect(json).toEqual({});
expect(outputMetadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie);
const date = outputMetadata?.get(SOLID_HTTP.terms.accountCookieExpiration);
expect(date).toBeDefined();
expect(new Date(date!.value) < new Date()).toBe(true);
expect(cookieStore.delete).toHaveBeenCalledTimes(1);
expect(cookieStore.delete).toHaveBeenLastCalledWith(cookie);
});
it('does nothing if the request is not logged in.', async(): Promise<void> => {
metadata = new RepresentationMetadata();
await expect(handler.handle({ metadata } as any)).resolves.toEqual({ json: {}});
expect(cookieStore.delete).toHaveBeenCalledTimes(0);
});
it('errors if the cookie does not belong to the authenticated account.', async(): Promise<void> => {
cookieStore.get.mockResolvedValueOnce('other-id');
await expect(handler.handle({ metadata, accountId } as any)).rejects.toThrow('Invalid cookie');
expect(cookieStore.delete).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,169 @@
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { AccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../../src/identity/interaction/account/util/Account';
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import type { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore';
import type { JsonRepresentation } from '../../../../../src/identity/interaction/InteractionUtil';
import type { JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler';
import type { LoginOutputType } from '../../../../../src/identity/interaction/login/ResolveLoginHandler';
import {
ResolveLoginHandler,
} from '../../../../../src/identity/interaction/login/ResolveLoginHandler';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
import { CONTENT_TYPE, CONTENT_TYPE_TERM, SOLID_HTTP } from '../../../../../src/util/Vocabularies';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
const accountId = 'accountId';
let output: JsonRepresentation<LoginOutputType>;
class DummyLoginHandler extends ResolveLoginHandler {
public constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) {
super(accountStore, cookieStore, accountRoute);
}
public async login(): Promise<JsonRepresentation<LoginOutputType>> {
return output;
}
}
describe('A ResolveLoginHandler', (): void => {
const cookie = 'cookie';
let metadata: RepresentationMetadata;
let input: JsonInteractionHandlerInput;
let account: Account;
let accountStore: jest.Mocked<AccountStore>;
let cookieStore: jest.Mocked<CookieStore>;
let accountRoute: jest.Mocked<AccountIdRoute>;
let handler: DummyLoginHandler;
beforeEach(async(): Promise<void> => {
input = {
json: {},
metadata: new RepresentationMetadata(),
target: { path: 'http://example.com/' },
method: 'POST',
};
metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
output = {
json: { accountId, data: 'data' } as LoginOutputType,
metadata,
};
account = createAccount();
accountStore = mockAccountStore(account);
cookieStore = {
generate: jest.fn().mockResolvedValue(cookie),
delete: jest.fn(),
} as any;
accountRoute = {
getPath: jest.fn().mockReturnValue('http://example.com/foo'),
matchPath: jest.fn().mockReturnValue(true),
};
handler = new DummyLoginHandler(accountStore, cookieStore, accountRoute);
});
it('removes the ID from the output and adds a cookie.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
cookie,
resource: 'http://example.com/foo',
},
metadata });
expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie);
expect(cookieStore.generate).toHaveBeenCalledTimes(1);
expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.delete).toHaveBeenCalledTimes(0);
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('generates a metadata object if the login handler did not provide one.', async(): Promise<void> => {
output = { json: { accountId, data: 'data' } as LoginOutputType };
const result = await handler.handle(input);
expect(result).toEqual({ json: {
data: 'data',
cookie,
resource: 'http://example.com/foo',
},
metadata: expect.any(RepresentationMetadata) });
expect(result.metadata).not.toBe(metadata);
expect(result.metadata?.get(CONTENT_TYPE_TERM)).toBeUndefined();
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('adds a location field if there is an OIDC interaction.', async(): Promise<void> => {
input.oidcInteraction = {
lastSubmission: { login: { accountId: 'id' }},
persist: jest.fn(),
returnTo: 'returnTo',
} as any;
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
cookie,
resource: 'http://example.com/foo',
location: 'returnTo',
},
metadata });
expect(input.oidcInteraction!.persist).toHaveBeenCalledTimes(1);
expect(input.oidcInteraction!.result).toEqual({
login: { accountId: 'id' },
});
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
it('updates the account remember settings if necessary.', async(): Promise<void> => {
output = {
json: { ...output.json, remember: true },
metadata,
};
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
cookie,
resource: 'http://example.com/foo',
},
metadata });
expect(cookieStore.generate).toHaveBeenCalledTimes(1);
expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN]).toBe(true);
});
it('errors if the account can not be found.', async(): Promise<void> => {
output = {
json: { ...output.json, remember: true },
metadata,
};
accountStore.get.mockResolvedValue(undefined);
await expect(handler.handle(input)).rejects.toThrow(InternalServerError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(accountStore.update).toHaveBeenCalledTimes(0);
});
it('deletes the old cookie if there was one in the input.', async(): Promise<void> => {
input.metadata.set(SOLID_HTTP.terms.accountCookie, 'old-cookie-value');
await expect(handler.handle(input)).resolves.toEqual({ json: {
data: 'data',
cookie,
resource: 'http://example.com/foo',
},
metadata });
expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie);
expect(cookieStore.generate).toHaveBeenCalledTimes(1);
expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId);
expect(cookieStore.delete).toHaveBeenCalledTimes(1);
expect(cookieStore.delete).toHaveBeenLastCalledWith('old-cookie-value');
expect(accountStore.get).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,33 @@
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { CancelOidcHandler } from '../../../../../src/identity/interaction/oidc/CancelOidcHandler';
describe('A CancelOidcHandler', (): void => {
let oidcInteraction: Interaction;
let handler: CancelOidcHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
lastSubmission: { login: { accountId: 'id' }},
persist: jest.fn(),
session: {
cookie: 'cookie',
},
returnTo: 'returnTo',
} as any;
handler = new CancelOidcHandler();
});
it('finishes the interaction with an error.', async(): Promise<void> => {
await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(expect.objectContaining({
statusCode: 302,
location: 'returnTo',
}));
expect(oidcInteraction.persist).toHaveBeenCalledTimes(1);
expect(oidcInteraction.result).toEqual({
error: 'access_denied',
// eslint-disable-next-line @typescript-eslint/naming-convention
error_description: 'User cancelled the interaction.',
});
});
});

View File

@@ -0,0 +1,62 @@
import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory';
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { ClientInfoHandler } from '../../../../../src/identity/interaction/oidc/ClientInfoHandler';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import type Provider from '../../../../../templates/types/oidc-provider';
/* eslint-disable @typescript-eslint/naming-convention */
describe('A ClientInfoHandler', (): void => {
let oidcInteraction: Interaction;
const clientMetadata = {
client_id: 'clientId',
client_name: 'clientName',
unknownField: 'super-secret',
};
let provider: jest.Mocked<Provider>;
let providerFactory: jest.Mocked<ProviderFactory>;
let handler: ClientInfoHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
params: { client_id: 'clientId' },
session: { accountId: 'http://example.com/card#me' },
} as any;
provider = {
Client: {
find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined),
},
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new ClientInfoHandler(providerFactory);
});
it('returns the known client metadata fields.', async(): Promise<void> => {
await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual({ json: {
client: {
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
client_id: 'clientId',
client_name: 'clientName',
},
webId: 'http://example.com/card#me',
}});
});
it('returns empty info if there is none.', async(): Promise<void> => {
delete oidcInteraction.params.client_id;
await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual({ json: {
client: {
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
},
webId: 'http://example.com/card#me',
}});
});
it('errors if there is no OIDC interaction.', async(): Promise<void> => {
await expect(handler.handle({} as any)).rejects.toThrow(BadRequestHttpError);
});
});

View File

@@ -1,11 +1,9 @@
import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
import type Provider from '../../../../templates/types/oidc-provider';
import { createPostJsonOperation } from './email-password/handler/Util';
import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory';
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { ConsentHandler } from '../../../../../src/identity/interaction/oidc/ConsentHandler';
import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
import type Provider from '../../../../../templates/types/oidc-provider';
const newGrantId = 'newGrantId';
class DummyGrant {
@@ -46,10 +44,6 @@ class DummyGrant {
describe('A ConsentHandler', (): void => {
const accountId = 'http://example.com/id#me';
const clientId = 'clientId';
const clientMetadata = {
// eslint-disable-next-line @typescript-eslint/naming-convention
client_id: 'clientId',
};
let grantFn: jest.Mock<DummyGrant> & { find: jest.Mock<DummyGrant> };
let knownGrant: DummyGrant;
let oidcInteraction: Interaction;
@@ -61,12 +55,12 @@ describe('A ConsentHandler', (): void => {
oidcInteraction = {
session: {
accountId,
save: jest.fn(),
persist: jest.fn(),
},
// eslint-disable-next-line @typescript-eslint/naming-convention
params: { client_id: clientId },
prompt: { details: {}},
save: jest.fn(),
persist: jest.fn(),
} as any;
knownGrant = new DummyGrant({ accountId, clientId });
@@ -76,11 +70,8 @@ describe('A ConsentHandler', (): void => {
provider = {
/* eslint-disable @typescript-eslint/naming-convention */
Grant: grantFn,
Client: {
find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined),
},
Session: {
find: (): Interaction['session'] => oidcInteraction.session,
find: (): unknown => oidcInteraction.session,
},
/* eslint-enable @typescript-eslint/naming-convention */
} as any;
@@ -92,51 +83,23 @@ describe('A ConsentHandler', (): void => {
handler = new ConsentHandler(providerFactory);
});
it('errors if no oidcInteraction is defined on POST requests.', async(): Promise<void> => {
it('errors if no oidcInteraction is defined.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error);
await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction }))
.resolves.toBeUndefined();
});
it('returns the client metadata on a GET request.', async(): Promise<void> => {
const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any;
const representation = await handler.handle({ operation, oidcInteraction });
await expect(readJsonStream(representation.data)).resolves.toEqual({
client: {
...clientMetadata,
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
},
webId: accountId,
});
});
it('returns an empty object if no client was found.', async(): Promise<void> => {
delete oidcInteraction.params.client_id;
const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any;
const representation = await handler.handle({ operation, oidcInteraction });
await expect(readJsonStream(representation.data)).resolves.toEqual({
client: {
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
},
webId: accountId,
});
await expect(handler.handle({ json: {}} as any)).rejects.toThrow(error);
});
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined;
await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
await expect(handler.handle({ json: {}, oidcInteraction } as any))
.rejects.toThrow(NotImplementedHttpError);
});
it('throws a redirect error.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError);
});
it('stores the requested scopes and claims in the grant.', async(): Promise<void> => {
@@ -146,8 +109,7 @@ describe('A ConsentHandler', (): void => {
missingResourceScopes: { resource: [ 'scope1', 'scope2' ]},
};
const operation = createPostJsonOperation({ remember: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
await expect(handler.handle({ json: { remember: true }, oidcInteraction } as any)).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]);
expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]);
@@ -156,33 +118,23 @@ describe('A ConsentHandler', (): void => {
});
it('creates a new Grant when needed.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(1);
expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId });
expect(grantFn.find).toHaveBeenCalledTimes(0);
});
it('reuses existing Grant objects.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
oidcInteraction.grantId = '123456';
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(0);
expect(grantFn.find).toHaveBeenCalledTimes(1);
expect(grantFn.find).toHaveBeenLastCalledWith('123456');
});
it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
it('rejects offline_access as scope if a user does not want to be remembered.', async(): Promise<void> => {
await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]);
});
it('deletes the accountId when logout is provided.', async(): Promise<void> => {
const operation = createPostJsonOperation({ logOut: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect((oidcInteraction!.session! as any).save).toHaveBeenCalledTimes(1);
expect(oidcInteraction!.session!.accountId).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory';
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { ForgetWebIdHandler } from '../../../../../src/identity/interaction/oidc/ForgetWebIdHandler';
import type Provider from '../../../../../templates/types/oidc-provider';
describe('A ForgetWebIdHandler', (): void => {
let oidcInteraction: Interaction;
let provider: jest.Mocked<Provider>;
let providerFactory: jest.Mocked<ProviderFactory>;
let handler: ForgetWebIdHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
lastSubmission: { login: { accountId: 'id' }},
persist: jest.fn(),
session: {
cookie: 'cookie',
},
returnTo: 'returnTo',
} as any;
provider = {
/* eslint-disable @typescript-eslint/naming-convention */
Session: {
find: jest.fn().mockResolvedValue({ persist: jest.fn() }),
},
/* eslint-enable @typescript-eslint/naming-convention */
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new ForgetWebIdHandler(providerFactory);
});
it('forgets the WebID and updates the interaction.', async(): Promise<void> => {
await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(expect.objectContaining({
statusCode: 302,
location: 'returnTo',
}));
});
});

View File

@@ -0,0 +1,103 @@
import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory';
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { PickWebIdHandler } from '../../../../../src/identity/interaction/oidc/PickWebIdHandler';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError';
import type Provider from '../../../../../templates/types/oidc-provider';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A PickWebIdHandler', (): void => {
const accountId = 'accountId';
const webId1 = 'http://example.com/.account/card1#me';
const webId2 = 'http://example.com/.account/card2#me';
let json: unknown;
let oidcInteraction: Interaction;
let account: Account;
let accountStore: jest.Mocked<AccountStore>;
let provider: jest.Mocked<Provider>;
let providerFactory: jest.Mocked<ProviderFactory>;
let picker: PickWebIdHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
lastSubmission: { login: { accountId: 'id' }},
persist: jest.fn(),
session: {
cookie: 'cookie',
},
returnTo: 'returnTo',
} as any;
json = {
webId: webId1,
};
account = createAccount(accountId);
account.webIds[webId1] = 'resource';
account.webIds[webId2] = 'resource';
accountStore = mockAccountStore(account);
provider = {
/* eslint-disable @typescript-eslint/naming-convention */
Session: {
find: jest.fn().mockResolvedValue({ persist: jest.fn() }),
},
/* eslint-enable @typescript-eslint/naming-convention */
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
picker = new PickWebIdHandler(accountStore, providerFactory);
});
it('requires a WebID as input and returns the available WebIDs.', async(): Promise<void> => {
await expect(picker.getView({ accountId } as any)).resolves.toEqual({
json: {
fields: {
webId: {
required: true,
type: 'string',
},
remember: {
required: false,
type: 'boolean',
},
},
webIds: [
webId1,
webId2,
],
},
});
});
it('allows users to pick a WebID.', async(): Promise<void> => {
const result = picker.handle({ oidcInteraction, accountId, json } as any);
await expect(result).rejects.toThrow(FoundHttpError);
await expect(result).rejects.toEqual(expect.objectContaining({ location: oidcInteraction.returnTo }));
expect((await (provider.Session.find as jest.Mock).mock.results[0].value).persist).toHaveBeenCalledTimes(1);
expect(oidcInteraction.persist).toHaveBeenCalledTimes(1);
expect(oidcInteraction.result).toEqual({
login: {
accountId: webId1,
remember: false,
},
});
});
it('errors if there is no OIDC interaction.', async(): Promise<void> => {
await expect(picker.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError);
});
it('errors if the WebID is not part of the account.', async(): Promise<void> => {
json = { webId: 'http://example.com/somewhere/else#me' };
await expect(picker.handle({ oidcInteraction, accountId, json } as any))
.rejects.toThrow('WebID does not belong to this account.');
});
});

View File

@@ -0,0 +1,33 @@
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { PromptHandler } from '../../../../../src/identity/interaction/oidc/PromptHandler';
import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
describe('A PromptHandler', (): void => {
let oidcInteraction: Interaction;
let promptRoutes: Record<string, jest.Mocked<InteractionRoute>>;
let handler: PromptHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = { prompt: { name: 'login' }} as any;
promptRoutes = {
login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any,
};
handler = new PromptHandler(promptRoutes);
});
it('errors if there is no interaction.', async(): Promise<void> => {
await expect(handler.handle({ } as any)).rejects.toThrow(BadRequestHttpError);
});
it('errors if the prompt is unsupported.', async(): Promise<void> => {
oidcInteraction.prompt.name = 'unsupported';
await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(BadRequestHttpError);
});
it('returns a JSON body with the location and prompt.', async(): Promise<void> => {
await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual(
{ json: { prompt: 'login', location: 'http://example.com/idp/login/' }},
);
});
});

View File

@@ -0,0 +1,91 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { CreatePasswordHandler } from '../../../../../src/identity/interaction/password/CreatePasswordHandler';
import type { PasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute';
import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A CreatePasswordHandler', (): void => {
const email = 'example@example.com';
const password = 'supersecret!';
const resource = 'http://example.com/foo';
let account: Account;
let json: unknown;
let passwordStore: jest.Mocked<PasswordStore>;
let accountStore: jest.Mocked<AccountStore>;
let passwordRoute: PasswordIdRoute;
let handler: CreatePasswordHandler;
beforeEach(async(): Promise<void> => {
json = { email, password };
passwordStore = {
create: jest.fn(),
confirmVerification: jest.fn(),
delete: jest.fn(),
} as any;
account = createAccount();
accountStore = mockAccountStore(account);
passwordRoute = {
getPath: jest.fn().mockReturnValue(resource),
matchPath: jest.fn().mockReturnValue(true),
};
handler = new CreatePasswordHandler(passwordStore, accountStore, passwordRoute);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
email: {
required: true,
type: 'string',
},
password: {
required: true,
type: 'string',
},
},
},
});
});
it('returns the resource URL of the created login.', async(): Promise<void> => {
await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({ json: { resource }});
expect(passwordStore.create).toHaveBeenCalledTimes(1);
expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password);
expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1);
expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.logins[PASSWORD_METHOD]?.[email]).toBe(resource);
expect(passwordStore.delete).toHaveBeenCalledTimes(0);
});
it('throws an error if the account already has a login with this email address.', async(): Promise<void> => {
await handler.handle({ accountId: account.id, json } as any);
jest.clearAllMocks();
await expect(handler.handle({ accountId: account.id, json } as any))
.rejects.toThrow('This account already has a login method for this e-mail address.');
expect(passwordStore.create).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
});
it('reverts changes if there is an error writing the data.', async(): Promise<void> => {
const error = new Error('bad data');
accountStore.update.mockRejectedValueOnce(error);
await expect(handler.handle({ accountId: account.id, json } as any)).rejects.toThrow(error);
expect(passwordStore.create).toHaveBeenCalledTimes(1);
expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password);
expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1);
expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(passwordStore.delete).toHaveBeenCalledTimes(1);
expect(passwordStore.delete).toHaveBeenLastCalledWith(email);
});
});

View File

@@ -0,0 +1,82 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { DeletePasswordHandler } from '../../../../../src/identity/interaction/password/DeletePasswordHandler';
import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A DeletePasswordHandler', (): void => {
const accountId = 'accountId';
const email = 'example@example.com';
const target = { path: 'http://example.com/.account/password' };
let accountStore: jest.Mocked<AccountStore>;
let passwordStore: jest.Mocked<PasswordStore>;
let handler: DeletePasswordHandler;
beforeEach(async(): Promise<void> => {
accountStore = mockAccountStore();
accountStore.get.mockImplementation(async(id: string): Promise<Account> => {
const account = createAccount(id);
account.logins[PASSWORD_METHOD] = { [email]: target.path };
return account;
});
passwordStore = {
delete: jest.fn(),
} as any;
handler = new DeletePasswordHandler(accountStore, passwordStore);
});
it('deletes the token.', async(): Promise<void> => {
await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: {}});
// Once to find initial account and once for backup during `safeUpdate`
expect(accountStore.get).toHaveBeenCalledTimes(2);
expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId);
expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId);
expect(accountStore.update).toHaveBeenCalledTimes(1);
const account: Account = await accountStore.get.mock.results[0].value;
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.logins[PASSWORD_METHOD]![email]).toBeUndefined();
expect(passwordStore.delete).toHaveBeenCalledTimes(1);
expect(passwordStore.delete).toHaveBeenLastCalledWith(email);
});
it('throws a 404 if there are no logins.', async(): Promise<void> => {
accountStore.get.mockResolvedValueOnce(createAccount());
await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(accountStore.update).toHaveBeenCalledTimes(0);
expect(passwordStore.delete).toHaveBeenCalledTimes(0);
});
it('throws a 404 if there is no such token.', async(): Promise<void> => {
const account = createAccount(accountId);
account.logins[PASSWORD_METHOD] = {};
accountStore.get.mockResolvedValueOnce(account);
await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError);
expect(accountStore.get).toHaveBeenCalledTimes(1);
expect(accountStore.get).toHaveBeenLastCalledWith(accountId);
expect(accountStore.update).toHaveBeenCalledTimes(0);
expect(passwordStore.delete).toHaveBeenCalledTimes(0);
});
it('reverts the changes if there was a data error.', async(): Promise<void> => {
const error = new Error('bad data');
passwordStore.delete.mockRejectedValueOnce(error);
await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(error);
expect(accountStore.get).toHaveBeenCalledTimes(2);
expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId);
expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId);
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenNthCalledWith(1, await accountStore.get.mock.results[0].value);
expect(accountStore.update).toHaveBeenNthCalledWith(2, expect.objectContaining({
logins: { [PASSWORD_METHOD]: { [email]: target.path }},
}));
expect(passwordStore.delete).toHaveBeenCalledTimes(1);
expect(passwordStore.delete).toHaveBeenLastCalledWith(email);
});
});

View File

@@ -0,0 +1,89 @@
import { ForgotPasswordHandler } from '../../../../../src/identity/interaction/password/ForgotPasswordHandler';
import type { EmailSender } from '../../../../../src/identity/interaction/password/util/EmailSender';
import type { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute';
import type { TemplateEngine } from '../../../../../src/util/templates/TemplateEngine';
describe('A ForgotPasswordHandler', (): void => {
const accountId = 'accountId';
let json: unknown;
const email = 'test@test.email';
const recordId = '123456';
const html = `<a href="/base/idp/resetpassword/?rid=${recordId}">Reset Password</a>`;
let passwordStore: jest.Mocked<PasswordStore>;
let forgotPasswordStore: jest.Mocked<ForgotPasswordStore>;
let templateEngine: TemplateEngine<{ resetLink: string }>;
let resetRoute: jest.Mocked<InteractionRoute>;
let emailSender: jest.Mocked<EmailSender>;
let handler: ForgotPasswordHandler;
beforeEach(async(): Promise<void> => {
json = { email };
passwordStore = {
get: jest.fn().mockResolvedValue(accountId),
} as any;
forgotPasswordStore = {
generate: jest.fn().mockResolvedValue(recordId),
} as any;
templateEngine = {
handleSafe: jest.fn().mockResolvedValue(html),
} as any;
resetRoute = {
getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'),
matchPath: jest.fn(),
};
emailSender = {
handleSafe: jest.fn(),
} as any;
handler = new ForgotPasswordHandler({
passwordStore,
forgotPasswordStore,
templateEngine,
emailSender,
resetRoute,
});
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
email: {
required: true,
type: 'string',
},
},
},
});
});
it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
passwordStore.get.mockResolvedValueOnce(undefined);
await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
});
it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
recipient: email,
subject: 'Reset your password',
text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`,
html,
});
});
it('catches the error if there was an issue sending the mail.', async(): Promise<void> => {
emailSender.handleSafe.mockRejectedValueOnce(new Error('bad data'));
await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,14 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import { BasePasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
describe('A BasePasswordIdRoute', (): void => {
it('uses the Password ID key.', async(): Promise<void> => {
const passwordIdRoute = new BasePasswordIdRoute(new BaseAccountIdRoute(
new AbsolutePathInteractionRoute('http://example.com/'),
));
expect(passwordIdRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', passwordId: '456' });
});
});

View File

@@ -0,0 +1,54 @@
import { PasswordLoginHandler } from '../../../../../src/identity/interaction/password/PasswordLoginHandler';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
describe('A PasswordLoginHandler', (): void => {
let json: unknown;
const accountId = 'accountId';
const email = 'alice@test.email';
const password = 'supersecret!';
let passwordStore: jest.Mocked<PasswordStore>;
let handler: PasswordLoginHandler;
beforeEach(async(): Promise<void> => {
json = { email, password };
passwordStore = {
authenticate: jest.fn().mockResolvedValue(accountId),
} as any;
handler = new PasswordLoginHandler({
passwordStore,
accountStore: {} as any,
accountRoute: {} as any,
cookieStore: {} as any,
});
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
email: {
required: true,
type: 'string',
},
password: {
required: true,
type: 'string',
},
remember: {
required: false,
type: 'boolean',
},
},
},
});
});
it('logs the user in.', async(): Promise<void> => {
await expect(handler.login({ json } as any)).resolves.toEqual({ json: { accountId, remember: false }});
expect(passwordStore.authenticate).toHaveBeenCalledTimes(1);
expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, password);
});
});

View File

@@ -0,0 +1,64 @@
import { ResetPasswordHandler } from '../../../../../src/identity/interaction/password/ResetPasswordHandler';
import type { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
describe('A ResetPasswordHandler', (): void => {
let json: unknown;
const email = 'test@test.email';
const password = 'newsecret!';
const recordId = '123456';
let passwordStore: jest.Mocked<PasswordStore>;
let forgotPasswordStore: jest.Mocked<ForgotPasswordStore>;
let handler: ResetPasswordHandler;
beforeEach(async(): Promise<void> => {
json = { password, recordId };
passwordStore = {
update: jest.fn(),
} as any;
forgotPasswordStore = {
get: jest.fn().mockResolvedValue(email),
delete: jest.fn(),
} as any;
handler = new ResetPasswordHandler(passwordStore, forgotPasswordStore);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
recordId: {
required: true,
type: 'string',
},
password: {
required: true,
type: 'string',
},
},
},
});
});
it('can reset a password.', async(): Promise<void> => {
await expect(handler.handle({ json } as any)).resolves.toEqual({ json: {}});
expect(forgotPasswordStore.get).toHaveBeenCalledTimes(1);
expect(forgotPasswordStore.get).toHaveBeenLastCalledWith(recordId);
expect(forgotPasswordStore.delete).toHaveBeenCalledTimes(1);
expect(forgotPasswordStore.delete).toHaveBeenLastCalledWith(recordId);
expect(passwordStore.update).toHaveBeenCalledTimes(1);
expect(passwordStore.update).toHaveBeenLastCalledWith(email, password);
});
it('throws an error if no matching email was found.', async(): Promise<void> => {
forgotPasswordStore.get.mockResolvedValueOnce(undefined);
await expect(handler.handle({ json } as any)).rejects.toThrow('This reset password link is no longer valid.');
expect(forgotPasswordStore.get).toHaveBeenCalledTimes(1);
expect(forgotPasswordStore.get).toHaveBeenLastCalledWith(recordId);
expect(forgotPasswordStore.delete).toHaveBeenCalledTimes(0);
expect(passwordStore.update).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,67 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { UpdatePasswordHandler } from '../../../../../src/identity/interaction/password/UpdatePasswordHandler';
import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('An UpdatePasswordHandler', (): void => {
let account: Account;
let json: unknown;
const email = 'email@example.com';
const target = { path: 'http://example.com/.account/password' };
const oldPassword = 'oldPassword!';
const newPassword = 'newPassword!';
let accountStore: jest.Mocked<AccountStore>;
let passwordStore: jest.Mocked<PasswordStore>;
let handler: UpdatePasswordHandler;
beforeEach(async(): Promise<void> => {
json = { oldPassword, newPassword };
account = createAccount();
account.logins[PASSWORD_METHOD] = { [email]: target.path };
accountStore = mockAccountStore(account);
passwordStore = {
authenticate: jest.fn(),
update: jest.fn(),
} as any;
handler = new UpdatePasswordHandler(accountStore, passwordStore);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
oldPassword: {
required: true,
type: 'string',
},
newPassword: {
required: true,
type: 'string',
},
},
},
});
});
it('updates the password.', async(): Promise<void> => {
await expect(handler.handle({ json, accountId: account.id, target } as any)).resolves.toEqual({ json: {}});
expect(passwordStore.authenticate).toHaveBeenCalledTimes(1);
expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword);
expect(passwordStore.update).toHaveBeenCalledTimes(1);
expect(passwordStore.update).toHaveBeenLastCalledWith(email, newPassword);
});
it('errors if authentication fails.', async(): Promise<void> => {
passwordStore.authenticate.mockRejectedValueOnce(new Error('bad data'));
await expect(handler.handle({ json, accountId: account.id, target } as any))
.rejects.toThrow('Old password is invalid.');
expect(passwordStore.authenticate).toHaveBeenCalledTimes(1);
expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword);
expect(passwordStore.update).toHaveBeenCalledTimes(0);
});
});

View File

@@ -1,6 +1,11 @@
import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender';
import type {
EmailSenderArgs,
} from '../../../../../../src/identity/interaction/password/util/BaseEmailSender';
import {
BaseEmailSender,
} from '../../../../../../src/identity/interaction/password/util/BaseEmailSender';
import type { EmailArgs } from '../../../../../../src/identity/interaction/password/util/EmailSender';
jest.mock('nodemailer');
describe('A BaseEmailSender', (): void => {

View File

@@ -0,0 +1,41 @@
import {
BaseForgotPasswordStore,
} from '../../../../../../src/identity/interaction/password/util/BaseForgotPasswordStore';
import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage';
const record = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3';
jest.mock('uuid', (): any => ({ v4: (): string => record }));
describe('A BaseForgotPasswordStore', (): void => {
const email = 'email@example.com';
let storage: jest.Mocked<ExpiringStorage<string, string>>;
let store: BaseForgotPasswordStore;
beforeEach(async(): Promise<void> => {
storage = {
get: jest.fn().mockResolvedValue(email),
set: jest.fn(),
delete: jest.fn(),
} as any;
store = new BaseForgotPasswordStore(storage);
});
it('can create new records.', async(): Promise<void> => {
await expect(store.generate(email)).resolves.toBe(record);
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(record, email, 15 * 60 * 1000);
});
it('returns the matching email.', async(): Promise<void> => {
await expect(store.get(record)).resolves.toBe(email);
expect(storage.get).toHaveBeenCalledTimes(1);
expect(storage.get).toHaveBeenLastCalledWith(record);
});
it('can delete records.', async(): Promise<void> => {
await expect(store.delete(record)).resolves.toBeUndefined();
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith(record);
});
});

View File

@@ -0,0 +1,87 @@
import { BasePasswordStore } from '../../../../../../src/identity/interaction/password/util/BasePasswordStore';
import type {
LoginPayload,
} from '../../../../../../src/identity/interaction/password/util/BasePasswordStore';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
describe('A BasePasswordStore', (): void => {
const email = 'test@example.com';
const accountId = 'accountId';
const password = 'password!';
let storage: jest.Mocked<KeyValueStorage<string, LoginPayload>>;
let store: BasePasswordStore;
beforeEach(async(): Promise<void> => {
const map = new Map();
storage = {
get: jest.fn((id: string): any => map.get(id)),
set: jest.fn((id: string, value: any): any => map.set(id, value)),
delete: jest.fn((id: string): any => map.delete(id)),
} as any;
store = new BasePasswordStore(storage);
});
it('can create logins.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
});
it('errors when creating a second login for an email.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.create(email, accountId, 'diffPass'))
.rejects.toThrow('here already is a login for this e-mail address.');
});
it('errors when authenticating a non-existent login.', async(): Promise<void> => {
await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.');
});
it('errors when authenticating an unverified login.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.authenticate(email, password)).rejects.toThrow('Login still needs to be verified.');
});
it('errors when verifying a non-existent login.', async(): Promise<void> => {
await expect(store.confirmVerification(email)).rejects.toThrow('Login does not exist.');
});
it('errors when authenticating with the wrong password.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.confirmVerification(email)).resolves.toBeUndefined();
await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password.');
});
it('can authenticate.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.confirmVerification(email)).resolves.toBeUndefined();
await expect(store.authenticate(email, password)).resolves.toBe(accountId);
});
it('errors when changing the password of a non-existent account.', async(): Promise<void> => {
await expect(store.update(email, password)).rejects.toThrow('Login does not exist.');
});
it('can change the password.', async(): Promise<void> => {
const newPassword = 'newPassword!';
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.confirmVerification(email)).resolves.toBeUndefined();
await expect(store.update(email, newPassword)).resolves.toBeUndefined();
await expect(store.authenticate(email, newPassword)).resolves.toBe(accountId);
});
it('can get the accountId.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.get(email)).resolves.toEqual(accountId);
});
it('can delete a login.', async(): Promise<void> => {
await expect(store.create(email, accountId, password)).resolves.toBeUndefined();
await expect(store.delete(email)).resolves.toBe(true);
await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.');
await expect(store.get(accountId)).resolves.toBeUndefined();
});
it('does nothing when deleting non-existent login.', async(): Promise<void> => {
await expect(store.delete(email)).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,178 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler';
import type { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore';
import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore';
import type { IdentifierGenerator } from '../../../../../src/pods/generate/IdentifierGenerator';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A CreatePodHandler', (): void => {
const name = 'name';
const webId = 'http://example.com/other/webId#me';
const accountId = 'accountId';
let json: unknown;
const baseUrl = 'http://example.com/';
const relativeWebIdPath = '/profile/card#me';
const podUrl = 'http://example.com/name/';
const generatedWebId = 'http://example.com/name/profile/card#me';
const webIdResource = 'http://example.com/.account/webID';
const podResource = 'http://example.com/.account/pod';
let identifierGenerator: jest.Mocked<IdentifierGenerator>;
let accountStore: jest.Mocked<AccountStore>;
let webIdStore: jest.Mocked<WebIdStore>;
let podStore: jest.Mocked<PodStore>;
let handler: CreatePodHandler;
beforeEach(async(): Promise<void> => {
json = {
name,
};
identifierGenerator = {
generate: jest.fn().mockReturnValue({ path: podUrl }),
extractPod: jest.fn(),
};
accountStore = mockAccountStore();
accountStore.get.mockImplementation(async(id: string): Promise<Account> => createAccount(id));
webIdStore = {
get: jest.fn(),
add: jest.fn().mockResolvedValue(webIdResource),
delete: jest.fn(),
};
podStore = {
create: jest.fn().mockResolvedValue(podResource),
};
handler = new CreatePodHandler(
{ accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: false },
);
});
it('requires specific input fields.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
name: {
required: true,
type: 'string',
},
settings: {
required: false,
type: 'object',
fields: {
webId: {
required: false,
type: 'string',
},
},
},
},
},
});
});
it('generates a pod and WebID.', async(): Promise<void> => {
await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: {
pod: podUrl, webId: generatedWebId, podResource, webIdResource,
}});
expect(webIdStore.add).toHaveBeenCalledTimes(1);
expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value);
expect(podStore.create).toHaveBeenCalledTimes(1);
expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, {
base: { path: podUrl },
webId: generatedWebId,
oidcIssuer: baseUrl,
}, false);
});
it('can use an external WebID for the pod generation.', async(): Promise<void> => {
json = { name, settings: { webId }};
await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: {
pod: podUrl, webId, podResource,
}});
expect(webIdStore.add).toHaveBeenCalledTimes(0);
expect(podStore.create).toHaveBeenCalledTimes(1);
expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, {
base: { path: podUrl },
webId,
}, false);
});
it('errors if the account is already linked to the WebID that would be generated.', async(): Promise<void> => {
const account = createAccount();
account.webIds[generatedWebId] = 'http://example.com/resource';
accountStore.get.mockResolvedValueOnce(account);
await expect(handler.handle({ json, accountId } as any))
.rejects.toThrow(`${generatedWebId} is already registered to this account.`);
expect(webIdStore.add).toHaveBeenCalledTimes(0);
expect(podStore.create).toHaveBeenCalledTimes(0);
});
it('undoes any changes if something goes wrong creating the pod.', async(): Promise<void> => {
const error = new Error('bad data');
podStore.create.mockRejectedValueOnce(error);
await expect(handler.handle({ json, accountId } as any)).rejects.toBe(error);
expect(webIdStore.add).toHaveBeenCalledTimes(1);
expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value);
expect(podStore.create).toHaveBeenCalledTimes(1);
expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, {
base: { path: podUrl },
webId: generatedWebId,
oidcIssuer: baseUrl,
}, false);
expect(webIdStore.delete).toHaveBeenCalledTimes(1);
expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[1].value);
});
describe('allowing root pods', (): void => {
beforeEach(async(): Promise<void> => {
handler = new CreatePodHandler(
{ accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: true },
);
});
it('does not require a name.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
name: {
required: false,
type: 'string',
},
settings: {
required: false,
type: 'object',
fields: {
webId: {
required: false,
type: 'string',
},
},
},
},
},
});
});
it('generates a pod and WebID in the root.', async(): Promise<void> => {
await expect(handler.handle({ json: {}, accountId } as any)).resolves.toEqual({ json: {
pod: baseUrl, webId: `${baseUrl}profile/card#me`, podResource, webIdResource,
}});
expect(webIdStore.add).toHaveBeenCalledTimes(1);
expect(webIdStore.add)
.toHaveBeenLastCalledWith(`${baseUrl}profile/card#me`, await accountStore.get.mock.results[0].value);
expect(podStore.create).toHaveBeenCalledTimes(1);
expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, {
base: { path: baseUrl },
webId: `${baseUrl}profile/card#me`,
oidcIssuer: baseUrl,
}, true);
});
});
});

View File

@@ -0,0 +1,14 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import { BasePodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
describe('A BasePodIdRoute', (): void => {
it('uses the Pod ID key.', async(): Promise<void> => {
const podIdRoute = new BasePodIdRoute(new BaseAccountIdRoute(
new AbsolutePathInteractionRoute('http://example.com/'),
));
expect(podIdRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', podId: '456' });
});
});

View File

@@ -0,0 +1,50 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore';
import type { PodIdRoute } from '../../../../../../src/identity/interaction/pod/PodIdRoute';
import { BasePodStore } from '../../../../../../src/identity/interaction/pod/util/BasePodStore';
import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings';
import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil';
describe('A BasePodStore', (): void => {
let account: Account;
const settings: PodSettings = { webId: 'http://example.com/card#me', base: { path: 'http://example.com/foo' }};
const route: PodIdRoute = {
getPath: (): string => 'http://example.com/.account/resource',
matchPath: (): any => ({}),
};
let accountStore: jest.Mocked<AccountStore>;
let manager: jest.Mocked<PodManager>;
let store: BasePodStore;
beforeEach(async(): Promise<void> => {
account = createAccount();
accountStore = mockAccountStore(createAccount());
manager = {
createPod: jest.fn(),
};
store = new BasePodStore(accountStore, route, manager);
});
it('calls the pod manager to create a pod.', async(): Promise<void> => {
await expect(store.create(account, settings, false)).resolves.toBe('http://example.com/.account/resource');
expect(manager.createPod).toHaveBeenCalledTimes(1);
expect(manager.createPod).toHaveBeenLastCalledWith(settings, false);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.pods['http://example.com/foo']).toBe('http://example.com/.account/resource');
});
it('does not update the account if something goes wrong.', async(): Promise<void> => {
manager.createPod.mockRejectedValueOnce(new Error('bad data'));
await expect(store.create(account, settings, false)).rejects.toThrow('Pod creation failed: bad data');
expect(manager.createPod).toHaveBeenCalledTimes(1);
expect(manager.createPod).toHaveBeenLastCalledWith(settings, false);
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.pods).toEqual({});
});
});

View File

@@ -9,4 +9,9 @@ describe('An AbsolutePathInteractionRoute', (): void => {
it('returns the given path.', async(): Promise<void> => {
expect(route.getPath()).toBe('http://example.com/idp/path/');
});
it('matches a path if it is identical to the stored path.', async(): Promise<void> => {
expect(route.matchPath(path)).toEqual({});
expect(route.matchPath('http://example.com/somewhere/else')).toBeUndefined();
});
});

View File

@@ -0,0 +1,56 @@
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import type { AccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import type { JsonInteractionHandler,
JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler';
import { AuthorizedRouteHandler } from '../../../../../src/identity/interaction/routing/AuthorizedRouteHandler';
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
import { UnauthorizedHttpError } from '../../../../../src/util/errors/UnauthorizedHttpError';
describe('An AuthorizedRouteHandler', (): void => {
const accountId = 'accountId';
const target: ResourceIdentifier = { path: 'http://example.com/foo' };
let input: JsonInteractionHandlerInput;
let route: jest.Mocked<AccountIdRoute>;
let source: jest.Mocked<JsonInteractionHandler>;
let handler: AuthorizedRouteHandler;
beforeEach(async(): Promise<void> => {
input = {
target,
json: { data: 'data' },
metadata: new RepresentationMetadata(),
method: 'GET',
accountId,
};
route = {
matchPath: jest.fn().mockReturnValue({ accountId }),
getPath: jest.fn(),
};
source = {
handle: jest.fn().mockResolvedValue('response'),
} as any;
handler = new AuthorizedRouteHandler(route, source);
});
it('calls the source handler with the input.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toBe('response');
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
});
it('errors if there is no account ID in the input.', async(): Promise<void> => {
delete input.accountId;
await expect(handler.handle(input)).rejects.toThrow(UnauthorizedHttpError);
expect(source.handle).toHaveBeenCalledTimes(0);
});
it('errors if the account ID does not match the route result.', async(): Promise<void> => {
route.matchPath.mockReturnValueOnce({ accountId: 'otherId' });
await expect(handler.handle(input)).rejects.toThrow(ForbiddenHttpError);
expect(source.handle).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,48 @@
import { IdInteractionRoute } from '../../../../../src/identity/interaction/routing/IdInteractionRoute';
import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
describe('An IdInteractionRoute', (): void => {
const idName = 'id';
let base: jest.Mocked<InteractionRoute<'base'>>;
let route: IdInteractionRoute<'base', 'id'>;
beforeEach(async(): Promise<void> => {
base = {
getPath: jest.fn().mockReturnValue('http://example.com/'),
matchPath: jest.fn().mockReturnValue({ base: 'base' }),
};
route = new IdInteractionRoute<'base', 'id'>(base, idName);
});
describe('#getPath', (): void => {
it('appends the identifier value to generate the path.', async(): Promise<void> => {
expect(route.getPath({ base: 'base', id: '12345' })).toBe('http://example.com/12345/');
});
it('errors if there is no input identifier.', async(): Promise<void> => {
expect((): string => route.getPath({ base: 'base' } as any)).toThrow(InternalServerError);
});
it('can be configured not to add a slash at the end.', async(): Promise<void> => {
route = new IdInteractionRoute<'base', 'id'>(base, idName, false);
expect(route.getPath({ base: 'base', id: '12345' })).toBe('http://example.com/12345');
});
});
describe('#matchPath', (): void => {
it('returns the matching values.', async(): Promise<void> => {
expect(route.matchPath('http://example.com/1234/')).toEqual({ base: 'base', id: '1234' });
});
it('returns undefined if there is no match.', async(): Promise<void> => {
expect(route.matchPath('http://example.com/1234')).toBeUndefined();
});
it('returns undefined if there is no base match.', async(): Promise<void> => {
base.matchPath.mockReturnValueOnce(undefined);
expect(route.matchPath('http://example.com/1234/')).toBeUndefined();
});
});
});

View File

@@ -1,53 +1,53 @@
import type { Operation } from '../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../../src/http/representation/Representation';
import type { InteractionHandler } from '../../../../../src/identity/interaction/InteractionHandler';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { JsonInteractionHandler,
JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler';
import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute';
import { InteractionRouteHandler } from '../../../../../src/identity/interaction/routing/InteractionRouteHandler';
import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createPostJsonOperation } from '../email-password/handler/Util';
describe('An InteractionRouteHandler', (): void => {
const path = 'http://example.com/idp/path/';
let operation: Operation;
let representation: Representation;
let route: InteractionRoute;
let source: jest.Mocked<InteractionHandler>;
let handler: InteractionRouteHandler;
const path = 'http://example.com/foo/';
let input: JsonInteractionHandlerInput;
let route: jest.Mocked<InteractionRoute<'base'>>;
let source: jest.Mocked<JsonInteractionHandler>;
let handler: InteractionRouteHandler<InteractionRoute<'base'>>;
beforeEach(async(): Promise<void> => {
operation = createPostJsonOperation({}, path);
representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON);
input = {
target: { path },
json: { data: 'data' },
metadata: new RepresentationMetadata(),
method: 'GET',
};
route = {
getPath: jest.fn().mockReturnValue(path),
matchPath: jest.fn().mockReturnValue({ base: 'base' }),
};
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(representation),
handle: jest.fn().mockResolvedValue('response'),
} as any;
handler = new InteractionRouteHandler(route, source);
});
it('rejects other paths.', async(): Promise<void> => {
operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/');
await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError);
route.matchPath.mockReturnValueOnce(undefined);
await expect(handler.canHandle(input)).rejects.toThrow(NotFoundHttpError);
});
it('rejects input its source cannot handle.', async(): Promise<void> => {
source.canHandle.mockRejectedValueOnce(new Error('bad data'));
await expect(handler.canHandle({ operation })).rejects.toThrow('bad data');
await expect(handler.canHandle(input)).rejects.toThrow('bad data');
});
it('can handle requests its source can handle.', async(): Promise<void> => {
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
await expect(handler.canHandle(input)).resolves.toBeUndefined();
});
it('lets its source handle requests.', async(): Promise<void> => {
await expect(handler.handle({ operation })).resolves.toBe(representation);
await expect(handler.handle(input)).resolves.toBe('response');
});
});

View File

@@ -2,23 +2,34 @@ import type { InteractionRoute } from '../../../../../src/identity/interaction/r
import {
RelativePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/RelativePathInteractionRoute';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
describe('A RelativePathInteractionRoute', (): void => {
const relativePath = '/relative/';
let route: jest.Mocked<InteractionRoute>;
let relativeRoute: RelativePathInteractionRoute;
let route: jest.Mocked<InteractionRoute<'base'>>;
let relativeRoute: RelativePathInteractionRoute<'base'>;
beforeEach(async(): Promise<void> => {
route = {
getPath: jest.fn().mockReturnValue('http://example.com/'),
matchPath: jest.fn().mockReturnValue({ base: 'base' }),
};
relativeRoute = new RelativePathInteractionRoute(route, relativePath);
});
it('returns the joined path.', async(): Promise<void> => {
relativeRoute = new RelativePathInteractionRoute(route, relativePath);
expect(relativeRoute.getPath()).toBe('http://example.com/relative/');
});
relativeRoute = new RelativePathInteractionRoute('http://example.com/test/', relativePath);
expect(relativeRoute.getPath()).toBe('http://example.com/test/relative/');
it('matches paths by checking if the tail matches the relative path.', async(): Promise<void> => {
expect(relativeRoute.matchPath('http://example.com/relative/')).toEqual({ base: 'base' });
expect(relativeRoute.matchPath('http://example.com/relative')).toBeUndefined();
});
it('errors if the base path does not end in a slash.', async(): Promise<void> => {
route.getPath.mockReturnValueOnce('http://example.com/foo');
expect((): string => relativeRoute.getPath()).toThrow(InternalServerError);
});
});

View File

@@ -0,0 +1,107 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { LinkWebIdHandler } from '../../../../../src/identity/interaction/webid/LinkWebIdHandler';
import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore';
import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute';
import type { OwnershipValidator } from '../../../../../src/identity/ownership/OwnershipValidator';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import type { IdentifierStrategy } from '../../../../../src/util/identifiers/IdentifierStrategy';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A LinkWebIdHandler', (): void => {
let account: Account;
const accountId = 'accountId';
const webId = 'http://example.com/profile/card#me';
let json: unknown;
const resource = 'http://example.com/.account/link';
const baseUrl = 'http://example.com/';
let ownershipValidator: jest.Mocked<OwnershipValidator>;
let accountStore: jest.Mocked<AccountStore>;
let webIdStore: jest.Mocked<WebIdStore>;
let webIdRoute: jest.Mocked<WebIdLinkRoute>;
let identifierStrategy: jest.Mocked<IdentifierStrategy>;
let handler: LinkWebIdHandler;
beforeEach(async(): Promise<void> => {
json = { webId };
ownershipValidator = {
handleSafe: jest.fn(),
} as any;
account = createAccount();
accountStore = mockAccountStore(account);
webIdStore = {
add: jest.fn().mockResolvedValue(resource),
} as any;
identifierStrategy = {
contains: jest.fn().mockReturnValue(true),
} as any;
handler = new LinkWebIdHandler({
accountStore,
identifierStrategy,
webIdRoute,
webIdStore,
ownershipValidator,
baseUrl,
});
});
it('requires a WebID as input.', async(): Promise<void> => {
await expect(handler.getView()).resolves.toEqual({
json: {
fields: {
webId: {
required: true,
type: 'string',
},
},
},
});
});
it('links the WebID.', async(): Promise<void> => {
await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({
json: { resource, webId, oidcIssuer: baseUrl },
});
expect(webIdStore.add).toHaveBeenCalledTimes(1);
expect(webIdStore.add).toHaveBeenLastCalledWith(webId, account);
});
it('throws an error if the WebID is already registered.', async(): Promise<void> => {
account.webIds[webId] = resource;
await expect(handler.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError);
expect(webIdStore.add).toHaveBeenCalledTimes(0);
});
it('checks if the WebID is in a pod owned by the account.', async(): Promise<void> => {
account.pods['http://example.com/.account/pod/'] = resource;
await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({
json: { resource, webId, oidcIssuer: baseUrl },
});
expect(identifierStrategy.contains).toHaveBeenCalledTimes(1);
expect(identifierStrategy.contains)
.toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true);
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the ownership validator if none of the pods contain the WebId.', async(): Promise<void> => {
identifierStrategy.contains.mockReturnValue(false);
account.pods['http://example.com/.account/pod/'] = resource;
account.pods['http://example.com/.account/pod2/'] = resource;
await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({
json: { resource, webId, oidcIssuer: baseUrl },
});
expect(identifierStrategy.contains).toHaveBeenCalledTimes(2);
expect(identifierStrategy.contains)
.toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true);
expect(identifierStrategy.contains)
.toHaveBeenCalledWith({ path: 'http://example.com/.account/pod2/' }, { path: webId }, true);
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
});
});

View File

@@ -0,0 +1,42 @@
import type { Account } from '../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore';
import { UnlinkWebIdHandler } from '../../../../../src/identity/interaction/webid/UnlinkWebIdHandler';
import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { createAccount, mockAccountStore } from '../../../../util/AccountUtil';
describe('A UnlinkWebIdHandler', (): void => {
const resource = 'http://example.com/.account/link';
const webId = 'http://example.com/.account/card#me';
const accountId = 'accountId';
let account: Account;
let accountStore: jest.Mocked<AccountStore>;
let webIdStore: jest.Mocked<WebIdStore>;
let handler: UnlinkWebIdHandler;
beforeEach(async(): Promise<void> => {
account = createAccount(accountId);
account.webIds[webId] = resource;
accountStore = mockAccountStore(account);
webIdStore = {
get: jest.fn(),
add: jest.fn(),
delete: jest.fn(),
};
handler = new UnlinkWebIdHandler(accountStore, webIdStore);
});
it('removes the WebID link.', async(): Promise<void> => {
await expect(handler.handle({ target: { path: resource }, accountId } as any)).resolves.toEqual({ json: {}});
expect(webIdStore.delete).toHaveBeenCalledTimes(1);
expect(webIdStore.delete).toHaveBeenLastCalledWith(webId, account);
});
it('errors if there is no matching link resource.', async(): Promise<void> => {
delete account.webIds[webId];
await expect(handler.handle({ target: { path: resource }, accountId } as any)).rejects.toThrow(NotFoundHttpError);
});
});

View File

@@ -0,0 +1,14 @@
import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import { BaseWebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute';
describe('A WebIdLinkRoute', (): void => {
it('uses the WebID link key.', async(): Promise<void> => {
const webIdLinkRoute = new BaseWebIdLinkRoute(new BaseAccountIdRoute(
new AbsolutePathInteractionRoute('http://example.com/'),
));
expect(webIdLinkRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', webIdLink: '456' });
});
});

View File

@@ -0,0 +1,112 @@
import type { Account } from '../../../../../../src/identity/interaction/account/util/Account';
import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore';
import { BaseWebIdStore } from '../../../../../../src/identity/interaction/webid/util/BaseWebIdStore';
import type { WebIdLinkRoute } from '../../../../../../src/identity/interaction/webid/WebIdLinkRoute';
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil';
describe('A BaseWebIdStore', (): void => {
const webId = 'http://example.com/card#me';
let account: Account;
const route: WebIdLinkRoute = {
getPath: (): string => 'http://example.com/.account/resource',
matchPath: (): any => ({}),
};
let accountStore: jest.Mocked<AccountStore>;
let storage: jest.Mocked<KeyValueStorage<string, string[]>>;
let store: BaseWebIdStore;
beforeEach(async(): Promise<void> => {
account = createAccount();
accountStore = mockAccountStore(createAccount());
storage = {
get: jest.fn().mockResolvedValue([ account.id ]),
set: jest.fn(),
delete: jest.fn(),
} as any;
store = new BaseWebIdStore(route, accountStore, storage);
});
it('returns the stored account identifiers.', async(): Promise<void> => {
await expect(store.get(webId)).resolves.toEqual([ account.id ]);
});
it('returns an empty list if there are no matching idenfitiers.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce(undefined);
await expect(store.get(webId)).resolves.toEqual([]);
});
it('can add an account to the linked list.', async(): Promise<void> => {
await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.webIds[webId]).toBe('http://example.com/.account/resource');
});
it('creates a new list if one did not exist yet.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce(undefined);
await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.webIds[webId]).toBe('http://example.com/.account/resource');
});
it('can not create a link if the WebID is already linked.', async(): Promise<void> => {
account.webIds[webId] = 'resource';
await expect(store.add(webId, account)).rejects.toThrow(BadRequestHttpError);
expect(storage.set).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
});
it('does not update the account if something goes wrong.', async(): Promise<void> => {
storage.set.mockRejectedValueOnce(new Error('bad data'));
await expect(store.add(webId, account)).rejects.toThrow('bad data');
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]);
expect(accountStore.update).toHaveBeenCalledTimes(2);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.webIds).toEqual({});
});
it('can delete a link.', async(): Promise<void> => {
await expect(store.delete(webId, account)).resolves.toBeUndefined();
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith(webId);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.webIds).toEqual({});
});
it('does not remove the entire list if there are still other entries.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce([ account.id, 'other-id' ]);
await expect(store.delete(webId, account)).resolves.toBeUndefined();
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenLastCalledWith(webId, [ 'other-id' ]);
expect(accountStore.update).toHaveBeenCalledTimes(1);
expect(accountStore.update).toHaveBeenLastCalledWith(account);
expect(account.webIds).toEqual({});
});
it('does not do anything if the the delete WebID target does not exist.', async(): Promise<void> => {
storage.get.mockResolvedValueOnce(undefined);
await expect(store.delete('random-webId', account)).resolves.toBeUndefined();
expect(storage.set).toHaveBeenCalledTimes(0);
expect(storage.delete).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
});
it('does not do anything if the the delete account target is not linked.', async(): Promise<void> => {
await expect(store.delete(webId, { ...account, id: 'random-id' })).resolves.toBeUndefined();
expect(storage.set).toHaveBeenCalledTimes(0);
expect(storage.delete).toHaveBeenCalledTimes(0);
expect(accountStore.update).toHaveBeenCalledTimes(0);
});
});