88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|