From 2ad1212bbb78ba2da67d5f92eac63341d95a102f Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 22:46:16 -0400 Subject: [PATCH] test(mfa): cover session invalidation on admin MFA reset (#1755) Add seedMfaUserWithToken helper that wraps seedMfaUser and returns a signed JWT with the current token_version claim, removing the manual sign-and-read-boilerplate from callers. Add an integration test in the MFA reset describe block that exercises the full invalidation cycle: a target user's pre-reset JWT is accepted before reset and rejected with 401 'Session invalidated' after an admin resets their MFA. The test also confirms a pre-minted admin JWT survives the reset unchanged. --- backend/src/__tests__/helpers/setupTestDb.ts | 21 ++++++++++++++++ backend/src/__tests__/mfa.test.ts | 25 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index fa604f56..aaf7d6a8 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -121,3 +121,24 @@ export async function seedMfaUser( return { userId, secret, backupCodes }; } + +/** + * Seed a user with MFA enrolled and return a session JWT signed with the + * user's current token_version so tests can verify that an operation that + * bumps it (MFA reset, password change) invalidates pre-existing sessions. + */ +export async function seedMfaUserWithToken( + username: string, + password: string, +): Promise<{ userId: number; secret: string; backupCodes: string[]; token: string }> { + const { userId, secret, backupCodes } = await seedMfaUser(username, password); + const { DatabaseService } = await import('../../services/DatabaseService'); + const jwtLib = (await import('jsonwebtoken')).default; + const user = DatabaseService.getInstance().getUser(userId)!; + const token = jwtLib.sign( + { username, role: user.role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + return { userId, secret, backupCodes, token }; +} diff --git a/backend/src/__tests__/mfa.test.ts b/backend/src/__tests__/mfa.test.ts index 1f702393..e96b8e2a 100644 --- a/backend/src/__tests__/mfa.test.ts +++ b/backend/src/__tests__/mfa.test.ts @@ -18,6 +18,7 @@ import { setupTestDb, cleanupTestDb, seedMfaUser, + seedMfaUserWithToken, TEST_USERNAME, TEST_JWT_SECRET, } from './helpers/setupTestDb'; @@ -568,6 +569,30 @@ describe('POST /api/users/:id/mfa/reset', () => { expect(resetRows).toHaveLength(1); expect(resetRows[0].summary).toBe(`Reset two-factor authentication: ${userId}`); }); + + it('invalidates target pre-reset JWT after admin MFA reset', async () => { + const { userId, token } = await seedMfaUserWithToken('victim4', 'victim4pass123'); + const adminJwt = adminToken(); // Pre-minted so we prove the admin session survives the reset. + const stacksWith = (jwt: string) => + request(app).get('/api/stacks').set('Authorization', `Bearer ${jwt}`); + + // Pre-reset JWT is accepted (viewer role grants stack:read). + expect((await stacksWith(token)).status).toBe(200); + + // Admin reset bumps the target's token_version, invalidating their sessions. + const reset = await request(app) + .post(`/api/users/${userId}/mfa/reset`) + .set('Authorization', `Bearer ${adminJwt}`); + expect(reset.status).toBe(200); + + // The same pre-reset JWT is now rejected. + const after = await stacksWith(token); + expect(after.status).toBe(401); + expect(after.body.error).toContain('Session invalidated'); + + // Admin session is untouched (only the target's token_version was bumped). + expect((await stacksWith(adminJwt)).status).toBe(200); + }); }); // ─── SSO bypass toggle ────────────────────────────────────────────────────────