diff --git a/deploy/system-wrangler.jsonc b/deploy/system-wrangler.jsonc index 9ccc1c1..206a31a 100644 --- a/deploy/system-wrangler.jsonc +++ b/deploy/system-wrangler.jsonc @@ -38,6 +38,22 @@ { "name": "EMAIL", "allowed_sender_addresses": ["finance@chitty.cc", "noreply@chitty.cc"] } ], + // Mercury API tokens — one per entity — from the ACCOUNT-LEVEL Cloudflare Secrets + // Store. Same store and same binding names as CHITTYOS/chittysecrets/wrangler.json; + // because the store is account-level, chittyfinance binds them directly and does not + // need the ChittySecrets broker in the path. Consumed by the daily keepalive probe in + // server/lib/mercury-token-keepalive.ts — Mercury deletes a token after 45 days with + // no API call. Bindings do NOT inherit into env blocks, so each env repeats them. + "secrets_store_secrets": [ + { "binding": "MERCURY_TOKEN_ARIBIA_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE" }, + { "binding": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS" }, + { "binding": "MERCURY_TOKEN_IT_CAN_BE_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_IT_CAN_BE_LLC" }, + { "binding": "MERCURY_TOKEN_CHITTY_SERVICES", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHITTY_SERVICES" }, + { "binding": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING" } + ], + "vars": { "MODE": "system", "NODE_ENV": "production", @@ -112,6 +128,16 @@ "send_email": [ { "name": "EMAIL" } ], + "secrets_store_secrets": [ + { "binding": "MERCURY_TOKEN_ARIBIA_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE" }, + { "binding": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS" }, + { "binding": "MERCURY_TOKEN_IT_CAN_BE_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_IT_CAN_BE_LLC" }, + { "binding": "MERCURY_TOKEN_CHITTY_SERVICES", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHITTY_SERVICES" }, + { "binding": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING" } + ], + "vars": { "MODE": "system", "NODE_ENV": "development", @@ -137,6 +163,16 @@ "staging": { "name": "chittyfinance-staging", "workers_dev": true, + "secrets_store_secrets": [ + { "binding": "MERCURY_TOKEN_ARIBIA_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE" }, + { "binding": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS" }, + { "binding": "MERCURY_TOKEN_IT_CAN_BE_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_IT_CAN_BE_LLC" }, + { "binding": "MERCURY_TOKEN_CHITTY_SERVICES", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHITTY_SERVICES" }, + { "binding": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING" } + ], + "vars": { "MODE": "system", "NODE_ENV": "staging", @@ -168,6 +204,16 @@ "send_email": [ { "name": "EMAIL", "allowed_sender_addresses": ["finance@chitty.cc", "noreply@chitty.cc"] } ], + "secrets_store_secrets": [ + { "binding": "MERCURY_TOKEN_ARIBIA_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_CITY_STUDIO" }, + { "binding": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_ARIBIA_LLC_APT_ARLENE" }, + { "binding": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHICAGO_FURNISHED_CONDOS" }, + { "binding": "MERCURY_TOKEN_IT_CAN_BE_LLC", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_IT_CAN_BE_LLC" }, + { "binding": "MERCURY_TOKEN_CHITTY_SERVICES", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_CHITTY_SERVICES" }, + { "binding": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING", "store_id": "e914522471964c3c8cf1e601770edcc3", "secret_name": "MERCURY_TOKEN_JEAN_ARLENE_VENTURING" } + ], + "vars": { "MODE": "system", "NODE_ENV": "production", diff --git a/server/__tests__/mercury-token-keepalive.test.ts b/server/__tests__/mercury-token-keepalive.test.ts new file mode 100644 index 0000000..64a9ddb --- /dev/null +++ b/server/__tests__/mercury-token-keepalive.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + MERCURY_TOKEN_BINDINGS, + MERCURY_PROBE_URL, + classifyProbe, + extractErrorCode, + probeToken, + runMercuryTokenKeepalive, + persistKeepaliveReport, + readLatestKeepaliveReport, + KV_RUN_LATEST, + kvTokenKey, + type MercuryKeepaliveEnv, +} from '../lib/mercury-token-keepalive'; + +/** + * No DB module is mocked here. The classifier is a pure function and is tested + * against real `Response` objects; the fetch seam is an injected `fetchImpl` + * parameter, not a module mock; KV is a real in-memory Map behind the KVNamespace + * surface the module actually uses. + */ +function makeKv() { + const store = new Map(); + return { + store, + get: async (k: string) => store.get(k) ?? null, + put: async (k: string, v: string) => { store.set(k, v); }, + delete: async (k: string) => { store.delete(k); }, + } as unknown as KVNamespace & { store: Map }; +} + +const secret = (value: string) => ({ get: async () => value }); + +describe('classifyProbe — the three states are distinguished', () => { + // The whole point of the probe: a 401 is a successful probe with a negative + // result, and must never be conflated with "we could not reach Mercury". + it('2xx is alive', () => { + for (const status of [200, 204, 299]) { + expect(classifyProbe({ status }).liveness).toBe('alive'); + } + }); + + it('401 is dead, not alive and not indeterminate', () => { + const c = classifyProbe({ status: 401, errorCode: 'noTokenInDB' }); + expect(c.liveness).toBe('dead'); + expect(c.liveness).not.toBe('alive'); + expect(c.liveness).not.toBe('indeterminate'); + expect(c.reason).toContain('401'); + expect(c.reason).toContain('noTokenInDB'); + }); + + it('403 is dead', () => { + expect(classifyProbe({ status: 403 }).liveness).toBe('dead'); + }); + + it('timeout / network failure is indeterminate, not dead', () => { + const c = classifyProbe({ transportError: 'TimeoutError' }); + expect(c.liveness).toBe('indeterminate'); + expect(c.liveness).not.toBe('dead'); + expect(c.reason).toContain('transport_error'); + }); + + it('5xx is indeterminate, not dead', () => { + for (const status of [500, 502, 503]) { + expect(classifyProbe({ status }).liveness).toBe('indeterminate'); + } + }); + + it('429 is indeterminate', () => { + expect(classifyProbe({ status: 429 }).liveness).toBe('indeterminate'); + }); + + it('an absent Secrets Store binding is indeterminate, never dead', () => { + const c = classifyProbe({ bindingMissing: true }); + expect(c.liveness).toBe('indeterminate'); + expect(c.reason).toContain('binding_missing'); + }); + + it('404 and other unexpected statuses are indeterminate', () => { + expect(classifyProbe({ status: 404 }).liveness).toBe('indeterminate'); + expect(classifyProbe({ status: 418 }).liveness).toBe('indeterminate'); + }); + + // Drive the classifier off real Response statuses rather than hand-written numbers, + // so a change to how status is read is caught too. + it('classifies real Response objects', async () => { + expect(classifyProbe({ status: new Response('', { status: 200 }).status }).liveness).toBe('alive'); + expect(classifyProbe({ status: new Response('', { status: 401 }).status }).liveness).toBe('dead'); + expect(classifyProbe({ status: new Response('', { status: 503 }).status }).liveness).toBe('indeterminate'); + }); +}); + +describe('extractErrorCode', () => { + it('pulls errors.errorCode from a Mercury error body', () => { + expect(extractErrorCode({ errors: { errorCode: 'noTokenInDB', message: 'No matching token found' } })) + .toBe('noTokenInDB'); + }); + + it('handles an array of errors', () => { + expect(extractErrorCode({ errors: [{ errorCode: 'noAuthTokenHeader' }] })).toBe('noAuthTokenHeader'); + }); + + it('returns null for shapes it does not recognise', () => { + expect(extractErrorCode(null)).toBeNull(); + expect(extractErrorCode('nope')).toBeNull(); + expect(extractErrorCode({})).toBeNull(); + expect(extractErrorCode({ errors: {} })).toBeNull(); + }); + + it('sanitizes and caps the code, and never returns the message', () => { + const out = extractErrorCode({ errors: { errorCode: 'bad code\n