mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(supply-chain): gate get-sector-dependency via premium-paths Non-PRO browser callers were receiving HTTP 200 with an empty payload from get-sector-dependency.ts instead of HTTP 403 from the gateway. Root cause: the RPC path was missing from PREMIUM_RPC_PATHS, so the gateway never enforced the API-key / bearer-token check and the handler silently returned its empty-shape fallback for non-PRO sessions. Add /api/supply-chain/v1/get-sector-dependency to PREMIUM_RPC_PATHS so the gateway forces the legacy-pro-bearer check and returns 401/403 for unauthenticated / non-PRO callers. Handler-level isCallerPremium stays as defense-in-depth. * test(supply-chain): assert get-sector-dependency is in PREMIUM_RPC_PATHS Guards against accidental removal of the premium gate added in this PR. Follows the established pattern in tests/supply-chain-sprint2.test.mjs (get-bypass-options, get-country-cost-shock) and tests/get-regional-snapshot.test.mts.
431 lines
16 KiB
JavaScript
431 lines
16 KiB
JavaScript
/**
|
|
* Tests for Sprint 2 supply-chain additions:
|
|
*
|
|
* - bypass-corridors.ts: data integrity + BYPASS_CORRIDORS_BY_CHOKEPOINT index
|
|
* - _insurance-tier.ts: threatLevelToInsurancePremiumBps pure function
|
|
* - get-bypass-options handler: unauthenticated returns empty
|
|
* - get-country-cost-shock handler: unauthenticated returns zeros
|
|
* - gateway.ts: new slow-browser entries for both RPCs
|
|
* - premium-paths.ts: both paths registered as PRO-gated
|
|
* - proto: new messages in generated types
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');
|
|
|
|
// ========================================================================
|
|
// 1. _insurance-tier.ts pure function
|
|
// ========================================================================
|
|
|
|
import { threatLevelToInsurancePremiumBps } from '../server/worldmonitor/supply-chain/v1/_insurance-tier.ts';
|
|
|
|
describe('threatLevelToInsurancePremiumBps', () => {
|
|
it('war_zone returns 300 bps', () => {
|
|
assert.equal(threatLevelToInsurancePremiumBps('war_zone'), 300);
|
|
});
|
|
|
|
it('critical returns 100 bps', () => {
|
|
assert.equal(threatLevelToInsurancePremiumBps('critical'), 100);
|
|
});
|
|
|
|
it('high returns 50 bps', () => {
|
|
assert.equal(threatLevelToInsurancePremiumBps('high'), 50);
|
|
});
|
|
|
|
it('elevated returns 20 bps', () => {
|
|
assert.equal(threatLevelToInsurancePremiumBps('elevated'), 20);
|
|
});
|
|
|
|
it('normal returns 5 bps', () => {
|
|
assert.equal(threatLevelToInsurancePremiumBps('normal'), 5);
|
|
});
|
|
|
|
it('premiums increase monotonically with threat severity', () => {
|
|
const levels = ['normal', 'elevated', 'high', 'critical', 'war_zone'];
|
|
const premiums = levels.map(threatLevelToInsurancePremiumBps);
|
|
for (let i = 1; i < premiums.length; i++) {
|
|
assert.ok(
|
|
premiums[i] > premiums[i - 1],
|
|
`Premium for ${levels[i]} (${premiums[i]}) should be > ${levels[i - 1]} (${premiums[i - 1]})`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 2. bypass-corridors.ts data integrity
|
|
// ========================================================================
|
|
|
|
import {
|
|
BYPASS_CORRIDORS,
|
|
BYPASS_CORRIDORS_BY_CHOKEPOINT,
|
|
} from '../src/config/bypass-corridors.ts';
|
|
|
|
describe('BYPASS_CORRIDORS data integrity', () => {
|
|
it('has at least 20 corridor entries', () => {
|
|
assert.ok(BYPASS_CORRIDORS.length >= 20, `Expected ≥20 corridors, got ${BYPASS_CORRIDORS.length}`);
|
|
});
|
|
|
|
it('every entry has required fields', () => {
|
|
for (const c of BYPASS_CORRIDORS) {
|
|
assert.ok(c.id, `missing id`);
|
|
assert.ok(c.name, `missing name for ${c.id}`);
|
|
assert.ok(c.primaryChokepointId, `missing primaryChokepointId for ${c.id}`);
|
|
assert.ok(['alternative_sea_route', 'land_bridge', 'modal_shift', 'pipeline'].includes(c.type),
|
|
`${c.id}: invalid type "${c.type}"`);
|
|
assert.ok(typeof c.addedTransitDays === 'number', `${c.id}: addedTransitDays must be number`);
|
|
assert.ok(typeof c.addedCostMultiplier === 'number', `${c.id}: addedCostMultiplier must be number`);
|
|
assert.ok(Array.isArray(c.suitableCargoTypes), `${c.id}: suitableCargoTypes must be array`);
|
|
assert.ok(['partial_closure', 'full_closure'].includes(c.activationThreshold),
|
|
`${c.id}: invalid activationThreshold "${c.activationThreshold}"`);
|
|
assert.ok(typeof c.notes === 'string', `${c.id}: notes must be string`);
|
|
assert.ok(Array.isArray(c.waypointChokepointIds), `${c.id}: waypointChokepointIds must be array`);
|
|
}
|
|
});
|
|
|
|
it('all IDs are unique', () => {
|
|
const ids = BYPASS_CORRIDORS.map(c => c.id);
|
|
const unique = new Set(ids);
|
|
assert.equal(unique.size, ids.length, 'Duplicate corridor IDs found');
|
|
});
|
|
|
|
it('capacityConstraintTonnage is null or a positive number', () => {
|
|
for (const c of BYPASS_CORRIDORS) {
|
|
if (c.capacityConstraintTonnage !== null) {
|
|
assert.ok(c.capacityConstraintTonnage >= 0, `${c.id}: capacityConstraintTonnage must be >= 0`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('addedCostMultiplier >= 0.9 and <= 2.0 (sanity range)', () => {
|
|
for (const c of BYPASS_CORRIDORS) {
|
|
assert.ok(c.addedCostMultiplier >= 0.9 && c.addedCostMultiplier <= 2.0,
|
|
`${c.id}: addedCostMultiplier ${c.addedCostMultiplier} out of expected range`);
|
|
}
|
|
});
|
|
|
|
it('covers all 13 canonical chokepoints', () => {
|
|
const canonicalIds = [
|
|
'suez', 'malacca_strait', 'hormuz_strait', 'bab_el_mandeb', 'panama',
|
|
'taiwan_strait', 'cape_of_good_hope', 'gibraltar', 'bosphorus',
|
|
'korea_strait', 'dover_strait', 'kerch_strait', 'lombok_strait',
|
|
];
|
|
const covered = new Set(BYPASS_CORRIDORS.map(c => c.primaryChokepointId));
|
|
for (const id of canonicalIds) {
|
|
assert.ok(covered.has(id), `No bypass entry for chokepoint: ${id}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('BYPASS_CORRIDORS_BY_CHOKEPOINT index', () => {
|
|
it('is a Record keyed by primaryChokepointId', () => {
|
|
assert.ok(typeof BYPASS_CORRIDORS_BY_CHOKEPOINT === 'object');
|
|
});
|
|
|
|
it('suez has at least 2 bypass options', () => {
|
|
assert.ok(
|
|
(BYPASS_CORRIDORS_BY_CHOKEPOINT['suez'] ?? []).length >= 2,
|
|
'suez should have at least 2 bypass options',
|
|
);
|
|
});
|
|
|
|
it('hormuz_strait has at least 3 bypass options', () => {
|
|
assert.ok(
|
|
(BYPASS_CORRIDORS_BY_CHOKEPOINT['hormuz_strait'] ?? []).length >= 3,
|
|
'hormuz_strait should have at least 3 bypass options',
|
|
);
|
|
});
|
|
|
|
it('every corridor in the index matches its primary chokepoint', () => {
|
|
for (const [cpId, corridors] of Object.entries(BYPASS_CORRIDORS_BY_CHOKEPOINT)) {
|
|
for (const c of corridors) {
|
|
assert.equal(c.primaryChokepointId, cpId,
|
|
`Corridor ${c.id} in index[${cpId}] has wrong primaryChokepointId: ${c.primaryChokepointId}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('total entries in index equals BYPASS_CORRIDORS length', () => {
|
|
const total = Object.values(BYPASS_CORRIDORS_BY_CHOKEPOINT).reduce((sum, arr) => sum + arr.length, 0);
|
|
assert.equal(total, BYPASS_CORRIDORS.length, 'Index total does not match BYPASS_CORRIDORS length');
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 3. get-bypass-options handler source code guards
|
|
// ========================================================================
|
|
|
|
describe('get-bypass-options handler source code', () => {
|
|
const src = readSrc('server/worldmonitor/supply-chain/v1/get-bypass-options.ts');
|
|
|
|
it('calls isCallerPremium and returns empty when not PRO', () => {
|
|
assert.match(src, /isCallerPremium/);
|
|
assert.match(src, /if \(!isPro\) return empty/);
|
|
});
|
|
|
|
it('filters by suitableCargoTypes.length === 0 (no-bypass placeholder guard)', () => {
|
|
assert.match(src, /suitableCargoTypes\.length === 0/);
|
|
});
|
|
|
|
it('filters by activation threshold when closurePct < 100', () => {
|
|
assert.match(src, /closurePct < 100.*full_closure/s);
|
|
});
|
|
|
|
it('reads chokepoint status cache via getCachedJson', () => {
|
|
assert.match(src, /getCachedJson\(CHOKEPOINT_STATUS_KEY\)/);
|
|
});
|
|
|
|
it('sorts options by liveScore ascending', () => {
|
|
assert.match(src, /liveScore - b\.liveScore/);
|
|
});
|
|
|
|
it('uses BYPASS_CORRIDORS_BY_CHOKEPOINT lookup', () => {
|
|
assert.match(src, /BYPASS_CORRIDORS_BY_CHOKEPOINT\[chokepointId\]/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 4. get-country-cost-shock handler source code guards
|
|
// ========================================================================
|
|
|
|
describe('get-country-cost-shock handler source code', () => {
|
|
const src = readSrc('server/worldmonitor/supply-chain/v1/get-country-cost-shock.ts');
|
|
|
|
it('calls isCallerPremium and returns empty when not PRO', () => {
|
|
assert.match(src, /isCallerPremium/);
|
|
assert.match(src, /if \(!isPro\) return empty/);
|
|
});
|
|
|
|
it('uses warRiskTierToInsurancePremiumBps for premium calculation', () => {
|
|
assert.match(src, /warRiskTierToInsurancePremiumBps/);
|
|
});
|
|
|
|
it('reads chokepoint status cache via getCachedJson', () => {
|
|
assert.match(src, /getCachedJson\(CHOKEPOINT_STATUS_KEY\)/);
|
|
});
|
|
|
|
it('returns unavailableReason for non-energy sectors', () => {
|
|
assert.match(src, /HS 27.*mineral fuels.*only/s);
|
|
});
|
|
|
|
it('validates iso2 with regex before proceeding', () => {
|
|
assert.match(src, /\^[^\]]*A-Z.*\$.*test\(iso2/s);
|
|
});
|
|
|
|
it('uses shockModelSupported from registry for hasEnergyModel', () => {
|
|
assert.match(src, /shockModelSupported/);
|
|
assert.match(src, /hasEnergyModel/);
|
|
});
|
|
|
|
it('averages deficitPct across all products (no crude product entry)', () => {
|
|
assert.match(src, /productDeficits/);
|
|
assert.doesNotMatch(src, /product.*===.*'crude'/);
|
|
});
|
|
|
|
it('productDeficits must NOT filter before averaging — zero-deficit products must stay in denominator', () => {
|
|
assert.ok(
|
|
!src.includes('.filter((d: number) => d > 0)') && !src.includes('.filter((d) => d > 0)'),
|
|
'productDeficits must NOT filter before averaging — zero-deficit products must stay in denominator'
|
|
);
|
|
});
|
|
|
|
it('coverageDays must clamp negative sentinel for net exporters', () => {
|
|
assert.ok(
|
|
src.includes('Math.max(0, shock?.effectiveCoverDays'),
|
|
'coverageDays must clamp negative sentinel for net exporters'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 5. Gateway: both RPCs registered as slow-browser
|
|
// ========================================================================
|
|
|
|
describe('Gateway slow-browser tier registration', () => {
|
|
const src = readSrc('server/gateway.ts');
|
|
|
|
it('get-bypass-options uses slow-browser tier', () => {
|
|
assert.match(src, /\/api\/supply-chain\/v1\/get-bypass-options':\s*'slow-browser'/);
|
|
});
|
|
|
|
it('get-country-cost-shock uses slow-browser tier', () => {
|
|
assert.match(src, /\/api\/supply-chain\/v1\/get-country-cost-shock':\s*'slow-browser'/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 6. Premium paths registered
|
|
// ========================================================================
|
|
|
|
describe('Premium paths registration', () => {
|
|
const src = readSrc('src/shared/premium-paths.ts');
|
|
|
|
it('get-bypass-options is in PREMIUM_RPC_PATHS', () => {
|
|
assert.match(src, /\/api\/supply-chain\/v1\/get-bypass-options/);
|
|
});
|
|
|
|
it('get-country-cost-shock is in PREMIUM_RPC_PATHS', () => {
|
|
assert.match(src, /\/api\/supply-chain\/v1\/get-country-cost-shock/);
|
|
});
|
|
|
|
it('get-sector-dependency is in PREMIUM_RPC_PATHS', () => {
|
|
assert.match(src, /\/api\/supply-chain\/v1\/get-sector-dependency/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 7. Proto definitions
|
|
// ========================================================================
|
|
|
|
describe('GetBypassOptions proto definition', () => {
|
|
const proto = readSrc('proto/worldmonitor/supply_chain/v1/get_bypass_options.proto');
|
|
|
|
it('has GetBypassOptionsRequest message', () => {
|
|
assert.match(proto, /message GetBypassOptionsRequest/);
|
|
});
|
|
|
|
it('has GetBypassOptionsResponse message', () => {
|
|
assert.match(proto, /message GetBypassOptionsResponse/);
|
|
});
|
|
|
|
it('has BypassOption message', () => {
|
|
assert.match(proto, /message BypassOption/);
|
|
});
|
|
|
|
it('chokepoint_id field has required validation', () => {
|
|
assert.match(proto, /\(buf\.validate\.field\)\.required\s*=\s*true/);
|
|
});
|
|
|
|
it('live_score field is at field 10', () => {
|
|
assert.match(proto, /double live_score\s*=\s*10/);
|
|
});
|
|
|
|
it('bypass_war_risk_tier field is at field 11', () => {
|
|
assert.match(proto, /WarRiskTier bypass_war_risk_tier\s*=\s*11/);
|
|
});
|
|
});
|
|
|
|
describe('GetCountryCostShock proto definition', () => {
|
|
const proto = readSrc('proto/worldmonitor/supply_chain/v1/get_country_cost_shock.proto');
|
|
|
|
it('has GetCountryCostShockRequest message', () => {
|
|
assert.match(proto, /message GetCountryCostShockRequest/);
|
|
});
|
|
|
|
it('has GetCountryCostShockResponse message', () => {
|
|
assert.match(proto, /message GetCountryCostShockResponse/);
|
|
});
|
|
|
|
it('iso2 field has pattern validation', () => {
|
|
assert.match(proto, /\^[^\]]*A-Z.*\$/);
|
|
});
|
|
|
|
it('war_risk_premium_bps is int32', () => {
|
|
assert.match(proto, /int32 war_risk_premium_bps/);
|
|
});
|
|
|
|
it('has_energy_model is bool', () => {
|
|
assert.match(proto, /bool has_energy_model/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 8. Generated types include new interfaces
|
|
// ========================================================================
|
|
|
|
describe('Generated server types include Sprint 2 interfaces', () => {
|
|
const serverSrc = readSrc('src/generated/server/worldmonitor/supply_chain/v1/service_server.ts');
|
|
|
|
it('BypassOption interface is generated', () => {
|
|
assert.match(serverSrc, /interface BypassOption/);
|
|
});
|
|
|
|
it('GetBypassOptionsRequest interface is generated', () => {
|
|
assert.match(serverSrc, /interface GetBypassOptionsRequest/);
|
|
});
|
|
|
|
it('GetBypassOptionsResponse interface is generated', () => {
|
|
assert.match(serverSrc, /interface GetBypassOptionsResponse/);
|
|
});
|
|
|
|
it('GetCountryCostShockRequest interface is generated', () => {
|
|
assert.match(serverSrc, /interface GetCountryCostShockRequest/);
|
|
});
|
|
|
|
it('GetCountryCostShockResponse interface is generated', () => {
|
|
assert.match(serverSrc, /interface GetCountryCostShockResponse/);
|
|
});
|
|
|
|
it('SupplyChainServiceHandler includes getBypassOptions', () => {
|
|
assert.match(serverSrc, /getBypassOptions\(.*GetBypassOptionsRequest.*GetBypassOptionsResponse/);
|
|
});
|
|
|
|
it('SupplyChainServiceHandler includes getCountryCostShock', () => {
|
|
assert.match(serverSrc, /getCountryCostShock\(.*GetCountryCostShockRequest.*GetCountryCostShockResponse/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 9. Client service: new methods exported
|
|
// ========================================================================
|
|
|
|
describe('Supply chain client service: Sprint 2 methods', () => {
|
|
const src = readSrc('src/services/supply-chain/index.ts');
|
|
|
|
it('exports fetchBypassOptions function', () => {
|
|
assert.match(src, /export async function fetchBypassOptions/);
|
|
});
|
|
|
|
it('exports fetchCountryCostShock function', () => {
|
|
assert.match(src, /export async function fetchCountryCostShock/);
|
|
});
|
|
|
|
it('imports GetBypassOptionsResponse from generated client', () => {
|
|
assert.match(src, /GetBypassOptionsResponse/);
|
|
});
|
|
|
|
it('imports GetCountryCostShockResponse from generated client', () => {
|
|
assert.match(src, /GetCountryCostShockResponse/);
|
|
});
|
|
});
|
|
|
|
// ========================================================================
|
|
// 10. Service proto registers both new RPCs
|
|
// ========================================================================
|
|
|
|
describe('Service proto registers Sprint 2 RPCs', () => {
|
|
const proto = readSrc('proto/worldmonitor/supply_chain/v1/service.proto');
|
|
|
|
it('imports get_bypass_options.proto', () => {
|
|
assert.match(proto, /import.*get_bypass_options\.proto/);
|
|
});
|
|
|
|
it('imports get_country_cost_shock.proto', () => {
|
|
assert.match(proto, /import.*get_country_cost_shock\.proto/);
|
|
});
|
|
|
|
it('registers GetBypassOptions RPC', () => {
|
|
assert.match(proto, /rpc GetBypassOptions\(GetBypassOptionsRequest\)/);
|
|
});
|
|
|
|
it('registers GetCountryCostShock RPC', () => {
|
|
assert.match(proto, /rpc GetCountryCostShock\(GetCountryCostShockRequest\)/);
|
|
});
|
|
|
|
it('GetBypassOptions path is /get-bypass-options', () => {
|
|
assert.match(proto, /path:\s*"\/get-bypass-options"/);
|
|
});
|
|
|
|
it('GetCountryCostShock path is /get-country-cost-shock', () => {
|
|
assert.match(proto, /path:\s*"\/get-country-cost-shock"/);
|
|
});
|
|
});
|