Add Users dashboard tab with per-user balances and credit grants
New cookie-gated "Users" tab on the operator dashboard: a sortable view
of every credit-ledger row (typed cloud/license/install) with computed
remaining/total balances, key filter, and a per-row "grant free credits"
action.
Endpoints (routes/admin.js):
- GET /admin/credits — snapshotAll() enriched with a type derived from
the credit-key prefix and a computed balance (computeRemaining against
live tier quotas), since the ledger stores consumed counters only.
- POST /admin/credits/grant {credit_key, amount} — adds free top-up via
addPurchasedCredits. Grants land in the never-expires purchased bucket
(spent after the tier allowance). Guards: positive integer, <=1,000,000,
and the row must already exist (a typo can't spawn a ghost row).
Admin-only; no /relay/* client contract change. Tests added in
server/test/admin-credits.test.js (mount the real router over HTTP).
Version bumped 0.2.124 -> 0.2.125.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// The /admin/credits read view + /admin/credits/grant action behind the
|
||||
// Users dashboard tab. Mounts the REAL admin router (sans the cookie
|
||||
// auth middleware, which lives in index.js) over an ephemeral HTTP
|
||||
// server so the handler's validation runs for real, not just the
|
||||
// underlying ledger primitives.
|
||||
|
||||
import { test, describe, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
|
||||
import { initCredits, setUserTier, addPurchasedCredits } from "../credits.js";
|
||||
import { adminRouter } from "../routes/admin.js";
|
||||
|
||||
let baseUrl;
|
||||
let server;
|
||||
|
||||
const getCredits = async () => {
|
||||
const r = await fetch(`${baseUrl}/admin/credits`, { cache: "no-store" });
|
||||
return { status: r.status, body: await r.json() };
|
||||
};
|
||||
const grant = async (payload) => {
|
||||
const r = await fetch(`${baseUrl}/admin/credits/grant`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
};
|
||||
const rowFor = (body, key) => body.rows.find((r) => r.credit_key === key);
|
||||
|
||||
describe("/admin/credits + /admin/credits/grant", () => {
|
||||
before(async () => {
|
||||
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-admincred-")) });
|
||||
// Seed one row of each ledger-key shape so the type derivation and
|
||||
// the per-tier balance math are both exercised. Default quotas:
|
||||
// Core lifetime=10, Pro monthly=50.
|
||||
await setUserTier({ userId: "alice", tier: "pro" }); // user:alice
|
||||
await addPurchasedCredits({ creditKey: "inst:bob", amount: 5 }); // Core install
|
||||
await addPurchasedCredits({ creditKey: "lic:deadbeefdeadbeef", amount: 2 }); // license pool
|
||||
|
||||
const app = express();
|
||||
app.use("/admin", adminRouter({ dataDir: "/tmp" }));
|
||||
await new Promise((resolve) => { server = app.listen(0, resolve); });
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => { server?.close(); });
|
||||
|
||||
test("GET /credits derives a type from the credit-key prefix", async () => {
|
||||
const { status, body } = await getCredits();
|
||||
assert.equal(status, 200);
|
||||
assert.equal(rowFor(body, "user:alice").type, "cloud");
|
||||
assert.equal(rowFor(body, "inst:bob").type, "install");
|
||||
assert.equal(rowFor(body, "lic:deadbeefdeadbeef").type, "license");
|
||||
});
|
||||
|
||||
test("GET /credits computes remaining/total (not raw counters)", async () => {
|
||||
const { body } = await getCredits();
|
||||
const alice = rowFor(body, "user:alice"); // Pro, monthly 50, nothing spent
|
||||
assert.equal(alice.remaining, 50);
|
||||
assert.equal(alice.purchased, 0);
|
||||
assert.equal(alice.total, 50);
|
||||
const bob = rowFor(body, "inst:bob"); // Core lifetime 10 + 5 purchased
|
||||
assert.equal(bob.remaining, 10);
|
||||
assert.equal(bob.purchased, 5);
|
||||
assert.equal(bob.total, 15);
|
||||
});
|
||||
|
||||
test("POST /grant adds to the purchased bucket and returns the new balance", async () => {
|
||||
const { status, body } = await grant({ credit_key: "inst:bob", amount: 7 });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.granted, 7);
|
||||
assert.equal(body.purchased_balance, 12); // 5 seeded + 7 granted
|
||||
|
||||
// ...and the read view reflects it (purchased 12, total 10 + 12).
|
||||
const { body: after } = await getCredits();
|
||||
const bob = rowFor(after, "inst:bob");
|
||||
assert.equal(bob.purchased, 12);
|
||||
assert.equal(bob.total, 22);
|
||||
});
|
||||
|
||||
test("POST /grant rejects non-positive / non-integer amounts", async () => {
|
||||
for (const amount of [0, -5, 1.5, "x"]) {
|
||||
const { status } = await grant({ credit_key: "inst:bob", amount });
|
||||
assert.equal(status, 400, `amount=${amount} should be 400`);
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /grant rejects an absurdly large amount (fat-finger guard)", async () => {
|
||||
const { status } = await grant({ credit_key: "inst:bob", amount: 2_000_000 });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
test("POST /grant 400s on missing credit_key, 404s on an unknown one", async () => {
|
||||
assert.equal((await grant({ amount: 5 })).status, 400);
|
||||
const unknown = await grant({ credit_key: "inst:nobody", amount: 5 });
|
||||
assert.equal(unknown.status, 404);
|
||||
// The unknown key must NOT have been created as a side effect.
|
||||
const { body } = await getCredits();
|
||||
assert.equal(rowFor(body, "inst:nobody"), undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user