mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
134
test/unit/identity/interaction/ControlHandler.test.ts
Normal file
134
test/unit/identity/interaction/ControlHandler.test.ts
Normal 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',
|
||||
},
|
||||
}});
|
||||
});
|
||||
});
|
||||
161
test/unit/identity/interaction/CookieInteractionHandler.test.ts
Normal file
161
test/unit/identity/interaction/CookieInteractionHandler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
97
test/unit/identity/interaction/InteractionUtil.test.ts
Normal file
97
test/unit/identity/interaction/InteractionUtil.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
test/unit/identity/interaction/JsonConversionHandler.test.ts
Normal file
99
test/unit/identity/interaction/JsonConversionHandler.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
17
test/unit/identity/interaction/OidcControlHandler.test.ts
Normal file
17
test/unit/identity/interaction/OidcControlHandler.test.ts
Normal 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: { }});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
31
test/unit/identity/interaction/VersionHandler.test.ts
Normal file
31
test/unit/identity/interaction/VersionHandler.test.ts
Normal 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',
|
||||
}});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
57
test/unit/identity/interaction/YupUtil.test.ts
Normal file
57
test/unit/identity/interaction/YupUtil.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' }});
|
||||
});
|
||||
});
|
||||
114
test/unit/identity/interaction/account/util/AccountUtil.test.ts
Normal file
114
test/unit/identity/interaction/account/util/AccountUtil.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ]});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
test/unit/identity/interaction/login/LogoutHandler.test.ts
Normal file
46
test/unit/identity/interaction/login/LogoutHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
169
test/unit/identity/interaction/login/ResolveLoginHandler.test.ts
Normal file
169
test/unit/identity/interaction/login/ResolveLoginHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
103
test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts
Normal file
103
test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
33
test/unit/identity/interaction/oidc/PromptHandler.test.ts
Normal file
33
test/unit/identity/interaction/oidc/PromptHandler.test.ts
Normal 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/' }},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
178
test/unit/identity/interaction/pod/CreatePodHandler.test.ts
Normal file
178
test/unit/identity/interaction/pod/CreatePodHandler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
test/unit/identity/interaction/pod/PodIdRoute.test.ts
Normal file
14
test/unit/identity/interaction/pod/PodIdRoute.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
50
test/unit/identity/interaction/pod/util/BasePodStore.test.ts
Normal file
50
test/unit/identity/interaction/pod/util/BasePodStore.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
107
test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts
Normal file
107
test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
14
test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts
Normal file
14
test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
112
test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts
Normal file
112
test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user