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:
@@ -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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user