diff --git a/tests/account_facts_history_currencies_validation.mjs b/tests/account_facts_history_currencies_validation.mjs index 3f3767fa..c5bee1a9 100644 --- a/tests/account_facts_history_currencies_validation.mjs +++ b/tests/account_facts_history_currencies_validation.mjs @@ -94,6 +94,11 @@ console.log('account facts history currency discovery: synthetic qualified linea // Exercise the actual Worker wiring: its binding loader must not swallow a KV // read failure before the history use case can distinguish it from no binding. const { default: worker, __test } = await import('../web/strategy-switch-console/worker.js'); +// Sync producers keep only the HTTP status, so each refusal must also name its +// code in one bounded log event. Capture the events instead of printing them. +const rejections=[]; +const realWarn=console.warn; +console.warn=(event,...rest)=>{ if(event==='account_facts_refused') rejections.push(rest[0]); else realWarn.apply(console,[event,...rest]); }; let kvFailure=null, missingBinding=false, storedHistory={ok:true,days:[]}, storedStatus=200; const workerEnv={ ACCOUNT_FACTS_READ_MODEL_ENABLED:'true', @@ -151,4 +156,41 @@ try { missingBinding=true; assert.equal((await requestHistory(legacyUrl)).status,200,'a successful missing binding read keeps its legacy empty state'); } finally {globalThis.fetch=originalFetch;} +// One event per refusal, the code identical to the one the client receives, and no +// field beyond the fixed endpoint, the status and the classified code. +assert.ok(rejections.length>=3,'every rejected account-facts read above is observable'); +assert.deepEqual(rejections.at(-1),{endpoint:'read',http_status:409,error_category:'account_facts_stored_invalid'}, + 'the logged code is exactly the code the producer receives'); +for(const event of rejections){ + assert.deepEqual(Object.keys(event).sort(),['endpoint','error_category','http_status'],'the event carries no other field'); + assert.equal(event.endpoint,'read','reads keep the default endpoint literal'); + assert.match(event.error_category,/^[a-z][a-z0-9_]{2,63}$/); + assert.ok(Number.isInteger(event.http_status)&&event.http_status>=400&&event.http_status<=599); +} +// A free-form failure keeps its previous response body but is classified, never copied. +const unclassified=await __test.accountFactsErrorResponse(new Error('net_assets 98765.432101 rejected')); +assert.equal(unclassified.status,400,'an unclassified failure stays a 400'); +assert.equal((await unclassified.json()).error,'net_assets 98765.432101 rejected','the response is unchanged'); +assert.equal(rejections.at(-1).error_category,'unknown','free-form text is classified, never copied'); +const syncRefusal=await __test.accountFactsErrorResponse( + Object.assign(new Error('period_return_conflict'),{status:409}),'period_return_sync'); +assert.equal(syncRefusal.status,409); +assert.equal((await syncRefusal.json()).error,'period_return_conflict'); +assert.deepEqual(rejections.at(-1),{endpoint:'period_return_sync',http_status:409,error_category:'period_return_conflict'}, + 'a sync refusal names the endpoint the producer posted to'); +// Both sync routes live in application/account_facts/sync.js and call their injected +// error mapper with no endpoint, so the worker injection site must pick the label from +// the route; otherwise a refused current snapshot would be logged as a history append. +for(const [path,endpoint] of [['/api/account-facts/sync','current_sync'],['/api/account-facts/history/sync','history_sync']]){ + const refused=await worker.fetch(new Request(`https://switch.example${path}`,{ + method:'POST',headers:{'Content-Type':'application/json'},body:'{}', + }),workerEnv); + assert.equal(refused.status,500,'this synthetic env configures no sync token'); + assert.deepEqual(await refused.json(),{ok:false,error:'account_facts_sync_token_not_configured'}); + assert.deepEqual(rejections.at(-1), + {endpoint,http_status:500,error_category:'account_facts_sync_token_not_configured'}, + `${path} is attributed to its own sync endpoint, not to a read`); +} +assert.ok(!JSON.stringify(rejections).includes('98765'),'no payload value reaches the log stream'); +console.warn=realWarn; console.log('account facts history Worker wiring: synthetic KV/DO failures and empty states passed'); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 56f7fa17..c41c6f01 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -11530,14 +11530,22 @@ function accountFactsDisabledResponse() { return json({ ok: false, error: "account_facts_disabled" }, 404); } -function accountFactsErrorResponse(error) { - if (error instanceof AccountFactsError) { - return json({ ok: false, error: error.code }, error.status || 400); - } - if (error instanceof HttpError) { - return json({ ok: false, error: error.message }, error.status || 400); - } - return json({ ok: false, error: error?.message || "invalid_account_facts_history" }, error?.status || 400); +// Sync producers keep only the HTTP status, so a refused push is undiagnosable +// from their logs. Publish the refusal code, tagged with a fixed endpoint literal +// from the caller; free-form exception text is classified to "unknown" so payload +// content, identities and credentials cannot enter the log stream. The response +// body and status are exactly what this helper already returned. +function accountFactsErrorResponse(error, endpoint = "read") { + const code = error instanceof AccountFactsError ? error.code + : error instanceof HttpError ? error.message + : error?.message || "invalid_account_facts_history"; + const status = error?.status || 400; + console.warn("account_facts_refused", { + endpoint, + http_status: status, + error_category: typeof code === "string" && /^[a-z][a-z0-9_]{2,63}$/.test(code) ? code : "unknown", + }); + return json({ ok: false, error: code }, status); } function requireCycleHealthProvisionToken(request, env) { @@ -11773,7 +11781,7 @@ async function syncAccountExternalFlowResponse(request, env) { record_count: new Set(flow.windows.flatMap(w => w.records.map(r => r.source_ref))).size }); } catch (error) { if (error instanceof AccountFactsError || error instanceof HttpError || error?.code?.startsWith("external_flow") - || error?.code === "invalid_external_flow") return accountFactsErrorResponse(error); + || error?.code === "invalid_external_flow") return accountFactsErrorResponse(error, "external_flow_sync"); return json({ ok: false, error: "account_external_flow_unavailable" }, 503); } } @@ -11798,7 +11806,7 @@ async function syncAccountExecutionsResponse(request, env) { account_key: binding.account_key, period: payload.period, record_count: payload.records.length }); } catch (error) { if (error instanceof AccountFactsError || error instanceof HttpError || error?.code?.startsWith("execution_") - || error?.code === "invalid_broker_executions") return accountFactsErrorResponse(error); + || error?.code === "invalid_broker_executions") return accountFactsErrorResponse(error, "executions_sync"); return json({ ok: false, error: "account_executions_unavailable" }, 503); } } @@ -11822,7 +11830,7 @@ async function syncAccountPeriodReturnResponse(request, env) { return json({ ok: true, stored: true, unchanged: stored.unchanged === true, account_key: binding.account_key, period: payload.period, currency: payload.currency, method: payload.method }); } catch (error) { - if (error instanceof AccountFactsError || error instanceof HttpError) return accountFactsErrorResponse(error); + if (error instanceof AccountFactsError || error instanceof HttpError) return accountFactsErrorResponse(error, "period_return_sync"); return json({ ok: false, error: "account_period_return_unavailable" }, 503); } } @@ -11837,7 +11845,8 @@ async function syncAccountFactsResponse(request, env, options) { runtimeInstanceCommand, readBoundedJson, requireDedicatedAccountFactsSyncToken, - accountFactsErrorResponse, + accountFactsErrorResponse: (error) => accountFactsErrorResponse( + error, options?.historical ? "history_sync" : "current_sync"), }, options); } @@ -17732,6 +17741,7 @@ export const __test = { resolveTrustedAccountFactsBinding, decideAccountFactsPut, accountFactsOptionMatchesBinding, + accountFactsErrorResponse, canonicalizeAccountFactsHistory, ux1JobUnknownAfterMs: UX1_JOB_UNKNOWN_AFTER_MS, ux1CalculatorDeadlineMs: UX1_CALCULATOR_DEADLINE_MS,