feat: Full rework of account management

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

View File

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

View File

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

View File

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

View File

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