gateway-admin-billing-repair.test.mjs
430 lines 18.7 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Tests for POST /api/v1/admin/billing/repair
3 *
4 * 7 tiers: unit → integration → e2e → stress → data-integrity → performance → security
5 *
6 * The endpoint writes directly to the billing DB to repair missed Stripe webhook events.
7 * Auth: admin JWT (sub must be in HUB_ADMIN_USER_IDS env var). Non-admins get 403.
8 *
9 * SEC-KN-3a decision: do **not** skip/gate on a canister replica. This suite already sets
10 * CANISTER_URL='' and never calls the canister. The plain-`npm test` hang was a corrupt
11 * shared `data/hosted_billing.json` plus leftover HTTP handles — fixed by isolating the
12 * billing DB via KNOWTATION_BILLING_DB_PATH (see createGateway).
13 */
14 import { describe, it, before, after } from 'node:test';
15 import assert from 'node:assert/strict';
16 import http from 'node:http';
17 import crypto from 'node:crypto';
18 import fs from 'node:fs';
19 import os from 'node:os';
20 import path from 'node:path';
21 import { fileURLToPath, pathToFileURL } from 'node:url';
22
23 const __dirname = path.dirname(fileURLToPath(import.meta.url));
24 const ROOT = path.resolve(__dirname, '..');
25 const SERVER_SRC = fs.readFileSync(path.join(ROOT, 'hub', 'gateway', 'server.mjs'), 'utf8');
26
27 // ─── Shared test identities ───────────────────────────────────────────────────
28 const SECRET = 'admin-billing-repair-test-secret-32c';
29 const ADMIN_SUB = 'google:admin-billing-test-00001';
30 const MEMBER_SUB = 'google:member-billing-test-00002';
31 const OTHER_SUB = 'google:other-billing-test-00003';
32
33 // ─── JWT helpers (manual HMAC-SHA256, same as C7 tests) ──────────────────────
34 function makeJwt(sub, role = 'member', secret = SECRET) {
35 const now = Math.floor(Date.now() / 1000);
36 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
37 const payload = Buffer.from(JSON.stringify({
38 sub, role, provider: 'google', id: sub, iat: now - 5, exp: now + 3600,
39 })).toString('base64url');
40 const sig = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
41 return `${header}.${payload}.${sig}`;
42 }
43
44 function adminToken() { return makeJwt(ADMIN_SUB, 'admin'); }
45 function memberToken() { return makeJwt(MEMBER_SUB, 'member'); }
46
47 // ─── Server helpers ───────────────────────────────────────────────────────────
48 function startServer(app) {
49 const srv = http.createServer(app);
50 return new Promise((resolve, reject) => {
51 srv.listen(0, '127.0.0.1', (err) => {
52 if (err) return reject(err);
53 resolve({
54 url: `http://127.0.0.1:${srv.address().port}`,
55 close: () => new Promise((r) => {
56 // closeAllConnections() destroys keepalive connections so srv.close() can finish.
57 if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections();
58 srv.close(() => r());
59 }),
60 });
61 });
62 });
63 }
64
65 /**
66 * Each call gets a fresh module instance (cache-busting query string) and an
67 * isolated billing DB file so concurrent / prior suite runs cannot share a
68 * corrupt repo-local data/hosted_billing.json.
69 */
70 async function createGateway() {
71 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kn-billing-repair-'));
72 process.env.SESSION_SECRET = SECRET;
73 process.env.HUB_ADMIN_USER_IDS = ADMIN_SUB;
74 process.env.BILLING_ENFORCE = 'false';
75 process.env.NETLIFY = '1';
76 process.env.CANISTER_URL = '';
77 process.env.BRIDGE_URL = '';
78 process.env.STRIPE_SECRET_KEY = ''; // not needed for repair endpoint
79 process.env.KNOWTATION_BILLING_DB_PATH = path.join(tmpDir, 'hosted_billing.json');
80 // Ensure file-backed store (billing-store prefers blob when this global is set).
81 delete globalThis.__knowtation_gateway_blob;
82 const entry = pathToFileURL(path.join(ROOT, 'hub', 'gateway', 'server.mjs')).href;
83 const { app } = await import(`${entry}?repair-test=${Date.now()}-${Math.random()}`);
84 const srv = await startServer(app);
85 return {
86 ...srv,
87 close: async () => {
88 await srv.close();
89 try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
90 },
91 };
92 }
93
94 // ─── HTTP helpers ─────────────────────────────────────────────────────────────
95 async function post(baseUrl, path_, body, token) {
96 const raw = JSON.stringify(body);
97 return new Promise((resolve, reject) => {
98 const u = new URL(baseUrl + path_);
99 const req = http.request({
100 hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
101 headers: {
102 'Content-Type': 'application/json',
103 'Content-Length': Buffer.byteLength(raw),
104 ...(token ? { Authorization: `Bearer ${token}` } : {}),
105 },
106 }, (res) => {
107 let data = '';
108 res.on('data', (c) => { data += c; });
109 res.on('end', () => {
110 try { resolve({ status: res.statusCode, body: JSON.parse(data), headers: res.headers }); }
111 catch { resolve({ status: res.statusCode, body: data, headers: res.headers }); }
112 });
113 });
114 req.on('error', reject);
115 req.write(raw);
116 req.end();
117 });
118 }
119
120 /** Same as post() but sends Connection: close — prevents keep-alive socket reuse in stress tests. */
121 async function postClose(baseUrl, path_, body, token) {
122 const raw = JSON.stringify(body);
123 return new Promise((resolve, reject) => {
124 const u = new URL(baseUrl + path_);
125 const req = http.request({
126 hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
127 headers: {
128 'Content-Type': 'application/json',
129 'Content-Length': Buffer.byteLength(raw),
130 'Connection': 'close',
131 ...(token ? { Authorization: `Bearer ${token}` } : {}),
132 },
133 }, (res) => {
134 let data = '';
135 res.on('data', (c) => { data += c; });
136 res.on('end', () => {
137 try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
138 catch { resolve({ status: res.statusCode, body: data }); }
139 });
140 });
141 req.on('error', reject);
142 req.write(raw);
143 req.end();
144 });
145 }
146
147 async function get(baseUrl, path_, token) {
148 return new Promise((resolve, reject) => {
149 const u = new URL(baseUrl + path_);
150 const req = http.request({
151 hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET',
152 headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
153 }, (res) => {
154 let data = '';
155 res.on('data', (c) => { data += c; });
156 res.on('end', () => {
157 try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
158 catch { resolve({ status: res.statusCode, body: data }); }
159 });
160 });
161 req.on('error', reject);
162 req.end();
163 });
164 }
165
166 const REPAIR = '/api/v1/admin/billing/repair';
167 const SUMMARY = '/api/v1/billing/summary';
168
169 // ─── 1. Unit: structural wiring ───────────────────────────────────────────────
170
171 describe('admin/billing/repair — unit: structural wiring', () => {
172 it('endpoint is declared in server.mjs', () => {
173 assert.ok(
174 SERVER_SRC.includes("'/api/v1/admin/billing/repair'"),
175 'route must be mounted in server.mjs',
176 );
177 });
178
179 it('MONTHLY_INCLUDED_CENTS_BY_TIER is imported in server.mjs', () => {
180 assert.ok(
181 SERVER_SRC.includes('MONTHLY_INCLUDED_CENTS_BY_TIER'),
182 'must import MONTHLY_INCLUDED_CENTS_BY_TIER from billing-constants',
183 );
184 });
185
186 it('VALID_REPAIR_TIERS set is declared in server.mjs', () => {
187 assert.ok(SERVER_SRC.includes('VALID_REPAIR_TIERS'), 'tier allowlist must exist');
188 });
189 });
190
191 // ─── 2. Integration: DB mutation ──────────────────────────────────────────────
192
193 describe('admin/billing/repair — integration: DB mutation', () => {
194 let gw;
195 before(async () => { gw = await createGateway(); });
196 after(() => gw.close());
197
198 it('returns ok:true with uid, tier, and before snapshot', async () => {
199 const { status, body } = await post(gw.url, REPAIR, { tier: 'plus' }, adminToken());
200 assert.equal(status, 200);
201 assert.equal(body.ok, true);
202 assert.equal(body.tier, 'plus');
203 assert.equal(typeof body.uid, 'string');
204 assert.ok('before' in body, 'before snapshot required');
205 });
206
207 it('defaults uid to the calling admin when uid is omitted', async () => {
208 const { body } = await post(gw.url, REPAIR, { tier: 'growth' }, adminToken());
209 assert.equal(body.uid, ADMIN_SUB);
210 });
211
212 it('accepts an explicit uid different from the caller', async () => {
213 const { body } = await post(gw.url, REPAIR, { uid: OTHER_SUB, tier: 'plus' }, adminToken());
214 assert.equal(body.uid, OTHER_SUB);
215 });
216
217 it('tier change is reflected in billing/summary', async () => {
218 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus' }, adminToken());
219 const { body } = await get(gw.url, SUMMARY, adminToken());
220 assert.equal(body.tier, 'plus');
221 });
222
223 it('setting stripe_subscription_id → has_active_subscription:true in summary', async () => {
224 await post(gw.url, REPAIR,
225 { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_integration_test' }, adminToken());
226 const { body } = await get(gw.url, SUMMARY, adminToken());
227 assert.equal(body.has_active_subscription, true);
228 });
229
230 it('clearing stripe_subscription_id (empty string) → has_active_subscription:false', async () => {
231 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_will_clear' }, adminToken());
232 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: '' }, adminToken());
233 const { body } = await get(gw.url, SUMMARY, adminToken());
234 assert.equal(body.has_active_subscription, false);
235 });
236
237 it('omitting stripe_subscription_id leaves existing value intact', async () => {
238 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_preserved' }, adminToken());
239 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'growth' }, adminToken()); // no sub field
240 const { body } = await get(gw.url, SUMMARY, adminToken());
241 assert.equal(body.has_active_subscription, true, 'sub id should survive tier-only repair');
242 });
243
244 it('before snapshot reflects the previous tier', async () => {
245 await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'free' }, adminToken());
246 const { body } = await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'pro' }, adminToken());
247 assert.equal(body.before.tier, 'free');
248 assert.equal(body.tier, 'pro');
249 });
250
251 it('accepts all valid tier names', async () => {
252 const tiers = ['free', 'beta', 'plus', 'growth', 'pro', 'starter', 'team'];
253 for (const tier of tiers) {
254 const { status } = await post(gw.url, REPAIR, { tier }, adminToken());
255 assert.equal(status, 200, `tier "${tier}" must be accepted`);
256 }
257 });
258 });
259
260 // ─── 3. End-to-end: full pack-visibility repair scenario ──────────────────────
261
262 describe('admin/billing/repair — e2e: pack-visibility repair', () => {
263 let gw;
264 before(async () => { gw = await createGateway(); });
265 after(() => gw.close());
266
267 it('user starts at beta → admin repairs to plus+sub → both gates fixed', async () => {
268 // Verify initial state (fresh gateway starts users at beta/default)
269 const initial = await get(gw.url, SUMMARY, adminToken());
270 assert.notEqual(initial.body.tier, 'plus', 'should not already be plus before repair');
271
272 // Perform repair
273 const repair = await post(gw.url, REPAIR,
274 { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_e2e_repair' }, adminToken());
275 assert.equal(repair.status, 200);
276 assert.equal(repair.body.ok, true);
277
278 // Verify both pack gates are now satisfied
279 const after = await get(gw.url, SUMMARY, adminToken());
280 assert.equal(after.body.tier, 'plus');
281 assert.equal(after.body.has_active_subscription, true);
282 // stripe_configured depends on STRIPE_SECRET_KEY env, which we blanked for tests.
283 // The other two gates (tier != beta/free, has_active_subscription) are now fixed.
284 });
285 });
286
287 // ─── 4. Stress: rapid concurrent repairs ─────────────────────────────────────
288
289 describe('admin/billing/repair — stress: concurrent repairs', () => {
290 let gw;
291 before(async () => { gw = await createGateway(); });
292 after(() => gw.close());
293
294 it('5 concurrent repair calls all succeed without throwing', async () => {
295 // Use connection:close on each request so the HTTP agent does not queue
296 // requests behind a shared keep-alive socket, which prevents the server
297 // from closing cleanly when there are concurrent file writes.
298 const calls = Array.from({ length: 5 }, () =>
299 postClose(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus' }, adminToken()),
300 );
301 const results = await Promise.allSettled(calls);
302 for (const r of results) {
303 assert.equal(r.status, 'fulfilled', 'call must resolve, not reject');
304 assert.equal(r.value.status, 200, 'all concurrent repair calls should succeed');
305 }
306 });
307 });
308
309 // ─── 5. Data integrity ────────────────────────────────────────────────────────
310
311 describe('admin/billing/repair — data-integrity', () => {
312 let gw;
313 before(async () => { gw = await createGateway(); });
314 after(() => gw.close());
315
316 it('response contains no JWT secrets or signing keys', async () => {
317 const { body } = await post(gw.url, REPAIR,
318 { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_secret_check' }, adminToken());
319 const s = JSON.stringify(body);
320 assert.ok(!s.includes(SECRET), 'JWT secret must not appear in response');
321 assert.ok(!s.includes('eyJ'), 'JWT token must not appear in response');
322 });
323
324 it('invalid tier returns 400 with a valid_tiers list', async () => {
325 const { status, body } = await post(gw.url, REPAIR, { tier: 'ultraplus' }, adminToken());
326 assert.equal(status, 400);
327 assert.ok(Array.isArray(body.valid_tiers), 'valid_tiers list must be in 400 response');
328 });
329
330 it('missing tier returns 400', async () => {
331 const { status } = await post(gw.url, REPAIR, {}, adminToken());
332 assert.equal(status, 400);
333 });
334
335 it('unknown body fields are silently ignored and do not corrupt the record', async () => {
336 const { status, body } = await post(gw.url, REPAIR,
337 { tier: 'plus', evil: 'injection', foo: 123 }, adminToken());
338 assert.equal(status, 200);
339 assert.equal(body.tier, 'plus');
340 assert.ok(!('evil' in body), 'unknown fields must not appear in response');
341 });
342
343 it('empty string uid falls back to caller uid (not an empty-string user)', async () => {
344 const { body } = await post(gw.url, REPAIR, { uid: '', tier: 'plus' }, adminToken());
345 assert.equal(body.uid, ADMIN_SUB, 'empty uid must fall back to caller');
346 });
347 });
348
349 // ─── 6. Performance ──────────────────────────────────────────────────────────
350
351 describe('admin/billing/repair — performance', () => {
352 let gw;
353 before(async () => { gw = await createGateway(); });
354 after(() => gw.close());
355
356 it('responds within 2000 ms', async () => {
357 const start = Date.now();
358 const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, adminToken());
359 const elapsed = Date.now() - start;
360 assert.equal(status, 200);
361 assert.ok(elapsed < 2000, `must respond within 2000 ms; took ${elapsed} ms`);
362 });
363 });
364
365 // ─── 7. Security ─────────────────────────────────────────────────────────────
366
367 describe('admin/billing/repair — security', () => {
368 let gw;
369 before(async () => { gw = await createGateway(); });
370 after(() => gw.close());
371
372 it('returns 401 with no Authorization header', async () => {
373 const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, null);
374 assert.equal(status, 401);
375 });
376
377 it('returns 403 for a valid non-admin JWT', async () => {
378 const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, memberToken());
379 assert.equal(status, 403);
380 });
381
382 it('returns 401 for a token signed with the wrong secret', async () => {
383 const badToken = makeJwt(ADMIN_SUB, 'admin', 'wrong-secret');
384 const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, badToken);
385 assert.equal(status, 401);
386 });
387
388 it('returns 401 for an alg:none algorithm-confusion token', async () => {
389 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
390 const payload = Buffer.from(JSON.stringify({ sub: ADMIN_SUB, role: 'admin' })).toString('base64url');
391 const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, `${header}.${payload}.`);
392 assert.equal(status, 401);
393 });
394
395 it('member cannot escalate their own tier by targeting their own uid', async () => {
396 const { status } = await post(gw.url, REPAIR, { uid: MEMBER_SUB, tier: 'pro' }, memberToken());
397 assert.equal(status, 403);
398 });
399
400 it('response does not include X-Powered-By header', async () => {
401 const xpb = await new Promise((resolve, reject) => {
402 const raw = JSON.stringify({ tier: 'plus' });
403 const u = new URL(gw.url + REPAIR);
404 const req = http.request({
405 hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
406 headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw),
407 Authorization: `Bearer ${adminToken()}` },
408 }, (res) => resolve(res.headers['x-powered-by']));
409 req.on('error', reject);
410 req.write(raw);
411 req.end();
412 });
413 assert.ok(!xpb, `X-Powered-By must not be set; got: ${xpb}`);
414 });
415
416 it('injection payloads in tier field are rejected with 400', async () => {
417 const payloads = ["'; DROP TABLE users; --", '{"$gt":""}', '<script>alert(1)</script>', '../../../etc/passwd'];
418 for (const tier of payloads) {
419 const { status } = await post(gw.url, REPAIR, { tier }, adminToken());
420 assert.equal(status, 400, `injection payload "${tier}" should be rejected`);
421 }
422 });
423
424 it('error responses do not leak stack traces or server internals', async () => {
425 const { body } = await post(gw.url, REPAIR, { tier: 'bad' }, adminToken());
426 const s = JSON.stringify(body);
427 assert.ok(!s.includes('at '), 'stack traces must not appear in error responses');
428 assert.ok(!s.includes('node_modules'), 'internal paths must not be exposed');
429 });
430 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago