import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { getGameDb } from '@/lib/db/client'; import { createSession, hashPassword, requireAuth, verifyPassword } from './auth'; // v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as // the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file. let dir: string; beforeAll(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db'); }); afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); }); const reqWith = (sessionId?: string) => new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} }); describe('hashPassword / verifyPassword', () => { it('round-trips a password through the stored digest', () => { const hash = hashPassword('correct-horse-battery-staple'); expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true); }); it('rejects a wrong password and malformed digests', () => { const hash = hashPassword('some-password'); expect(verifyPassword('other-password', hash)).toBe(false); expect(verifyPassword('x', 'not-a-digest')).toBe(false); expect(verifyPassword('x', '')).toBe(false); }); }); describe('requireAuth', () => { it('401s when the header is missing or the session id is unknown', async () => { for (const req of [reqWith(), reqWith('no-such-session')]) { const guard = await requireAuth(req); expect(guard.ok).toBe(false); if (!guard.ok) { expect(guard.response.status).toBe(401); expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i); } } }); it('401s an expired session', async () => { const inst = await getGameDb(); inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]); const guard = await requireAuth(reqWith('expired-sess')); expect(guard.ok).toBe(false); if (!guard.ok) expect(guard.response.status).toBe(401); }); it('resolves the seeded admin session to the local player row (joined on username)', async () => { const inst = await getGameDb(); const sessionId = await createSession(inst, 'admin-kamilkrzywda'); const guard = await requireAuth(reqWith(sessionId)); expect(guard.ok).toBe(true); if (guard.ok) { expect(guard.user.username).toBe('kamilkrzywda'); expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row expect(guard.player.cash).toBe(20_000); // starter economy from the seed } }); it('500s when the account has no players row (broken data, not a client error)', async () => { const inst = await getGameDb(); inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [ hashPassword('whatever-123'), Date.now(), ]); const sessionId = await createSession(inst, 'user-no-player'); const guard = await requireAuth(reqWith(sessionId)); expect(guard.ok).toBe(false); if (!guard.ok) { expect(guard.response.status).toBe(500); expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i); } }); });