v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s

This commit is contained in:
2026-09-26 14:40:56 +00:00
parent 21dd37f88c
commit b0f1bd8b67
54 changed files with 1002 additions and 91 deletions
+16 -1
View File
@@ -2,9 +2,12 @@ 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 { createSession } from '@/lib/auth';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
@@ -12,6 +15,8 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
// v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -20,8 +25,18 @@ afterAll(async () => {
});
describe('GET /api/events', () => {
// v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query
// param — this test exercises exactly that path (requireAuthSse).
const req = () => new Request(`http://localhost/api/events?session=${sessionId}`);
it('rejects an unauthenticated request with 401', async () => {
const res = await GET(new Request('http://localhost/api/events'));
expect(res.status).toBe(401);
expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i);
});
it('streams SSE with the current snapshot as the first event', async () => {
const res = await GET();
const res = await GET(req());
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache');
+10 -2
View File
@@ -1,3 +1,4 @@
import { requireAuthSse } from '@/lib/auth';
import { getStateSnapshot, sseFrame } from '@/lib/game/state';
import { subscribe } from '@/lib/sse';
@@ -5,12 +6,19 @@ import { subscribe } from '@/lib/sse';
// then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions
// stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0):
// frames carry the sseFrame projection (terminal package history dropped) — same shape as the
// pushed frames, so a connect never costs one full-history frame.
// pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded —
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
// be one indexed lookup per tick if that ever matters).
const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000;
export async function GET() {
export async function GET(req: Request) {
const auth = await requireAuthSse(req);
if (!auth.ok) return auth.response;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
const cleanup = () => {
+12 -1
View File
@@ -4,16 +4,20 @@ import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -21,7 +25,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/buy-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/buy-plane', () => {
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const model = input.model;
return performAction((inst) => buyPlane(inst, model));
return performAction(req, (inst) => buyPlane(inst, model));
}
+12 -1
View File
@@ -6,16 +6,20 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -23,7 +27,14 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
const post = (body: unknown) =>
POST(
new Request('http://localhost/api/market/sell-plane', {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
);
describe('POST /api/market/sell-plane', () => {
it('rejects a missing planeId and unknown fields with 400', async () => {
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const planeId = input.planeId;
return performAction((inst) => sellPlane(inst, planeId));
return performAction(req, (inst) => sellPlane(inst, planeId));
}
+9 -3
View File
@@ -6,15 +6,19 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -22,9 +26,11 @@ afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
params: Promise.resolve({ id }),
});
const post = (id: string) =>
POST(
new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }),
{ params: Promise.resolve({ id }) },
);
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
// the exact created/deadline times so tests can compute the penalty from the
+2 -2
View File
@@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
// id; 409 wrong status or the plane is in flight. On success returns the full
// state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => abandonPackage(inst, id));
return performAction(req, (inst) => abandonPackage(inst, id));
}
+12 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
});
const post = (id: string, body: unknown) =>
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ id }),
});
POST(
new Request(`http://localhost/api/packages/${id}/load`, {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id }) },
);
describe('POST /api/packages/:id/load', () => {
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
}
const planeId = input.planeId;
return performAction((inst) => loadPackage(inst, id, planeId));
return performAction(req, (inst) => loadPackage(inst, id, planeId));
}
+12 -3
View File
@@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { createSession } from '@/lib/auth';
import { POST } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
// v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user.
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
afterAll(async () => {
@@ -22,9 +26,14 @@ afterAll(async () => {
});
const post = (planeId: string, body: unknown) =>
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ planeId }),
});
POST(
new Request(`http://localhost/api/planes/${planeId}/fly`, {
method: 'POST',
headers: { 'X-Flight-Session': sessionId },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ planeId }) },
);
describe('POST /api/planes/:planeId/fly', () => {
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction((inst) => flyPlane(inst, planeId, destIcao));
return performAction(req, (inst) => flyPlane(inst, planeId, destIcao));
}
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const index = input.index as number;
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
return performAction(req, (inst) => cancelQueuedLeg(inst, planeId, index));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction((inst) => queueLeg(inst, planeId, destIcao));
return performAction(req, (inst) => queueLeg(inst, planeId, destIcao));
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { performAction, repairPlane } from '@/lib/game/actions';
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
// On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
return performAction((inst) => repairPlane(inst, planeId));
return performAction(req, (inst) => repairPlane(inst, planeId));
}
+70
View File
@@ -0,0 +1,70 @@
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 { verifyPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { POST } from './route';
// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB
// singleton points at a throwaway file, and every created user is verified through verifyPassword().
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'register.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/register', { method: 'POST', body: JSON.stringify(body) }));
describe('POST /api/register', () => {
it('rejects malformed bodies and bad credentials with 400', async () => {
for (const body of [
{}, // missing both fields
{ username: 'ab', password: 'longenough' }, // too short
{ username: 'a'.repeat(21), password: 'longenough' }, // too long
{ username: 'bad user!', password: 'longenough' }, // bad charset (space + !)
{ username: 'validuser', password: 'short' }, // password < 8 chars
{ username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field
]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => {
const res = await post({ username: 'NewUser42', password: 'correct-horse-1' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } };
expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0];
const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true);
expect(String(playerRow[1])).toBe('local');
expect(Number(playerRow[2])).toBe(STARTING_CASH);
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('returns 409 when the username is taken (case-insensitively)', async () => {
await post({ username: 'taken-user', password: 'correct-horse-1' });
const res = await post({ username: 'Taken-User', password: 'different-pass-2' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// v1.1.0 (ISS-107) — POST /api/register
// body { username: string, password: string } (both required; unknown fields 400).
// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400.
// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same
// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact
// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a
// session immediately and returns { sessionId, user } like /api/login — no second round-trip.
const USERNAME_RE = /^[a-z0-9_-]{3,20}$/;
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : undefined;
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username' && key !== 'password') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const username = typeof input.username === 'string' ? input.username.toLowerCase() : '';
if (!USERNAME_RE.test(username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
const password = input.password;
if (typeof password !== 'string' || password.length < 8) {
return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 });
}
const inst = await getGameDb();
const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]);
if (taken[0]?.values?.length) {
return Response.json({ error: 'username is already taken' }, { status: 409 });
}
// One UUID for both rows — the account and its player share an id; requireAuth still joins on username.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(password),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
await persist(inst);
const sessionId = await createSession(inst, id);
return Response.json({ sessionId, user: { username, admin: false } });
}
+13 -4
View File
@@ -5,9 +5,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession } from '@/lib/auth';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route';
let dir: string;
let sessionId: string;
beforeAll(async () => {
// Point the app's DB singleton at a throwaway file so this test never
@@ -15,8 +18,14 @@ beforeAll(async () => {
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
// (no password needed: createSession only inserts into sessions; the guard joins players on
// username → the 'local' row).
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
});
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
@@ -24,7 +33,7 @@ afterAll(async () => {
describe('GET /api/state', () => {
it('returns 200 with the full snapshot shape', async () => {
const res = await GET();
const res = await GET(req());
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
@@ -75,13 +84,13 @@ describe('GET /api/state', () => {
});
it('is stable across repeated calls (same seeded DB)', async () => {
const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash);
});
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{
const body = ((await GET(req())).json()) as Promise<{
catalog: Array<Record<string, unknown>>;
fuelPricePerL: number;
sellFraction: number;
+6 -1
View File
@@ -1,7 +1,12 @@
import { requireAuth } from '@/lib/auth';
import { tickNow } from '@/lib/sim/engine';
import { getStateSnapshot } from '@/lib/game/state';
export async function GET() {
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
// unchanged; per-player snapshots are a later step.
export async function GET(req: Request) {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try {
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
// read reflects current game time (expiry/finalize/spawn run inside the tick).
@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { getGameDb } from '@/lib/db/client';
import { POST } from './route';
// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set
// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence.
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db');
});
afterAll(async () => {
vi.unstubAllEnvs();
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body?: unknown) =>
POST(
new Request('http://localhost/api/test/mint-player-session', {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
}),
);
describe('POST /api/test/mint-player-session', () => {
it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '');
const res = await post();
expect(res.status).toBe(404);
});
it('creates the default test account + player row and mints a session when enabled', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post(); // no body → DEFAULT_USERNAME
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
expect(userRow.length).toBe(1);
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('is idempotent — re-minting an existing username mints another session, no duplicate rows', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const count = async () => Number((await getGameDb()).raw.exec("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")[0].values[0][0]);
expect(await count()).toBe(1);
const res = await post();
expect(res.status).toBe(200);
expect(await count()).toBe(1); // still one account…
});
it('rejects bad usernames and unknown fields with 400', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
// /api/test/admin-session). Creates the users + players rows if missing — idempotent across
// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every
// other deployment 404s here, like its /api/test/* siblings.
//
// POST /api/test/mint-player-session
// body { username?: string } — defaults to 'e2e-tester'; must match the register rules when given.
// Returns { sessionId }.
const DEFAULT_USERNAME = 'e2e-tester';
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
// server-side); a fixed value keeps the row debuggable from the admin panel.
const TEST_PASSWORD = 'e2e-mint-password-42';
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'username') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
let username = DEFAULT_USERNAME;
if (input.username !== undefined) {
if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) {
return Response.json(
{ error: 'username must be 3–20 characters of a-z, 0-9, _ or -' },
{ status: 400 },
);
}
username = input.username;
}
const inst = await getGameDb();
let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]);
let userId = res[0]?.values?.[0]?.[0];
if (typeof userId !== 'string') {
// New test account — one UUID for both rows, starter economy like a fresh world.
const id = randomUUID();
inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [
id,
username,
hashPassword(TEST_PASSWORD),
Date.now(),
]);
await inst.db.insert(schema.players).values({
id,
provider: 'local',
username,
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
userId = id;
}
const sessionId = await createSession(inst, userId); // persists the rows + session together
return Response.json({ sessionId });
}
+71
View File
@@ -727,3 +727,74 @@ body {
.admin-row-actions {
white-space: nowrap;
}
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
.sign-in {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: rgba(15, 23, 42, 0.97);
color: #f8fafc;
}
.sign-in h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0.06em;
}
.sign-in form {
display: flex;
flex-direction: column;
gap: 6px;
width: 300px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
background: rgba(30, 41, 59, 0.6);
}
.sign-in label {
font-size: 12px;
color: #cbd5e1;
}
.sign-in input {
font-size: 13px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(15, 23, 42, 0.9);
color: #f8fafc;
}
.sign-in .pane-btn {
margin-top: 6px;
padding: 5px 10px;
}
.sign-in-divider {
margin: 0;
font-size: 12px;
color: #94a3b8;
}
.sign-in-error {
margin: 0;
font-size: 13px;
color: #fca5a5;
}
.sign-in-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
color: #cbd5e1;
}
+47 -1
View File
@@ -4,8 +4,10 @@ import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane';
import SignInScreen from '@/components/SignInScreen';
import ToastStack from '@/components/Toasts';
import type { HoverTarget } from '@/components/hoverTypes';
import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient';
import { useGameState } from '@/lib/useGameState';
import { useToasts } from '@/lib/useToasts';
import { diffSnapshots } from '@/lib/deriveEvents';
@@ -13,6 +15,50 @@ import { diffSnapshots } from '@/lib/deriveEvents';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() {
// v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
// feed never polls an unauthenticated /api/state.
const [authed, setAuthed] = useState<boolean | null>(null);
useEffect(() => {
const sid = storedSession();
if (!sid) {
setAuthed(false);
return;
}
let cancelled = false;
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
.then((res) => {
if (cancelled) return;
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
setAuthed(res.ok);
})
.catch(() => {
if (!cancelled) setAuthed(false); // network error → let the user try to sign in
});
return () => {
cancelled = true;
};
}, []);
if (authed === null) {
return (
<div className="app-shell">
<p data-testid="auth-checking" className="sign-in-loading">
Loading…
</p>
</div>
);
}
if (!authed) {
return <SignInScreen onAuthed={() => setAuthed(true)} />;
}
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
return <AppShell onLogout={() => setAuthed(false)} />;
}
function AppShell({ onLogout }: { onLogout: () => void }) {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately.
@@ -49,7 +95,7 @@ export default function Home() {
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */}
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
<ToastStack toasts={toasts} onDismiss={dismiss} />
</div>