Skip to content

Commit 14a9af1

Browse files
committed
Add e2e scenarios for the pre-execution limit guards
Four cloud scenarios drive a real MCP client against the workerd + session-DO topology and read Autumn's ledger as ground truth: an org at its cap is blocked before the code runs and not metered; a balance-check 500 and a balance-check timeout each fail open and still run the execution (with the faulted check visible in the ledger as proof the gate consulted Autumn); and the rate-limit backstop blocks the execution past the hourly cap without metering it. Faults are one-shot and cleared in finalizers, and each scenario uses a fresh org.
1 parent 176ee37 commit 14a9af1

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Cloud-only (billing): the pre-execution guards that sit in front of the MCP
2+
// session Durable Object's execution engine. Two independent gates run before
3+
// every new `execute`:
4+
//
5+
// - the BALANCE gate consults Autumn's `balances.check` and blocks once an
6+
// org has used its plan's included executions, and
7+
// - the RATE-LIMIT backstop counts executions per org per hour and blocks
8+
// runaway automation even when billing is down.
9+
//
10+
// Both FAIL OPEN: an Autumn error or timeout must never take executions down
11+
// with the billing provider. These scenarios pin all four behaviours end to
12+
// end against the real workerd + McpSessionDO topology, driving a real MCP
13+
// client and reading Autumn's own ledger as ground truth — a blocked execution
14+
// must not reach the meter, and a failed-open one must show the check was
15+
// actually attempted (and faulted) rather than silently skipped.
16+
import { expect } from "@effect/vitest";
17+
import { Effect } from "effect";
18+
19+
import {
20+
EXECUTION_LIMIT_BLOCKED_MESSAGE,
21+
RATE_LIMIT_BLOCKED_MESSAGE,
22+
} from "../../apps/cloud/src/engine/execution-limit-messages";
23+
import { scenario } from "../src/scenario";
24+
import { Autumn, Billing, Mcp, Target } from "../src/services";
25+
import type { Identity } from "../src/target";
26+
27+
const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
28+
29+
/** The org the bearer is scoped to — the Autumn customer id the guards decide
30+
* against — read from the JWT's public claims. */
31+
const orgIdOf = (bearer: string): string => {
32+
const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {
33+
readonly org_id?: string;
34+
};
35+
if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");
36+
return claims.org_id;
37+
};
38+
39+
// The e2e worker is booted with a small EXECUTION_RATE_LIMIT_PER_HOUR so the
40+
// backstop is reachable with real executions (cloud.boot.ts). The prod cap of
41+
// 1000/hour can't be. Kept in sync with that env value.
42+
const RATE_LIMIT = 3;
43+
44+
scenario(
45+
"Billing · an org out of executions is blocked before the code ever runs",
46+
{ timeout: 180_000 },
47+
Effect.gen(function* () {
48+
// Gates: billing is enforced here AND the Autumn ledger is observable.
49+
// Yield before any work so a target missing either capability skips cleanly.
50+
yield* Billing;
51+
const autumn = yield* Autumn;
52+
const target = yield* Target;
53+
const mcp = yield* Mcp;
54+
55+
const identity = yield* target.newIdentity();
56+
const bearer = yield* mcp.mintBearer(emailOf(identity));
57+
const customerId = orgIdOf(bearer);
58+
59+
// Burn the whole included allotment so the org's remaining balance is zero.
60+
// This records one `balances.track` event (the plan's included amount) —
61+
// the baseline the blocked execution is measured against below.
62+
yield* autumn.exhaustExecutions(customerId);
63+
const afterExhaust = yield* autumn.usageEvents({ customerId, featureId: "executions" });
64+
expect(afterExhaust.length, "exhausting the balance records exactly one usage event").toBe(1);
65+
66+
// A NEW MCP session => a fresh session DO => a fresh gate cache, so the
67+
// balance is checked live (no 60s stale-allow window from a prior session).
68+
const session = mcp.session(identity);
69+
const result = yield* session.call("execute", { code: "return 1 + 1;" });
70+
71+
expect(result.ok, "a blocked execution surfaces as an MCP error result").toBe(false);
72+
expect(result.text, "the client sees the execution-limit message").toContain(
73+
EXECUTION_LIMIT_BLOCKED_MESSAGE,
74+
);
75+
76+
// The meter is the source of truth: a blocked execution is neither run nor
77+
// usage-tracked. Give any (erroneous) fire-and-forget track ample time to
78+
// land, then assert nothing beyond the exhaust event was recorded.
79+
const settle = yield* autumn.usageEvents({ customerId, featureId: "executions" });
80+
expect(settle.length, "the blocked execution adds no 'executions' usage event").toBe(
81+
afterExhaust.length,
82+
);
83+
}),
84+
);
85+
86+
scenario(
87+
"Billing · a balance-check failure fails open and still runs the execution",
88+
{ timeout: 180_000 },
89+
Effect.gen(function* () {
90+
yield* Billing;
91+
const autumn = yield* Autumn;
92+
const target = yield* Target;
93+
const mcp = yield* Mcp;
94+
95+
const identity = yield* target.newIdentity();
96+
97+
yield* Effect.gen(function* () {
98+
// One-shot 500 on the very next balances.check the gate makes.
99+
yield* autumn.armFault({
100+
match: { operationId: "balances.check" },
101+
response: { status: 500, body: { message: "emulated billing outage" } },
102+
times: 1,
103+
});
104+
105+
const session = mcp.session(identity);
106+
const result = yield* session.call("execute", { code: "return 6 * 7;" });
107+
108+
// Fail open: the outage must not block a solvent org's execution.
109+
expect(result.ok, "the execution succeeds despite the billing outage").toBe(true);
110+
expect(result.text, "it returns its value").toContain("42");
111+
112+
// Proof the gate actually CONSULTED Autumn and failed open (rather than
113+
// never checking): the faulted balances.check is in the emulator ledger.
114+
const checks = yield* autumn.ledgerFor("balances.check");
115+
expect(
116+
checks.some((entry) => entry.faulted),
117+
"the gate's balances.check reached Autumn and was faulted",
118+
).toBe(true);
119+
}).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
120+
}),
121+
);
122+
123+
scenario(
124+
"Billing · a balance-check timeout fails open and still runs the execution",
125+
{ timeout: 180_000 },
126+
Effect.gen(function* () {
127+
yield* Billing;
128+
const autumn = yield* Autumn;
129+
const target = yield* Target;
130+
const mcp = yield* Mcp;
131+
132+
const identity = yield* target.newIdentity();
133+
134+
yield* Effect.gen(function* () {
135+
// The armed check stalls 3s before returning — past the gate's 2s budget,
136+
// so the gate times out client-side and fails open. (The response still
137+
// arrives after the delay; the ~2s added latency fits the timeout.)
138+
yield* autumn.armFault({
139+
match: { operationId: "balances.check" },
140+
response: { status: 200, body: { allowed: true } },
141+
times: 1,
142+
delayMs: 3000,
143+
});
144+
145+
const session = mcp.session(identity);
146+
const result = yield* session.call("execute", { code: "return 21 * 2;" });
147+
148+
expect(result.ok, "the execution succeeds despite the slow balance check").toBe(true);
149+
expect(result.text, "it returns its value").toContain("42");
150+
151+
const checks = yield* autumn.ledgerFor("balances.check");
152+
expect(checks.length, "the gate did attempt the (stalled) balances.check").toBeGreaterThan(0);
153+
}).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
154+
}),
155+
);
156+
157+
scenario(
158+
"Billing · the rate-limit backstop blocks runaway executions and doesn't meter them",
159+
{ timeout: 180_000 },
160+
Effect.gen(function* () {
161+
yield* Billing;
162+
const autumn = yield* Autumn;
163+
const target = yield* Target;
164+
const mcp = yield* Mcp;
165+
166+
const identity = yield* target.newIdentity();
167+
const bearer = yield* mcp.mintBearer(emailOf(identity));
168+
const customerId = orgIdOf(bearer);
169+
170+
// All executions run in ONE session so the balance cache stays allowed
171+
// (the org is solvent) — only the per-org hourly counter should trip.
172+
const session = mcp.session(identity);
173+
174+
// Run exactly the limit's worth of allowed executions.
175+
for (let i = 1; i <= RATE_LIMIT; i++) {
176+
const ok = yield* session.call("execute", { code: `return ${i};` });
177+
expect(ok.ok, `execution ${i} (within the limit) succeeds`).toBe(true);
178+
}
179+
180+
// The next one crosses the hourly cap: blocked with the backstop message.
181+
const blocked = yield* session.call("execute", { code: "return 999;" });
182+
expect(blocked.ok, "the execution past the cap is an MCP error result").toBe(false);
183+
expect(blocked.text, "the client sees the rate-limit message").toContain(
184+
RATE_LIMIT_BLOCKED_MESSAGE,
185+
);
186+
187+
// A rate-limited execution is neither run nor metered: exactly the allowed
188+
// runs reached the Autumn meter, and the blocked one added nothing.
189+
const metered = yield* autumn.expectUsage({
190+
customerId,
191+
featureId: "executions",
192+
count: RATE_LIMIT,
193+
});
194+
expect(metered.length, "only the allowed executions are metered — the blocked one is not").toBe(
195+
RATE_LIMIT,
196+
);
197+
}),
198+
);

0 commit comments

Comments
 (0)