admin-auth.test.js 8.56 KB
// 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);
});