mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(captcha): make ALTCHA the only captcha (#3035)
This commit is contained in:
1 parent
d433a039b5
commit
4f968bbc47
209 files changed
+4097
-8692
No files matched your search
@@ -1,178 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
const FORGOT_PATH = '/auth/forgot';
|
||||
const FORGOT_BODY = {email: '[email protected]'};
|
||||
|
||||
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
cost: 1000,
|
||||
max_counter: 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function solve(challenge: Challenge): Promise<string> {
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('ALTCHA challenge was not solved');
|
||||
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
|
||||
}
|
||||
|
||||
describe('ALTCHA captcha experiment', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousCaptchaEnabled: boolean;
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
previousCaptchaEnabled = Config.captcha.enabled;
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.captcha.enabled = previousCaptchaEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the configured provider while the experiment is off', async () => {
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('captcha_provider');
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
|
||||
await setAltchaConfig({rollout_basis_points: 10000});
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
|
||||
await forgot(harness)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const replayed = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge as Challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
await setAltchaConfig({anonymous_enabled: false});
|
||||
|
||||
const rejected = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(rejected).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
|
||||
Config.captcha.enabled = false;
|
||||
const included = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
Config.captcha.enabled = true;
|
||||
await setAltchaConfig({
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [included.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
const redeemPath = '/gifts/altcha-gift-code/redeem';
|
||||
|
||||
const excludedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
expect(excludedBody).not.toHaveProperty('altcha_challenge');
|
||||
|
||||
const includedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
const token = await solve(includedBody.altcha_challenge as Challenge);
|
||||
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
|
||||
|
||||
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', 'hcaptcha-token')
|
||||
.header('X-Captcha-Type', 'hcaptcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,13 @@ import {
|
||||
createUniqueUsername,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAndFlag(
|
||||
harness: ApiTestHarness,
|
||||
flags: Array<string>,
|
||||
@@ -56,29 +43,28 @@ describe('Auth Captcha Bypass Flags', () => {
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
|
||||
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
|
||||
await withCaptchaEnabled(async () => {
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
|
||||
const account = await registerAndFlag(harness, []);
|
||||
await withCaptchaEnabled(async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {setPhoneRpcConnection} from '@app/api/auth/PhoneVerificationClient';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {
|
||||
CAPTCHA_TEST_HEADER,
|
||||
type CaptchaErrorBody,
|
||||
solveCaptchaChallenge,
|
||||
useCheapCaptcha,
|
||||
} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {CaptchaConfigResponse} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness)
|
||||
.post('/auth/forgot')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: '[email protected]'});
|
||||
}
|
||||
|
||||
async function createPhoneRequiredAccount(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true, suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return account;
|
||||
}
|
||||
|
||||
function usePhoneServiceThatAsksForCaptcha(): Array<boolean> {
|
||||
const captchaPassed: Array<boolean> = [];
|
||||
const connection = {
|
||||
async request(_subject: string, payload: string) {
|
||||
const passed = (JSON.parse(payload) as {captcha_passed: boolean}).captcha_passed;
|
||||
captchaPassed.push(passed);
|
||||
const reply = passed
|
||||
? {result: 'sms_sent'}
|
||||
: {
|
||||
result: 'error',
|
||||
code: 'captcha_required',
|
||||
retry_after_s: null,
|
||||
rate_limit_scope: null,
|
||||
limit: null,
|
||||
message: null,
|
||||
};
|
||||
return {string: () => JSON.stringify(reply)};
|
||||
},
|
||||
} as unknown as NatsConnection;
|
||||
setPhoneRpcConnection(() => connection);
|
||||
return captchaPassed;
|
||||
}
|
||||
|
||||
function sendPhoneCode(harness: ApiTestHarness, account: TestAccount): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilder<CaptchaErrorBody>(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({phone: '+15551230001'});
|
||||
}
|
||||
|
||||
async function turnCaptchaOff(harness: ApiTestHarness): Promise<void> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({captcha: {enabled: false}})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Captcha challenge', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPhoneRpcConnection(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('issues an ALTCHA challenge to a request without a token', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({
|
||||
algorithm: 'PBKDF2/SHA-256',
|
||||
cost: 1000,
|
||||
keyLength: 32,
|
||||
});
|
||||
expect(required.altcha_challenge?.signature).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a solved challenge once and answers a replay with a fresh challenge', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solveCaptchaChallenge(required);
|
||||
|
||||
await forgot(harness).header('X-Captcha-Token', token).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
|
||||
const replayed = await rejectWith(forgot(harness).header('X-Captcha-Token', token), APIErrorCodes.INVALID_CAPTCHA);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).toBeTruthy();
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({
|
||||
challenge: {parameters: challenge?.parameters, signature: challenge?.signature},
|
||||
solution: {counter: 1, derivedKey: '00'.repeat(32)},
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
const rejected = await rejectWith(forgot(harness).header('X-Captcha-Token', forged), APIErrorCodes.INVALID_CAPTCHA);
|
||||
expect(rejected.altcha_challenge?.signature).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips the check once an admin turns it off', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const updated = await createBuilder<{captcha: CaptchaConfigResponse}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({captcha: {enabled: false}})
|
||||
.execute();
|
||||
expect(updated.captcha).toEqual({enabled: false, cost: 1000, max_counter: 100});
|
||||
|
||||
await forgot(harness).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
});
|
||||
|
||||
it('challenges a phone send the phone service flags and lets it through once solved', async () => {
|
||||
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
|
||||
const account = await createPhoneRequiredAccount(harness);
|
||||
const required = await rejectWith(sendPhoneCode(harness, account), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.altcha_challenge?.signature).toBeTruthy();
|
||||
expect(captchaPassed).toEqual([false]);
|
||||
const token = await solveCaptchaChallenge(required);
|
||||
|
||||
await sendPhoneCode(harness, account).header('X-Captcha-Token', token).expect(HTTP_STATUS.OK).execute();
|
||||
expect(captchaPassed).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('refuses a flagged phone send while the check is off', async () => {
|
||||
await turnCaptchaOff(harness);
|
||||
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
|
||||
const account = await createPhoneRequiredAccount(harness);
|
||||
|
||||
const {response, json} = await sendPhoneCode(harness, account).executeRaw();
|
||||
expect(response.status).toBe(429);
|
||||
expect(json).toMatchObject({code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED});
|
||||
expect(captchaPassed).toEqual([false]);
|
||||
});
|
||||
|
||||
it('skips the check in test mode unless the request opts in', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: '[email protected]'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,9 @@ describe('phone verification client', () => {
|
||||
for (const code of ['unavailable', 'deadline_exceeded', 'unsupported_contract'] as const) {
|
||||
expect(errorForPhoneReply({code, ...blank})).toBeInstanceOf(SmsVerificationUnavailableError);
|
||||
}
|
||||
const captchaRequired = errorForPhoneReply({code: 'captcha_required', ...blank});
|
||||
expect(captchaRequired).toBeInstanceOf(RateLimitError);
|
||||
expect((captchaRequired as RateLimitError).code).toBe(APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('Auth registration', () => {
|
||||
expect(reg.user_id.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
|
||||
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
||||
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
|
||||
const first = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadminone'));
|
||||
const second = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadmintwo'));
|
||||
|
||||
Reference in new issue
Block a user