screentinker/server/test/billing-authz.test.js
ScreenTinker 677b17028e feat(#146): billing:read scoped token — dual-path auth for the Usage Report (Option C)
Least-privilege way to read GET /api/billing/usage without requiring platform admin.
Additive + isolated: reuses the existing api_tokens scope system (the off-ladder 'agency'
scope is the precedent) and does NOT touch the shared role/permission checks other
endpoints rely on.

- New off-ladder scope 'billing:read' (routes/tokens.js SCOPES). Like 'agency' it is NOT
  on the read<write<full ladder, so tokenScopeGate rejects a billing token on every
  PUBLIC_ROUTER and JWT-only routers reject any st_ token -> the scope grants billing-read
  and NOTHING else.
- DUAL-PATH gate requireBillingRead (middleware/apiToken.js), written as an EXPLICIT OR:
  authorize if (billing:read token) OR (platform-admin session). Admins keep read access
  but are NOT required to; the token path doesn't lock out admins or vice versa. Billing
  route now mounted with bearerAuth (token OR JWT front door) + requireBillingRead (was
  requireAuth + requirePlatformAdmin).
- MINTING is platform-admin only (stricter than read/write/full/agency, which any
  workspace member may mint) since a billing:read token grants GLOBAL billing-read. Note:
  no finer "owner" tier exists here (#14 collapsed superadmin->platform_admin), so
  PLATFORM_ROLES is the top level required.

Tests (5, test/billing-authz.test.js): dual-path positive (token AND admin session both
200) + negative (user 403 / anon 401); scope isolation (billing token 403 on /api/devices,
401 on /api/admin; read token 200 on devices but 403 on billing); minting owner-only
(user + ordinary-admin 403, platform-admin 201); revocation -> 401. Existing token
firewall/partition suite (api.test.js) + billing-endpoint tests unchanged & green. Reused
the exact SHA-256 token-verification path (no bcrypt/new mechanism). Suite 306/306.

NOTE: spec described bcrypt + JSON `scopes` + an analytics:read precedent; this codebase
actually uses SHA-256 + a single `scope` TEXT column + 'agency' as the off-ladder
precedent. Implemented faithfully to the real system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:16:21 -05:00

101 lines
6.1 KiB
JavaScript

'use strict';
// #146 Option C — billing:read scoped token authz. Booted server + JWT + DB access.
// Covers the DUAL PATH (token OR admin session, both directions), SCOPE ISOLATION (a
// billing token grants billing-read and nothing else), OWNER-ONLY minting, revocation,
// and a regression that ordinary token minting is unchanged.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const PORT = 4011;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-billauthz-' + crypto.randomBytes(4).toString('hex'));
let proc, db;
const reg = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
const jwtHdr = (t) => ({ headers: { Authorization: 'Bearer ' + t } });
const post = (t, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
async function register(email) {
return (await (await fetch(BASE + '/api/auth/register', reg({ email, password: 'Passw0rd123' }))).json()).token;
}
const setRole = (email, role) => db.prepare('UPDATE users SET role = ? WHERE email = ?').run(role, email);
let adminJwt, userJwt, billingToken, billingTokenId, readToken;
before(async () => {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-billauthz.log'), 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await new Promise(r => setTimeout(r, 250)); }
if (!up) throw new Error('server did not boot');
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
const adminEmail = 'adm' + crypto.randomBytes(4).toString('hex') + '@x.local';
const userEmail = 'usr' + crypto.randomBytes(4).toString('hex') + '@x.local';
adminJwt = await register(adminEmail);
userJwt = await register(userEmail);
setRole(adminEmail, 'platform_admin'); // role is read from DB per request
// platform-admin mints a billing:read token; a normal user mints an ordinary read token.
const minted = await (await fetch(BASE + '/api/tokens', post(adminJwt, { name: 'invoice-bot', scope: 'billing:read' }))).json();
billingToken = minted.token; billingTokenId = minted.id;
readToken = (await (await fetch(BASE + '/api/tokens', post(userJwt, { name: 'reader', scope: 'read' }))).json()).token;
});
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
const S = (r) => r.status;
test('DUAL PATH positive: a billing:read token AND an admin session each read billing', async () => {
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 200, 'billing:read token can read billing');
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(adminJwt))), 200, 'platform-admin session can read billing (not required to use a token)');
// both return the real report shape
const viaToken = await (await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))).json();
assert.equal(typeof viaToken.billable_screens, 'number');
});
test('DUAL PATH negative: non-admin session and anonymous are refused', async () => {
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(userJwt))), 403, 'ordinary user session denied');
assert.equal(S(await fetch(BASE + '/api/billing/usage')), 401, 'anonymous denied');
});
test('SCOPE ISOLATION: a billing:read token grants billing-read and NOTHING else', async () => {
// off the read/write/full ladder -> tokenScopeGate rejects it on a normal public router
assert.equal(S(await fetch(BASE + '/api/devices', jwtHdr(billingToken))), 403, 'billing token cannot read devices');
// and JWT-only routers reject any st_ token outright
assert.equal(S(await fetch(BASE + '/api/admin/orgs', jwtHdr(billingToken))), 401, 'billing token cannot reach admin');
// an ordinary read token can read devices (proves the 403 above is scope isolation, not a broken token)
assert.equal(S(await fetch(BASE + '/api/devices', jwtHdr(readToken))), 200, 'ordinary read token still reads devices');
// ...but the ordinary read token CANNOT read billing (isolation from the other side)
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(readToken))), 403, 'read token cannot read billing');
});
test('MINTING is platform-admin only (owner-tier); ordinary admin and user cannot', async () => {
// ordinary user
assert.equal(S(await fetch(BASE + '/api/tokens', post(userJwt, { name: 'x', scope: 'billing:read' }))), 403, 'user cannot mint');
// ordinary admin (ELEVATED but not PLATFORM) also cannot
const aEmail = 'ord' + crypto.randomBytes(4).toString('hex') + '@x.local';
const aJwt = await register(aEmail); setRole(aEmail, 'admin');
assert.equal(S(await fetch(BASE + '/api/tokens', post(aJwt, { name: 'x', scope: 'billing:read' }))), 403, 'ordinary admin cannot mint');
// platform-admin can (already used in setup) — and an ordinary read token still mints fine (regression)
assert.equal(S(await fetch(BASE + '/api/tokens', post(adminJwt, { name: 'ok', scope: 'billing:read' }))), 201, 'platform-admin can mint');
assert.equal(S(await fetch(BASE + '/api/tokens', post(userJwt, { name: 'r', scope: 'read' }))), 201, 'ordinary token minting unchanged');
});
test('REVOCATION: a revoked billing:read token is refused', async () => {
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 200, 'valid before revoke');
const del = await fetch(BASE + '/api/tokens/' + billingTokenId, { method: 'DELETE', ...jwtHdr(adminJwt) });
assert.ok(del.status === 200 || del.status === 204, 'revoke succeeded');
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 401, 'revoked token refused');
});