auth.test.js 11 KB
// 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", "pricing.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);
});

// ---- T20: email verification ----

test("verify: no vendor configured yet, so register's dev response carries a usable verify link that actually flips emailVerified", async () => {
  const registerRes = await fetch(`${BASE}/api/auth/register`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "erin@example.com", password: "correct-horse-battery" }),
  });
  const registered = await registerRes.json();
  assert.ok(registered.devVerifyUrl, "dev/test environments must be able to complete verification without a real inbox");

  const cookie = sessionCookie(registerRes);
  const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  assert.equal(meBefore.emailVerified, false);

  const verifyRes = await fetch(`${BASE}${registered.devVerifyUrl}`);
  assert.equal(verifyRes.status, 200);

  const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  assert.equal(meAfter.emailVerified, true);
});

test("verify: an unknown/garbage token is a clear error, not a silent success", async () => {
  const res = await fetch(`${BASE}/api/auth/verify?token=not-a-real-token`);
  assert.equal(res.status, 400);
});

test("verify: a token can't be reused once it's already verified the account", async () => {
  const registerRes = await fetch(`${BASE}/api/auth/register`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "frank@example.com", password: "correct-horse-battery" }),
  });
  const { devVerifyUrl } = await registerRes.json();

  const firstRes = await fetch(`${BASE}${devVerifyUrl}`);
  assert.equal(firstRes.status, 200);
  const secondRes = await fetch(`${BASE}${devVerifyUrl}`);
  assert.equal(secondRes.status, 400, "a used verify token must not silently succeed again");
});

// ---- T21: binding an existing anonymous viewer_uid/viewer_nickname ----

test("register: an existing viewer_uid/viewer_nickname cookie gets bound to the new account as legacyUid/nickname", async () => {
  const registerRes = await fetch(`${BASE}/api/auth/register`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Cookie: "viewer_uid=old-anon-uid-123; viewer_nickname=%E8%80%81%E8%A7%82%E4%BC%97", // "老观众"
    },
    body: JSON.stringify({ email: "grace@example.com", password: "correct-horse-battery" }),
  });
  const registered = await registerRes.json();
  assert.equal(registered.nickname, "老观众", "nickname should migrate from the cookie, not require re-entering it");

  const cookie = sessionCookie(registerRes);
  const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  assert.equal(me.nickname, "老观众");
});

test("login: an account with a bound legacyUid pushes it back onto the browser, even with no/fresh viewer_uid cookie", async () => {
  await fetch(`${BASE}/api/auth/register`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: "viewer_uid=henry-bound-uid; viewer_nickname=Henry" },
    body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
  });

  // Log in from what looks like a clean browser — no viewer_uid cookie at
  // all this time (simulating cleared cookies / a different device).
  const loginRes = await fetch(`${BASE}/api/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
  });
  assert.equal(loginRes.status, 200);
  const setCookies = loginRes.headers.getSetCookie();
  const viewerUidCookie = setCookies.find((c) => c.startsWith("viewer_uid="));
  const viewerNicknameCookie = setCookies.find((c) => c.startsWith("viewer_nickname="));
  assert.ok(viewerUidCookie, "login must re-issue the account's bound viewer_uid, not leave the browser without one");
  assert.match(viewerUidCookie, /^viewer_uid=henry-bound-uid;/);
  assert.match(viewerNicknameCookie, /^viewer_nickname=Henry;/);
  // Not HttpOnly — live.html/dance.html's own client-side JS reads these
  // via document.cookie, same as the identity cookies they already set.
  assert.ok(!viewerUidCookie.includes("HttpOnly"));
});