chore(repo): remove dead code and useless tests (#3375)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 21:43:29 +02:00
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');
});
});