798a698132
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.
107 lines
4.4 KiB
JavaScript
107 lines
4.4 KiB
JavaScript
// 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);
|
|
});
|
|
});
|