// Integration test for the minimal admin auth gate on /api/admin/* // (dev-plan.md T6). Spawns server.js on a throwaway port, same isolation // approach as uploads-route.test.js. const test = require("node:test"); const assert = require("node:assert/strict"); const { spawn } = require("node:child_process"); // Same data.sqlite the spawned server.js opens (both default to // __dirname/data.sqlite, no DB_PATH override below) — used to promote a // test account to role='admin' directly, since there's no HTTP endpoint // for that by design (dev-plan-4.md T30: "手动置 role='admin'"). const { users: userStore, creditLedger } = require("./db.js"); const PORT = 19934 + 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(loginRes) { const setCookie = loginRes.headers.get("set-cookie") || ""; return setCookie.split(";")[0]; } let child; test.before(async () => { child = spawn(process.execPath, ["server.js"], { cwd: __dirname, env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, stdio: "pipe", }); await waitForServer(BASE); }); test.after(() => child.kill()); test("any /api/admin/* route returns 401 without a session cookie", async () => { const res = await fetch(`${BASE}/api/admin/library`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: "x", videoUrl: "http://example.com/x.mp4" }), }); assert.equal(res.status, 401); }); test("logging in with the wrong password is rejected and sets no cookie", async () => { const res = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: "definitely-wrong" }), }); assert.equal(res.status, 401); assert.equal(res.headers.get("set-cookie"), null); }); test("logging in with the right password grants a session that unlocks /api/admin/*, and logout revokes it", async () => { const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); assert.equal(loginRes.status, 200); const cookie = sessionCookie(loginRes); assert.match(cookie, /^admin_session=.+/); const authedRes = await fetch(`${BASE}/api/admin/library`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ prompt: "authed test entry", videoUrl: "http://example.com/x.mp4" }), }); assert.equal(authedRes.status, 200); const created = await authedRes.json(); // clean up the entry this test created in the real library.json await fetch(`${BASE}/api/admin/library/${created.id}`, { method: "DELETE", headers: { Cookie: cookie } }); const logoutRes = await fetch(`${BASE}/api/admin/logout`, { method: "POST", headers: { Cookie: cookie } }); assert.equal(logoutRes.status, 200); const afterLogout = await fetch(`${BASE}/api/admin/library`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ prompt: "should be rejected", videoUrl: "http://example.com/x.mp4" }), }); assert.equal(afterLogout.status, 401); }); // ---- T30: users.role='admin' via the regular user_session cookie is the // second accepted path — the ADMIN_PASSWORD flow above must keep working // unchanged (already covered above) since role='admin' accounts don't // exist yet on a fresh install ---- test("a plain viewer account (no role='admin') does not unlock /api/admin/*", async () => { const email = `viewer-${Date.now()}@example.com`; const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "correct-horse-battery" }), }); const cookie = sessionCookie(registerRes); const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); assert.equal(res.status, 401); }); test("promoting an account to role='admin' unlocks /api/admin/*, and POST /api/admin/credit/adjust records that admin as actorUserId", async () => { const email = `admin-${Date.now()}@example.com`; const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "correct-horse-battery" }), }); const { id: adminUserId } = await registerRes.json(); const cookie = sessionCookie(registerRes); // Still 401 before promotion — same session, only the role changed. assert.equal((await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } })).status, 401); userStore.update(adminUserId, { role: "admin" }); assert.equal((await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } })).status, 200); // credit/adjust needs a target user to adjust and a positive starting // balance to prove the delta actually applied (0 -> negative would also // pass a "balance changed" check even if the sign were flipped). const targetRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: `adjust-target-${Date.now()}@example.com`, password: "correct-horse-battery" }), }); const { id: targetUserId } = await targetRes.json(); creditLedger.topup({ userId: targetUserId, amount: 20 }); const adjustRes = await fetch(`${BASE}/api/admin/credit/adjust`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ userId: targetUserId, delta: -5, reason: "admin_adjust" }), }); assert.equal(adjustRes.status, 200); const row = await adjustRes.json(); assert.equal(row.delta, -5); assert.equal(row.balanceAfter, 15); assert.equal(row.actorUserId, adminUserId); assert.equal(userStore.get(targetUserId).creditBalance, 15); }); test("credit/adjust over the legacy ADMIN_PASSWORD session (no per-admin identity) still works, with a null actorUserId", async () => { const targetRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: `legacy-adjust-target-${Date.now()}@example.com`, password: "correct-horse-battery" }), }); const { id: targetUserId } = await targetRes.json(); const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); const adminCookie = sessionCookie(loginRes); const adjustRes = await fetch(`${BASE}/api/admin/credit/adjust`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ userId: targetUserId, delta: 10 }), }); assert.equal(adjustRes.status, 200); const row = await adjustRes.json(); assert.equal(row.actorUserId, null); await fetch(`${BASE}/api/admin/logout`, { method: "POST", headers: { Cookie: adminCookie } }); }); test("credit/adjust refuses to push a balance negative", async () => { const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: `broke-admin-${Date.now()}@example.com`, password: "correct-horse-battery" }), }); const { id: adminUserId } = await registerRes.json(); const cookie = sessionCookie(registerRes); userStore.update(adminUserId, { role: "admin" }); const targetRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: `broke-target-${Date.now()}@example.com`, password: "correct-horse-battery" }), }); const { id: targetUserId } = await targetRes.json(); const res = await fetch(`${BASE}/api/admin/credit/adjust`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ userId: targetUserId, delta: -1 }), }); assert.equal(res.status, 400); assert.equal(userStore.get(targetUserId).creditBalance, 0); });