mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
chore(repo): remove dead code and useless tests (#3375)
This commit is contained in:
1 parent
7e177a1c90
commit
1cd62eb661
1391 files changed
+783
-120415
No files matched your search
@@ -250,10 +250,4 @@ describe('Auth sudo required operations', () => {
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
it('allows non-sudo operations without sudo', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await loginAccount(harness, account);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
await createBuilder(harness, account.token).get('/auth/sessions').expect(200).execute();
|
||||
});
|
||||
});
|
||||
@@ -97,12 +97,4 @@ describe('Captcha challenge', () => {
|
||||
|
||||
await forgot(harness).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
fetchMe,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth case-insensitive email', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('login with different case variations succeeds', () => {
|
||||
const baseEmail = createUniqueEmail('login-case');
|
||||
const password = 'Xk9#mP2$vL5@nQ8';
|
||||
beforeEach(async () => {
|
||||
await registerUser(harness, {
|
||||
email: baseEmail,
|
||||
username: createUniqueUsername('login'),
|
||||
global_name: 'Login Test User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
});
|
||||
it('allows login with lowercase email', async () => {
|
||||
const login = await loginUser(harness, {
|
||||
email: baseEmail.toLowerCase(),
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
it('allows login with uppercase email', async () => {
|
||||
const login = await loginUser(harness, {
|
||||
email: baseEmail.toUpperCase(),
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
it('allows login with mixed case email', async () => {
|
||||
const mixedCaseEmail = baseEmail
|
||||
.split('')
|
||||
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
|
||||
.join('');
|
||||
const login = await loginUser(harness, {
|
||||
email: mixedCaseEmail,
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
it('allows login with title case email', async () => {
|
||||
const titleCaseEmail = baseEmail
|
||||
.toLowerCase()
|
||||
.replace(/(^|[.@])([a-z])/g, (_match, prefix, char) => `${prefix}${char.toUpperCase()}`);
|
||||
const login = await loginUser(harness, {
|
||||
email: titleCaseEmail,
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
});
|
||||
it('rejects registration with different case as duplicate', async () => {
|
||||
const baseEmail = createUniqueEmail('duplicate-case');
|
||||
const password = 'Rt7&kW3!qL9@mP2';
|
||||
await registerUser(harness, {
|
||||
email: baseEmail,
|
||||
username: createUniqueUsername('duplicate1'),
|
||||
global_name: 'Duplicate Test User 1',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: baseEmail.toUpperCase(),
|
||||
username: createUniqueUsername('duplicate2'),
|
||||
global_name: 'Duplicate Test User 2',
|
||||
password: 'different-password-456',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('allows forgot password with different case', async () => {
|
||||
const baseEmail = createUniqueEmail('forgot-case');
|
||||
const password = 'Mn8$jX4&vB6@pL1';
|
||||
await registerUser(harness, {
|
||||
email: baseEmail,
|
||||
username: createUniqueUsername('forgot'),
|
||||
global_name: 'Forgot Test User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({
|
||||
email: baseEmail.toUpperCase(),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
it('preserves original email case in user record', async () => {
|
||||
const mixedEmail = `${createUniqueEmail('normalized').split('@')[0]}@Example.COM`;
|
||||
const password = 'Df5&gH9@kW3!qL2';
|
||||
const reg = await registerUser(harness, {
|
||||
email: mixedEmail,
|
||||
username: createUniqueUsername('normalize'),
|
||||
global_name: 'Normalize Test User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const {response, json} = await fetchMe(harness, reg.token);
|
||||
expect(response.status).toBe(200);
|
||||
const user = json as {
|
||||
email: string | null;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
};
|
||||
expect(user.email).toBe(mixedEmail);
|
||||
const login = await loginUser(harness, {
|
||||
email: mixedEmail.toLowerCase(),
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
expect(nonMfaLogin.token).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -17,17 +17,6 @@ describe('Auth concurrent sessions', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('same user can have multiple concurrent sessions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const session1Token = account.token;
|
||||
const account2 = await loginAccount(harness, account);
|
||||
const session2Token = account2.token;
|
||||
if (session1Token === session2Token) {
|
||||
console.warn('warning: multiple logins returned the same token, may indicate single-session behavior');
|
||||
}
|
||||
await createBuilder(harness, session1Token).get('/users/@me').expect(200).execute();
|
||||
await createBuilder(harness, session2Token).get('/users/@me').expect(200).execute();
|
||||
});
|
||||
it('logging out one session does not affect other sessions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const session1Token = account.token;
|
||||
@@ -66,26 +55,4 @@ describe('Auth concurrent sessions', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
it('can log out specific session by ID', async () => {
|
||||
let account = await createTestAccount(harness);
|
||||
await loginAccount(harness, account);
|
||||
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
expect(sessions.length).toBeGreaterThanOrEqual(2);
|
||||
const targetSessionID = sessions[0]!.id_hash;
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({
|
||||
session_id_hashes: [targetSessionID],
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
account = await loginAccount(harness, account);
|
||||
const sessions2 = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
expect(sessions2.find((s) => s.id_hash === targetSessionID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -22,18 +22,6 @@ describe('Auth desktop handoff negative paths', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('rejects unknown handoff code on status endpoint', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/auth/handoff/unknown-code/status')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
it('rejects unknown handoff code on info endpoint', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/auth/handoff/unknown-code/info')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
it('rejects handoff complete with bad token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
@@ -69,11 +57,4 @@ describe('Auth desktop handoff negative paths', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
it('handles cancel for unknown handoff code gracefully', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.delete('/auth/handoff/unknown-code')
|
||||
.body({poll_secret: 'not-the-secret'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -117,19 +117,6 @@ describe('Email blocklist at signup and email change', () => {
|
||||
expect(banned).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a malformed domain entry', async () => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/email/entries')
|
||||
.body({email: '@not a domain'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('still registers an address that is not blocklisted', async () => {
|
||||
await blocklist(createUniqueEmail('blocked-other'));
|
||||
await createTestAccount(harness);
|
||||
});
|
||||
|
||||
it('refuses an email change to a blocklisted address before sending a code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const blocked = createUniqueEmail('blocked-change');
|
||||
|
||||
@@ -117,10 +117,6 @@ async function verifyNewEmailChange(
|
||||
return resp.email_token;
|
||||
}
|
||||
|
||||
async function unclaimAccount(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness).post(`/test/users/${userId}/unclaim`).body(null).expect(200).execute();
|
||||
}
|
||||
|
||||
describe('Email change flow', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -250,50 +246,6 @@ describe('Email change flow', () => {
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
it('returns original_proof from start when require_original is false', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const startResp = await createBuilder<EmailChangeStartResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
expect(startResp.require_original).toBe(false);
|
||||
expect(startResp.original_proof).toBeDefined();
|
||||
expect(startResp.original_proof!.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('verify-original returns original_proof for verified email accounts', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResp = await startEmailChange(harness, account, account.password);
|
||||
let originalProof: string;
|
||||
if (startResp.require_original) {
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const originalEmail = findLastTestEmail(emails, 'email_change_original');
|
||||
expect(originalEmail?.metadata?.code).toBeDefined();
|
||||
const originalCode = originalEmail!.metadata!.code!;
|
||||
originalProof = await verifyOriginalEmailChange(
|
||||
harness,
|
||||
account,
|
||||
startResp.ticket,
|
||||
originalCode,
|
||||
account.password,
|
||||
);
|
||||
expect(originalProof.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(startResp.original_proof).toBeDefined();
|
||||
expect(startResp.original_proof!.length).toBeGreaterThan(0);
|
||||
originalProof = startResp.original_proof!;
|
||||
}
|
||||
const newEmail = `integration-verify-flow-${Date.now()}@example.com`;
|
||||
const newReq = await requestNewEmailChange(
|
||||
harness,
|
||||
account,
|
||||
startResp.ticket,
|
||||
newEmail,
|
||||
originalProof,
|
||||
account.password,
|
||||
);
|
||||
expect(newReq.new_email).toBe(newEmail);
|
||||
});
|
||||
it('keeps email_token valid when another account grabs the address before final apply', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResp = await startEmailChange(harness, account, account.password);
|
||||
@@ -657,40 +609,6 @@ describe('Email change flow', () => {
|
||||
.execute();
|
||||
expect(updated.email).toBe(newEmail);
|
||||
});
|
||||
it('claims clean unclaimed accounts', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const startResp = await createBuilder<EmailChangeStartResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(startResp.require_original).toBe(false);
|
||||
expect(startResp.original_proof).toBeDefined();
|
||||
const originalProof = startResp.original_proof!;
|
||||
const newEmail = `integration-claim-clean-${Date.now()}@example.com`;
|
||||
await requestNewEmailChange(harness, account, startResp.ticket, newEmail, originalProof, account.password);
|
||||
const newEmails = await listTestEmails(harness, {recipient: newEmail});
|
||||
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
|
||||
expect(newEmailData?.metadata?.code).toBeDefined();
|
||||
const token = await verifyNewEmailChange(
|
||||
harness,
|
||||
account,
|
||||
startResp.ticket,
|
||||
newEmailData!.metadata!.code!,
|
||||
originalProof,
|
||||
account.password,
|
||||
);
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({
|
||||
email_token: token,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(updated.email).toBe(newEmail);
|
||||
expect(updated.verified).toBe(true);
|
||||
});
|
||||
it('e2e: reporter scenario — MFA user with TOTP completes "Use Different Email" recovery without sudo loop', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type EmailVerificationRequiredReason, requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import {type APIErrorCode, APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const ReasonCodeCases: Array<[EmailVerificationRequiredReason, APIErrorCode]> = [
|
||||
['direct_message', APIErrorCodes.DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED],
|
||||
['friend_request', APIErrorCodes.FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED],
|
||||
['guild', APIErrorCodes.GUILD_EMAIL_VERIFICATION_REQUIRED],
|
||||
['guild_creation', APIErrorCodes.GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED],
|
||||
['mfa', APIErrorCodes.MFA_EMAIL_VERIFICATION_REQUIRED],
|
||||
['profile', APIErrorCodes.PROFILE_EMAIL_VERIFICATION_REQUIRED],
|
||||
['reaction', APIErrorCodes.REACTION_EMAIL_VERIFICATION_REQUIRED],
|
||||
['report', APIErrorCodes.REPORT_EMAIL_VERIFICATION_REQUIRED],
|
||||
];
|
||||
|
||||
describe('requireEmailVerified', () => {
|
||||
it.each(ReasonCodeCases)('throws the %s email verification code', (reason, expectedCode) => {
|
||||
try {
|
||||
requireEmailVerified({emailVerified: false}, reason);
|
||||
throw new Error('Expected requireEmailVerified to throw');
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty('code', expectedCode);
|
||||
expect(error).toHaveProperty('status', 403);
|
||||
}
|
||||
});
|
||||
it('keeps the generic code when no reason is provided', () => {
|
||||
try {
|
||||
requireEmailVerified({emailVerified: false});
|
||||
throw new Error('Expected requireEmailVerified to throw');
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty('code', APIErrorCodes.EMAIL_VERIFICATION_REQUIRED);
|
||||
}
|
||||
});
|
||||
it('allows verified users and bots', () => {
|
||||
expect(() => requireEmailVerified({emailVerified: true}, 'mfa')).not.toThrow();
|
||||
expect(() => requireEmailVerified({emailVerified: false, isBot: true}, 'mfa')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -26,18 +26,6 @@ describe('Auth IP Authorization Bypass Flags', () => {
|
||||
name: 'APP_STORE_REVIEWER only',
|
||||
flags: ['APP_STORE_REVIEWER'],
|
||||
},
|
||||
{
|
||||
name: 'APP_STORE_REVIEWER with STAFF',
|
||||
flags: ['APP_STORE_REVIEWER', 'STAFF'],
|
||||
},
|
||||
{
|
||||
name: 'APP_STORE_REVIEWER with BUG_HUNTER and STAFF',
|
||||
flags: ['APP_STORE_REVIEWER', 'BUG_HUNTER', 'STAFF'],
|
||||
},
|
||||
{
|
||||
name: 'APP_STORE_REVIEWER with BUG_HUNTER',
|
||||
flags: ['APP_STORE_REVIEWER', 'BUG_HUNTER'],
|
||||
},
|
||||
];
|
||||
for (const tc of testCases) {
|
||||
it(`validates that users with ${tc.name} can login from any IP without authorization`, async () => {
|
||||
@@ -171,23 +159,4 @@ describe('Auth IP Authorization Bypass Flags', () => {
|
||||
.execute();
|
||||
expect(loginResp.token).toBeTruthy();
|
||||
});
|
||||
it('verifies that regular users still require IP authorization when logging in from a new location', async () => {
|
||||
const email = createUniqueEmail('regular-user-ip-check');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('regularip'),
|
||||
global_name: 'Regular IP User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const differentIP = '10.88.77.66';
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', differentIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
listTestEmails,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth IP Authorization Multiple IPs', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates that a user can authorize multiple different IPs independently', async () => {
|
||||
const email = createUniqueEmail('ip-multi');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('multiip'),
|
||||
global_name: 'Multi IP User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await clearTestEmails(harness);
|
||||
const firstNewIP = '10.11.12.13';
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', firstNewIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
const emails1 = await listTestEmails(harness);
|
||||
const ipAuthEmail1 = emails1.find((e) => e.type === 'ip_authorization' && e.to === email);
|
||||
expect(ipAuthEmail1).toBeDefined();
|
||||
const token1 = ipAuthEmail1!.metadata['token'];
|
||||
expect(token1).toBeTruthy();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/authorize-ip')
|
||||
.body({token: token1})
|
||||
.header('x-forwarded-for', firstNewIP)
|
||||
.expect(204)
|
||||
.execute();
|
||||
await clearTestEmails(harness);
|
||||
const secondNewIP = '10.22.33.44';
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', secondNewIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
const emails2 = await listTestEmails(harness);
|
||||
const ipAuthEmail2 = emails2.find((e) => e.type === 'ip_authorization' && e.to === email);
|
||||
expect(ipAuthEmail2).toBeDefined();
|
||||
const token2 = ipAuthEmail2!.metadata['token'];
|
||||
expect(token2).toBeTruthy();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/authorize-ip')
|
||||
.body({token: token2})
|
||||
.header('x-forwarded-for', secondNewIP)
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', firstNewIP)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', secondNewIP)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -165,10 +165,4 @@ describe('Auth IP Authorization Poll', () => {
|
||||
user_id: reg.user_id,
|
||||
});
|
||||
});
|
||||
it('rejects poll with invalid ticket', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/auth/ip-authorization/poll?ticket=does-not-exist')
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -56,27 +56,6 @@ describe('Auth IP Authorization Resend', () => {
|
||||
return ipAuthResp;
|
||||
}
|
||||
describe('Resend rate limit enforcement', () => {
|
||||
it('returns 429 when resend is attempted immediately after ticket creation', async () => {
|
||||
const email = createUniqueEmail('ip-resend-immediate');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('resendimm'),
|
||||
global_name: 'Resend Immediate User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const newIP = '10.200.210.220';
|
||||
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
|
||||
});
|
||||
it('returns resend_available_in and a matching Retry-After header when rate limited', async () => {
|
||||
const email = createUniqueEmail('ip-resend-cooldown');
|
||||
const password = 'a-strong-password';
|
||||
@@ -103,30 +82,6 @@ describe('Auth IP Authorization Resend', () => {
|
||||
expect(errorResp.retry_after).toBe(errorResp.resend_available_in);
|
||||
});
|
||||
});
|
||||
describe('Multiple resend attempts handling', () => {
|
||||
it('returns 429 for multiple consecutive resend attempts', async () => {
|
||||
const email = createUniqueEmail('ip-multi-resend');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('multiresend'),
|
||||
global_name: 'Multi Resend User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const newIP = '10.30.31.32';
|
||||
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('Resend with already-used ticket', () => {
|
||||
it('returns 400 when resend flag is already set on the ticket', async () => {
|
||||
const email = createUniqueEmail('ip-resend-used');
|
||||
@@ -198,25 +153,7 @@ describe('Auth IP Authorization Resend', () => {
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Resend with empty ticket', () => {
|
||||
it('returns 400 when ticket is empty string', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ''})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Resend with invalid ticket', () => {
|
||||
it('returns 400 when ticket does not exist', async () => {
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: 'invalid-ticket-xyz-12345'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(errorResp.code).toBeDefined();
|
||||
expect(errorResp.message).toBeDefined();
|
||||
});
|
||||
it('returns 400 when ticket has expired', async () => {
|
||||
const email = createUniqueEmail('ip-ticket-expired');
|
||||
const password = 'a-strong-password';
|
||||
@@ -242,75 +179,4 @@ describe('Auth IP Authorization Resend', () => {
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Resend response format validation', () => {
|
||||
it('returns proper error structure with code and message on rate limit', async () => {
|
||||
const email = createUniqueEmail('ip-resend-format');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('resendformat'),
|
||||
global_name: 'Resend Format User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const newIP = '10.231.241.251';
|
||||
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
|
||||
expect(typeof errorResp.message).toBe('string');
|
||||
expect(errorResp.message!.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('returns proper error structure when ticket is invalid', async () => {
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: 'nonexistent-ticket-abc'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(typeof errorResp.code).toBe('string');
|
||||
expect(typeof errorResp.message).toBe('string');
|
||||
});
|
||||
it('returns proper error structure when resend limit exceeded', async () => {
|
||||
const email = createUniqueEmail('ip-limit-format');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('limitformat'),
|
||||
global_name: 'Limit Format User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const ticket = `ticket-limit-${Date.now()}`;
|
||||
const token = `token-limit-${Date.now()}`;
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/test/auth/ip-authorization')
|
||||
.body({
|
||||
ticket,
|
||||
token,
|
||||
user_id: reg.user_id,
|
||||
email,
|
||||
username: 'limit-format-user',
|
||||
client_ip: '203.0.113.20',
|
||||
user_agent: 'IntegrationTest/1.0',
|
||||
client_location: 'Testland',
|
||||
resend_used: true,
|
||||
created_at: Date.now() - 2 * 60 * 1000,
|
||||
ttl_seconds: 900,
|
||||
})
|
||||
.execute();
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
|
||||
expect(typeof errorResp.message).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth IP Authorization Ticket', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates the new IP authorization ticket error response and the resend rate limit behavior', async () => {
|
||||
const email = createUniqueEmail('ip-ticket');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('ticket'),
|
||||
global_name: 'Ticket User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const otherIP = '10.55.44.33';
|
||||
const body = await createBuilderWithoutAuth<{
|
||||
code?: string;
|
||||
ticket?: string;
|
||||
ip_authorization_required?: boolean;
|
||||
email?: string;
|
||||
resend_available_in?: number;
|
||||
message?: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', otherIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(body.ip_authorization_required).toBe(true);
|
||||
expect(body.ticket).toBeTruthy();
|
||||
expect(body.email).toBeTruthy();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: body.ticket})
|
||||
.header('x-forwarded-for', otherIP)
|
||||
.expect(429)
|
||||
.execute();
|
||||
});
|
||||
it('validates that the ticket returned in the login response expires and cannot be used for resending after expiration', async () => {
|
||||
const email = createUniqueEmail('ip-ticket-expire');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('ticketexpire'),
|
||||
global_name: 'Ticket Expire User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const newIP = '10.70.80.90';
|
||||
const ipAuthResp = await createBuilderWithoutAuth<{
|
||||
ticket?: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(ipAuthResp.ticket).toBeTruthy();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/test/auth/ip-authorization/expire')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,6 @@ describe('Auth IP Authorization Token Validation', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates that attempting to authorize an IP with an invalid token fails with appropriate error', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/authorize-ip')
|
||||
.body({token: 'invalid-token-12345'})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
it('validates that an IP authorization token can only be used once and becomes invalid after successful use', async () => {
|
||||
const email = createUniqueEmail('ip-single-use');
|
||||
const password = 'a-strong-password';
|
||||
@@ -68,38 +61,6 @@ describe('Auth IP Authorization Token Validation', () => {
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
it('validates that a token generated for one IP cannot be used to authorize a different IP', async () => {
|
||||
const email = createUniqueEmail('ip-wrong-ip');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('wrongip'),
|
||||
global_name: 'Wrong IP User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await clearTestEmails(harness);
|
||||
const firstNewIP = '10.110.120.130';
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', firstNewIP)
|
||||
.expect(403)
|
||||
.execute();
|
||||
const emails = await listTestEmails(harness);
|
||||
const ipAuthEmail = emails.find((e) => e.type === 'ip_authorization' && e.to === email);
|
||||
expect(ipAuthEmail).toBeDefined();
|
||||
const authToken = ipAuthEmail!.metadata['token'];
|
||||
expect(authToken).toBeTruthy();
|
||||
const secondNewIP = '10.140.150.160';
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/authorize-ip')
|
||||
.body({token: authToken})
|
||||
.header('x-forwarded-for', secondNewIP)
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
it('validates that IP authorization tokens expire after a reasonable time period and cannot be used after expiration', async () => {
|
||||
const email = createUniqueEmail('ip-expire');
|
||||
const password = 'a-strong-password';
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Auth login invalid credentials', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('wrong password returns bad request with field errors', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: 'WrongPassword123!',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('non-existent email returns bad request with field errors', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: '[email protected]',
|
||||
password: 'SomePassword123!',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('invalid email format returns bad request', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: 'not-an-email',
|
||||
password: 'SomePassword123!',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('empty password returns bad request or unauthorized', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: '[email protected]',
|
||||
password: '',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('empty email returns bad request or unauthorized', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: '',
|
||||
password: 'SomePassword123!',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginAccount, loginUser} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth login with invite code auto-join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('auto-joins a guild when logging in with invite_code', async () => {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({
|
||||
acls: ['*'],
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guildName = `InviteGuild-${Date.now()}`;
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: guildName,
|
||||
})
|
||||
.execute();
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild creation did not return a system_channel_id');
|
||||
}
|
||||
const invite = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
unique: false,
|
||||
temporary: false,
|
||||
})
|
||||
.execute();
|
||||
const member = await createTestAccount(harness);
|
||||
const login = await loginUser(harness, {
|
||||
email: member.email,
|
||||
password: member.password,
|
||||
invite_code: invite.code,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
if (!('mfa' in login)) {
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
expect(nonMfaLogin.token).toBeTruthy();
|
||||
}
|
||||
if (!('mfa' in login)) {
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
const guilds = await createBuilder<Array<GuildResponse>>(harness, nonMfaLogin.token)
|
||||
.get('/users/@me/guilds')
|
||||
.execute();
|
||||
const foundGuild = guilds.find((g) => g.id === guild.id);
|
||||
expect(foundGuild).toBeDefined();
|
||||
expect(foundGuild?.id).toBe(guild.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface BackupCodesResponse {
|
||||
@@ -171,187 +170,6 @@ describe('MFA Consistency Tests', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('WebAuthn sudo verification flow', () => {
|
||||
test('WebAuthn user with two-factor on can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn user with two-factor off can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn-only user with two-factor on cannot use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('WebAuthn-only user with two-factor off can use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Password-only sudo flow for non-MFA users', () => {
|
||||
test('Non-MFA user can use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('Non-MFA user cannot perform sudo operation without password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('Non-MFA user rejected with wrong password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: 'wrong-password-123!',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('TOTP sudo verification flow', () => {
|
||||
test('TOTP user can complete sudo verification with TOTP code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('TOTP user can use backup code for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[0]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('TOTP user cannot use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(403)
|
||||
.execute();
|
||||
});
|
||||
test('TOTP user rejected with wrong TOTP code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: '000000',
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Sudo MFA method availability', () => {
|
||||
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an orphaned TOTP secret', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -423,111 +241,4 @@ describe('MFA Consistency Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('MFA requirement propagates to sensitive operations', () => {
|
||||
test('Account disable requires sudo for all users', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('Account delete requires sudo for all users', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/delete')
|
||||
.body({})
|
||||
.expect(403)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('Session logout requires sudo for non-MFA user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({
|
||||
session_id_hashes: [sessions[0]!.id_hash],
|
||||
})
|
||||
.expect(403)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({
|
||||
session_id_hashes: [sessions[0]!.id_hash],
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('Session logout requires MFA for TOTP user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({
|
||||
session_id_hashes: [sessions[0]!.id_hash],
|
||||
password: account.password,
|
||||
})
|
||||
.expect(403)
|
||||
.execute();
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({
|
||||
session_id_hashes: [sessions[0]!.id_hash],
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('TOTP disable requires MFA for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: 'invalid-code',
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, createTotpSecret, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string}>;
|
||||
}
|
||||
|
||||
function wrongCodeFor(code: string): string {
|
||||
return ((Number(code) + 500_000) % 1_000_000).toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
describe('Enabling TOTP checks the setup code before sudo', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('rejects a wrong setup code on the code field without asking for a password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: wrongCodeFor(totpCodeNow(secret))})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_CODE);
|
||||
});
|
||||
it('asks for sudo for a valid setup code, then enables with the password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const code = totpCodeNow(secret);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
const enabled = await createBuilder<{backup_codes: Array<{code: string}>}>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code, password: account.password})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(enabled.backup_codes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type BackupCodesResponse,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
totpCodeNow,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth MFA TOTP flag matches authenticator types', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('WebAuthn-only reports TOTP false', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = 'JBSWY3DPEHPK3PXP';
|
||||
const totpData = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: totpCodeNow(secret), password: account.password})
|
||||
.execute();
|
||||
expect(totpData.backup_codes.length).toBeGreaterThan(0);
|
||||
const totpLogin = await createBuilderWithoutAuth<{
|
||||
ticket: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(totpLogin.ticket).toBeDefined();
|
||||
const totpResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({code: totpCodeNow(secret), ticket: totpLogin.ticket})
|
||||
.execute();
|
||||
account.token = totpResp.token;
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: totpData.backup_codes[0]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: totpCodeNow(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const loginResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
user_id: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect('mfa' in loginResp).toBe(false);
|
||||
expect(loginResp.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
loginAccount,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
@@ -77,26 +75,4 @@ describe('Password change invalidates sessions', () => {
|
||||
.execute();
|
||||
expect(response.errors?.some((error) => error.path === 'password' && error.code === 'PASSWORD_NOT_SET')).toBe(true);
|
||||
});
|
||||
it('invalidates all sessions after password reset', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const session2 = await loginAccount(harness, account);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const resetEmail = findLastTestEmail(emails, 'password_reset');
|
||||
expect(resetEmail?.metadata?.token).toBeDefined();
|
||||
const token = resetEmail!.metadata!.token!;
|
||||
const newPassword = `reset-password-${Date.now()}`;
|
||||
await createBuilderWithoutAuth(harness).post('/auth/reset').body({token, password: newPassword}).execute();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
||||
await createBuilder(harness, session2.token).get('/users/@me').expect(401).execute();
|
||||
const login = await loginUser(harness, {email: account.email, password: newPassword});
|
||||
if ('mfa' in login && login.mfa) {
|
||||
throw new Error('Expected non-MFA login');
|
||||
}
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
expect(nonMfaLogin.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -52,10 +52,6 @@ describe('isPasswordPwned', () => {
|
||||
beforeEach(() => {
|
||||
resetPwnedPasswordCacheForTesting();
|
||||
});
|
||||
test('the fixture passwords are distinct but share a range prefix', () => {
|
||||
expect(PWNED_PASSWORD).not.toBe(SAFE_PASSWORD_SAME_PREFIX);
|
||||
expect(prefixOf(PWNED_PASSWORD)).toBe(prefixOf(SAFE_PASSWORD_SAME_PREFIX));
|
||||
});
|
||||
test('reports a breached password from the range response', async () => {
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
|
||||
@@ -150,19 +150,6 @@ describe('Auth registration', () => {
|
||||
await createBuilder(harness, account.token).get('/admin/instance/config').execute();
|
||||
});
|
||||
});
|
||||
it('allows emoji global name', async () => {
|
||||
const globalName = '🌻 Sunflower';
|
||||
const reg = await registerUser(harness, {
|
||||
email: createUniqueEmail('global-name-emoji'),
|
||||
username: createUniqueUsername('globalnameemoji'),
|
||||
global_name: globalName,
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const me = (await fetchMe(harness, reg.token)).json as UserMeResponse;
|
||||
expect(me.global_name).toBe(globalName);
|
||||
});
|
||||
it('derives username from display name when username is omitted', async () => {
|
||||
const reg = await registerUser(harness, {
|
||||
email: createUniqueEmail('derived-username'),
|
||||
@@ -174,132 +161,6 @@ describe('Auth registration', () => {
|
||||
const me = (await fetchMe(harness, reg.token)).json as UserMeResponse;
|
||||
expect(me.username).toBe('Magic_Tester');
|
||||
});
|
||||
it('rejects invalid registration payloads', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: 'not-an-email',
|
||||
username: 'itest',
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('weak-password'),
|
||||
username: 'itest',
|
||||
global_name: 'Test User',
|
||||
password: 'weak',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
await registerUser(harness, {
|
||||
email: '[email protected]',
|
||||
username: createUniqueUsername('firstuser'),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const duplicateJson = await createBuilderWithoutAuth<{
|
||||
code: string;
|
||||
errors: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
}>;
|
||||
}>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: '[email protected]',
|
||||
username: createUniqueUsername('seconduser'),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
expect(duplicateJson.code).toBe('INVALID_FORM_BODY');
|
||||
const emailError = duplicateJson.errors.find((e) => e.path === 'email');
|
||||
expect(emailError?.message).toBe('Email is already in use.');
|
||||
const missingFieldsCases: Array<{
|
||||
name: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: 'missing email',
|
||||
body: {
|
||||
email: '',
|
||||
username: 'itest',
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing username',
|
||||
body: {
|
||||
email: '[email protected]',
|
||||
username: '',
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing password',
|
||||
body: {
|
||||
email: '[email protected]',
|
||||
username: 'itest',
|
||||
global_name: 'Test User',
|
||||
password: '',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'missing date of birth',
|
||||
body: {
|
||||
email: '[email protected]',
|
||||
username: 'itest',
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '',
|
||||
consent: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const testCase of missingFieldsCases) {
|
||||
await createBuilderWithoutAuth(harness).post('/auth/register').body(testCase.body).expect(400).execute();
|
||||
}
|
||||
});
|
||||
it('allows login after registration', async () => {
|
||||
const email = createUniqueEmail('login');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('loginuser'),
|
||||
global_name: 'Login User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const login = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.execute();
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect(login.token.length).toBeGreaterThan(0);
|
||||
expect(login.user_id).toBe(reg.user_id);
|
||||
});
|
||||
it('blocks any request from an address on the shared blocked list', async () => {
|
||||
applySharedListUpdate('ip_blocked', '127.0.0.1\n');
|
||||
try {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function createGuildInvite(harness: ApiTestHarness): Promise<{
|
||||
guildId: string;
|
||||
inviteCode: string;
|
||||
ownerToken: string;
|
||||
}> {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({
|
||||
acls: ['*'],
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: `InviteGuild-${Date.now()}`,
|
||||
})
|
||||
.execute();
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild creation did not return a system_channel_id');
|
||||
}
|
||||
const invite = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
unique: false,
|
||||
temporary: false,
|
||||
})
|
||||
.execute();
|
||||
return {guildId: guild.id, inviteCode: invite.code, ownerToken: owner.token};
|
||||
}
|
||||
|
||||
describe('Auth registration invite auto-join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('joins the invite guild on registration', async () => {
|
||||
const {guildId, inviteCode, ownerToken} = await createGuildInvite(harness);
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('invite-join'),
|
||||
username: createUniqueUsername('invite_join'),
|
||||
global_name: 'Invite Join',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
invite_code: inviteCode,
|
||||
});
|
||||
const memberLookup = await createBuilder(harness, ownerToken)
|
||||
.get(`/guilds/${guildId}/members/${registration.user_id}`)
|
||||
.expect(200)
|
||||
.executeWithResponse();
|
||||
expect(memberLookup.response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createAuthHarness, createUniqueEmail, createUniqueUsername} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {applySharedListUpdate, resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
@@ -32,24 +27,6 @@ describe('Registration validation', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('rejects invalid email format', async () => {
|
||||
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.header('accept-language', 'fr')
|
||||
.body({
|
||||
email: 'not-an-email',
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
const emailError = json.errors?.find((e) => e.path === 'email');
|
||||
expect(emailError?.code).toBe('INVALID_EMAIL_FORMAT');
|
||||
expect(emailError?.message).toBe("Format d'adresse e-mail invalide.");
|
||||
});
|
||||
it('rejects blocked registration email TLDs as invalid email', async () => {
|
||||
applySharedListUpdate('email_tld_blocked', 'blocked\n');
|
||||
try {
|
||||
@@ -69,68 +46,6 @@ describe('Registration validation', () => {
|
||||
resetSharedListsForTests();
|
||||
}
|
||||
});
|
||||
it('rejects weak password', async () => {
|
||||
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail(),
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'weak',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
const passwordError = json.errors?.find((e) => e.path === 'password');
|
||||
expect(passwordError?.code).toBe('PASSWORD_LENGTH_INVALID');
|
||||
expect(passwordError?.message).toBe('String length must be between 8 and 256 characters.');
|
||||
});
|
||||
it('includes bounds in username length validation message', async () => {
|
||||
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail(),
|
||||
username: 'a'.repeat(33),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
const usernameError = json.errors?.find((e) => e.path === 'username');
|
||||
expect(usernameError?.code).toBe('USERNAME_LENGTH_INVALID');
|
||||
expect(usernameError?.message).toBe('Username must be between 1 and 32 characters.');
|
||||
});
|
||||
it('rejects duplicate email', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: account.email,
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
it('rejects missing date of birth', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail(),
|
||||
username: createUniqueUsername(),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
consent: true,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('rejects an impossible calendar date with INVALID_DATE_OF_BIRTH_FORMAT', async () => {
|
||||
for (const dateOfBirth of ['2000-02-30', '2000-13-45']) {
|
||||
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
|
||||
@@ -165,62 +80,4 @@ describe('Registration validation', () => {
|
||||
const dateOfBirthError = json.errors?.find((e) => e.path === 'date_of_birth');
|
||||
expect(dateOfBirthError?.code).toBe('MUST_BE_MINIMUM_AGE');
|
||||
});
|
||||
it('accepts a real calendar date above the minimum age', async () => {
|
||||
const reg = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('valid-dob'),
|
||||
username: createUniqueUsername('validdob'),
|
||||
global_name: 'Test User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.execute();
|
||||
expect(reg.token).toBeTruthy();
|
||||
});
|
||||
it('allows emoji in global name', async () => {
|
||||
const globalName = '🌻 Sunflower';
|
||||
const reg = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('emoji'),
|
||||
username: createUniqueUsername('emoji'),
|
||||
global_name: globalName,
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.execute();
|
||||
const me = await createBuilder<{
|
||||
global_name: string;
|
||||
}>(harness, reg.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.global_name).toBe(globalName);
|
||||
});
|
||||
it('derives username from global name when username is not provided', async () => {
|
||||
const reg = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('derived'),
|
||||
global_name: 'Magic Tester',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.execute();
|
||||
const me = await createBuilder<{
|
||||
username: string;
|
||||
}>(harness, reg.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.username).toBe('Magic_Tester');
|
||||
});
|
||||
});
|
||||
@@ -281,36 +281,6 @@ describe('Auth SSO flow', () => {
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
it('uses default redirect when missing', async () => {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const email = `sso-default-redirect-${Date.now()}@example.com`;
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: email,
|
||||
state: startData.state,
|
||||
})
|
||||
.execute();
|
||||
expect(completeData.redirect_to.trim()).toBe('');
|
||||
});
|
||||
it('treats empty redirect as missing', async () => {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({redirect_to: ''})
|
||||
.execute();
|
||||
const email = `sso-empty-redirect-${Date.now()}@example.com`;
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: email,
|
||||
state: startData.state,
|
||||
})
|
||||
.execute();
|
||||
expect(completeData.redirect_to.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
describe('state validation', () => {
|
||||
let admin: TestAccount;
|
||||
@@ -360,42 +330,6 @@ describe('Auth SSO flow', () => {
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('rejects missing state', async () => {
|
||||
const email = `sso-missing-state-${Date.now()}@example.com`;
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: email,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('rejects empty code', async () => {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: '',
|
||||
state: startData.state,
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
it('generates unique states', async () => {
|
||||
const states = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
expect(states.has(startData.state)).toBe(false);
|
||||
states.add(startData.state);
|
||||
}
|
||||
expect(states.size).toBe(10);
|
||||
});
|
||||
});
|
||||
describe('domain validation', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
@@ -257,30 +257,6 @@ describe('Sudo mode negative cases', () => {
|
||||
});
|
||||
});
|
||||
describe('existing MFA token allows skipping MFA', () => {
|
||||
it('valid sudo token allows skipping MFA for subsequent requests', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
await enableTotpForAccount(harness, account, secret);
|
||||
const loggedIn = await loginWithTotp(harness, account, secret);
|
||||
const {response: firstResp} = await createBuilder<BackupCodesResponse>(harness, loggedIn.token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: await generateTotpCode(secret),
|
||||
regenerate: false,
|
||||
})
|
||||
.executeWithResponse();
|
||||
const sudoToken = firstResp.headers.get(SUDO_MODE_HEADER);
|
||||
expect(sudoToken).toBeTruthy();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, loggedIn.token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.header(SUDO_MODE_HEADER, sudoToken!)
|
||||
.body({
|
||||
regenerate: true,
|
||||
})
|
||||
.execute();
|
||||
expect(backupCodes.backup_codes.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('sudo token from MFA allows skipping MFA verification on sensitive endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = generateTotpSecret();
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createFakeAuthToken,
|
||||
createTestAccount,
|
||||
type UserMeResponse,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Auth token validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('malformed token returns unauthorized', async () => {
|
||||
const malformedTokens = ['', 'not-a-token', 'Bearer invalid', 'invalid.token.format', 'ey123.ey456.sig789'];
|
||||
for (const token of malformedTokens) {
|
||||
await createBuilder(harness, token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
}
|
||||
});
|
||||
it('non-existent token returns unauthorized', async () => {
|
||||
const fakeToken = createFakeAuthToken();
|
||||
await createBuilder(harness, fakeToken).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
it('revoked token returns unauthorized', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
await createBuilder(harness, account.token).post('/auth/logout').expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
it('valid token allows access', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const user = await createBuilder<UserMeResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(user.id).toBe(account.userId);
|
||||
});
|
||||
it('valid session token with Bearer prefix allows access', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const user = await createBuilder<UserMeResponse>(harness, `Bearer ${account.token}`).get('/users/@me').execute();
|
||||
expect(user.id).toBe(account.userId);
|
||||
});
|
||||
it('token with wrong signature returns unauthorized', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const tamperedToken = `${account.token.slice(0, Math.max(0, account.token.length - 10))}0123456789`;
|
||||
await createBuilder(harness, tamperedToken).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
it('missing authorization header returns unauthorized', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,6 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setBotFlag(harness: ApiTestHarness, userId: string, isBot: boolean): Promise<void> {
|
||||
@@ -97,25 +95,6 @@ describe('Unclaimed Account Restrictions', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('unclaimed account can receive DM', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const receiver = await createTestAccount(harness);
|
||||
await createFriendship(harness, sender, receiver);
|
||||
const dmChannel = await createDMChannel(harness, sender.token, receiver.userId);
|
||||
await unclaimAccount(harness, receiver.userId);
|
||||
const message = await sendMessage(harness, sender.token, dmChannel.id, 'Hello to unclaimed');
|
||||
expect(message.id).toBeTruthy();
|
||||
expect(message.content).toBe('Hello to unclaimed');
|
||||
});
|
||||
test('unclaimed account can join guild by invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const invite = await createChannelInvite(harness, owner.token, channelId);
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
await createBuilder(harness, unclaimed.token).post(`/invites/${invite.code}`).body({}).execute();
|
||||
});
|
||||
test('unclaimed account cannot send guild messages', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
@@ -152,40 +131,6 @@ describe('Unclaimed Account Restrictions', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('unclaimed account can view their own profile', async () => {
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
await createBuilder(harness, unclaimed.token).get('/users/@me').execute();
|
||||
});
|
||||
test('unclaimed account can view guild they are member of', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const invite = await createChannelInvite(harness, owner.token, channelId);
|
||||
await acceptInvite(harness, unclaimed.token, invite.code);
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
await createBuilder(harness, unclaimed.token).get(`/guilds/${guild.id}`).execute();
|
||||
});
|
||||
test('unclaimed account can read messages in guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const invite = await createChannelInvite(harness, owner.token, channelId);
|
||||
await acceptInvite(harness, unclaimed.token, invite.code);
|
||||
await sendMessage(harness, owner.token, channelId, 'Test message');
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
await createBuilder(harness, unclaimed.token).get(`/channels/${channelId}/messages`).execute();
|
||||
});
|
||||
test('claimed account creates guild without INVITES_DISABLED feature', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, user.token)
|
||||
.post('/guilds')
|
||||
.body({name: 'Guild'})
|
||||
.execute();
|
||||
expect(guild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
});
|
||||
test('unclaimed account cannot open DM with another user', async () => {
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
@@ -203,16 +148,6 @@ describe('Unclaimed Account Restrictions', () => {
|
||||
.executeWithResponse();
|
||||
expect(error.code).toBe('UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES');
|
||||
});
|
||||
test('unclaimed account can use personal notes', async () => {
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
await createBuilder(harness, unclaimed.token)
|
||||
.put(`/users/@me/notes/${target.userId}`)
|
||||
.body({note: 'This is a personal note'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
test('unclaimed account can delete without password', async () => {
|
||||
const unclaimed = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, unclaimed.userId);
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn credential delete', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates deleting a WebAuthn credential including sudo verification with WebAuthn', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'Passkey To Delete');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'Passkey To Delete',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials1 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentials1).toHaveLength(1);
|
||||
const credentialId = credentials1[0].id;
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body({})
|
||||
.execute();
|
||||
if (sudoOptions.rpId) {
|
||||
device.rpId = sudoOptions.rpId;
|
||||
}
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials2 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentials2).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn credential list', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates listing all WebAuthn credentials for a user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const emptyCredentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(emptyCredentials).toHaveLength(0);
|
||||
const device1 = createWebAuthnDevice();
|
||||
const regOptions1 = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions1.rp.id) {
|
||||
device1.rpId = regOptions1.rp.id;
|
||||
}
|
||||
const registrationResponse1 = createRegistrationResponse(device1, regOptions1, 'First Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse1,
|
||||
challenge: regOptions1.challenge,
|
||||
name: 'First Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const device2 = createWebAuthnDevice();
|
||||
const regOptions2 = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions2.rp.id) {
|
||||
device2.rpId = regOptions2.rp.id;
|
||||
}
|
||||
const registrationResponse2 = createRegistrationResponse(device2, regOptions2, 'Second Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse2,
|
||||
challenge: regOptions2.challenge,
|
||||
name: 'Second Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentials).toHaveLength(2);
|
||||
const firstCred = credentials.find(
|
||||
(c) => c.name === 'First Passkey' && c.id === device1.credentialId.toString('base64url'),
|
||||
);
|
||||
expect(firstCred).toBeDefined();
|
||||
const secondCred = credentials.find(
|
||||
(c) => c.name === 'Second Passkey' && c.id === device2.credentialId.toString('base64url'),
|
||||
);
|
||||
expect(secondCred).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn credential rename', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates renaming a WebAuthn credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'Original Name');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'Original Name',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials1 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentials1).toHaveLength(1);
|
||||
expect(credentials1[0].name).toBe('Original Name');
|
||||
const credentialId = credentials1[0].id;
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.body({
|
||||
name: 'Renamed Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials2 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentials2).toHaveLength(1);
|
||||
expect(credentials2[0].name).toBe('Renamed Passkey');
|
||||
expect(credentials2[0].id).toBe(credentialId);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn error localization', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('ensures WebAuthn registration errors return localized messages', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const badChallenge = `${regOptions.challenge}-tampered`;
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'Localized error');
|
||||
const errResp = await createBuilder<{
|
||||
code: string;
|
||||
message: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: badChallenge,
|
||||
name: 'Localized error',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(400)
|
||||
.execute();
|
||||
expect(errResp.code).toBe('INVALID_WEBAUTHN_CREDENTIAL');
|
||||
expect(errResp.message).toBe('Failed to verify WebAuthn credential.');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
@@ -130,39 +129,4 @@ describe('WebAuthn MFA login', () => {
|
||||
.execute();
|
||||
expect(webauthnMfaLogin.token).toBeTruthy();
|
||||
});
|
||||
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'MFA Passkey',
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in loginResp).toBe(false);
|
||||
const loginSuccessResp = loginResp as LoginSuccessResponse;
|
||||
expect(loginSuccessResp.token).toBeTruthy();
|
||||
const userInfo = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, loginSuccessResp.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
});
|
||||
@@ -403,39 +403,6 @@ export function createAuthenticationResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthenticationResponseWithoutUV(
|
||||
device: WebAuthnDevice,
|
||||
options: WebAuthnAuthenticationOptions,
|
||||
): WebAuthnAuthenticationResponse {
|
||||
const challenge = decodeBase64URL(options.challenge);
|
||||
if (options.rpId) {
|
||||
device.rpId = options.rpId;
|
||||
}
|
||||
const clientData = {
|
||||
type: 'webauthn.get',
|
||||
challenge: encodeBase64URL(challenge),
|
||||
origin: device.origin,
|
||||
crossOrigin: false,
|
||||
};
|
||||
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
|
||||
const authData = buildAssertionAuthData(device, false);
|
||||
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
|
||||
const sigInput = Buffer.concat([authData, clientDataHash]);
|
||||
const signature = signWithPrivateKey(device, sigInput);
|
||||
return {
|
||||
id: encodeBase64URL(device.credentialId),
|
||||
rawId: encodeBase64URL(device.credentialId),
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: encodeBase64URL(clientDataJSON),
|
||||
authenticatorData: encodeBase64URL(authData),
|
||||
signature: encodeBase64URL(signature),
|
||||
userHandle: encodeBase64URL(device.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerWebAuthnCredential(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
|
||||
@@ -32,12 +32,6 @@ describe('WebAuthn two-factor toggle', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('reports an empty authenticator types array for an account with no second factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
it('rejects enabling passkey two-factor when the account has no registered credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponseWithoutUV,
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn user verification required', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('ensures WebAuthn authentication requires user verification (UV flag set)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'UV required');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'UV required',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const authOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(authOptions.userVerification).toBe('required');
|
||||
if (authOptions.rpId) {
|
||||
device.rpId = authOptions.rpId;
|
||||
}
|
||||
const assertion = createAuthenticationResponseWithoutUV(device, authOptions);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: assertion,
|
||||
challenge: authOptions.challenge,
|
||||
})
|
||||
.execute();
|
||||
expect(authOptions.userVerification).toBe('required');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user