import assert from 'node:assert/strict'; import test from 'node:test'; import { buildApp } from '../../src/app/build-app.js'; import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js'; function createTestApp(authenticationService, dependencies = {}) { return buildApp({ authenticationService, loginRateLimiter: { async consume() { return { allowed: true }; } }, ...dependencies, }); } test('places a successful login session only in a strict HttpOnly cookie', async (testContext) => { const app = createTestApp({ async login() { return { sessionToken: 'raw-session-token', csrfToken: 'raw-csrf-token', expiresAt: new Date(Date.now() + 60 * 60 * 1000), }; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@example.test', password: 'correct-password' }, }); assert.equal(response.statusCode, 200); assert.equal(response.json().data.sessionToken, undefined); const cookies = response.headers['set-cookie'].join('\n'); assert.match(cookies, /HttpOnly/); assert.match(cookies, /SameSite=Strict/); assert.match(cookies, /pokemon_inventory_session=raw-session-token/); assert.match(cookies, /pokemon_inventory_csrf=raw-csrf-token/); }); test('rejects malformed login bodies before authentication', async (testContext) => { const app = createTestApp({ async login() { throw new Error('Authentication should not run.'); }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'not-an-email', password: '' }, }); assert.equal(response.statusCode, 400); assert.equal(response.json().error.code, 'VALIDATION_ERROR'); }); test('reads the authenticated principal from the session cookie', async (testContext) => { const app = createTestApp({ async authenticate(sessionToken) { assert.equal(sessionToken, 'raw-session-token'); return { userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' }; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { cookie: 'pokemon_inventory_session=raw-session-token' }, }); assert.equal(response.statusCode, 200); assert.equal(response.json().data.sellerId, 'seller-id'); }); test('revokes the session and clears its cookie on logout', async (testContext) => { let revokedToken; const app = createTestApp({ async authorizeUnsafeRequest(sessionToken, csrfToken) { assert.equal(sessionToken, 'raw-session-token'); assert.equal(csrfToken, 'raw-csrf-token'); return { userId: 'user-id' }; }, async logout(sessionToken) { revokedToken = sessionToken; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: 'pokemon_inventory_session=raw-session-token', 'x-csrf-token': 'raw-csrf-token', }, }); assert.equal(response.statusCode, 200); assert.equal(revokedToken, 'raw-session-token'); assert.match(response.headers['set-cookie'].join('\n'), /Max-Age=0/); }); test('rejects logout without a session-bound CSRF token', async (testContext) => { const app = createTestApp({ async authorizeUnsafeRequest() { const error = new Error('CSRF validation failed.'); error.code = 'FORBIDDEN'; error.statusCode = 403; throw error; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: 'pokemon_inventory_session=raw-session-token' }, }); assert.equal(response.statusCode, 403); assert.equal(response.json().error.code, 'FORBIDDEN'); }); test('limits login attempts by client before credential verification', async (testContext) => { let loginCalls = 0; const attemptsByKey = new Map(); const redisClient = { async eval(script, { arguments: scriptArguments, keys }) { assert.match(script, /INCR/); assert.equal(scriptArguments[0], String(15 * 60 * 1000)); const key = keys[0]; const count = (attemptsByKey.get(key) ?? 0) + 1; attemptsByKey.set(key, count); return [count, 15 * 60 * 1000]; }, }; const app = createTestApp( { async login() { loginCalls += 1; throw new Error('Invalid credentials'); }, }, { loginRateLimiter: createRedisLoginRateLimiter({ maxAttempts: 2, redisClient, }), }, ); testContext.after(() => app.close()); const request = { method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@example.test', password: 'incorrect-password' }, remoteAddress: '198.51.100.8', }; const firstResponse = await app.inject(request); const secondResponse = await app.inject(request); const blockedResponse = await app.inject(request); const otherClientResponse = await app.inject({ ...request, remoteAddress: '198.51.100.9', }); assert.equal(firstResponse.statusCode, 500); assert.equal(secondResponse.statusCode, 500); assert.equal(blockedResponse.statusCode, 429); assert.equal(blockedResponse.json().error.code, 'AUTH_RATE_LIMITED'); assert.equal(blockedResponse.headers['retry-after'], '900'); assert.equal(otherClientResponse.statusCode, 500); assert.equal(loginCalls, 3); }); test('lists active sessions without exposing credential material', async (testContext) => { const app = createTestApp({ async listActiveSessions(sessionToken) { assert.equal(sessionToken, 'raw-session-token'); return { currentSessionId: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1', sessions: [ { id: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1', createdAt: new Date('2026-09-07T00:00:00.000Z'), expiresAt: new Date('2026-09-08T00:00:00.000Z'), lastActivityAt: new Date('2026-09-07T00:30:00.000Z'), ipAddress: '198.51.100.8', userAgent: 'Test Browser', sessionTokenHash: 'must-not-be-serialized', }, ], }; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'GET', url: '/api/v1/auth/sessions', headers: { cookie: 'pokemon_inventory_session=raw-session-token' }, }); assert.equal(response.statusCode, 200); assert.equal(response.json().data[0].isCurrent, true); assert.equal(response.json().data[0].sessionTokenHash, undefined); }); test('requires CSRF validation to revoke a session and clears current-session cookies', async (testContext) => { const app = createTestApp({ async authorizeUnsafeRequest(sessionToken, csrfToken) { assert.equal(sessionToken, 'raw-session-token'); assert.equal(csrfToken, 'raw-csrf-token'); }, async revokeSession(sessionToken, sessionId) { assert.equal(sessionToken, 'raw-session-token'); assert.equal(sessionId, '3d0a3713-5b8f-4080-af25-b2e6eb4150b1'); return { currentSessionRevoked: true }; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'DELETE', url: '/api/v1/auth/sessions/3d0a3713-5b8f-4080-af25-b2e6eb4150b1', headers: { cookie: 'pokemon_inventory_session=raw-session-token', 'x-csrf-token': 'raw-csrf-token', }, }); assert.equal(response.statusCode, 200); assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_session=; Max-Age=0/); assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_csrf=; Max-Age=0/); }); test('revokes other sessions while retaining the current session', async (testContext) => { let revokedOtherSessions = false; const app = createTestApp({ async authorizeUnsafeRequest() {}, async revokeOtherSessions(sessionToken) { assert.equal(sessionToken, 'raw-session-token'); revokedOtherSessions = true; }, }); testContext.after(() => app.close()); const response = await app.inject({ method: 'DELETE', url: '/api/v1/auth/sessions', headers: { cookie: 'pokemon_inventory_session=raw-session-token', 'x-csrf-token': 'raw-csrf-token', }, }); assert.equal(response.statusCode, 200); assert.equal(revokedOtherSessions, true); assert.equal(response.headers['set-cookie'], undefined); }); test('accepts password reset requests without returning a reset token', async (testContext) => { let resetRequest; const app = createTestApp( {}, { passwordRecoveryService: { async requestReset(input) { resetRequest = input; }, }, }, ); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: 'admin@example.test' }, }); assert.equal(response.statusCode, 202); assert.equal(response.json().data, null); assert.equal('token' in response.json(), false); assert.equal(resetRequest.email, 'admin@example.test'); }); test('resets a password only with a valid-shaped recovery token', async (testContext) => { let resetInput; const app = createTestApp( {}, { passwordRecoveryService: { async resetPassword(input) { resetInput = input; }, }, }, ); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: 'a'.repeat(32), password: 'new-password-value' }, }); assert.equal(response.statusCode, 200); assert.equal(resetInput.password, 'new-password-value'); }); test('limits password reset requests independently from login attempts', async (testContext) => { const app = createTestApp( {}, { passwordRecoveryService: { async requestReset() {} }, passwordResetRateLimiter: { async consume() { return { allowed: false, retryAfterSeconds: 60 }; }, }, }, ); testContext.after(() => app.close()); const response = await app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: 'admin@example.test' }, }); assert.equal(response.statusCode, 429); assert.equal(response.json().error.code, 'AUTH_RATE_LIMITED'); assert.equal(response.headers['retry-after'], '60'); });