mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* chore(api-manifest): rewrite brief-why-matters reason as proper internal-helper justification Carried in from #3248 merge as a band-aid (called out in #3242 review followup checklist item 7). The endpoint genuinely belongs in internal-helper — RELAY_SHARED_SECRET-bearer auth, cron-only caller, never reached by dashboards or partners. Same shape constraint as api/notify.ts. Replaces the apologetic "filed here to keep the lint green" framing with a proper structural justification: modeling it as a generated service would publish internal cron plumbing as user-facing API surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(lint): premium-fetch parity check for ServiceClients (closes #3279) Adds scripts/enforce-premium-fetch.mjs — AST-walks src/, finds every `new <ServiceClient>(...)` (variable decl OR `this.foo =` assignment), tracks which methods each instance actually calls, and fails if any called method targets a path in src/shared/premium-paths.ts PREMIUM_RPC_PATHS without `{ fetch: premiumFetch }` on the constructor. Per-call-site analysis (not class-level) keeps the trade/index.ts pattern clean — publicClient with globalThis.fetch + premiumClient with premiumFetch on the same TradeServiceClient class — since publicClient never calls a premium method. Wired into: - npm run lint:premium-fetch - .husky/pre-push (right after lint:rate-limit-policies) - .github/workflows/lint-code.yml (right after lint:api-contract) Found and fixed three latent instances of the HIGH(new) #1 class from #3242 review (silent 401 → empty fallback for signed-in browser pros): - src/services/correlation-engine/engine.ts — IntelligenceServiceClient built with no fetch option called deductSituation. LLM-assessment overlay on convergence cards never landed for browser pros without a WM key. - src/services/economic/index.ts — EconomicServiceClient with globalThis.fetch called getNationalDebt. National-debt panel rendered empty for browser pros. - src/services/sanctions-pressure.ts — SanctionsServiceClient with globalThis.fetch called listSanctionsPressure. Sanctions-pressure panel rendered empty for browser pros. All three swap to premiumFetch (single shared client, mirrors the supply-chain/index.ts justification — premiumFetch no-ops safely on public methods, so the public methods on those clients keep working). Verification: - lint:premium-fetch clean (34 ServiceClient classes, 28 premium paths, 466 src/ files analyzed) - Negative test: revert any of the three to globalThis.fetch → exit 1 with file:line and called-premium-method names - typecheck + typecheck:api clean - lint:api-contract / lint:rate-limit-policies / lint:boundaries clean - tests/sanctions-pressure.test.mjs + premium-fetch.test.mts: 16/16 pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(military): fetchStaleFallback NEG_TTL=30s parity (closes #3277) The legacy /api/military-flights handler had NEG_TTL = 30_000ms — a short suppression window after a failed live + stale read so we don't Redis-hammer the stale key during sustained relay+seed outages. Carried into the sebuf list-military-flights handler: - Module-scoped `staleNegUntil` timestamp (per-isolate on Vercel Edge, which is fine — each warm isolate gets its own 30s suppression window). - Set whenever fetchStaleFallback returns null (key missing, parse fail, empty array after staleToProto filter, or thrown error). - Checked at the entry of fetchStaleFallback before doing the Redis read. - Test seam `_resetStaleNegativeCacheForTests()` exposed for unit tests. Test pinned in tests/redis-caching.test.mjs: drives a stale-empty cycle three times — first read hits Redis, second within window doesn't, after test-only reset it does again. Verified: 18/18 redis-caching tests pass, typecheck:api clean, lint:premium-fetch clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(lint): rate-limit-policies regex → import() (closes #3278) The previous lint regex-parsed ENDPOINT_RATE_POLICIES from the source file. That worked because the literal happens to fit a single line per key today, but a future reformat (multi-line key wrap, formatter swap, etc.) would silently break the lint without breaking the build — exactly the failure mode that's worse than no lint at all. Fix: - Export ENDPOINT_RATE_POLICIES from server/_shared/rate-limit.ts. - Convert scripts/enforce-rate-limit-policies.mjs to async + dynamic import() of the policy object directly. Same TS module that the gateway uses at runtime → no source-of-truth drift possible. - Run via tsx (already a dev dep, used by test:data) so the .mjs shebang can resolve a .ts import. - npm script swapped to `tsx scripts/...`. .husky/pre-push uses `npm run lint:rate-limit-policies` so no hook change needed. Verified: - Clean: 6 policies / 182 gateway routes. - Negative test (rename a key to the original sanctions typo /api/sanctions/v1/lookup-entity): exit 1 with the same incident- attributed remedy message as before. - Reformat test (split a single-line entry across multiple lines): still passes — the property is what's read, not the source layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(shipping/v2): alertThreshold: 0 preserved; drop dead validation branch (#3242 followup) Before: alert_threshold was a plain int32. proto3 scalar default is 0, so the handler couldn't distinguish "partner explicitly sent 0 (deliver every disruption)" from "partner omitted the field (apply legacy default 50)" — both arrived as 0 and got coerced to 50 by `> 0 ? : 50`. Silent intent-drop for any partner who wanted every alert. The subsequent `alertThreshold < 0` branch was also unreachable after that coercion. After: - Proto field is `optional int32 alert_threshold` — TS type becomes `alertThreshold?: number`, so omitted = undefined and explicit 0 stays 0. - Handler uses `req.alertThreshold ?? 50` — undefined → 50, any number passes through unchanged. - Dead `< 0 || > 100` runtime check removed; buf.validate `int32.gte = 0, int32.lte = 100` already enforces the range at the wire layer. Partner wire contract: identical for the omit-field and 1..100 cases. Only behavioural change is explicit 0 — previously impossible to request, now honored per proto3 optional semantics. Scoped `buf generate --path worldmonitor/shipping/v2` to avoid the full- regen `@ts-nocheck` drift Seb documented in the #3242 PR comments. Re-applied `@ts-nocheck` on the two regenerated files manually. Tests: - `alertThreshold 0 coerces to 50` flipped to `alertThreshold 0 preserved`. - New test: `alertThreshold omitted (undefined) applies legacy default 50`. - `rejects > 100` test removed — proto/wire validation handles it; direct handler calls intentionally bypass wire and the handler no longer carries a redundant runtime range check. Verified: 18/18 shipping-v2-handler tests pass, typecheck + typecheck:api clean, all 4 custom lints clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(shipping/v2): document missing webhook delivery worker + DNS-rebinding contract (#3242 followup) #3242 followup checklist item 6 from @koala73 — sanity-check that the delivery worker honors the re-resolve-and-re-check contract that isBlockedCallbackUrl explicitly delegates to it. Audit finding: no delivery worker for shipping/v2 webhooks exists in this repo. Grep across the entire tree (excluding generated/dist) shows the only readers of webhook:sub:* records are the registration / inspection / rotate-secret handlers themselves. No code reads them and POSTs to the stored callbackUrl. The delivery worker is presumed to live in Railway (separate repo) or hasn't been built yet — neither is auditable from this repo. Refreshes the comment block at the top of webhook-shared.ts to: - explicitly state DNS rebinding is NOT mitigated at registration - spell out the four-step contract the delivery worker MUST follow (re-validate URL, dns.lookup, re-check resolved IP against patterns, fetch with resolved IP + Host header preserved) - flag the in-repo gap so anyone landing delivery code can't miss it Tracking the gap as #3288 — acceptance there is "delivery worker imports the patterns + helpers from webhook-shared.ts and applies the four steps before each send." Action moves to wherever the delivery worker actually lives (Railway likely). No code change. Tests + lints unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(lint): add rate-limit-policies step (greptile P1 #3287) Pre-push hook ran lint:rate-limit-policies but the CI workflow did not, so fork PRs and --no-verify pushes bypassed the exact drift check the lint was added to enforce (closes #3278). Adding it right after lint:api-contract so it runs in the same context the lint was designed for. * refactor(lint): premium-fetch regex → import() + loop classRe (greptile P2 #3287) Two fragilities greptile flagged on enforce-premium-fetch.mjs: 1. loadPremiumPaths regex-parsed src/shared/premium-paths.ts with /'(\/api\/[^']+)'/g — same class of silent drift we just removed from enforce-rate-limit-policies in #3278. Reformatting the source Set (double quotes, spread, helper-computed entries) would drop paths from the lint while leaving the runtime untouched. Fix: flip the shebang to `#!/usr/bin/env -S npx tsx` and dynamic-import PREMIUM_RPC_PATHS directly, mirroring the rate-limit pattern. package.json lint:premium-fetch now invokes via tsx too so the npm-script path matches direct execution. 2. loadClientClassMap ran classRe.exec once, silently dropping every ServiceClient after the first if a file ever contained more than one. Current codegen emits one class per file so this was latent, but a template change would ship un-linted classes. Fix: collect every class-open match with matchAll, slice each class body with the next class's start as the boundary, and scan methods per-body so method-to-class binding stays correct even with multiple classes per file. Verification: - lint:premium-fetch clean (34 classes / 28 premium paths / 466 files — identical counts to pre-refactor, so no coverage regression). - Negative test: revert src/services/economic/index.ts to globalThis.fetch → exit 1 with file:line, bound var name, and premium method list (getNationalDebt). Restore → clean. - lint:rate-limit-policies still clean. * fix(shipping/v2): re-add alertThreshold handler range guard (greptile nit 1 #3287) Wire-layer buf.validate enforces 0..100, but direct handler invocation (internal jobs, test harnesses, future transports) bypasses it. Cheap invariant-at-the-boundary — rejects < 0 or > 100 with ValidationError before the record is stored. Tests: restored the rejects-out-of-range cases that were dropped when the branch was (correctly) deleted as dead code on the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(lint): premium-fetch method-regex → TS AST (greptile nits 2+5 #3287) loadClientClassMap: The method regex `async (\w+)\s*\([^)]*\)\s*:\s*Promise<[^>]+>\s*\{\s*let path = "..."` assumed (a) no nested `)` in arg types, (b) no nested `>` in the return type, (c) `let path = "..."` as the literal first statement. Any codegen template shift would silently drop methods with the lint still passing clean — the same silent-drift class #3287 just closed on the premium-paths side. Now walks the service_client.ts AST, matches `export class *ServiceClient`, iterates `MethodDeclaration` members, and reads the first `let path: string = '...'` variable statement as a StringLiteral. Tolerant to any reformatting of arg/return types or method shape. findCalls scope-blindness: Added limitation comment — the walker matches `<varName>.<method>()` anywhere in the file without respecting scope. Two constructions in different function scopes sharing a var name merge their called-method sets. No current src/ file hits this; the lint errs cautiously (flags both instances). Keeping the walker simple until scope-aware binding is needed. webhook-shared.ts: Inlined issue reference (#3288) so the breadcrumb resolves without bouncing through an MDX that isn't in the diff. Verification: - lint:premium-fetch clean — 34 classes / 28 premium paths / 489 files. Pre-refactor: 34 / 28 / 466. Class + path counts identical; file bump is from the main-branch rebase, not the refactor. - Negative test: revert src/services/economic/index.ts premiumFetch → globalThis.fetch. Lint exits 1 at `src/services/economic/index.ts:64:7` with `premium method(s) called: getNationalDebt`. Restore → clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(lint): rate-limit OpenAPI regex → yaml parser (greptile nit 3 #3287) Input side (ENDPOINT_RATE_POLICIES) was flipped to live `import()` in4e79d029. Output side (OpenAPI routes) still regex-scraped top-level `paths:` keys with `/^\s{4}(\/api\/[^\s:]+):/gm` — hard-coded 4-space indent. Any YAML formatter change (2-space indent, flow style, line folding) would silently drop routes and let policy-drift slip through — same silent-drift class the input-side fix closed. Now uses the `yaml` package (already a dep) to parse each .openapi.yaml and reads `doc.paths` directly. Verification: - Clean: 6 policies / 189 routes (was 182 — yaml parser picks up a handful the regex missed, closing a silent coverage gap). - Negative test: rename policy key back to /api/sanctions/v1/lookup-entity → exits 1 with the same incident-attributed remedy. Restore → clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(codegen): regenerate unified OpenAPI bundle for alert_threshold proto change The shipping/v2 webhook alert_threshold field was flipped from `int32` to `optional int32` with an expanded doc comment inf3339464. That comment now surfaces in the unified docs/api/worldmonitor.openapi.yaml bundle (introduced by #3341). Regenerated with sebuf v0.11.1 to pick it up. No behaviour change — bundle-only documentation drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
/**
|
|
* Functional tests for ShippingV2Service handlers. Tests the typed handlers
|
|
* directly (not the HTTP gateway). Covers the security invariants the legacy
|
|
* edge functions enforced:
|
|
* - routeIntelligence: PRO gate, iso2 regex, hs2 non-digit stripping,
|
|
* cargoType coercion to legal enum, wire-shape byte-for-byte with partner
|
|
* contract (camelCase field names, ISO-8601 fetchedAt).
|
|
* - registerWebhook: PRO gate, SSRF guards (https-only, private IP, cloud
|
|
* metadata), chokepointIds whitelist, alertThreshold 0-100 range,
|
|
* subscriberId / secret format (wh_ + 24 hex / 64 hex), 30-day TTL
|
|
* atomic pipeline (SET + SADD + EXPIRE).
|
|
* - listWebhooks: PRO gate, owner-filter isolation, `secret` never in response.
|
|
*/
|
|
|
|
import { strict as assert } from 'node:assert';
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnv = { ...process.env };
|
|
|
|
function makeCtx(headers = {}) {
|
|
const req = new Request('https://worldmonitor.app/api/v2/shipping/route-intelligence', {
|
|
method: 'GET',
|
|
headers,
|
|
});
|
|
return { request: req, pathParams: {}, headers };
|
|
}
|
|
|
|
function proCtx() {
|
|
return makeCtx({ 'X-WorldMonitor-Key': 'pro-test-key' });
|
|
}
|
|
|
|
let routeIntelligence;
|
|
let registerWebhook;
|
|
let listWebhooks;
|
|
let webhookShared;
|
|
let ValidationError;
|
|
let ApiError;
|
|
|
|
describe('ShippingV2Service handlers', () => {
|
|
beforeEach(async () => {
|
|
process.env.WORLDMONITOR_VALID_KEYS = 'pro-test-key';
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.example';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token';
|
|
|
|
const riMod = await import('../server/worldmonitor/shipping/v2/route-intelligence.ts');
|
|
const rwMod = await import('../server/worldmonitor/shipping/v2/register-webhook.ts');
|
|
const lwMod = await import('../server/worldmonitor/shipping/v2/list-webhooks.ts');
|
|
webhookShared = await import('../server/worldmonitor/shipping/v2/webhook-shared.ts');
|
|
routeIntelligence = riMod.routeIntelligence;
|
|
registerWebhook = rwMod.registerWebhook;
|
|
listWebhooks = lwMod.listWebhooks;
|
|
const gen = await import('../src/generated/server/worldmonitor/shipping/v2/service_server.ts');
|
|
ValidationError = gen.ValidationError;
|
|
ApiError = gen.ApiError;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
Object.keys(process.env).forEach((k) => {
|
|
if (!(k in originalEnv)) delete process.env[k];
|
|
});
|
|
Object.assign(process.env, originalEnv);
|
|
});
|
|
|
|
describe('routeIntelligence', () => {
|
|
it('rejects non-PRO callers with 403', async () => {
|
|
await assert.rejects(
|
|
() => routeIntelligence(makeCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: '' }),
|
|
(err) => err instanceof ApiError && err.statusCode === 403,
|
|
);
|
|
});
|
|
|
|
it('rejects malformed fromIso2 with ValidationError', async () => {
|
|
// Stub redis GET for CHOKEPOINT_STATUS_KEY so the handler never panics.
|
|
globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 });
|
|
// 'usa' uppercases to 'USA' (3 chars) — regex `^[A-Z]{2}$` rejects.
|
|
await assert.rejects(
|
|
() => routeIntelligence(proCtx(), { fromIso2: 'usa', toIso2: 'NL', cargoType: '', hs2: '' }),
|
|
(err) => err instanceof ValidationError && err.violations[0].field === 'fromIso2',
|
|
);
|
|
});
|
|
|
|
it('preserves partner wire shape with ISO-8601 fetchedAt and camelCase fields', async () => {
|
|
globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 });
|
|
const before = Date.now();
|
|
const res = await routeIntelligence(proCtx(), {
|
|
fromIso2: 'AE',
|
|
toIso2: 'NL',
|
|
cargoType: 'tanker',
|
|
hs2: '27',
|
|
});
|
|
const after = Date.now();
|
|
|
|
// Partner-visible top-level fields — exact names, camelCase, full set.
|
|
assert.deepEqual(new Set(Object.keys(res)).size, 10);
|
|
assert.equal(res.fromIso2, 'AE');
|
|
assert.equal(res.toIso2, 'NL');
|
|
assert.equal(res.cargoType, 'tanker');
|
|
assert.equal(res.hs2, '27');
|
|
assert.equal(typeof res.primaryRouteId, 'string');
|
|
assert.ok(Array.isArray(res.chokepointExposures));
|
|
assert.ok(Array.isArray(res.bypassOptions));
|
|
assert.match(res.warRiskTier, /^WAR_RISK_TIER_/);
|
|
assert.equal(typeof res.disruptionScore, 'number');
|
|
|
|
// fetchedAt must be ISO-8601, NOT epoch ms — partners parse this string directly.
|
|
assert.match(res.fetchedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/);
|
|
const parsedTs = Date.parse(res.fetchedAt);
|
|
assert.ok(parsedTs >= before && parsedTs <= after, 'fetchedAt within request window');
|
|
});
|
|
|
|
it('defaults hs2 to "27" when blank or all non-digits', async () => {
|
|
globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 });
|
|
const res1 = await routeIntelligence(proCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: '' });
|
|
const res2 = await routeIntelligence(proCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: 'abc' });
|
|
assert.equal(res1.hs2, '27');
|
|
assert.equal(res2.hs2, '27');
|
|
});
|
|
|
|
it('coerces unknown cargoType to container', async () => {
|
|
globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 });
|
|
const res = await routeIntelligence(proCtx(), {
|
|
fromIso2: 'AE',
|
|
toIso2: 'NL',
|
|
cargoType: 'spaceship',
|
|
hs2: '',
|
|
});
|
|
assert.equal(res.cargoType, 'container');
|
|
});
|
|
});
|
|
|
|
describe('registerWebhook', () => {
|
|
// Capture pipeline commands dispatched to Upstash for the happy-path Redis stub.
|
|
function stubRedisOk() {
|
|
const calls = [];
|
|
globalThis.fetch = async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
calls.push(body);
|
|
// Upstash pipeline returns one result per command.
|
|
return new Response(
|
|
JSON.stringify(body.map(() => ({ result: 'OK' }))),
|
|
{ status: 200 },
|
|
);
|
|
};
|
|
return calls;
|
|
}
|
|
|
|
it('rejects callers without an API key with 401 (tenant-isolation gate)', async () => {
|
|
// Without this gate, Clerk-authenticated pro callers with no X-WorldMonitor-Key
|
|
// collapse into a shared 'anon' fingerprint bucket and can see each other's
|
|
// webhooks. Must fire before any premium check.
|
|
await assert.rejects(
|
|
() => registerWebhook(makeCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 50,
|
|
}),
|
|
(err) => err instanceof ApiError && err.statusCode === 401,
|
|
);
|
|
});
|
|
|
|
it('rejects missing callbackUrl with ValidationError', async () => {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), { callbackUrl: '', chokepointIds: [], alertThreshold: 50 }),
|
|
(err) => err instanceof ValidationError && err.violations[0].field === 'callbackUrl',
|
|
);
|
|
});
|
|
|
|
it('SSRF guards reject http:// (must be https)', async () => {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), {
|
|
callbackUrl: 'http://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 50,
|
|
}),
|
|
(err) => err instanceof ValidationError && /https/.test(err.violations[0].description),
|
|
);
|
|
});
|
|
|
|
it('SSRF guards reject localhost, RFC1918, and cloud metadata hostnames', async () => {
|
|
const blockedHosts = [
|
|
'https://localhost/hook',
|
|
'https://127.0.0.1/hook',
|
|
'https://10.0.0.1/hook',
|
|
'https://192.168.1.1/hook',
|
|
'https://169.254.169.254/latest/meta-data/',
|
|
'https://metadata.google.internal/',
|
|
];
|
|
for (const callbackUrl of blockedHosts) {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), { callbackUrl, chokepointIds: [], alertThreshold: 50 }),
|
|
(err) => err instanceof ValidationError,
|
|
`expected SSRF block for ${callbackUrl}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('rejects unknown chokepointIds', async () => {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: ['not_a_real_chokepoint'],
|
|
alertThreshold: 50,
|
|
}),
|
|
(err) => err instanceof ValidationError && /Unknown chokepoint/.test(err.violations[0].description),
|
|
);
|
|
});
|
|
|
|
// alert_threshold 0..100 range is enforced primarily by buf.validate at
|
|
// the wire layer. The handler re-enforces it so direct invocations
|
|
// (internal jobs, test harnesses, future transports) can't store out-of-
|
|
// range values — cheap invariant-at-the-boundary (#3287 review nit 1).
|
|
it('rejects alertThreshold > 100 with ValidationError', async () => {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 9999,
|
|
}),
|
|
(err) => err instanceof ValidationError && err.violations[0].field === 'alertThreshold',
|
|
);
|
|
});
|
|
|
|
it('rejects alertThreshold < 0 with ValidationError', async () => {
|
|
await assert.rejects(
|
|
() => registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: -1,
|
|
}),
|
|
(err) => err instanceof ValidationError && err.violations[0].field === 'alertThreshold',
|
|
);
|
|
});
|
|
|
|
it('happy path returns wh_-prefixed subscriberId and 64-char hex secret; issues SET + SADD + EXPIRE pipeline with 30-day TTL', async () => {
|
|
const calls = stubRedisOk();
|
|
const res = await registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 60,
|
|
});
|
|
|
|
// Partner-visible shape: subscriberId + secret only (no extras).
|
|
assert.deepEqual(Object.keys(res).sort(), ['secret', 'subscriberId']);
|
|
assert.match(res.subscriberId, /^wh_[0-9a-f]{24}$/);
|
|
assert.match(res.secret, /^[0-9a-f]{64}$/);
|
|
|
|
// Exactly one Redis pipeline call with 3 commands in order.
|
|
assert.equal(calls.length, 1);
|
|
const pipeline = calls[0];
|
|
assert.equal(pipeline.length, 3);
|
|
assert.equal(pipeline[0][0], 'SET');
|
|
assert.ok(pipeline[0][1].startsWith('webhook:sub:wh_'), 'SET key is webhook:sub:wh_*:v1');
|
|
assert.equal(pipeline[0][3], 'EX');
|
|
assert.equal(pipeline[0][4], String(86400 * 30), '30-day TTL on the webhook record');
|
|
assert.equal(pipeline[1][0], 'SADD');
|
|
assert.ok(pipeline[1][1].startsWith('webhook:owner:'), 'SADD key is webhook:owner:*:v1');
|
|
assert.equal(pipeline[2][0], 'EXPIRE');
|
|
assert.equal(pipeline[2][1], pipeline[1][1], 'EXPIRE targets same owner index key');
|
|
assert.equal(pipeline[2][2], String(86400 * 30));
|
|
});
|
|
|
|
it('alertThreshold omitted (undefined) applies the legacy default of 50', async () => {
|
|
const calls = stubRedisOk();
|
|
await registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
// alertThreshold omitted — proto3 `optional int32` arrives as undefined
|
|
});
|
|
const record = JSON.parse(calls[0][0][2]);
|
|
assert.equal(record.alertThreshold, 50);
|
|
});
|
|
|
|
it('alertThreshold explicit 0 is preserved (deliver every alert)', async () => {
|
|
// #3242 followup #4 — proto3 `optional` lets the handler distinguish
|
|
// "partner explicitly sent 0" from "partner omitted the field". The
|
|
// pre-fix handler coerced both to 50, silently dropping the partner's
|
|
// intent to receive every disruption.
|
|
const calls = stubRedisOk();
|
|
await registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 0,
|
|
});
|
|
const record = JSON.parse(calls[0][0][2]);
|
|
assert.equal(record.alertThreshold, 0);
|
|
});
|
|
|
|
it('empty chokepointIds subscribes to the full CHOKEPOINT_REGISTRY', async () => {
|
|
const calls = stubRedisOk();
|
|
await registerWebhook(proCtx(), {
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: [],
|
|
alertThreshold: 50,
|
|
});
|
|
const record = JSON.parse(calls[0][0][2]);
|
|
assert.ok(record.chokepointIds.length > 0, 'empty list expands to registry');
|
|
assert.equal(record.chokepointIds.length, webhookShared.VALID_CHOKEPOINT_IDS.size);
|
|
});
|
|
});
|
|
|
|
describe('listWebhooks', () => {
|
|
it('rejects callers without an API key with 401 (tenant-isolation gate)', async () => {
|
|
// Mirror of registerWebhook: the defense-in-depth ownerTag check collapses
|
|
// when callers fall through to 'anon', so we reject unauthenticated callers
|
|
// before hitting Redis.
|
|
await assert.rejects(
|
|
() => listWebhooks(makeCtx(), {}),
|
|
(err) => err instanceof ApiError && err.statusCode === 401,
|
|
);
|
|
});
|
|
|
|
it('returns empty webhooks array when SMEMBERS is empty', async () => {
|
|
globalThis.fetch = async () =>
|
|
new Response(JSON.stringify([{ result: [] }]), { status: 200 });
|
|
const res = await listWebhooks(proCtx(), {});
|
|
assert.deepEqual(res, { webhooks: [] });
|
|
});
|
|
|
|
it('filters out records whose ownerTag does not match the caller fingerprint (cross-tenant isolation)', async () => {
|
|
const otherOwnerRecord = {
|
|
subscriberId: 'wh_deadbeef000000000000beef',
|
|
ownerTag: 'someone-elses-hash',
|
|
callbackUrl: 'https://other.example/hook',
|
|
chokepointIds: ['hormuz_strait'],
|
|
alertThreshold: 50,
|
|
createdAt: '2026-04-01T00:00:00.000Z',
|
|
active: true,
|
|
secret: 'other-caller-secret-never-returned',
|
|
};
|
|
globalThis.fetch = async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.length === 1 && body[0][0] === 'SMEMBERS') {
|
|
return new Response(
|
|
JSON.stringify([{ result: ['wh_deadbeef000000000000beef'] }]),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
return new Response(
|
|
JSON.stringify(body.map(() => ({ result: JSON.stringify(otherOwnerRecord) }))),
|
|
{ status: 200 },
|
|
);
|
|
};
|
|
const res = await listWebhooks(proCtx(), {});
|
|
assert.deepEqual(res.webhooks, [], 'mismatched ownerTag must not leak across callers');
|
|
});
|
|
|
|
it('omits `secret` from matched records — partner contract invariant', async () => {
|
|
// Build a record whose ownerTag matches the caller's SHA-256 fingerprint.
|
|
const key = 'pro-test-key';
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
|
|
const ownerTag = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
const record = {
|
|
subscriberId: 'wh_abc123456789012345678901',
|
|
ownerTag,
|
|
callbackUrl: 'https://hooks.example.com/wm',
|
|
chokepointIds: ['hormuz_strait'],
|
|
alertThreshold: 60,
|
|
createdAt: '2026-04-01T00:00:00.000Z',
|
|
active: true,
|
|
secret: 'must-not-be-in-response',
|
|
};
|
|
globalThis.fetch = async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.length === 1 && body[0][0] === 'SMEMBERS') {
|
|
return new Response(
|
|
JSON.stringify([{ result: [record.subscriberId] }]),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
return new Response(
|
|
JSON.stringify(body.map(() => ({ result: JSON.stringify(record) }))),
|
|
{ status: 200 },
|
|
);
|
|
};
|
|
const res = await listWebhooks(proCtx(), {});
|
|
assert.equal(res.webhooks.length, 1);
|
|
const [summary] = res.webhooks;
|
|
assert.equal(summary.subscriberId, record.subscriberId);
|
|
assert.equal(summary.callbackUrl, record.callbackUrl);
|
|
assert.ok(!('secret' in summary), '`secret` must never appear in ListWebhooks response');
|
|
});
|
|
});
|
|
});
|