gateway-admin-billing-repair.test.mjs file-level

at sha256:0 · View file ↗ · Intel ↗

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