mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add migration for v6 account data
This commit is contained in:
parent
fedd9e04d8
commit
0ac7d407bf
@ -15,6 +15,7 @@
|
||||
- Pod seeding has been updated to account for the new account management, with an update CLI parameter `--seedConfig`,
|
||||
see the [updated documentation](https://communitysolidserver.github.io/CommunitySolidServer/7.x/usage/seeding-pods/)
|
||||
for more details.
|
||||
- Migration was added to update account data automatically from previous versions. See below for more details.
|
||||
- Due to the changes in account management, setup has been removed completely.
|
||||
The `*-no-setup.json` configurations have been renamed to `*-root.json` to indicate their focus on the root container.
|
||||
- The `StaticAssetHandler` can now be used to link static pages to containers.
|
||||
@ -23,8 +24,15 @@
|
||||
|
||||
### Data migration
|
||||
|
||||
Old account data will need to be migrated as described in the
|
||||
[documentation](https://communitysolidserver.github.io/CommunitySolidServer/7.x/usage/account/migration/).
|
||||
Old account data will need to be migrated.
|
||||
When starting the server for the first time after updating the version,
|
||||
this will happen automatically.
|
||||
A prompt will be shown to confirm.
|
||||
It is advised to first backup the account data in case something goes wrong.
|
||||
When using the filesystem backend with default storage options,
|
||||
these can be found in the `.internal/accounts/` folder.
|
||||
|
||||
In case the prompt causes issues, it can be skipped automatically with the `--confirmMigration` CLI option.
|
||||
|
||||
### Configuration changes
|
||||
|
||||
|
@ -6,7 +6,8 @@
|
||||
"css:config/app/init/initializers/server.json",
|
||||
"css:config/app/init/initializers/seeding.json",
|
||||
"css:config/app/init/initializers/version.json",
|
||||
"css:config/app/init/initializers/workers.json"
|
||||
"css:config/app/init/initializers/workers.json",
|
||||
"css:config/app/init/migration/base.json"
|
||||
],
|
||||
"@graph": [
|
||||
{
|
||||
@ -15,6 +16,7 @@
|
||||
"@type": "SequenceHandler",
|
||||
"handlers": [
|
||||
{ "@id": "urn:solid-server:default:LoggerInitializer" },
|
||||
{ "@id": "urn:solid-server:default:EarlyProcessParallelInitializer" },
|
||||
{ "@id": "urn:solid-server:default:PrimaryInitializer" },
|
||||
{ "@id": "urn:solid-server:default:WorkerInitializer" }
|
||||
]
|
||||
@ -32,6 +34,7 @@
|
||||
"handlers": [
|
||||
{ "@id": "urn:solid-server:default:CleanupInitializer"},
|
||||
{ "@id": "urn:solid-server:default:BaseUrlVerifier" },
|
||||
{ "@id": "urn:solid-server:default:MigrationInitializer" },
|
||||
{ "@id": "urn:solid-server:default:PrimaryParallelInitializer" },
|
||||
{ "@id": "urn:solid-server:default:SeededAccountInitializer" },
|
||||
{ "@id": "urn:solid-server:default:ModuleVersionVerifier" },
|
||||
|
@ -4,6 +4,12 @@
|
||||
"css:config/app/init/base/init.json"
|
||||
],
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "These handlers are called for all processes whenever the server is started, and can be used to ensure that all necessary resources for booting are available.",
|
||||
"@id": "urn:solid-server:default:EarlyProcessParallelInitializer",
|
||||
"@type": "ParallelHandler",
|
||||
"handlers": [ ]
|
||||
},
|
||||
{
|
||||
"comment": "These handlers are called only for the Primary process whenever the server is started, and can be used to ensure that all necessary resources for booting are available. (in singlethreaded mode, these are always called)",
|
||||
"@id": "urn:solid-server:default:PrimaryParallelInitializer",
|
||||
|
16
config/app/init/migration/base.json
Normal file
16
config/app/init/migration/base.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||
"import": [
|
||||
"css:config/app/init/migration/v6.json"
|
||||
],
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Handles all migration initializers.",
|
||||
"@id": "urn:solid-server:default:MigrationInitializer",
|
||||
"@type": "SequenceHandler",
|
||||
"handlers": [
|
||||
{ "@id": "urn:solid-server:default:V6MigrationHandler" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
73
config/app/init/migration/v6.json
Normal file
73
config/app/init/migration/v6.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Handles migration of v6 account data.",
|
||||
"@id": "urn:solid-server:default:V6MigrationHandler",
|
||||
"@type": "ConditionalHandler",
|
||||
"storageKey": "v6-migration",
|
||||
"storageValue": true,
|
||||
"storage": { "@id": "urn:solid-server:default:SetupStorage" },
|
||||
"handleStorage": true,
|
||||
"source": {
|
||||
"@id": "urn:solid-server:default:V6MigrationInitializer",
|
||||
"@type": "V6MigrationInitializer",
|
||||
"versionKey": "current-server-version",
|
||||
"versionStorage": { "@id": "urn:solid-server:default:SetupStorage" },
|
||||
"accountStorage": { "@id": "urn:solid-server:default:V6MigrationAccountStorage" },
|
||||
"clientCredentialsStorage": { "@id": "urn:solid-server:default:V6MigrationClientCredentialsStorage" },
|
||||
"forgotPasswordStorage": { "@id": "urn:solid-server:default:V6MigrationForgotPasswordStorage" },
|
||||
"newStorage": { "@id": "urn:solid-server:default:AccountStorage" },
|
||||
"skipConfirmation": { "@id": "urn:solid-server:default:variable:confirmMigration" }
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:V6MigrationAccountStorage",
|
||||
"@type": "Base64EncodingStorage",
|
||||
"source": {
|
||||
"comment": "Relative path of `/` is necessary to strip leading slash from keys.",
|
||||
"@type": "ContainerPathStorage",
|
||||
"relativePath": "/",
|
||||
"source": {
|
||||
"@type": "SingleContainerJsonStorage",
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"container": "/.internal/accounts/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:V6MigrationClientCredentialsStorage",
|
||||
"@type": "Base64EncodingStorage",
|
||||
"source": {
|
||||
"comment": "Relative path of `/` is necessary to strip leading slash from keys.",
|
||||
"@type": "ContainerPathStorage",
|
||||
"relativePath": "/",
|
||||
"source": {
|
||||
"@type": "SingleContainerJsonStorage",
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"container": "/.internal/accounts/credentials/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:V6MigrationForgotPasswordStorage",
|
||||
"@type": "Base64EncodingStorage",
|
||||
"source": {
|
||||
"comment": "Relative path of `/` is necessary to strip leading slash from keys.",
|
||||
"@type": "ContainerPathStorage",
|
||||
"relativePath": "/",
|
||||
"source": {
|
||||
"@type": "SingleContainerJsonStorage",
|
||||
"source": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"container": "/.internal/forgot-password/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -121,6 +121,14 @@
|
||||
"type": "number",
|
||||
"describe": "Run the server in multithreaded mode using workers. (special values: -1: num_cores-1, 0: num_cores). Defaults to 1 (singlethreaded)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "YargsParameter",
|
||||
"name": "confirmMigration",
|
||||
"options": {
|
||||
"type": "boolean",
|
||||
"describe": "Skips the confirmation prompts during migration from older versions."
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
|
@ -81,6 +81,14 @@
|
||||
"key": "workers",
|
||||
"defaultValue": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"CombinedShorthandResolver:_resolvers_key": "urn:solid-server:default:variable:confirmMigration",
|
||||
"CombinedShorthandResolver:_resolvers_value": {
|
||||
"@type": "KeyExtractor",
|
||||
"key": "confirmMigration",
|
||||
"defaultValue": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -75,7 +75,7 @@
|
||||
},
|
||||
{
|
||||
"comment": "Initialize all the stores.",
|
||||
"@id": "urn:solid-server:default:WorkerParallelInitializer",
|
||||
"@id": "urn:solid-server:default:EarlyProcessParallelInitializer",
|
||||
"@type": "ParallelHandler",
|
||||
"handlers": [
|
||||
{ "@id": "urn:solid-server:default:AccountStore" },
|
||||
|
@ -16,7 +16,7 @@
|
||||
},
|
||||
{
|
||||
"comment": "Initialize the password store.",
|
||||
"@id": "urn:solid-server:default:WorkerParallelInitializer",
|
||||
"@id": "urn:solid-server:default:EarlyProcessParallelInitializer",
|
||||
"@type": "ParallelHandler",
|
||||
"handlers": [
|
||||
{ "@id": "urn:solid-server:default:PasswordStore" }
|
||||
@ -33,7 +33,7 @@
|
||||
"@type": "Base64EncodingStorage",
|
||||
"source": {
|
||||
"@type": "ContainerPathStorage",
|
||||
"relativePath": "/accounts/logins/password/forgot/",
|
||||
"relativePath": "/accounts/forgot-password/",
|
||||
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +51,11 @@
|
||||
"comment": "Run the server in multithreaded mode with the set amount of workers.",
|
||||
"@id": "urn:solid-server:default:variable:workers",
|
||||
"@type": "Variable"
|
||||
},
|
||||
{
|
||||
"comment": "If true, skips the confirmation prompts during migration.",
|
||||
"@id": "urn:solid-server:default:variable:confirmMigration",
|
||||
"@type": "Variable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -26,12 +26,9 @@ Additionally to the details below, the tail of all resource identifiers were bas
|
||||
* Resource identifiers: the token label
|
||||
* Data format: `{ webId, secret }`
|
||||
|
||||
The best way to migrate the data would be to read in the old data,
|
||||
and make use of the new classes to generate the new account objects,
|
||||
as generating the data manually might be too cumbersome.
|
||||
Ideally the account classes of the previous version can be reused to read in the older data
|
||||
to prevent having to read the old data directly.
|
||||
|
||||
During migration, WebID ownership validation would need to be disabled
|
||||
as otherwise the server won't allow linking the WebIDs.
|
||||
The password values can be reused as the password storage method was not changed.
|
||||
The `V6MigrationInitializer` class is responsible for migrating from this format to the new one
|
||||
and does so by reading in the old data and creating new instances in the `IndexedStorage`.
|
||||
In case you have an instance that made impactful changes to how storage is handled
|
||||
that would be the class to investigate and replace.
|
||||
Password data can be reused as the algorithm there was not changed.
|
||||
Email addresses are now stored in lowercase, so these need to be converted during migration.
|
||||
|
@ -8,7 +8,7 @@ import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './AccountStore';
|
||||
import type { AccountLoginStorage } from './LoginStorage';
|
||||
import { ACCOUNT_TYPE } from './LoginStorage';
|
||||
|
||||
const STORAGE_DESCRIPTION = {
|
||||
export const ACCOUNT_STORAGE_DESCRIPTION = {
|
||||
[ACCOUNT_SETTINGS_REMEMBER_LOGIN]: 'boolean?',
|
||||
} as const;
|
||||
|
||||
@ -19,7 +19,7 @@ const STORAGE_DESCRIPTION = {
|
||||
export class BaseAccountStore extends Initializer implements AccountStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{ [ACCOUNT_TYPE]: typeof STORAGE_DESCRIPTION }>;
|
||||
private readonly storage: AccountLoginStorage<{ [ACCOUNT_TYPE]: typeof ACCOUNT_STORAGE_DESCRIPTION }>;
|
||||
private initialized = false;
|
||||
|
||||
public constructor(storage: AccountLoginStorage<any>) {
|
||||
@ -33,7 +33,7 @@ export class BaseAccountStore extends Initializer implements AccountStore {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.defineType(ACCOUNT_TYPE, STORAGE_DESCRIPTION, false);
|
||||
await this.storage.defineType(ACCOUNT_TYPE, ACCOUNT_STORAGE_DESCRIPTION, false);
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining account in storage: ${createErrorMessage(cause)}`, { cause });
|
||||
@ -58,6 +58,6 @@ export class BaseAccountStore extends Initializer implements AccountStore {
|
||||
|
||||
public async updateSetting<T extends keyof AccountSettings>(id: string, setting: T, value: AccountSettings[T]):
|
||||
Promise<void> {
|
||||
await this.storage.setField(ACCOUNT_TYPE, id, setting, value as ValueType<typeof STORAGE_DESCRIPTION[T]>);
|
||||
await this.storage.setField(ACCOUNT_TYPE, id, setting, value as ValueType<typeof ACCOUNT_STORAGE_DESCRIPTION[T]>);
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
|
||||
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
|
||||
import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore';
|
||||
|
||||
const STORAGE_TYPE = 'clientCredentials';
|
||||
const STORAGE_DESCRIPTION = {
|
||||
export const CLIENT_CREDENTIALS_STORAGE_TYPE = 'clientCredentials';
|
||||
export const CLIENT_CREDENTIALS_STORAGE_DESCRIPTION = {
|
||||
label: 'string',
|
||||
accountId: `id:${ACCOUNT_TYPE}`,
|
||||
secret: 'string',
|
||||
@ -22,7 +22,9 @@ const STORAGE_DESCRIPTION = {
|
||||
export class BaseClientCredentialsStore extends Initializer implements ClientCredentialsStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
|
||||
private readonly storage: AccountLoginStorage<{ [CLIENT_CREDENTIALS_STORAGE_TYPE]:
|
||||
typeof CLIENT_CREDENTIALS_STORAGE_DESCRIPTION; }>;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
public constructor(storage: AccountLoginStorage<any>) {
|
||||
@ -36,9 +38,9 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'label');
|
||||
await this.storage.defineType(CLIENT_CREDENTIALS_STORAGE_TYPE, CLIENT_CREDENTIALS_STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(CLIENT_CREDENTIALS_STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(CLIENT_CREDENTIALS_STORAGE_TYPE, 'label');
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining client credentials in storage: ${createErrorMessage(cause)}`,
|
||||
@ -47,11 +49,11 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<ClientCredentials | undefined> {
|
||||
return this.storage.get(STORAGE_TYPE, id);
|
||||
return this.storage.get(CLIENT_CREDENTIALS_STORAGE_TYPE, id);
|
||||
}
|
||||
|
||||
public async findByLabel(label: string): Promise<ClientCredentials | undefined> {
|
||||
const result = await this.storage.find(STORAGE_TYPE, { label });
|
||||
const result = await this.storage.find(CLIENT_CREDENTIALS_STORAGE_TYPE, { label });
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -59,7 +61,7 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
|
||||
}
|
||||
|
||||
public async findByAccount(accountId: string): Promise<ClientCredentials[]> {
|
||||
return this.storage.find(STORAGE_TYPE, { accountId });
|
||||
return this.storage.find(CLIENT_CREDENTIALS_STORAGE_TYPE, { accountId });
|
||||
}
|
||||
|
||||
public async create(label: string, webId: string, accountId: string): Promise<ClientCredentials> {
|
||||
@ -69,11 +71,11 @@ export class BaseClientCredentialsStore extends Initializer implements ClientCre
|
||||
`Creating client credentials token with label ${label} for WebID ${webId} and account ${accountId}`,
|
||||
);
|
||||
|
||||
return this.storage.create(STORAGE_TYPE, { accountId, label, webId, secret });
|
||||
return this.storage.create(CLIENT_CREDENTIALS_STORAGE_TYPE, { accountId, label, webId, secret });
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
this.logger.debug(`Deleting client credentials token with ID ${id}`);
|
||||
return this.storage.delete(STORAGE_TYPE, id);
|
||||
return this.storage.delete(CLIENT_CREDENTIALS_STORAGE_TYPE, id);
|
||||
}
|
||||
}
|
||||
|
@ -9,8 +9,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
|
||||
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
|
||||
import type { PasswordStore } from './PasswordStore';
|
||||
|
||||
const STORAGE_TYPE = 'password';
|
||||
const STORAGE_DESCRIPTION = {
|
||||
export const PASSWORD_STORAGE_TYPE = 'password';
|
||||
export const PASSWORD_STORAGE_DESCRIPTION = {
|
||||
email: 'string',
|
||||
password: 'string',
|
||||
verified: 'boolean',
|
||||
@ -25,7 +25,7 @@ const STORAGE_DESCRIPTION = {
|
||||
export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
|
||||
private readonly storage: AccountLoginStorage<{ [PASSWORD_STORAGE_TYPE]: typeof PASSWORD_STORAGE_DESCRIPTION }>;
|
||||
private readonly saltRounds: number;
|
||||
private initialized = false;
|
||||
|
||||
@ -41,9 +41,9 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, true);
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'email');
|
||||
await this.storage.defineType(PASSWORD_STORAGE_TYPE, PASSWORD_STORAGE_DESCRIPTION, true);
|
||||
await this.storage.createIndex(PASSWORD_STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(PASSWORD_STORAGE_TYPE, 'email');
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining email/password in storage: ${createErrorMessage(cause)}`,
|
||||
@ -56,7 +56,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
this.logger.warn(`Trying to create duplicate login for email ${email}`);
|
||||
throw new BadRequestHttpError('There already is a login for this e-mail address.');
|
||||
}
|
||||
const payload = await this.storage.create(STORAGE_TYPE, {
|
||||
const payload = await this.storage.create(PASSWORD_STORAGE_TYPE, {
|
||||
accountId,
|
||||
email: email.toLowerCase(),
|
||||
password: await hash(password, this.saltRounds),
|
||||
@ -66,7 +66,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<{ email: string; accountId: string } | undefined> {
|
||||
const result = await this.storage.get(STORAGE_TYPE, id);
|
||||
const result = await this.storage.get(PASSWORD_STORAGE_TYPE, id);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
@ -74,7 +74,7 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
}
|
||||
|
||||
public async findByEmail(email: string): Promise<{ accountId: string; id: string } | undefined> {
|
||||
const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() });
|
||||
const payload = await this.storage.find(PASSWORD_STORAGE_TYPE, { email: email.toLowerCase() });
|
||||
if (payload.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -82,21 +82,21 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
}
|
||||
|
||||
public async findByAccount(accountId: string): Promise<{ id: string; email: string }[]> {
|
||||
return (await this.storage.find(STORAGE_TYPE, { accountId }))
|
||||
return (await this.storage.find(PASSWORD_STORAGE_TYPE, { accountId }))
|
||||
.map(({ id, email }): { id: string; email: string } => ({ id, email }));
|
||||
}
|
||||
|
||||
public async confirmVerification(id: string): Promise<void> {
|
||||
if (!await this.storage.has(STORAGE_TYPE, id)) {
|
||||
if (!await this.storage.has(PASSWORD_STORAGE_TYPE, id)) {
|
||||
this.logger.warn(`Trying to verify unknown password login ${id}`);
|
||||
throw new ForbiddenHttpError('Login does not exist.');
|
||||
}
|
||||
|
||||
await this.storage.setField(STORAGE_TYPE, id, 'verified', true);
|
||||
await this.storage.setField(PASSWORD_STORAGE_TYPE, id, 'verified', true);
|
||||
}
|
||||
|
||||
public async authenticate(email: string, password: string): Promise<{ accountId: string; id: string }> {
|
||||
const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() });
|
||||
const payload = await this.storage.find(PASSWORD_STORAGE_TYPE, { email: email.toLowerCase() });
|
||||
if (payload.length === 0) {
|
||||
this.logger.warn(`Trying to get account info for unknown email ${email}`);
|
||||
throw new ForbiddenHttpError('Invalid email/password combination.');
|
||||
@ -114,14 +114,14 @@ export class BasePasswordStore extends Initializer implements PasswordStore {
|
||||
}
|
||||
|
||||
public async update(id: string, password: string): Promise<void> {
|
||||
if (!await this.storage.has(STORAGE_TYPE, id)) {
|
||||
if (!await this.storage.has(PASSWORD_STORAGE_TYPE, id)) {
|
||||
this.logger.warn(`Trying to update unknown password login ${id}`);
|
||||
throw new ForbiddenHttpError('Login does not exist.');
|
||||
}
|
||||
await this.storage.setField(STORAGE_TYPE, id, 'password', await hash(password, this.saltRounds));
|
||||
await this.storage.setField(PASSWORD_STORAGE_TYPE, id, 'password', await hash(password, this.saltRounds));
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
return this.storage.delete(STORAGE_TYPE, id);
|
||||
return this.storage.delete(PASSWORD_STORAGE_TYPE, id);
|
||||
}
|
||||
}
|
||||
|
@ -9,17 +9,17 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
|
||||
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
|
||||
import type { PodStore } from './PodStore';
|
||||
|
||||
const STORAGE_TYPE = 'pod';
|
||||
const STORAGE_DESCRIPTION = {
|
||||
export const POD_STORAGE_TYPE = 'pod';
|
||||
export const POD_STORAGE_DESCRIPTION = {
|
||||
baseUrl: 'string',
|
||||
accountId: `id:${ACCOUNT_TYPE}`,
|
||||
} as const;
|
||||
|
||||
const OWNER_TYPE = 'owner';
|
||||
const OWNER_DESCRIPTION = {
|
||||
export const OWNER_STORAGE_TYPE = 'owner';
|
||||
export const OWNER_STORAGE_DESCRIPTION = {
|
||||
webId: 'string',
|
||||
visible: 'boolean',
|
||||
podId: `id:${STORAGE_TYPE}`,
|
||||
podId: `id:${POD_STORAGE_TYPE}`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -35,8 +35,8 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{
|
||||
[STORAGE_TYPE]: typeof STORAGE_DESCRIPTION;
|
||||
[OWNER_TYPE]: typeof OWNER_DESCRIPTION; }>;
|
||||
[POD_STORAGE_TYPE]: typeof POD_STORAGE_DESCRIPTION;
|
||||
[OWNER_STORAGE_TYPE]: typeof OWNER_STORAGE_DESCRIPTION; }>;
|
||||
|
||||
private readonly manager: PodManager;
|
||||
private readonly visible: boolean;
|
||||
@ -56,11 +56,11 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'baseUrl');
|
||||
await this.storage.defineType(OWNER_TYPE, OWNER_DESCRIPTION, false);
|
||||
await this.storage.createIndex(OWNER_TYPE, 'podId');
|
||||
await this.storage.defineType(POD_STORAGE_TYPE, POD_STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(POD_STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(POD_STORAGE_TYPE, 'baseUrl');
|
||||
await this.storage.defineType(OWNER_STORAGE_TYPE, OWNER_STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(OWNER_STORAGE_TYPE, 'podId');
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`,
|
||||
@ -71,14 +71,14 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
public async create(accountId: string, settings: PodSettings, overwrite: boolean): Promise<string> {
|
||||
// Adding pod to storage first as we cannot undo creating the pod below.
|
||||
// This call might also fail because there is no login method yet on the account.
|
||||
const pod = await this.storage.create(STORAGE_TYPE, { baseUrl: settings.base.path, accountId });
|
||||
await this.storage.create(OWNER_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
|
||||
const pod = await this.storage.create(POD_STORAGE_TYPE, { baseUrl: settings.base.path, accountId });
|
||||
await this.storage.create(OWNER_STORAGE_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
|
||||
|
||||
try {
|
||||
await this.manager.createPod(settings, overwrite);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(`Pod creation failed for account ${accountId}: ${createErrorMessage(error)}`);
|
||||
await this.storage.delete(STORAGE_TYPE, pod.id);
|
||||
await this.storage.delete(POD_STORAGE_TYPE, pod.id);
|
||||
throw new BadRequestHttpError(`Pod creation failed: ${createErrorMessage(error)}`, { cause: error });
|
||||
}
|
||||
this.logger.debug(`Created pod ${settings.name} for account ${accountId}`);
|
||||
@ -87,7 +87,7 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<{ baseUrl: string; accountId: string } | undefined> {
|
||||
const pod = await this.storage.get(STORAGE_TYPE, id);
|
||||
const pod = await this.storage.get(POD_STORAGE_TYPE, id);
|
||||
if (!pod) {
|
||||
return;
|
||||
}
|
||||
@ -95,7 +95,7 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
}
|
||||
|
||||
public async findByBaseUrl(baseUrl: string): Promise<{ id: string; accountId: string } | undefined> {
|
||||
const result = await this.storage.find(STORAGE_TYPE, { baseUrl });
|
||||
const result = await this.storage.find(POD_STORAGE_TYPE, { baseUrl });
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -103,12 +103,12 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
}
|
||||
|
||||
public async findPods(accountId: string): Promise<{ id: string; baseUrl: string }[]> {
|
||||
return (await this.storage.find(STORAGE_TYPE, { accountId }))
|
||||
return (await this.storage.find(POD_STORAGE_TYPE, { accountId }))
|
||||
.map(({ id, baseUrl }): { id: string; baseUrl: string } => ({ id, baseUrl }));
|
||||
}
|
||||
|
||||
public async getOwners(id: string): Promise<{ webId: string; visible: boolean }[] | undefined> {
|
||||
const results = await this.storage.find(OWNER_TYPE, { podId: id });
|
||||
const results = await this.storage.find(OWNER_STORAGE_TYPE, { podId: id });
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -119,16 +119,16 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
public async updateOwner(id: string, webId: string, visible: boolean): Promise<void> {
|
||||
// Need to first check if there already is an owner with the given WebID
|
||||
// so we know if we need to create or update.
|
||||
const matches = await this.storage.find(OWNER_TYPE, { webId, podId: id });
|
||||
const matches = await this.storage.find(OWNER_STORAGE_TYPE, { webId, podId: id });
|
||||
if (matches.length === 0) {
|
||||
await this.storage.create(OWNER_TYPE, { webId, visible, podId: id });
|
||||
await this.storage.create(OWNER_STORAGE_TYPE, { webId, visible, podId: id });
|
||||
} else {
|
||||
await this.storage.setField(OWNER_TYPE, matches[0].id, 'visible', visible);
|
||||
await this.storage.setField(OWNER_STORAGE_TYPE, matches[0].id, 'visible', visible);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeOwner(id: string, webId: string): Promise<void> {
|
||||
const owners = await this.storage.find(OWNER_TYPE, { podId: id });
|
||||
const owners = await this.storage.find(OWNER_STORAGE_TYPE, { podId: id });
|
||||
const match = owners.find((owner): boolean => owner.webId === webId);
|
||||
if (!match) {
|
||||
return;
|
||||
@ -136,6 +136,6 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
if (owners.length === 1) {
|
||||
throw new BadRequestHttpError('Unable to remove the last owner of a pod.');
|
||||
}
|
||||
await this.storage.delete(OWNER_TYPE, match.id);
|
||||
await this.storage.delete(OWNER_STORAGE_TYPE, match.id);
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,8 @@ import { ACCOUNT_TYPE } from '../../account/util/LoginStorage';
|
||||
import type { AccountLoginStorage } from '../../account/util/LoginStorage';
|
||||
import type { WebIdStore } from './WebIdStore';
|
||||
|
||||
const STORAGE_TYPE = 'webIdLink';
|
||||
const STORAGE_DESCRIPTION = {
|
||||
export const WEBID_STORAGE_TYPE = 'webIdLink';
|
||||
export const WEBID_STORAGE_DESCRIPTION = {
|
||||
webId: 'string',
|
||||
accountId: `id:${ACCOUNT_TYPE}`,
|
||||
} as const;
|
||||
@ -20,7 +20,7 @@ const STORAGE_DESCRIPTION = {
|
||||
export class BaseWebIdStore extends Initializer implements WebIdStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
|
||||
private readonly storage: AccountLoginStorage<{ [WEBID_STORAGE_TYPE]: typeof WEBID_STORAGE_DESCRIPTION }>;
|
||||
private initialized = false;
|
||||
|
||||
public constructor(storage: AccountLoginStorage<any>) {
|
||||
@ -34,9 +34,9 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'webId');
|
||||
await this.storage.defineType(WEBID_STORAGE_TYPE, WEBID_STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(WEBID_STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(WEBID_STORAGE_TYPE, 'webId');
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining WebID links in storage: ${createErrorMessage(cause)}`,
|
||||
@ -45,16 +45,16 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<{ accountId: string; webId: string } | undefined> {
|
||||
return this.storage.get(STORAGE_TYPE, id);
|
||||
return this.storage.get(WEBID_STORAGE_TYPE, id);
|
||||
}
|
||||
|
||||
public async isLinked(webId: string, accountId: string): Promise<boolean> {
|
||||
const result = await this.storage.find(STORAGE_TYPE, { webId, accountId });
|
||||
const result = await this.storage.find(WEBID_STORAGE_TYPE, { webId, accountId });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
public async findLinks(accountId: string): Promise<{ id: string; webId: string }[]> {
|
||||
return (await this.storage.find(STORAGE_TYPE, { accountId }))
|
||||
return (await this.storage.find(WEBID_STORAGE_TYPE, { accountId }))
|
||||
.map(({ id, webId }): { id: string; webId: string } => ({ id, webId }));
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
|
||||
throw new BadRequestHttpError(`${webId} is already registered to this account.`);
|
||||
}
|
||||
|
||||
const result = await this.storage.create(STORAGE_TYPE, { webId, accountId });
|
||||
const result = await this.storage.create(WEBID_STORAGE_TYPE, { webId, accountId });
|
||||
|
||||
this.logger.debug(`Linked WebID ${webId} to account ${accountId}`);
|
||||
|
||||
@ -73,6 +73,6 @@ export class BaseWebIdStore extends Initializer implements WebIdStore {
|
||||
|
||||
public async delete(linkId: string): Promise<void> {
|
||||
this.logger.debug(`Deleting WebID link with ID ${linkId}`);
|
||||
return this.storage.delete(STORAGE_TYPE, linkId);
|
||||
return this.storage.delete(WEBID_STORAGE_TYPE, linkId);
|
||||
}
|
||||
}
|
||||
|
@ -275,6 +275,10 @@ export * from './init/final/Finalizer';
|
||||
export * from './init/cli/CliExtractor';
|
||||
export * from './init/cli/YargsCliExtractor';
|
||||
|
||||
// Init/Migration
|
||||
export * from './init/migration/SingleContainerJsonStorage';
|
||||
export * from './init/migration/V6MigrationInitializer';
|
||||
|
||||
// Init/Variables/Extractors
|
||||
export * from './init/variables/extractors/KeyExtractor';
|
||||
export * from './init/variables/extractors/AssetPathExtractor';
|
||||
|
42
src/init/migration/SingleContainerJsonStorage.ts
Normal file
42
src/init/migration/SingleContainerJsonStorage.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { JsonResourceStorage } from '../../storage/keyvalue/JsonResourceStorage';
|
||||
import { isContainerIdentifier } from '../../util/PathUtil';
|
||||
import { readableToString } from '../../util/StreamUtil';
|
||||
import { LDP } from '../../util/Vocabularies';
|
||||
|
||||
/**
|
||||
* A variant of a {@link JsonResourceStorage} where the `entries()` call
|
||||
* does not recursively iterate through all containers.
|
||||
* Only the documents that are found in the root container are returned.
|
||||
*
|
||||
* This class was created to support migration where different storages are nested in one main `.internal` container,
|
||||
* and we specifically want to only return entries of one storage.
|
||||
*/
|
||||
export class SingleContainerJsonStorage<T> extends JsonResourceStorage<T> {
|
||||
protected async* getResourceEntries(containerId: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
|
||||
const container = await this.safelyGetResource(containerId);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only need the metadata
|
||||
container.data.destroy();
|
||||
const members = container.metadata.getAll(LDP.terms.contains).map((term): string => term.value);
|
||||
|
||||
for (const path of members) {
|
||||
const documentId = { path };
|
||||
if (isContainerIdentifier(documentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const document = await this.safelyGetResource(documentId);
|
||||
if (!document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = JSON.parse(await readableToString(document.data));
|
||||
const key = this.identifierToKey(documentId);
|
||||
yield [ key, json ];
|
||||
}
|
||||
}
|
||||
}
|
239
src/init/migration/V6MigrationInitializer.ts
Normal file
239
src/init/migration/V6MigrationInitializer.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { createInterface } from 'readline';
|
||||
import { ACCOUNT_STORAGE_DESCRIPTION } from '../../identity/interaction/account/util/BaseAccountStore';
|
||||
import type { AccountLoginStorage } from '../../identity/interaction/account/util/LoginStorage';
|
||||
import { ACCOUNT_TYPE } from '../../identity/interaction/account/util/LoginStorage';
|
||||
import {
|
||||
CLIENT_CREDENTIALS_STORAGE_DESCRIPTION,
|
||||
CLIENT_CREDENTIALS_STORAGE_TYPE,
|
||||
} from '../../identity/interaction/client-credentials/util/BaseClientCredentialsStore';
|
||||
import {
|
||||
PASSWORD_STORAGE_DESCRIPTION,
|
||||
PASSWORD_STORAGE_TYPE,
|
||||
} from '../../identity/interaction/password/util/BasePasswordStore';
|
||||
import {
|
||||
OWNER_STORAGE_DESCRIPTION,
|
||||
OWNER_STORAGE_TYPE,
|
||||
POD_STORAGE_DESCRIPTION,
|
||||
POD_STORAGE_TYPE,
|
||||
} from '../../identity/interaction/pod/util/BasePodStore';
|
||||
import { WEBID_STORAGE_DESCRIPTION, WEBID_STORAGE_TYPE } from '../../identity/interaction/webid/util/BaseWebIdStore';
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
||||
import { Initializer } from '../Initializer';
|
||||
|
||||
type Account = {
|
||||
webId: string;
|
||||
email: string;
|
||||
password: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
useIdp: boolean;
|
||||
podBaseUrl?: string;
|
||||
clientCredentials?: string[];
|
||||
};
|
||||
|
||||
type ClientCredentials = {
|
||||
webId: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
const STORAGE_DESCRIPTION = {
|
||||
[ACCOUNT_TYPE]: ACCOUNT_STORAGE_DESCRIPTION,
|
||||
[PASSWORD_STORAGE_TYPE]: PASSWORD_STORAGE_DESCRIPTION,
|
||||
[WEBID_STORAGE_TYPE]: WEBID_STORAGE_DESCRIPTION,
|
||||
[POD_STORAGE_TYPE]: POD_STORAGE_DESCRIPTION,
|
||||
[OWNER_STORAGE_TYPE]: OWNER_STORAGE_DESCRIPTION,
|
||||
[CLIENT_CREDENTIALS_STORAGE_TYPE]: CLIENT_CREDENTIALS_STORAGE_DESCRIPTION,
|
||||
} as const;
|
||||
|
||||
export interface V6MigrationInitializerArgs {
|
||||
/**
|
||||
* The storage in which the version is saved that was stored last time the server was started.
|
||||
*/
|
||||
versionStorage: KeyValueStorage<string, string>;
|
||||
/**
|
||||
* The key necessary to get the version from the `versionStorage`.
|
||||
*/
|
||||
versionKey: string;
|
||||
/**
|
||||
* The storage in which account data of the previous version is stored.
|
||||
*/
|
||||
accountStorage: KeyValueStorage<string, Account | Settings>;
|
||||
/**
|
||||
* The storage in which client credentials are stored from the previous version.
|
||||
*/
|
||||
clientCredentialsStorage: KeyValueStorage<string, ClientCredentials>;
|
||||
/**
|
||||
* The storage in which the forgot password entries of the previous version are stored.
|
||||
* These will all just be removed, not migrated.
|
||||
*/
|
||||
forgotPasswordStorage: KeyValueStorage<string, unknown>;
|
||||
/**
|
||||
* The storage that will contain the account data in the new format.
|
||||
*/
|
||||
newStorage: AccountLoginStorage<any>;
|
||||
/**
|
||||
* If true, no confirmation prompt will be printed to the stdout.
|
||||
*/
|
||||
skipConfirmation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles migrating account data from v6 to the newer format.
|
||||
* Will only trigger if it is detected that this server was previously started on an older version
|
||||
* and at least one account was found.
|
||||
* Confirmation will be asked to the user through a CLI prompt.
|
||||
* After migration is complete the old data will be removed.
|
||||
*/
|
||||
export class V6MigrationInitializer extends Initializer {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly skipConfirmation: boolean;
|
||||
|
||||
private readonly versionKey: string;
|
||||
private readonly versionStorage: KeyValueStorage<string, string>;
|
||||
|
||||
private readonly accountStorage: KeyValueStorage<string, Account | Settings>;
|
||||
private readonly clientCredentialsStorage: KeyValueStorage<string, ClientCredentials>;
|
||||
private readonly forgotPasswordStorage: KeyValueStorage<string, unknown>;
|
||||
|
||||
private readonly newStorage: AccountLoginStorage<typeof STORAGE_DESCRIPTION>;
|
||||
|
||||
public constructor(args: V6MigrationInitializerArgs) {
|
||||
super();
|
||||
this.skipConfirmation = Boolean(args.skipConfirmation);
|
||||
this.versionKey = args.versionKey;
|
||||
this.versionStorage = args.versionStorage;
|
||||
this.accountStorage = args.accountStorage;
|
||||
this.clientCredentialsStorage = args.clientCredentialsStorage;
|
||||
this.forgotPasswordStorage = args.forgotPasswordStorage;
|
||||
this.newStorage = args.newStorage;
|
||||
}
|
||||
|
||||
public async handle(): Promise<void> {
|
||||
const previousVersion = await this.versionStorage.get(this.versionKey);
|
||||
if (!previousVersion) {
|
||||
// This happens if this is the first time the server is started
|
||||
this.logger.debug('No previous version found');
|
||||
return;
|
||||
}
|
||||
|
||||
const [ prevMajor ] = previousVersion.split('.');
|
||||
if (Number.parseInt(prevMajor, 10) > 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountIterator = this.accountStorage.entries();
|
||||
const next = await accountIterator.next();
|
||||
if (next.done) {
|
||||
this.logger.debug('No account data was found so no migration is necessary.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask the user for confirmation
|
||||
if (!this.skipConfirmation) {
|
||||
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve): void => {
|
||||
readline.question([
|
||||
'The server is now going to migrate v6 account data to the new storage format internally.',
|
||||
'In case you have not yet done this,',
|
||||
'it is recommended to cancel startup and first backup the existing account data,',
|
||||
'in case something goes wrong.',
|
||||
'When using default configurations with a file backend,',
|
||||
'this data can be found in the ".internal/accounts" folder.',
|
||||
'\n\nDo you want to migrate the data now? [y/N] ',
|
||||
].join(' '), resolve);
|
||||
});
|
||||
readline.close();
|
||||
if (!/^y(?:es)?$/ui.test(answer)) {
|
||||
throw new Error('Stopping server as migration was cancelled.');
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Migrating v6 account data to the new format...');
|
||||
|
||||
const webIdAccountMap: Record<string, string> = {};
|
||||
|
||||
// Need to migrate the first entry we already extracted from the iterator above
|
||||
const firstResult = await this.createAccount(next.value[1]);
|
||||
if (firstResult) {
|
||||
// Store link between WebID and account ID for client credentials
|
||||
webIdAccountMap[firstResult.webId] = firstResult.accountId;
|
||||
}
|
||||
|
||||
for await (const [ , account ] of accountIterator) {
|
||||
const result = await this.createAccount(account);
|
||||
if (result) {
|
||||
// Store link between WebID and account ID for client credentials
|
||||
webIdAccountMap[result.webId] = result.accountId;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the existing client credentials tokens
|
||||
for await (const [ label, { webId, secret }] of this.clientCredentialsStorage.entries()) {
|
||||
const accountId = webIdAccountMap[webId];
|
||||
if (!accountId) {
|
||||
this.logger.warn(`Unable to find account for client credentials ${label}. Skipping migration of this token.`);
|
||||
continue;
|
||||
}
|
||||
await this.newStorage.create(CLIENT_CREDENTIALS_STORAGE_TYPE, { webId, label, secret, accountId });
|
||||
}
|
||||
|
||||
// Delete all old entries
|
||||
for await (const [ key ] of this.accountStorage.entries()) {
|
||||
await this.accountStorage.delete(key);
|
||||
}
|
||||
for await (const [ key ] of this.clientCredentialsStorage.entries()) {
|
||||
await this.clientCredentialsStorage.delete(key);
|
||||
}
|
||||
for await (const [ key ] of this.forgotPasswordStorage.entries()) {
|
||||
await this.forgotPasswordStorage.delete(key);
|
||||
}
|
||||
|
||||
this.logger.info('Finished migrating v6 account data.');
|
||||
}
|
||||
|
||||
protected isAccount(data: Account | Settings): data is Account {
|
||||
return Boolean((data as Account).email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new account based on the account data found in the old storage.
|
||||
* Will always create an account and password entry.
|
||||
* In case `useIdp` is true, will create a WebID link entry.
|
||||
* In case there is an associated `podBaseUrl`, will create a pod and owner entry.
|
||||
*/
|
||||
protected async createAccount(account: Account | Settings):
|
||||
Promise<{ accountId: string; webId: string } | undefined> {
|
||||
if (!this.isAccount(account)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { webId, email, password, verified } = account;
|
||||
|
||||
this.logger.debug(`Migrating account ${email} with WebID ${webId}`);
|
||||
|
||||
const settings = await this.accountStorage.get(webId) as Settings | undefined;
|
||||
if (!settings) {
|
||||
this.logger.warn(`Unable to find settings for account ${email}. Skipping migration of this account.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id: accountId } = await this.newStorage.create(ACCOUNT_TYPE, {});
|
||||
// The `toLowerCase` call is important here to have the expected value
|
||||
await this.newStorage.create(PASSWORD_STORAGE_TYPE,
|
||||
{ email: email.toLowerCase(), password, verified, accountId });
|
||||
if (settings.useIdp) {
|
||||
await this.newStorage.create(WEBID_STORAGE_TYPE, { webId, accountId });
|
||||
}
|
||||
if (settings.podBaseUrl) {
|
||||
const { id: podId } = await this.newStorage.create(POD_STORAGE_TYPE,
|
||||
{ baseUrl: settings.podBaseUrl, accountId });
|
||||
await this.newStorage.create(OWNER_STORAGE_TYPE, { webId, podId, visible: false });
|
||||
}
|
||||
|
||||
return { accountId, webId };
|
||||
}
|
||||
}
|
@ -27,8 +27,8 @@ const KEY_LENGTH_LIMIT = 255;
|
||||
* All non-404 errors will be re-thrown.
|
||||
*/
|
||||
export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
private readonly source: ResourceStore;
|
||||
private readonly container: string;
|
||||
protected readonly source: ResourceStore;
|
||||
protected readonly container: string;
|
||||
|
||||
public constructor(source: ResourceStore, baseUrl: string, container: string) {
|
||||
this.source = source;
|
||||
@ -79,7 +79,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
/**
|
||||
* Recursively iterates through the container to find all documents.
|
||||
*/
|
||||
private async* getResourceEntries(identifier: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
|
||||
protected async* getResourceEntries(identifier: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
|
||||
const representation = await this.safelyGetResource(identifier);
|
||||
if (representation) {
|
||||
if (isContainerIdentifier(identifier)) {
|
||||
@ -101,7 +101,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
* Returns undefined if a 404 error is thrown.
|
||||
* Re-throws the error in all other cases.
|
||||
*/
|
||||
private async safelyGetResource(identifier: ResourceIdentifier): Promise<Representation | undefined> {
|
||||
protected async safelyGetResource(identifier: ResourceIdentifier): Promise<Representation | undefined> {
|
||||
let representation: Representation | undefined;
|
||||
try {
|
||||
const preferences = isContainerIdentifier(identifier) ? {} : { type: { 'application/json': 1 }};
|
||||
@ -119,7 +119,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
/**
|
||||
* Converts a key into an identifier for internal storage.
|
||||
*/
|
||||
private keyToIdentifier(key: string): ResourceIdentifier {
|
||||
protected keyToIdentifier(key: string): ResourceIdentifier {
|
||||
// Parse the key as a file path
|
||||
const parsedPath = parse(key);
|
||||
// Hash long filenames to prevent issues with the underlying storage.
|
||||
@ -135,7 +135,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
/**
|
||||
* Converts an internal identifier to an external key.
|
||||
*/
|
||||
private identifierToKey(identifier: ResourceIdentifier): string {
|
||||
protected identifierToKey(identifier: ResourceIdentifier): string {
|
||||
// Due to the usage of `joinUrl` we don't know for sure if there was a preceding slash,
|
||||
// so we always add one for consistency.
|
||||
// In practice this would only be an issue if a class depends
|
||||
@ -143,7 +143,7 @@ export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
|
||||
return ensureLeadingSlash(identifier.path.slice(this.container.length));
|
||||
}
|
||||
|
||||
private applyHash(key: string): string {
|
||||
protected applyHash(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
{"email":"test@example.com","password":"$2a$10$t0eTjR3aUe3BSc3Cy4wXdeEpNoFK6O7vdtAvVyQTswOPlMmeT4sYK","verified":true,"webId":"http://localhost:6999/test/profile/card#me"}
|
@ -0,0 +1 @@
|
||||
{"email":"test2@example.com","password":"$2a$10$5hVJglnlJDbPymrA8SzBrus7zUndQfWi9wwBq.sYUuUdS8fjskEGm","verified":true,"webId":"http://localhost:6999/test2/profile/card#me"}
|
@ -0,0 +1 @@
|
||||
{"useIdp":true,"podBaseUrl":"http://localhost:6999/test/","clientCredentials":["token_fd13b73d-2527-4280-82af-278e5b8fe607"]}
|
@ -0,0 +1 @@
|
||||
{"useIdp":true,"podBaseUrl":"http://localhost:6999/test2/","clientCredentials":[]}
|
@ -0,0 +1 @@
|
||||
{"secret":"a809d7ce5daf0e9acd457c91d712ff05038e4a87192e27191c837602bd4b370c633282864c133650b0e9a35b59018b064157532642f628affb2f79e81999e898","webId":"http://localhost:6999/test/profile/card#me"}
|
@ -0,0 +1 @@
|
||||
{"expires":"2023-10-03T15:21:29.106Z","payload":{"recordId":"9524afb4-dfb9-48bf-a139-a30aaf4ff685","email":"test@example.com"}}
|
@ -0,0 +1 @@
|
||||
"http://localhost:6999/"
|
@ -0,0 +1 @@
|
||||
"6.0.2"
|
@ -0,0 +1 @@
|
||||
true
|
1
test/assets/migration/v6/test/.acl
Normal file
1
test/assets/migration/v6/test/.acl
Normal file
@ -0,0 +1 @@
|
||||
# Test comment for integration test
|
1
test/assets/migration/v6/test/.meta
Normal file
1
test/assets/migration/v6/test/.meta
Normal file
@ -0,0 +1 @@
|
||||
<http://localhost:6999/test/> a <http://www.w3.org/ns/pim/space#Storage>.
|
12
test/assets/migration/v6/test/profile/card$.ttl
Normal file
12
test/assets/migration/v6/test/profile/card$.ttl
Normal file
@ -0,0 +1,12 @@
|
||||
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
||||
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
||||
|
||||
<>
|
||||
a foaf:PersonalProfileDocument;
|
||||
foaf:maker <http://localhost:6999/test/profile/card#me>;
|
||||
foaf:primaryTopic <http://localhost:6999/test/profile/card#me>.
|
||||
|
||||
<http://localhost:6999/test/profile/card#me>
|
||||
|
||||
solid:oidcIssuer <http://localhost:6999/>;
|
||||
a foaf:Person.
|
8
test/assets/migration/v6/test/profile/card.acl
Normal file
8
test/assets/migration/v6/test/profile/card.acl
Normal file
@ -0,0 +1,8 @@
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
||||
|
||||
<#public>
|
||||
a acl:Authorization;
|
||||
acl:agentClass foaf:Agent;
|
||||
acl:accessTo <./card>;
|
||||
acl:mode acl:Read.
|
@ -56,5 +56,6 @@ export function getDefaultVariables(port: number, baseUrl?: string): Record<stri
|
||||
'urn:solid-server:default:variable:showStackTrace': true,
|
||||
'urn:solid-server:default:variable:seedConfig': null,
|
||||
'urn:solid-server:default:variable:workers': 1,
|
||||
'urn:solid-server:default:variable:confirmMigration': false,
|
||||
};
|
||||
}
|
||||
|
145
test/integration/V6Migration.test.ts
Normal file
145
test/integration/V6Migration.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { createDpopHeader, generateDpopKeyPair } from '@inrupt/solid-client-authn-core';
|
||||
import fetch from 'cross-fetch';
|
||||
import { copy, readdir } from 'fs-extra';
|
||||
import type { App } from '../../src/init/App';
|
||||
import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes';
|
||||
import { joinFilePath, joinUrl, resolveAssetPath } from '../../src/util/PathUtil';
|
||||
import { getPort } from '../util/Util';
|
||||
import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
|
||||
import { IdentityTestState } from './IdentityTestState';
|
||||
|
||||
// This port needs to remain fixed as the assets used are generated with this port in mind
|
||||
const port = getPort('V6Migration');
|
||||
const baseUrl = `http://localhost:${port}/`;
|
||||
const rootFilePath = getTestFolder('v6-migration');
|
||||
const assetPath = resolveAssetPath('@css:test/assets/migration/v6/');
|
||||
|
||||
// Prevent panva/node-openid-client from emitting DraftWarning
|
||||
jest.spyOn(process, 'emitWarning').mockImplementation();
|
||||
|
||||
describe('A server migrating from v6', (): void => {
|
||||
let app: App;
|
||||
|
||||
beforeAll(async(): Promise<void> => {
|
||||
await removeFolder(rootFilePath);
|
||||
const variables = {
|
||||
...getDefaultVariables(port, baseUrl),
|
||||
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
|
||||
// Skip the confirmation prompt
|
||||
'urn:solid-server:default:variable:confirmMigration': true,
|
||||
};
|
||||
|
||||
// Create and start the server
|
||||
const instances = await instantiateFromConfig(
|
||||
'urn:solid-server:test:Instances',
|
||||
[
|
||||
getTestConfigPath('file-pod.json'),
|
||||
],
|
||||
variables,
|
||||
) as Record<string, any>;
|
||||
({ app } = instances);
|
||||
|
||||
// Move the v6 internal data to the server
|
||||
await copy(assetPath, rootFilePath);
|
||||
});
|
||||
|
||||
afterAll(async(): Promise<void> => {
|
||||
// Await removeFolder(rootFilePath);
|
||||
await app.stop();
|
||||
});
|
||||
|
||||
it('can start the server to migrate the data.', async(): Promise<void> => {
|
||||
// This is going to trigger the migration step
|
||||
await expect(app.start()).resolves.toBeUndefined();
|
||||
// If migration was successful, there should be no files left in these folders
|
||||
const accountDir = await readdir(joinFilePath(rootFilePath, '.internal/accounts/'));
|
||||
expect(accountDir).toEqual(expect.arrayContaining([ 'data', 'index', 'credentials' ]));
|
||||
const credentialsDir = await readdir(joinFilePath(rootFilePath, '.internal/accounts/credentials/'));
|
||||
expect(credentialsDir).toEqual([]);
|
||||
const forgotDir = await readdir(joinFilePath(rootFilePath, '.internal/forgot-password/'));
|
||||
expect(forgotDir).toEqual([]);
|
||||
});
|
||||
|
||||
it('still allows existing accounts to log in.', async(): Promise<void> => {
|
||||
const indexUrl = joinUrl(baseUrl, '.account/');
|
||||
let res = await fetch(indexUrl);
|
||||
expect(res.status).toBe(200);
|
||||
const { controls } = await res.json();
|
||||
|
||||
res = await fetch(controls.password.login, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'test@example.com', password: 'password' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
res = await fetch(controls.password.login, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'test2@example.com', password: 'password2' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('still gives control access to pod owners.', async(): Promise<void> => {
|
||||
// Init
|
||||
const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl);
|
||||
let url = await state.initSession();
|
||||
expect(url.startsWith(baseUrl)).toBeTruthy();
|
||||
url = await state.handleRedirect(url);
|
||||
|
||||
// Log in
|
||||
let res = await state.fetchIdp(url);
|
||||
expect(res.status).toBe(200);
|
||||
const { controls } = await res.json();
|
||||
res = await state.fetchIdp(controls.password.login,
|
||||
'POST',
|
||||
JSON.stringify({ email: 'test@example.com', password: 'password' }));
|
||||
await state.handleLocationRedirect(res);
|
||||
|
||||
res = await state.fetchIdp(controls.oidc.webId);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Pick WebID
|
||||
const webId = joinUrl(baseUrl, 'test/profile/card#me');
|
||||
res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId, remember: true });
|
||||
await state.handleLocationRedirect(res);
|
||||
|
||||
// Consent
|
||||
res = await state.fetchIdp(controls.oidc.consent, 'POST');
|
||||
|
||||
// Redirect back to the client and verify login success
|
||||
await state.handleIncomingRedirect(res, webId);
|
||||
|
||||
// GET the root ACL (which is initialized as an empty file with the given comment)
|
||||
url = joinUrl(baseUrl, 'test/.acl');
|
||||
res = await state.session.fetch(url);
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.text()).resolves.toBe('# Test comment for integration test\n');
|
||||
|
||||
// Log out of session again
|
||||
await state.session.logout();
|
||||
});
|
||||
|
||||
it('still supports the existing client credentials.', async(): Promise<void> => {
|
||||
// These are the values stored in the original assets
|
||||
const id = 'token_fd13b73d-2527-4280-82af-278e5b8fe607';
|
||||
// eslint-disable-next-line max-len
|
||||
const secret = 'a809d7ce5daf0e9acd457c91d712ff05038e4a87192e27191c837602bd4b370c633282864c133650b0e9a35b59018b064157532642f628affb2f79e81999e898';
|
||||
const tokenUrl = joinUrl(baseUrl, '.oidc/token');
|
||||
const dpopHeader = await createDpopHeader(tokenUrl, 'POST', await generateDpopKeyPair());
|
||||
const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`;
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from(authString).toString('base64')}`,
|
||||
'content-type': APPLICATION_X_WWW_FORM_URLENCODED,
|
||||
dpop: dpopHeader,
|
||||
},
|
||||
body: 'grant_type=client_credentials&scope=webid',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { access_token: accessToken } = await res.json();
|
||||
expect(typeof accessToken).toBe('string');
|
||||
});
|
||||
});
|
51
test/integration/config/file-pod.json
Normal file
51
test/integration/config/file-pod.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||
"import": [
|
||||
"css:config/app/main/default.json",
|
||||
"css:config/app/init/static-root.json",
|
||||
"css:config/http/handler/default.json",
|
||||
"css:config/http/middleware/default.json",
|
||||
"css:config/http/notifications/all.json",
|
||||
"css:config/http/server-factory/http.json",
|
||||
"css:config/http/static/default.json",
|
||||
"css:config/identity/access/public.json",
|
||||
"css:config/identity/email/example.json",
|
||||
"css:config/identity/handler/default.json",
|
||||
"css:config/identity/interaction/default.json",
|
||||
"css:config/identity/ownership/token.json",
|
||||
"css:config/identity/pod/static.json",
|
||||
"css:config/ldp/authentication/dpop-bearer.json",
|
||||
"css:config/ldp/authorization/webacl.json",
|
||||
"css:config/ldp/handler/default.json",
|
||||
"css:config/ldp/metadata-parser/default.json",
|
||||
"css:config/ldp/metadata-writer/default.json",
|
||||
"css:config/ldp/modes/default.json",
|
||||
"css:config/storage/backend/file.json",
|
||||
"css:config/storage/key-value/resource-store.json",
|
||||
"css:config/storage/location/pod.json",
|
||||
"css:config/storage/middleware/default.json",
|
||||
"css:config/util/auxiliary/acl.json",
|
||||
"css:config/util/identifiers/suffix.json",
|
||||
"css:config/util/index/default.json",
|
||||
"css:config/util/logging/winston.json",
|
||||
"css:config/util/representation-conversion/default.json",
|
||||
"css:config/util/resource-locker/file.json",
|
||||
"css:config/util/variables/default.json"
|
||||
],
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "urn:solid-server:test:Instances",
|
||||
"@type": "RecordObject",
|
||||
"record": [
|
||||
{
|
||||
"RecordObject:_record_key": "app",
|
||||
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
|
||||
},
|
||||
{
|
||||
"RecordObject:_record_key": "store",
|
||||
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore_Backend" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
69
test/unit/init/migration/SingleContainerJsonStorage.test.ts
Normal file
69
test/unit/init/migration/SingleContainerJsonStorage.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../src/http/representation/Representation';
|
||||
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
|
||||
import { SingleContainerJsonStorage } from '../../../../src/init/migration/SingleContainerJsonStorage';
|
||||
import { ResourceStore } from '../../../../src/storage/ResourceStore';
|
||||
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
||||
import { isContainerIdentifier } from '../../../../src/util/PathUtil';
|
||||
import { LDP } from '../../../../src/util/Vocabularies';
|
||||
|
||||
describe('A SingleContainerJsonStorage', (): void => {
|
||||
const baseUrl = 'http://example.com/';
|
||||
const container = '.internal/accounts/';
|
||||
let store: jest.Mocked<ResourceStore>;
|
||||
let storage: SingleContainerJsonStorage<any>;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
store = {
|
||||
getRepresentation: jest.fn(async(id): Promise<Representation> => {
|
||||
if (isContainerIdentifier(id)) {
|
||||
const metadata = new RepresentationMetadata(id);
|
||||
metadata.add(LDP.terms.contains, 'http://example.com/.internal/accounts/foo');
|
||||
metadata.add(LDP.terms.contains, 'http://example.com/.internal/accounts/bar/');
|
||||
metadata.add(LDP.terms.contains, 'http://example.com/.internal/accounts/baz');
|
||||
metadata.add(LDP.terms.contains, 'http://example.com/.internal/accounts/unknown');
|
||||
return new BasicRepresentation('', metadata);
|
||||
}
|
||||
if (id.path.endsWith('unknown')) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
return new BasicRepresentation(`{ "id": "${id.path}" }`, 'text/plain');
|
||||
}),
|
||||
} satisfies Partial<ResourceStore> as any;
|
||||
|
||||
storage = new SingleContainerJsonStorage(store, baseUrl, container);
|
||||
});
|
||||
|
||||
it('only iterates over the documents in the base container.', async(): Promise<void> => {
|
||||
const entries = [];
|
||||
for await (const entry of storage.entries()) {
|
||||
entries.push(entry);
|
||||
}
|
||||
expect(entries).toEqual([
|
||||
[ '/foo', { id: 'http://example.com/.internal/accounts/foo' }],
|
||||
[ '/baz', { id: 'http://example.com/.internal/accounts/baz' }],
|
||||
]);
|
||||
expect(store.getRepresentation).toHaveBeenCalledTimes(4);
|
||||
expect(store.getRepresentation).toHaveBeenNthCalledWith(1,
|
||||
{ path: 'http://example.com/.internal/accounts/' },
|
||||
{});
|
||||
expect(store.getRepresentation).toHaveBeenNthCalledWith(2,
|
||||
{ path: 'http://example.com/.internal/accounts/foo' },
|
||||
{ type: { 'application/json': 1 }});
|
||||
expect(store.getRepresentation).toHaveBeenNthCalledWith(3,
|
||||
{ path: 'http://example.com/.internal/accounts/baz' },
|
||||
{ type: { 'application/json': 1 }});
|
||||
expect(store.getRepresentation).toHaveBeenNthCalledWith(4,
|
||||
{ path: 'http://example.com/.internal/accounts/unknown' },
|
||||
{ type: { 'application/json': 1 }});
|
||||
});
|
||||
|
||||
it('does nothing if the container does not exist.', async(): Promise<void> => {
|
||||
store.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
|
||||
const entries = [];
|
||||
for await (const entry of storage.entries()) {
|
||||
entries.push(entry);
|
||||
}
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
});
|
223
test/unit/init/migration/V6MigrationInitializer.test.ts
Normal file
223
test/unit/init/migration/V6MigrationInitializer.test.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { ACCOUNT_TYPE, AccountLoginStorage } from '../../../../src/identity/interaction/account/util/LoginStorage';
|
||||
import {
|
||||
CLIENT_CREDENTIALS_STORAGE_TYPE,
|
||||
} from '../../../../src/identity/interaction/client-credentials/util/BaseClientCredentialsStore';
|
||||
import { PASSWORD_STORAGE_TYPE } from '../../../../src/identity/interaction/password/util/BasePasswordStore';
|
||||
import { OWNER_STORAGE_TYPE, POD_STORAGE_TYPE } from '../../../../src/identity/interaction/pod/util/BasePodStore';
|
||||
import { WEBID_STORAGE_TYPE } from '../../../../src/identity/interaction/webid/util/BaseWebIdStore';
|
||||
import { V6MigrationInitializer } from '../../../../src/init/migration/V6MigrationInitializer';
|
||||
import { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
|
||||
type Account = {
|
||||
webId: string;
|
||||
email: string;
|
||||
password: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
useIdp: boolean;
|
||||
podBaseUrl?: string;
|
||||
clientCredentials?: string[];
|
||||
};
|
||||
|
||||
type ClientCredentials = {
|
||||
webId: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
const questionMock = jest.fn().mockImplementation((input, callback): void => callback('y'));
|
||||
const closeMock = jest.fn();
|
||||
jest.mock('readline', (): any => ({
|
||||
createInterface: jest.fn().mockImplementation((): any => ({
|
||||
question: questionMock,
|
||||
close: closeMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('A V6MigrationInitializer', (): void => {
|
||||
const webId = 'http://example.com/test/profile/card#me';
|
||||
const webId2 = 'http://example.com/test2/profile/card#me';
|
||||
let settings: Record<string, Settings>;
|
||||
let accounts: Record<string, Account>;
|
||||
let clientCredentials: Record<string, ClientCredentials>;
|
||||
const versionKey = 'version';
|
||||
let versionStorage: jest.Mocked<KeyValueStorage<string, string>>;
|
||||
let accountStorage: jest.Mocked<KeyValueStorage<string, Account | Settings>>;
|
||||
let clientCredentialsStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
|
||||
let forgotPasswordStorage: jest.Mocked<KeyValueStorage<string, unknown>>;
|
||||
let newStorage: jest.Mocked<AccountLoginStorage<any>>;
|
||||
let initializer: V6MigrationInitializer;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
settings = {
|
||||
[webId]: { useIdp: true, podBaseUrl: 'http://example.com/test/', clientCredentials: [ 'token' ]},
|
||||
[webId2]: { useIdp: true, podBaseUrl: 'http://example.com/test2/' },
|
||||
};
|
||||
accounts = {
|
||||
account: { email: 'EMAIL@example.com', password: '123', webId, verified: true },
|
||||
account2: { email: 'email2@example.com', password: '1234', webId: webId2, verified: true },
|
||||
};
|
||||
clientCredentials = {
|
||||
token: { webId, secret: 'secret!' },
|
||||
};
|
||||
|
||||
versionStorage = {
|
||||
get: jest.fn().mockResolvedValue('6.0.0'),
|
||||
} satisfies Partial<KeyValueStorage<string, string>> as any;
|
||||
|
||||
accountStorage = {
|
||||
get: jest.fn((id): any => settings[id] ?? accounts[id]),
|
||||
delete: jest.fn(),
|
||||
entries: jest.fn(async function* (): AsyncIterableIterator<[string, any]> {
|
||||
yield* Object.entries(accounts);
|
||||
yield* Object.entries(settings);
|
||||
}),
|
||||
} satisfies Partial<KeyValueStorage<string, any>> as any;
|
||||
|
||||
clientCredentialsStorage = {
|
||||
delete: jest.fn(),
|
||||
entries: jest.fn(async function* (): AsyncIterableIterator<[string, any]> {
|
||||
yield* Object.entries(clientCredentials);
|
||||
}),
|
||||
} satisfies Partial<KeyValueStorage<string, any>> as any;
|
||||
|
||||
forgotPasswordStorage = {
|
||||
delete: jest.fn(),
|
||||
entries: jest.fn(async function* (): AsyncIterableIterator<[string, any]> {
|
||||
yield [ 'forgot', {}];
|
||||
}),
|
||||
} satisfies Partial<KeyValueStorage<string, any>> as any;
|
||||
|
||||
newStorage = {
|
||||
create: jest.fn((type): any => ({ id: `${type}-id` })),
|
||||
} satisfies Partial<AccountLoginStorage<any>> as any;
|
||||
|
||||
initializer = new V6MigrationInitializer({
|
||||
versionKey,
|
||||
versionStorage,
|
||||
accountStorage,
|
||||
clientCredentialsStorage,
|
||||
forgotPasswordStorage,
|
||||
newStorage,
|
||||
skipConfirmation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates the data.', async(): Promise<void> => {
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
|
||||
expect(versionStorage.get).toHaveBeenCalledTimes(1);
|
||||
expect(versionStorage.get).toHaveBeenLastCalledWith(versionKey);
|
||||
|
||||
expect(accountStorage.get).toHaveBeenCalledTimes(2);
|
||||
expect(accountStorage.get).toHaveBeenCalledWith(webId);
|
||||
expect(accountStorage.get).toHaveBeenCalledWith(webId2);
|
||||
expect(accountStorage.delete).toHaveBeenCalledTimes(4);
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith(webId);
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith(webId2);
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith('account');
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith('account2');
|
||||
|
||||
expect(clientCredentialsStorage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(clientCredentialsStorage.delete).toHaveBeenCalledWith('token');
|
||||
|
||||
expect(forgotPasswordStorage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(forgotPasswordStorage.delete).toHaveBeenCalledWith('forgot');
|
||||
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(11);
|
||||
expect(newStorage.create).toHaveBeenCalledWith(ACCOUNT_TYPE, {});
|
||||
expect(newStorage.create).toHaveBeenCalledWith(PASSWORD_STORAGE_TYPE,
|
||||
{ email: 'email@example.com', password: '123', verified: true, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(PASSWORD_STORAGE_TYPE,
|
||||
{ email: 'email2@example.com', password: '1234', verified: true, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(WEBID_STORAGE_TYPE, { webId, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(WEBID_STORAGE_TYPE, { webId: webId2, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(POD_STORAGE_TYPE, { baseUrl: 'http://example.com/test/', accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(POD_STORAGE_TYPE, { baseUrl: 'http://example.com/test2/', accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(OWNER_STORAGE_TYPE, { webId, podId: 'pod-id', visible: false });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(OWNER_STORAGE_TYPE,
|
||||
{ webId: webId2, podId: 'pod-id', visible: false });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(CLIENT_CREDENTIALS_STORAGE_TYPE,
|
||||
{ label: 'token', secret: 'secret!', webId, accountId: 'account-id' });
|
||||
});
|
||||
|
||||
it('does nothing if the server has no stored version number.', async(): Promise<void> => {
|
||||
versionStorage.get.mockResolvedValueOnce(undefined);
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
expect(accountStorage.get).toHaveBeenCalledTimes(0);
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does nothing if stored version is more than 6.', async(): Promise<void> => {
|
||||
versionStorage.get.mockResolvedValueOnce('7.0.0');
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
expect(accountStorage.get).toHaveBeenCalledTimes(0);
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('ignores accounts and credentials for which it cannot find the settings.', async(): Promise<void> => {
|
||||
delete settings[webId];
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
|
||||
expect(versionStorage.get).toHaveBeenCalledTimes(1);
|
||||
expect(versionStorage.get).toHaveBeenLastCalledWith(versionKey);
|
||||
|
||||
expect(accountStorage.get).toHaveBeenCalledTimes(2);
|
||||
expect(accountStorage.get).toHaveBeenCalledWith(webId);
|
||||
expect(accountStorage.get).toHaveBeenCalledWith(webId2);
|
||||
expect(accountStorage.delete).toHaveBeenCalledTimes(3);
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith(webId2);
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith('account');
|
||||
expect(accountStorage.delete).toHaveBeenCalledWith('account2');
|
||||
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(5);
|
||||
expect(newStorage.create).toHaveBeenCalledWith(ACCOUNT_TYPE, {});
|
||||
expect(newStorage.create).toHaveBeenCalledWith(PASSWORD_STORAGE_TYPE,
|
||||
{ email: 'email2@example.com', password: '1234', verified: true, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(WEBID_STORAGE_TYPE, { webId: webId2, accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(POD_STORAGE_TYPE, { baseUrl: 'http://example.com/test2/', accountId: 'account-id' });
|
||||
expect(newStorage.create).toHaveBeenCalledWith(OWNER_STORAGE_TYPE,
|
||||
{ webId: webId2, podId: 'pod-id', visible: false });
|
||||
});
|
||||
|
||||
describe('with prompts enabled', (): void => {
|
||||
beforeEach(async(): Promise<void> => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
initializer = new V6MigrationInitializer({
|
||||
versionKey,
|
||||
versionStorage,
|
||||
accountStorage,
|
||||
clientCredentialsStorage,
|
||||
forgotPasswordStorage,
|
||||
newStorage,
|
||||
skipConfirmation: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a prompt before migrating the data.', async(): Promise<void> => {
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
|
||||
expect(questionMock).toHaveBeenCalledTimes(1);
|
||||
expect(questionMock.mock.invocationCallOrder[0]).toBeLessThan(newStorage.create.mock.invocationCallOrder[0]);
|
||||
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(11);
|
||||
});
|
||||
|
||||
it('throws an error to stop the server if no positive answer is received.', async(): Promise<void> => {
|
||||
questionMock.mockImplementation((input, callback): void => callback('n'));
|
||||
await expect(initializer.handle()).rejects.toThrow('Stopping server as migration was cancelled.');
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not show the prompt if there are no accounts.', async(): Promise<void> => {
|
||||
settings = {};
|
||||
accounts = {};
|
||||
await expect(initializer.handle()).resolves.toBeUndefined();
|
||||
expect(questionMock).toHaveBeenCalledTimes(0);
|
||||
expect(accountStorage.get).toHaveBeenCalledTimes(0);
|
||||
expect(newStorage.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -38,12 +38,26 @@ const portNames = [
|
||||
'BaseServerFactory',
|
||||
] as const;
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
// These are ports that are not allowed to change for various reasons
|
||||
const fixedPorts = {
|
||||
V6Migration: 6999,
|
||||
} as const;
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
const socketNames = [
|
||||
// Unit
|
||||
'BaseHttpServerFactory',
|
||||
];
|
||||
|
||||
export function getPort(name: typeof portNames[number]): number {
|
||||
function isFixedPortName(name: string): name is keyof typeof fixedPorts {
|
||||
return Boolean(fixedPorts[name as keyof typeof fixedPorts]);
|
||||
}
|
||||
|
||||
export function getPort(name: typeof portNames[number] | keyof typeof fixedPorts): number {
|
||||
if (isFixedPortName(name)) {
|
||||
return fixedPorts[name];
|
||||
}
|
||||
const idx = portNames.indexOf(name);
|
||||
// Just in case something doesn't listen to the typings
|
||||
if (idx < 0) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user