v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s
CI / build + unit + e2e (pull_request) Failing after 26m20s
This commit is contained in:
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user