Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions tests/account_facts_history_currencies_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
34 changes: 22 additions & 12 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -17732,6 +17741,7 @@ export const __test = {
resolveTrustedAccountFactsBinding,
decideAccountFactsPut,
accountFactsOptionMatchesBinding,
accountFactsErrorResponse,
canonicalizeAccountFactsHistory,
ux1JobUnknownAfterMs: UX1_JOB_UNKNOWN_AFTER_MS,
ux1CalculatorDeadlineMs: UX1_CALCULATOR_DEADLINE_MS,
Expand Down
Loading