mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 22:18:27 +09:00
650 lines
27 KiB
TypeScript
650 lines
27 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createHash} from 'node:crypto';
|
|
import {createAuthHarness, createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
|
import {getConfig} from '@app/api/Config';
|
|
import {getCachedAccountIdentity, getCachedAccountIdentityMode} from '@app/api/instance/AccountIdentityModeCache';
|
|
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
|
import {getCacheService, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
|
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
|
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
|
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
|
|
const ACCOUNT_IDENTITY_CONFIG_KEY = 'account_identity_config';
|
|
const SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
|
|
const WRONG_SETUP_TOKEN = 'wrong-setup-token-0123456789-abcdefghijklmnopqrstuvwxyz';
|
|
|
|
interface DiscoveryResponse {
|
|
features: {account_identity: string; emails_enabled: boolean; tag_style: string};
|
|
app_public: {setup: {configured: boolean; account_identity_locked?: boolean; setup_token_required?: boolean}};
|
|
}
|
|
|
|
interface AdminInstanceConfigResponse {
|
|
account_identity: {mode: string; locked: boolean; tag_style: string};
|
|
}
|
|
|
|
describe('Account identity mode', () => {
|
|
let harness: ApiTestHarness;
|
|
let repository: InstanceConfigRepository;
|
|
const config = getConfig();
|
|
let originalSelfHosted: boolean;
|
|
let originalEmailEnabled: boolean;
|
|
let originalSetupConfigured: boolean;
|
|
let originalAccountIdentity: typeof config.instance.accountIdentity;
|
|
let originalTagStyle: typeof config.instance.tagStyle;
|
|
let originalSetupToken: typeof config.instance.setup.token;
|
|
|
|
beforeAll(async () => {
|
|
harness = await createAuthHarness();
|
|
});
|
|
beforeEach(async () => {
|
|
await harness.reset();
|
|
repository = getInstanceConfigRepository();
|
|
originalSelfHosted = config.instance.selfHosted;
|
|
originalEmailEnabled = config.email.enabled;
|
|
originalSetupConfigured = config.instance.setup.configured;
|
|
originalAccountIdentity = config.instance.accountIdentity;
|
|
originalTagStyle = config.instance.tagStyle;
|
|
originalSetupToken = config.instance.setup.token;
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
config.instance.selfHosted = originalSelfHosted;
|
|
config.email.enabled = originalEmailEnabled;
|
|
config.instance.setup.configured = originalSetupConfigured;
|
|
config.instance.accountIdentity = originalAccountIdentity;
|
|
config.instance.tagStyle = originalTagStyle;
|
|
config.instance.setup.token = originalSetupToken;
|
|
repository.clearCacheForTesting();
|
|
});
|
|
afterAll(async () => {
|
|
await harness?.shutdown();
|
|
});
|
|
|
|
function useFreshSelfHostedInstance(): void {
|
|
config.instance.selfHosted = true;
|
|
config.email.enabled = false;
|
|
config.instance.setup.configured = false;
|
|
config.instance.accountIdentity = null;
|
|
config.instance.tagStyle = null;
|
|
}
|
|
|
|
async function readStoredRow(): Promise<{
|
|
mode: string;
|
|
source: string;
|
|
decided_at: string;
|
|
tag_style?: string;
|
|
} | null> {
|
|
const raw = await repository.getConfig(ACCOUNT_IDENTITY_CONFIG_KEY);
|
|
return raw === null ? null : JSON.parse(raw);
|
|
}
|
|
|
|
async function createAccountWhileHosted(): Promise<void> {
|
|
const selfHosted = config.instance.selfHosted;
|
|
config.instance.selfHosted = false;
|
|
try {
|
|
await createTestAccount(harness);
|
|
} finally {
|
|
config.instance.selfHosted = selfHosted;
|
|
}
|
|
}
|
|
|
|
async function fetchDiscovery(): Promise<DiscoveryResponse> {
|
|
return await createBuilderWithoutAuth<DiscoveryResponse>(harness).get('/.well-known/fluxer').execute();
|
|
}
|
|
|
|
it('never stores a mode on the hosted service and always reports email', async () => {
|
|
config.instance.selfHosted = false;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toBeNull();
|
|
expect(await repository.getAccountIdentityMode()).toBe(AccountIdentityModes.EMAIL);
|
|
expect(getCachedAccountIdentityMode()).toBe(AccountIdentityModes.EMAIL);
|
|
const discovery = await fetchDiscovery();
|
|
expect(discovery.features.account_identity).toBe(AccountIdentityModes.EMAIL);
|
|
expect(discovery.features.tag_style).toBe('random');
|
|
});
|
|
|
|
it('reports email on the hosted service even when a row exists', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
|
|
config.instance.selfHosted = false;
|
|
expect(await repository.getAccountIdentityMode()).toBe(AccountIdentityModes.EMAIL);
|
|
expect(getCachedAccountIdentityMode()).toBe(AccountIdentityModes.EMAIL);
|
|
expect((await fetchDiscovery()).features.account_identity).toBe(AccountIdentityModes.EMAIL);
|
|
});
|
|
|
|
it('stamps username on a fresh self-hosted instance', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
const row = await readStoredRow();
|
|
expect(row).toMatchObject({mode: AccountIdentityModes.USERNAME, source: 'new_instance'});
|
|
expect(Number.isNaN(Date.parse(row!.decided_at))).toBe(false);
|
|
expect(await repository.getAccountIdentityMode()).toBe(AccountIdentityModes.USERNAME);
|
|
expect(getCachedAccountIdentityMode()).toBe(AccountIdentityModes.USERNAME);
|
|
const discovery = await fetchDiscovery();
|
|
expect(discovery.features).toMatchObject({
|
|
account_identity: AccountIdentityModes.USERNAME,
|
|
emails_enabled: false,
|
|
tag_style: 'none',
|
|
});
|
|
expect(row).toMatchObject({tag_style: 'none'});
|
|
expect(getCachedAccountIdentity().tagStyle).toBe('none');
|
|
});
|
|
|
|
it('ignores a random tag style env on a fresh username instance', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.tagStyle = 'random';
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
tag_style: 'none',
|
|
source: 'new_instance',
|
|
});
|
|
expect(await repository.getAccountIdentity()).toEqual({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
tagStyle: 'none',
|
|
});
|
|
expect((await fetchDiscovery()).features.tag_style).toBe('none');
|
|
});
|
|
|
|
it('ignores the tag style env on an existing instance', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.tagStyle = 'none';
|
|
await createAccountWhileHosted();
|
|
await repository.ensureAccountIdentityMode();
|
|
const row = await readStoredRow();
|
|
expect(row).toMatchObject({mode: AccountIdentityModes.EMAIL, tag_style: 'random', source: 'existing_instance'});
|
|
expect((await fetchDiscovery()).features.tag_style).toBe('random');
|
|
});
|
|
|
|
it('reads a username row written before the field existed as no tags', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setConfig(
|
|
ACCOUNT_IDENTITY_CONFIG_KEY,
|
|
JSON.stringify({mode: AccountIdentityModes.USERNAME, decided_at: new Date().toISOString(), source: 'setup'}),
|
|
);
|
|
repository.clearCacheForTesting();
|
|
expect(await repository.getAccountIdentity()).toEqual({mode: AccountIdentityModes.USERNAME, tagStyle: 'none'});
|
|
});
|
|
|
|
it('reads an email row written before the field existed as tagged', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setConfig(
|
|
ACCOUNT_IDENTITY_CONFIG_KEY,
|
|
JSON.stringify({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
decided_at: new Date().toISOString(),
|
|
source: 'existing_instance',
|
|
}),
|
|
);
|
|
repository.clearCacheForTesting();
|
|
expect(await repository.getAccountIdentity()).toEqual({mode: AccountIdentityModes.EMAIL, tagStyle: 'random'});
|
|
});
|
|
|
|
it('stamps no tags for a fresh email instance', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.accountIdentity = AccountIdentityModes.EMAIL;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
source: 'new_instance',
|
|
});
|
|
expect((await fetchDiscovery()).features).toMatchObject({
|
|
account_identity: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[{mode: AccountIdentityModes.USERNAME, unique_usernames: true}, 'none'],
|
|
[{mode: AccountIdentityModes.USERNAME, unique_usernames: false}, 'none'],
|
|
[{mode: AccountIdentityModes.USERNAME, tag_style: 'random'}, 'none'],
|
|
[{mode: AccountIdentityModes.USERNAME, tag_style: 'zero_first'}, 'none'],
|
|
[{mode: AccountIdentityModes.EMAIL, unique_usernames: true}, 'none'],
|
|
[{mode: AccountIdentityModes.EMAIL, unique_usernames: false}, 'random'],
|
|
[{mode: AccountIdentityModes.EMAIL, tag_style: 'zero_first'}, 'none'],
|
|
[{mode: AccountIdentityModes.EMAIL, tag_style: 'random'}, 'random'],
|
|
])('reads the stored row %j as %s', async (row, tagStyle) => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setConfig(
|
|
ACCOUNT_IDENTITY_CONFIG_KEY,
|
|
JSON.stringify({...row, decided_at: new Date().toISOString(), source: 'setup'}),
|
|
);
|
|
repository.clearCacheForTesting();
|
|
expect((await repository.getAccountIdentity()).tagStyle).toBe(tagStyle);
|
|
});
|
|
|
|
it.each(['none', 'random'] as const)('stamps the %s tag style from the env on email', async (tagStyle) => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.accountIdentity = AccountIdentityModes.EMAIL;
|
|
config.instance.tagStyle = tagStyle;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({tag_style: tagStyle, source: 'new_instance'});
|
|
expect((await fetchDiscovery()).features.tag_style).toBe(tagStyle);
|
|
});
|
|
|
|
it('stamps email when a users row exists', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await createAccountWhileHosted();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
});
|
|
|
|
it('stamps email when the env override says username but users already exist', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.accountIdentity = AccountIdentityModes.USERNAME;
|
|
await createAccountWhileHosted();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
});
|
|
|
|
it('stamps email when the admin was bootstrapped', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.claimAdminBootstrap();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
});
|
|
|
|
it('stamps email when setup is configured in env', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.instance.setup.configured = true;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
});
|
|
|
|
it('stamps email when setup is configured in storage', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAppPublicConfig({setup: {configured: true}});
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
});
|
|
|
|
it('starts a new instance with email delivery in env on email with no tags', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.email.enabled = true;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
source: 'new_instance',
|
|
});
|
|
expect(await repository.isAccountIdentityLocked()).toBe(false);
|
|
});
|
|
|
|
it('starts a new instance with email delivery in storage on email', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setInstanceIntegrationsConfig({email: {enabled: true}});
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'new_instance'});
|
|
});
|
|
|
|
it('takes the tag style env on a new instance with email delivery', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.email.enabled = true;
|
|
config.instance.tagStyle = 'random';
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'random',
|
|
source: 'new_instance',
|
|
});
|
|
});
|
|
|
|
it('lets the sign-in env win over email delivery on a new instance', async () => {
|
|
useFreshSelfHostedInstance();
|
|
config.email.enabled = true;
|
|
config.instance.accountIdentity = AccountIdentityModes.USERNAME;
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
tag_style: 'none',
|
|
source: 'new_instance',
|
|
});
|
|
});
|
|
|
|
it('keeps an existing row', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'setup'});
|
|
});
|
|
|
|
it('writes nothing and stays on email when a probe fails', async () => {
|
|
useFreshSelfHostedInstance();
|
|
vi.spyOn(repository as unknown as {hasAnyUser: () => Promise<boolean>}, 'hasAnyUser').mockRejectedValue(
|
|
new Error('database not ready'),
|
|
);
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toBeNull();
|
|
expect(await repository.getAccountIdentityMode()).toBe(AccountIdentityModes.EMAIL);
|
|
});
|
|
|
|
it('skips the users probe when a cheaper signal already decides', async () => {
|
|
useFreshSelfHostedInstance();
|
|
const probe = vi.spyOn(repository as unknown as {hasAnyUser: () => Promise<boolean>}, 'hasAnyUser');
|
|
await repository.claimAdminBootstrap();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
|
expect(await repository.isAccountIdentityLocked()).toBe(true);
|
|
expect(probe).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forces the effective email config off in username mode', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setInstanceIntegrationsConfig({
|
|
email: {
|
|
enabled: true,
|
|
provider: 'smtp',
|
|
from_email: '[email protected]',
|
|
smtp: {host: 'smtp.example.com', port: 587, username: 'mailer', password: 'mailer-password'},
|
|
},
|
|
});
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup');
|
|
expect(await repository.isEmailEnabled()).toBe(true);
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
|
|
expect((await repository.getEffectiveEmailConfig()).enabled).toBe(false);
|
|
expect(await repository.isEmailEnabled()).toBe(false);
|
|
expect((await repository.getInstanceIntegrationsAdminConfig()).email.effective_enabled).toBe(false);
|
|
expect((await fetchDiscovery()).features.emails_enabled).toBe(false);
|
|
});
|
|
|
|
describe('setup choice', () => {
|
|
async function putMode(mode: string, status = 200, tagStyle?: string) {
|
|
return await createBuilderWithoutAuth<{mode: string; tag_style: string}>(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body(tagStyle === undefined ? {mode} : {mode, tag_style: tagStyle})
|
|
.expect(status)
|
|
.execute();
|
|
}
|
|
|
|
it('is locked on the hosted service', async () => {
|
|
config.instance.selfHosted = false;
|
|
const {json} = await createBuilderWithoutAuth<{code: string}>(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.USERNAME})
|
|
.expect(409)
|
|
.executeWithResponse();
|
|
expect(json.code).toBe(APIErrorCodes.ACCOUNT_IDENTITY_LOCKED);
|
|
expect(await readStoredRow()).toBeNull();
|
|
});
|
|
|
|
it('switches freely before the first account and before setup completes', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await putMode(AccountIdentityModes.EMAIL)).toEqual({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
});
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'setup'});
|
|
expect((await fetchDiscovery()).features.account_identity).toBe(AccountIdentityModes.EMAIL);
|
|
expect(await putMode(AccountIdentityModes.USERNAME)).toEqual({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
tag_style: 'none',
|
|
});
|
|
expect((await fetchDiscovery()).features.account_identity).toBe(AccountIdentityModes.USERNAME);
|
|
});
|
|
|
|
it('takes the username style together with email', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect(await putMode(AccountIdentityModes.EMAIL, 200, 'random')).toEqual({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'random',
|
|
});
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, tag_style: 'random'});
|
|
expect(await putMode(AccountIdentityModes.EMAIL, 200, 'none')).toEqual({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
});
|
|
expect((await fetchDiscovery()).features).toMatchObject({
|
|
account_identity: AccountIdentityModes.EMAIL,
|
|
tag_style: 'none',
|
|
});
|
|
});
|
|
|
|
it('refuses random tags with username sign-in', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
const {json} = await createBuilderWithoutAuth<{errors: Array<{path: string; code: string}>}>(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.USERNAME, tag_style: 'random'})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.executeWithResponse();
|
|
expect(json.errors.map(({path, code}) => ({path, code}))).toEqual([
|
|
{path: 'tag_style', code: ValidationErrorCodes.TAG_STYLE_REQUIRES_EMAIL_SIGN_IN},
|
|
]);
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.USERNAME, tag_style: 'none'});
|
|
expect(await putMode(AccountIdentityModes.USERNAME, 200, 'none')).toEqual({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
tag_style: 'none',
|
|
});
|
|
});
|
|
|
|
it('refuses the retired zero_first style', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
await createBuilderWithoutAuth(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.EMAIL, tag_style: 'zero_first'})
|
|
.expect(400, 'INVALID_FORM_BODY')
|
|
.execute();
|
|
});
|
|
|
|
it('locks the tag choice once an account exists', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup', 'random');
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/register')
|
|
.body({email: '[email protected]', username: 'taggedadmin', password: 'a-strong-password-123'})
|
|
.execute();
|
|
await createBuilderWithoutAuth(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.EMAIL, tag_style: 'none'})
|
|
.expect(409, APIErrorCodes.ACCOUNT_IDENTITY_LOCKED)
|
|
.execute();
|
|
expect(await repository.getAccountIdentity()).toEqual({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
tagStyle: 'random',
|
|
});
|
|
});
|
|
|
|
it('locks once an account exists', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/register')
|
|
.body({username: 'firstadmin', global_name: 'First', password: 'a-strong-password-123'})
|
|
.execute();
|
|
await createBuilderWithoutAuth(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.EMAIL})
|
|
.expect(409, APIErrorCodes.ACCOUNT_IDENTITY_LOCKED)
|
|
.execute();
|
|
expect(await repository.getAccountIdentityMode()).toBe(AccountIdentityModes.USERNAME);
|
|
});
|
|
|
|
it('locks once setup is configured', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAppPublicConfig({setup: {configured: true}});
|
|
await createBuilderWithoutAuth(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.EMAIL})
|
|
.expect(409, APIErrorCodes.ACCOUNT_IDENTITY_LOCKED)
|
|
.execute();
|
|
});
|
|
|
|
it('locks an instance stored as already in use even with no account', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.EMAIL, 'existing_instance', 'random');
|
|
expect(await repository.isAccountIdentityLocked()).toBe(true);
|
|
await createBuilderWithoutAuth(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body({mode: AccountIdentityModes.USERNAME})
|
|
.expect(409, APIErrorCodes.ACCOUNT_IDENTITY_LOCKED)
|
|
.execute();
|
|
expect((await fetchDiscovery()).app_public.setup.account_identity_locked).toBe(true);
|
|
});
|
|
|
|
it('reports the lock in discovery only while setup is open', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect((await fetchDiscovery()).app_public.setup.account_identity_locked).toBe(false);
|
|
await createBuilderWithoutAuth(harness)
|
|
.post('/auth/register')
|
|
.body({username: 'lockedadmin', global_name: 'Locked', password: 'a-strong-password-123'})
|
|
.execute();
|
|
expect((await fetchDiscovery()).app_public.setup.account_identity_locked).toBe(true);
|
|
await repository.setAppPublicConfig({setup: {configured: true}});
|
|
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('account_identity_locked');
|
|
config.instance.selfHosted = false;
|
|
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('account_identity_locked');
|
|
});
|
|
|
|
it('waits for a registration holding the setup lock', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
const cache = getCacheService();
|
|
const token = await cache.acquireLock('account-identity-setup', 30);
|
|
expect(token).not.toBeNull();
|
|
let settled = false;
|
|
const pending = putMode(AccountIdentityModes.EMAIL).then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
expect(settled).toBe(false);
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.USERNAME});
|
|
await cache.releaseLock('account-identity-setup', token!);
|
|
expect(await pending).toEqual({mode: AccountIdentityModes.EMAIL, tag_style: 'none'});
|
|
});
|
|
|
|
it('makes the first registration wait for a setup choice in progress', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
const cache = getCacheService();
|
|
const token = await cache.acquireLock('account-identity-setup', 30);
|
|
expect(token).not.toBeNull();
|
|
let settled = false;
|
|
const pending = createBuilderWithoutAuth<{token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({username: 'waitingadmin', global_name: 'Waiting', password: 'a-strong-password-123'})
|
|
.execute()
|
|
.then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
expect(settled).toBe(false);
|
|
await cache.releaseLock('account-identity-setup', token!);
|
|
expect((await pending).token).toBeTruthy();
|
|
expect(await repository.isAccountIdentityLocked()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('setup token', () => {
|
|
async function putModeWithToken(setupToken?: string) {
|
|
return await createBuilderWithoutAuth<{
|
|
mode?: string;
|
|
tag_style?: string;
|
|
errors?: Array<{path: string; code: string}>;
|
|
}>(harness)
|
|
.put('/instance/setup/account-identity')
|
|
.body(
|
|
setupToken === undefined
|
|
? {mode: AccountIdentityModes.EMAIL}
|
|
: {mode: AccountIdentityModes.EMAIL, setup_token: setupToken},
|
|
)
|
|
.executeRaw();
|
|
}
|
|
|
|
it('reports in discovery whether registration needs the setup token', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
|
|
config.instance.setup.token = SETUP_TOKEN;
|
|
const {text, json} = await createBuilderWithoutAuth<DiscoveryResponse>(harness)
|
|
.get('/.well-known/fluxer')
|
|
.executeRaw();
|
|
expect(json.app_public.setup.setup_token_required).toBe(true);
|
|
expect(text).not.toContain(SETUP_TOKEN);
|
|
expect(text).not.toContain(createHash('sha256').update(SETUP_TOKEN).digest('hex'));
|
|
await repository.claimAdminBootstrap();
|
|
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
|
|
await repository.setAppPublicConfig({setup: {configured: true}});
|
|
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
|
|
});
|
|
|
|
it('leaves the setup token out of discovery on the hosted service', async () => {
|
|
config.instance.selfHosted = false;
|
|
config.instance.setup.token = SETUP_TOKEN;
|
|
await repository.setAppPublicConfig({setup: {configured: false}});
|
|
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
|
|
});
|
|
|
|
it('needs the setup token to choose the sign-in method while one is required', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
config.instance.setup.token = SETUP_TOKEN;
|
|
const missing = await putModeWithToken();
|
|
expect(missing.response.status).toBe(400);
|
|
expect(missing.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
|
|
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
|
|
]);
|
|
const wrong = await putModeWithToken(WRONG_SETUP_TOKEN);
|
|
expect(wrong.response.status).toBe(400);
|
|
expect(wrong.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
|
|
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
|
|
]);
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.USERNAME});
|
|
const correct = await putModeWithToken(SETUP_TOKEN);
|
|
expect(correct.response.status).toBe(200);
|
|
expect(correct.json).toEqual({mode: AccountIdentityModes.EMAIL, tag_style: 'none'});
|
|
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'setup'});
|
|
});
|
|
|
|
it('chooses the sign-in method without a setup token when none is required', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.ensureAccountIdentityMode();
|
|
const tokenless = await putModeWithToken();
|
|
expect(tokenless.response.status).toBe(200);
|
|
const ignored = await putModeWithToken(WRONG_SETUP_TOKEN);
|
|
expect(ignored.response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('admin instance config', () => {
|
|
async function fetchAdminConfig(token: string): Promise<AdminInstanceConfigResponse> {
|
|
return await createBuilder<AdminInstanceConfigResponse>(harness, token).get('/admin/instance/config').execute();
|
|
}
|
|
|
|
it('reports email and locked on the hosted service', async () => {
|
|
config.instance.selfHosted = false;
|
|
const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
|
|
expect((await fetchAdminConfig(admin.token)).account_identity).toEqual({
|
|
mode: AccountIdentityModes.EMAIL,
|
|
locked: true,
|
|
tag_style: 'random',
|
|
});
|
|
});
|
|
|
|
it('reports the stored mode and locks once an account exists on self-hosted', async () => {
|
|
useFreshSelfHostedInstance();
|
|
await repository.setAccountIdentityMode(AccountIdentityModes.USERNAME, 'setup');
|
|
const registration = await createBuilderWithoutAuth<{token: string}>(harness)
|
|
.post('/auth/register')
|
|
.body({username: 'setupadmin', global_name: 'Setup Admin', password: 'a-strong-password-123'})
|
|
.execute();
|
|
expect((await fetchAdminConfig(registration.token)).account_identity).toEqual({
|
|
mode: AccountIdentityModes.USERNAME,
|
|
locked: true,
|
|
tag_style: 'none',
|
|
});
|
|
});
|
|
});
|
|
});
|