Ctrl K
ComunidadGuías…

test/recovery.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";

/**
 * Account recovery and address confirmation.
 *
 * Everything here is a boundary a stranger hits on day one: a forgotten
 * password with no way back, an address nobody can reach, and a token that
 * should work once. The auth suite deliberately runs on the JSON store, so
 * these run against real SQLite — that is where the token table lives, and a
 * single-use claim that has only ever been tested against an in-memory object
 * has not been tested.
 */

const scratch = await mkdtemp(path.join(tmpdir(), "forge-recovery-"));
process.env.FORGE_CONFIG_DIR = path.join(scratch, "config");
process.env.FORGE_DB_PATH = path.join(scratch, "config", "forge.db");
await mkdir(process.env.FORGE_CONFIG_DIR, { recursive: true });

// Verification is only enforced where a message can be delivered, so the SMTP
// vars decide what register() does. Set before the first import.
const smtp = {
  FORGE_SMTP_HOST: "127.0.0.1",
  FORGE_SMTP_USER: "u",
  FORGE_SMTP_PASS: "p",
  FORGE_SMTP_FROM: "forge@example.com",
};
const setSmtp = (on) => {
  for (const [k, v] of Object.entries(smtp)) {
    if (on) process.env[k] = v;
    else delete process.env[k];
  }
};

setSmtp(true);
const auth = await import("../src/auth.js");
const { getDb, closeDb } = await import("../src/db.js");

const openDb = () => new DatabaseSync(process.env.FORGE_DB_PATH);

test.after(async () => {
  closeDb();
  await rm(scratch, { recursive: true, force: true });
});

/* ------------------------------------------------------ what a gate needs */

test("verification is required exactly when a message can be delivered", () => {
  setSmtp(true);
  assert.equal(auth.verificationRequired(), true);
  setSmtp(false);
  // A gate whose only key is an email nobody can send is a locked door with no
  // handle: an install with no SMTP must not enforce one.
  assert.equal(auth.verificationRequired(), false);
  setSmtp(true);
});

test("an account is unverified on registration, and verified without SMTP", async () => {
  setSmtp(true);
  const gated = await auth.register({ name: "Gated", email: "gated@example.com", password: "correct horse" });
  assert.equal(gated.verified, false);

  setSmtp(false);
  const open = await auth.register({ name: "Open", email: "open@example.com", password: "correct horse" });
  assert.equal(open.verified, true, "no SMTP means the account is usable immediately");
  setSmtp(true);
});

test("a Google account arrives verified", async () => {
  // verifyGoogleIdToken refuses a token whose email_verified is false, so
  // reaching findOrCreateGoogleUser is itself the proof of ownership.
  const u = await auth.findOrCreateGoogleUser({ sub: "g-1", email: "goog@example.com", name: "Goog" });
  assert.equal(u.verified, true);
});

test("linking Google to an existing unverified account verifies it", async () => {
  await auth.register({ name: "Linker", email: "linker@example.com", password: "correct horse" });
  assert.equal((await auth.authenticate("linker@example.com", "correct horse")).verified, false);
  const linked = await auth.findOrCreateGoogleUser({ sub: "g-2", email: "linker@example.com", name: "Linker" });
  assert.equal(linked.verified, true);
});

/* ------------------------------------------------------------ the policy */

test("reading is free, changing anything is not", () => {
  const needs = (method, p) => auth.verifiedRequestOnly({ method, path: p });
  for (const m of ["GET", "HEAD", "OPTIONS"]) assert.equal(needs(m, "/whatever"), false, `${m} is a read`);
  // Someone who cannot generate must still be able to clear up after themselves.
  assert.equal(needs("DELETE", "/some-deck"), false);
  // A local filter over decks already owned spends nothing.
  assert.equal(needs("POST", "/search"), false);

  // Default-deny: every writing route, including ones that do not exist yet.
  for (const p of ["/", "/a/generate", "/a/render", "/a/chat", "/a/report/render", "/a/some-future-route"]) {
    assert.equal(needs("POST", p), true, `POST ${p} must be gated`);
  }
  assert.equal(needs("PUT", "/a"), true);
  assert.equal(needs("PATCH", "/a"), true);
});

/* -------------------------------------------------------------- tokens */

test("a token round-trips once and only once", async () => {
  await auth.register({ name: "Once", email: "once@example.com", password: "correct horse" });
  const token = auth.issueAuthToken("once@example.com", "reset");
  assert.ok(token, "a token is issued for a real account");

  const first = auth.consumeAuthToken(token, "reset");
  assert.equal(first.ok, true);
  assert.equal(first.email, "once@example.com");

  const second = auth.consumeAuthToken(token, "reset");
  assert.equal(second.ok, false);
  assert.equal(second.reason, "used", "a reset link is spent by the first use, not by the second");
});

test("the plaintext token is never written to the database", () => {
  const token = auth.issueAuthToken("once@example.com", "reset");
  const db = openDb();
  try {
    const rows = db.prepare("SELECT token_hash FROM auth_tokens").all();
    assert.ok(rows.length);
    for (const r of rows) {
      assert.notEqual(r.token_hash, token);
    }
    // A leaked database must not be a list of live password resets.
    const hit = db.prepare("SELECT COUNT(*) c FROM auth_tokens WHERE token_hash=?").get(token);
    assert.equal(hit.c, 0);
  } finally {
    db.close();
  }
});

test("a token cannot be spent for the purpose it was not issued for", () => {
  const verify = auth.issueAuthToken("once@example.com", "verify");
  const wrong = auth.consumeAuthToken(verify, "reset");
  assert.equal(wrong.ok, false);
  assert.equal(wrong.reason, "invalid");
  // ...and is still good for its own purpose afterwards.
  assert.equal(auth.consumeAuthToken(verify, "verify").ok, true);
});

test("issuing a second token kills the first", () => {
  const older = auth.issueAuthToken("once@example.com", "reset");
  const newer = auth.issueAuthToken("once@example.com", "reset");
  assert.notEqual(older, newer);
  // Two live reset links doubles the window an intercepted email is useful in.
  assert.equal(auth.consumeAuthToken(older, "reset").reason, "invalid");
  assert.equal(auth.consumeAuthToken(newer, "reset").ok, true);
});

test("an expired token is refused", () => {
  const token = auth.issueAuthToken("once@example.com", "reset");
  const db = openDb();
  try {
    db.prepare("UPDATE auth_tokens SET expires_at=? WHERE used_at IS NULL").run(Date.now() - 1000);
  } finally {
    db.close();
  }
  const spent = auth.consumeAuthToken(token, "reset");
  assert.equal(spent.ok, false);
  assert.equal(spent.reason, "expired");
});

test("an unknown token is refused without saying why it is unknown", () => {
  const spent = auth.consumeAuthToken("0".repeat(64), "reset");
  assert.equal(spent.ok, false);
  assert.equal(spent.reason, "invalid");
});

/* --------------------------------------------------------------- reset */

test("a reset changes the password, kills every session, and proves the address", async () => {
  setSmtp(true);
  const email = "locked@example.com";
  await auth.register({ name: "Locked", email, password: "old password" });
  const user = await auth.authenticate(email, "old password");
  assert.equal(user.verified, false);

  const a = await auth.startSession(user);
  const b = await auth.startSession(user);
  assert.ok(await auth.userForToken(a));
  assert.ok(await auth.userForToken(b));

  const token = auth.issueAuthToken(email, "reset");
  const spent = auth.consumeAuthToken(token, "reset");
  assert.equal(spent.ok, true);
  auth.resetPassword(spent.email, "a brand new one");

  assert.equal(await auth.authenticate(email, "old password"), null, "the old password stops working");
  const after = await auth.authenticate(email, "a brand new one");
  assert.ok(after, "the new password works");

  // A reset is what someone does when they believe they have lost control of
  // the account; leaving the sessions that prompted it alive answers the wrong
  // half of the problem.
  assert.equal(await auth.userForToken(a), null);
  assert.equal(await auth.userForToken(b), null);

  // Completing a reset is itself proof that the address is readable.
  assert.equal(after.verified, true);
});

test("a reset refuses a password the registration rules would refuse", async () => {
  await auth.register({ name: "Short", email: "short@example.com", password: "long enough" });
  assert.throws(() => auth.resetPassword("short@example.com", "abc"), /at least 8 characters/);
  // ...and the old password still works, so a rejected attempt costs nothing.
  assert.ok(await auth.authenticate("short@example.com", "long enough"));
});

/* -------------------------------------------------------------- verify */

test("marking verified is idempotent and does not move the timestamp", async () => {
  setSmtp(true);
  const email = "confirm@example.com";
  await auth.register({ name: "Confirm", email, password: "correct horse" });
  const token = auth.issueAuthToken(email, "verify");
  assert.equal(auth.consumeAuthToken(token, "verify").ok, true);
  assert.equal(auth.markVerified(email), true);

  const db = openDb();
  let first;
  try {
    first = db.prepare("SELECT verified_at FROM users WHERE email=?").get(email).verified_at;
  } finally { db.close(); }
  assert.ok(first);

  auth.markVerified(email);
  const db2 = openDb();
  try {
    const again = db2.prepare("SELECT verified_at FROM users WHERE email=?").get(email).verified_at;
    assert.equal(again, first, "a second confirmation must not rewrite when the address was proved");
  } finally { db2.close(); }

  assert.equal((await auth.authenticate(email, "correct horse")).verified, true);
});

test("verification state distinguishes no account from a Google account", async () => {
  assert.equal(auth.accountVerificationState("nobody@example.com"), null);
  const google = auth.accountVerificationState("goog@example.com");
  // A Google account has no password to reset — the forgot route must be able
  // to tell that apart from a missing account without saying so to the caller.
  assert.equal(google.hasPassword, false);
  assert.equal(google.verified, true);
  assert.equal(auth.accountVerificationState("once@example.com").hasPassword, true);
});

test("tokens for a deleted account go with it", async () => {
  setSmtp(true);
  const email = "transient@example.com";
  await auth.register({ name: "Transient", email, password: "correct horse" });
  const token = auth.issueAuthToken(email, "reset");
  await auth.deleteUserAccount(email);
  const spent = auth.consumeAuthToken(token, "reset");
  assert.equal(spent.ok, false, "a reset link must not outlive the account it opens");
});

test("pruning drops spent and expired rows, never live ones", () => {
  const live = auth.issueAuthToken("once@example.com", "reset");
  const db = openDb();
  try {
    db.prepare("INSERT INTO auth_tokens (token_hash,user_id,email,purpose,created_at,expires_at) VALUES ('stale',1,'x@y.z','reset',0,1)").run();
  } finally { db.close(); }
  auth.pruneAuthTokens();
  const db2 = openDb();
  try {
    assert.equal(db2.prepare("SELECT COUNT(*) c FROM auth_tokens WHERE token_hash='stale'").get().c, 0);
  } finally { db2.close(); }
  assert.equal(auth.consumeAuthToken(live, "reset").ok, true, "a live token survives a prune");
});

/* ------------------------------------------------------------ migration */

test("accounts that predate verification are grandfathered, not locked out", async () => {
  // A database written before the column existed: users, no verified_at.
  const old = path.join(scratch, "legacy.db");
  const raw = new DatabaseSync(old);
  raw.exec(`
    CREATE TABLE users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
      password_hash TEXT, salt TEXT, google_sub TEXT UNIQUE, google_email TEXT,
      role TEXT, created_at TEXT NOT NULL
    );
    INSERT INTO users (email,name,created_at) VALUES ('early@example.com','Early','2026-01-01T00:00:00.000Z');
  `);
  raw.close();

  const prevPath = process.env.FORGE_DB_PATH;
  closeDb();
  process.env.FORGE_DB_PATH = old;
  getDb(); // running ensureSchema is the migration
  closeDb();
  process.env.FORGE_DB_PATH = prevPath;
  getDb();

  const check = new DatabaseSync(old);
  try {
    const row = check.prepare("SELECT verified_at FROM users WHERE email='early@example.com'").get();
    // These people registered when nothing was asked of them. A migration that
    // locks every one of them out of their own decks is a worse failure than
    // the one verification prevents.
    assert.equal(row.verified_at, "2026-01-01T00:00:00.000Z");
  } finally {
    check.close();
  }
});