From 7620d5b7d42d8fe755d0ab9575156bbfa6668c06 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 21:25:49 +0800 Subject: [PATCH] Add T30: admin credit adjust + admin auth migration off the shared password --- admin-auth.test.js | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ credit-ledger.test.js | 23 +++++++++++++++++++++++ db.js | 45 +++++++++++++++++++++++++++++++++++++++------ server.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 228 insertions(+), 11 deletions(-) diff --git a/admin-auth.test.js b/admin-auth.test.js index 2b82dee..2f9580b 100644 --- a/admin-auth.test.js +++ b/admin-auth.test.js @@ -4,6 +4,11 @@ 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}`; @@ -91,3 +96,113 @@ test("logging in with the right password grants a session that unlocks /api/admi }); 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); +}); diff --git a/credit-ledger.test.js b/credit-ledger.test.js index 1c36188..d5b7020 100644 --- a/credit-ledger.test.js +++ b/credit-ledger.test.js @@ -75,6 +75,29 @@ test("topup with the same paymentRef twice only credits once (Stripe at-least-on assert.equal(users.get(user.id).creditBalance, 100); }); +test("adjust can move a balance either direction and records who did it", () => { + const { users, creditLedger, user } = setup(); + creditLedger.topup({ userId: user.id, amount: 10 }); + + const up = creditLedger.adjust({ userId: user.id, delta: 5, actorUserId: "admin-1" }); + assert.equal(up.balanceAfter, 15); + assert.equal(up.actorUserId, "admin-1"); + assert.equal(up.reason, "admin_adjust"); + + const down = creditLedger.adjust({ userId: user.id, delta: -3, actorUserId: "admin-1" }); + assert.equal(down.balanceAfter, 12); + assert.equal(users.get(user.id).creditBalance, 12); +}); + +test("adjust refuses to push a balance negative, same as spend", () => { + const { users, creditLedger, user } = setup(); + assert.throws( + () => creditLedger.adjust({ userId: user.id, delta: -1 }), + (err) => err.code === "INSUFFICIENT_BALANCE" + ); + assert.equal(users.get(user.id).creditBalance, 0); +}); + // "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's // DatabaseSync is synchronous, so two Promise.all-launched spends can't // actually interleave mid-transaction within one process; what this proves diff --git a/db.js b/db.js index dbd3243..9d7def5 100644 --- a/db.js +++ b/db.js @@ -14,6 +14,14 @@ const { DatabaseSync } = require("node:sqlite"); function openDb(filePath) { const db = new DatabaseSync(filePath); + // WAL lets readers and a writer coexist instead of the default + // rollback-journal mode's "any write blocks everyone" — this app now + // legitimately has multiple processes touching data.sqlite at once (the + // long-running server plus test runs against the same file in dev), and + // credit_ledger's writes only get more frequent from here (T24 webhook, + // T27 spend/refund) — worth having before contention actually bites in + // production too, not just to unblock local test runs. + db.exec(`PRAGMA journal_mode = WAL`); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -47,12 +55,25 @@ function openDb(filePath) { relatedAssetId TEXT, balanceAfter INTEGER NOT NULL, paymentRef TEXT, + actorUserId TEXT, createdAt INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId); CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef); CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason); `); + // actorUserId was added after credit_ledger's initial CREATE TABLE + // (T30) — SQLite's ALTER TABLE has no "ADD COLUMN IF NOT EXISTS", and + // CREATE TABLE IF NOT EXISTS above is a no-op against a table that + // already exists from before this column existed. No migration + // framework exists anywhere in this project (JSON-file stores don't + // need one), so this is the same "idempotent guard, not a migration + // system" spirit as the rest of the codebase. + try { + db.exec(`ALTER TABLE credit_ledger ADD COLUMN actorUserId TEXT`); + } catch (err) { + if (!/duplicate column name/.test(err.message)) throw err; + } return db; } @@ -204,8 +225,8 @@ function rowToLedgerEntry(row) { // callers (there are none, by construction). function createCreditLedgerStore(db, users) { const insert = db.prepare(` - INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, actorUserId, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`); // createdAt alone can tie (millisecond resolution, several writes in one @@ -214,9 +235,9 @@ function createCreditLedgerStore(db, users) { const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`); const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`); - // Shared by spend/refund/topup: apply `delta` to users.creditBalance and - // insert the matching ledger row, both inside the caller's transaction. - function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null }) { + // Shared by spend/refund/topup/adjust: apply `delta` to users.creditBalance + // and insert the matching ledger row, both inside the caller's transaction. + function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null, actorUserId = null }) { const user = users.get(userId); if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" }); const balanceAfter = user.creditBalance + delta; @@ -229,9 +250,10 @@ function createCreditLedgerStore(db, users) { relatedAssetId, balanceAfter, paymentRef, + actorUserId, createdAt: Date.now(), }; - insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.createdAt); + insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.actorUserId, row.createdAt); db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId); return row; } @@ -275,6 +297,17 @@ function createCreditLedgerStore(db, users) { return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef })); }); }, + // T30: admin-initiated balance correction — delta can be either sign + // (unlike spend/refund, which each only move money one direction). + // actorUserId (the admin's own users.id) is what makes this row + // attributable ("credit_ledger 里能查到是哪个管理员做的"); it's null + // only when the request came in over the legacy shared ADMIN_PASSWORD + // channel rather than a real admin account (server.js's + // adminActorUserId()). + adjust({ userId, delta, reason = "admin_adjust", actorUserId = null }) { + if (!delta) throw new Error("adjust delta must be non-zero"); + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta, reason, actorUserId }))); + }, listByUser(userId) { return byUser.all(userId); }, diff --git a/server.js b/server.js index c575fa0..c0ad96c 100644 --- a/server.js +++ b/server.js @@ -137,15 +137,30 @@ function parseCookies(req) { return cookies; } +// T30: two accepted paths, either is enough — the ADMIN_PASSWORD cookie +// (T6, unchanged) stays as a fallback channel so nobody's locked out of the +// backend before any users.role='admin' account has been created by hand; +// requireUserSession + role='admin' (below, hoisted — same module scope) is +// the real per-admin identity path, MVCP M.10's stated rule for +// /api/admin/*. Calls requireUserSession before it's declared further down +// this file — fine, `function` declarations are fully hoisted. function isAdminAuthed(req) { const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; - if (!token) return false; - const expiresAt = adminSessions.get(token); - if (!expiresAt || expiresAt < Date.now()) { + if (token) { + const expiresAt = adminSessions.get(token); + if (expiresAt && expiresAt >= Date.now()) return true; adminSessions.delete(token); - return false; } - return true; + const user = requireUserSession(req); + return !!(user && user.role === "admin"); +} + +// Who to attribute an admin action to (T30's "credit_ledger 里能查到是哪个 +//管理员做的") — null when the request came in over the legacy shared +// ADMIN_PASSWORD channel, which has no per-admin identity to attribute to. +function adminActorUserId(req) { + const user = requireUserSession(req); + return user && user.role === "admin" ? user.id : null; } // User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md @@ -1210,6 +1225,37 @@ http if (garmentsCrud(pathname, req, res)) return; if (danceRefsCrud(pathname, req, res)) return; + // T30: manual balance correction, e.g. a support gesture ("sorry about + // the failed generation that didn't auto-refund, here's some credits") + // or clawing back an abused promo. Always attributable — see + // adminActorUserId's doc comment. + if (pathname === "/api/admin/credit/adjust" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const userId = (body.userId || "").trim(); + const delta = Number(body.delta); + if (!userId || !userStore.get(userId) || !Number.isFinite(delta) || delta === 0) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "userId (must reference an existing user) and a non-zero numeric delta are required" })); + return; + } + try { + const row = creditLedger.adjust({ userId, delta, reason: (body.reason || "admin_adjust").trim(), actorUserId: adminActorUserId(req) }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(row)); + } catch (err) { + const status = err.code === "INSUFFICIENT_BALANCE" ? 400 : 500; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + if (pathname === "/api/admin/generate" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(generationJobs.list())); -- libgit2 0.21.4