test/recovery.test.js
Rama main · réplica de lectura del repositorio original.
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();
}
});