|
| 1 | +/** |
| 2 | + * Regression test: scripts/notification-relay.cjs sendTelegram() must NOT |
| 3 | + * recurse infinitely on sustained 429 responses. |
| 4 | + * |
| 5 | + * Before the fix, the 429 handler called sendTelegram() unconditionally with |
| 6 | + * no retry counter, creating unbounded recursion during sustained rate limiting. |
| 7 | + * This could stack-overflow the Railway relay process. |
| 8 | + * |
| 9 | + * The fix adds a `_retryCount` parameter (default 0) and bails after one retry. |
| 10 | + * |
| 11 | + * Run: node --test tests/notification-relay-telegram-retry.test.mjs |
| 12 | + */ |
| 13 | + |
| 14 | +import { describe, it } from 'node:test'; |
| 15 | +import assert from 'node:assert/strict'; |
| 16 | +import { readFileSync } from 'node:fs'; |
| 17 | +import { resolve, dirname } from 'node:path'; |
| 18 | +import { fileURLToPath } from 'node:url'; |
| 19 | + |
| 20 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 21 | +const relaySrc = readFileSync( |
| 22 | + resolve(__dirname, '..', 'scripts', 'notification-relay.cjs'), |
| 23 | + 'utf-8', |
| 24 | +); |
| 25 | + |
| 26 | +function extractSendTelegram(src) { |
| 27 | + const idx = src.indexOf('async function sendTelegram('); |
| 28 | + assert.ok(idx !== -1, 'sendTelegram not found in notification-relay.cjs'); |
| 29 | + const openIdx = src.indexOf('{', idx); |
| 30 | + let depth = 1; |
| 31 | + let i = openIdx + 1; |
| 32 | + while (i < src.length && depth > 0) { |
| 33 | + if (src[i] === '{') depth++; |
| 34 | + else if (src[i] === '}') depth--; |
| 35 | + i++; |
| 36 | + } |
| 37 | + return src.slice(idx, i); |
| 38 | +} |
| 39 | + |
| 40 | +describe('notification-relay sendTelegram retry discipline', () => { |
| 41 | + const fn = extractSendTelegram(relaySrc); |
| 42 | + |
| 43 | + it('has a _retryCount parameter defaulting to 0', () => { |
| 44 | + assert.match( |
| 45 | + fn, |
| 46 | + /async function sendTelegram\s*\(\s*\w+,\s*\w+,\s*\w+,\s*_retryCount\s*=\s*0\s*\)/, |
| 47 | + 'sendTelegram must accept _retryCount = 0 parameter', |
| 48 | + ); |
| 49 | + }); |
| 50 | + |
| 51 | + it('bails and returns false when _retryCount >= 1 (no infinite recursion)', () => { |
| 52 | + // There must be a guard that returns false when retry count is exceeded. |
| 53 | + // The guard code is: if ((_retryCount ?? 0) >= 1) { console.warn(...); return false; } |
| 54 | + assert.match( |
| 55 | + fn, |
| 56 | + /_retryCount.*?\)\s*>=\s*1/, |
| 57 | + 'sendTelegram must guard against _retryCount >= 1 to prevent infinite recursion', |
| 58 | + ); |
| 59 | + |
| 60 | + // The guard must return false (not recurse again) |
| 61 | + assert.ok( |
| 62 | + /if\s*\([^)]*_retryCount[^)]*\)\s*>=\s*1/.test(fn), |
| 63 | + 'guard if-statement with _retryCount >= 1 check not found', |
| 64 | + ); |
| 65 | + assert.ok( |
| 66 | + fn.includes('return false'), |
| 67 | + 'guard must return false when retry limit is exceeded', |
| 68 | + ); |
| 69 | + |
| 70 | + // The return false must be INSIDE the guard if-block, not somewhere else |
| 71 | + const guardBlockIdx = fn.indexOf('if ((_retryCount ?? 0) >= 1)'); |
| 72 | + assert.ok(guardBlockIdx !== -1, 'guard block not found'); |
| 73 | + const afterGuard = fn.slice(guardBlockIdx); |
| 74 | + const returnIdx = afterGuard.indexOf('return false'); |
| 75 | + |
| 76 | + // Find the matching closing brace of the guard block (not the first '}') |
| 77 | + const openBraceIdx = afterGuard.indexOf('{'); |
| 78 | + let braceDepth = 0; |
| 79 | + let closingBraceIdx = -1; |
| 80 | + for (let j = openBraceIdx; j < afterGuard.length; j++) { |
| 81 | + if (afterGuard[j] === '{') braceDepth++; |
| 82 | + else if (afterGuard[j] === '}') { |
| 83 | + braceDepth--; |
| 84 | + if (braceDepth === 0) { closingBraceIdx = j; break; } |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + assert.ok( |
| 89 | + returnIdx !== -1 && returnIdx < closingBraceIdx, |
| 90 | + 'return false must appear before the closing brace of the guard block', |
| 91 | + ); |
| 92 | + }); |
| 93 | + |
| 94 | + it('passes incremented retry count on the recursive 429 call', () => { |
| 95 | + // The recursive call inside the 429 block must pass an incremented retry count. |
| 96 | + // Since regex can't easily match nested parens, we verify: |
| 97 | + // 1. sendTelegram is called recursively (the only call site in the 429 block) |
| 98 | + // 2. _retryCount is passed as an argument |
| 99 | + // 3. + 1 is present in the function (the increment) |
| 100 | + const recursiveCallIdx = fn.indexOf('return sendTelegram('); |
| 101 | + assert.ok(recursiveCallIdx !== -1, 'recursive sendTelegram call not found'); |
| 102 | + const afterCall = fn.slice(recursiveCallIdx); |
| 103 | + assert.ok( |
| 104 | + /_retryCount/.test(afterCall), |
| 105 | + 'recursive call must pass _retryCount parameter', |
| 106 | + ); |
| 107 | + assert.ok( |
| 108 | + /\+\s*1/.test(afterCall), |
| 109 | + 'recursive call must increment retry count with + 1', |
| 110 | + ); |
| 111 | + }); |
| 112 | + |
| 113 | + it('does not recurse for any status code other than 429', () => { |
| 114 | + // Count all sendTelegram( calls inside the function body. |
| 115 | + // There should be exactly 1: the recursive one in the 429 handler. |
| 116 | + const bodyOnly = fn.slice(fn.indexOf('{') + 1, fn.lastIndexOf('}')); |
| 117 | + const allCalls = bodyOnly.match(/sendTelegram\s*\(/g) || []; |
| 118 | + assert.equal( |
| 119 | + allCalls.length, 1, |
| 120 | + `sendTelegram body should contain exactly 1 self-call (the 429 retry); found ${allCalls.length}`, |
| 121 | + ); |
| 122 | + }); |
| 123 | +}); |
0 commit comments