gateway-admin-billing-repair.test.mjs
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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 | }); |