// Integration test for T19 (register/login/logout/me), dev-plan-3.md. // Spawns server.js on a throwaway port against an isolated tmpdir copy // (own data.sqlite too — db.js defaults to __dirname/data.sqlite), same // isolation approach as admin-auth.test.js / uploads-route.test.js. const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const { spawn } = require("node:child_process"); const PORT = 24934 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; const ADMIN_PASSWORD = "test-password-only-for-this-run"; function waitForServer(url, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; return (async function poll() { while (Date.now() < deadline) { try { await fetch(url); return; } catch { await new Promise((r) => setTimeout(r, 100)); } } throw new Error("server did not come up in time"); })(); } function sessionCookie(res) { const setCookie = res.headers.get("set-cookie") || ""; return setCookie.split(";")[0]; } let child; let cwd; test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } child = spawn(process.execPath, ["server.js"], { cwd, env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, stdio: "pipe", }); await waitForServer(BASE); }); test.after(() => { child.kill(); fs.rmSync(cwd, { recursive: true, force: true }); }); test("register: rejects a malformed email", async () => { const res = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "not-an-email", password: "longenoughpassword" }), }); assert.equal(res.status, 400); }); test("register: rejects a too-short password", async () => { const res = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "short-pw@example.com", password: "short1" }), }); assert.equal(res.status, 400); }); test("register -> auto-logged-in session -> GET /api/auth/me -> logout closes the loop", async () => { const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "alice@example.com", password: "correct-horse-battery" }), }); assert.equal(registerRes.status, 200); const registered = await registerRes.json(); assert.equal(registered.email, "alice@example.com"); assert.equal(registered.emailVerified, false); const cookie = sessionCookie(registerRes); assert.ok(cookie.startsWith("user_session="), "register should auto-issue a user_session cookie, distinct from admin_session"); const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); assert.equal(meRes.status, 200); const me = await meRes.json(); assert.equal(me.id, registered.id); assert.equal(me.email, "alice@example.com"); const logoutRes = await fetch(`${BASE}/api/auth/logout`, { method: "POST", headers: { Cookie: cookie } }); assert.equal(logoutRes.status, 200); const meAfterLogoutRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); assert.equal(meAfterLogoutRes.status, 401, "the old session token must be dead after logout"); }); test("GET /api/auth/me with no cookie at all is unauthorized", async () => { const res = await fetch(`${BASE}/api/auth/me`); assert.equal(res.status, 401); }); test("register: duplicate email is a 409, not a silently-created second account", async () => { await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "dup@example.com", password: "correct-horse-battery" }), }); const secondRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "dup@example.com", password: "a-totally-different-password" }), }); assert.equal(secondRes.status, 409); }); test("login: correct credentials issue a fresh session", async () => { await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }), }); const loginRes = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }), }); assert.equal(loginRes.status, 200); const cookie = sessionCookie(loginRes); const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); assert.equal(meRes.status, 200); const me = await meRes.json(); assert.equal(me.email, "bob@example.com"); }); test("login: wrong password and unknown email return the exact same error (no email enumeration)", async () => { await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "carol@example.com", password: "correct-horse-battery" }), }); const wrongPasswordRes = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "carol@example.com", password: "totally-wrong-password" }), }); const unknownEmailRes = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "nobody-registered-this@example.com", password: "whatever" }), }); assert.equal(wrongPasswordRes.status, 401); assert.equal(unknownEmailRes.status, 401); const [wrongBody, unknownBody] = await Promise.all([wrongPasswordRes.json(), unknownEmailRes.json()]); assert.deepEqual(wrongBody, unknownBody, "the two failure modes must be indistinguishable to the caller"); }); test("a user_session cookie doesn't unlock /api/admin/* (the two session systems stay separate)", async () => { const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "dana@example.com", password: "correct-horse-battery" }), }); const cookie = sessionCookie(registerRes); const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); assert.equal(res.status, 401); });