Skip to content

Commit bedf280

Browse files
committed
Add local e2e for the OAuth await long-poll
1 parent 79266af commit bedf280

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// The desktop system-browser OAuth flow: the renderer cannot receive a
2+
// postMessage from the user's external browser, so it asks the local server
3+
// for the result at `/api/oauth/await/:sessionId`. Guarantee under test: the
4+
// server LONG-POLLS that route — a request issued while the flow is still in
5+
// flight is held open and answered the instant the provider callback
6+
// completes, instead of answered `null` (which made the renderer wait up to a
7+
// full 1s poll tick with the result already sitting in memory).
8+
//
9+
// The journey, against a real `executor web` boot:
10+
//
11+
// 1. `oauth.start` a connect → the await sessionId (= `started.state`).
12+
// 2. Issue TWO await requests BEFORE the provider callback. Both must be
13+
// held open — a pre-long-poll server answers `null` immediately, which
14+
// this scenario rejects explicitly.
15+
// 3. Complete the provider consent and land the REAL `/api/oauth/callback`
16+
// redirect (the path that publishes the result and wakes waiters).
17+
// 4. Both held requests resolve promptly (well under the old 1s poll tick).
18+
// Exactly ONE carries the ok result; the other answers `null` — still
19+
// pending — pinning single-consumer delivery of the one-shot result.
20+
import { randomBytes } from "node:crypto";
21+
import { writeFileSync } from "node:fs";
22+
import { join } from "node:path";
23+
24+
import { expect } from "@effect/vitest";
25+
import { Effect } from "effect";
26+
import { HttpApiClient } from "effect/unstable/httpapi";
27+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
28+
import { composePluginApi } from "@executor-js/api/server";
29+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
30+
import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
31+
import {
32+
AuthTemplateSlug,
33+
ConnectionName,
34+
IntegrationSlug,
35+
OAuthClientSlug,
36+
} from "@executor-js/sdk/shared";
37+
import { serveOAuthTestServer } from "@executor-js/sdk/testing";
38+
39+
import { scenario } from "../src/scenario";
40+
import { Cli, RunDir } from "../src/services";
41+
import { withLocalServer, type ServerHandle } from "./local-server";
42+
43+
const api = composePluginApi([mcpHttpPlugin()] as const);
44+
45+
const name = ConnectionName.make("default");
46+
const template = AuthTemplateSlug.make("oauth2");
47+
48+
// The old server answered a pending await with `null` immediately; the new
49+
// one holds it (up to 25s per hold). Observing "still unresolved" after this
50+
// window separates the two deterministically: a holding server cannot answer
51+
// within it, and an answer within it IS the regression.
52+
const HELD_OBSERVATION_MS = 600;
53+
54+
// The renderer used to learn about completion up to a full poll tick (1s)
55+
// after the callback. The held request must beat that with margin for CI
56+
// jitter.
57+
const PROMPT_RESOLUTION_BUDGET_MS = 900;
58+
59+
const apiClient = (server: ServerHandle) =>
60+
HttpApiClient.make(api, {
61+
baseUrl: new URL("/api", server.origin).toString(),
62+
transformClient: HttpClient.mapRequest((request) =>
63+
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
64+
),
65+
}).pipe(Effect.provide(FetchHttpClient.layer));
66+
67+
interface AwaitAnswer {
68+
/** When the server answered (response headers received). */
69+
readonly settledAt: number;
70+
readonly status: number;
71+
readonly body: unknown;
72+
}
73+
74+
/** One `/api/oauth/await/:sessionId` request, exactly as the desktop renderer
75+
* issues it (bearer-gated GET). The promise settles only when the server
76+
* answers — which is the behavior under test. */
77+
const issueAwait = (server: ServerHandle, sessionId: string): Promise<AwaitAnswer> =>
78+
fetch(`${server.origin}/api/oauth/await/${encodeURIComponent(sessionId)}`, {
79+
headers: { authorization: `Bearer ${server.token}` },
80+
}).then(async (response) => {
81+
const settledAt = Date.now();
82+
return { settledAt, status: response.status, body: (await response.json()) as unknown };
83+
});
84+
85+
/** Drive the test AS's consent (authorize → login) and return the app
86+
* callback URL the provider redirects to — carrying code + state. Unlike the
87+
* durability scenario this does NOT redeem the code by hand: landing the real
88+
* callback is the exact path that publishes the result and wakes waiters. */
89+
const resolveProviderConsent = (authorizationUrl: string) =>
90+
Effect.promise(async () => {
91+
const authorize = await fetch(authorizationUrl, { redirect: "manual" });
92+
const loginUrl = authorize.headers.get("location");
93+
if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
94+
const login = await fetch(loginUrl, {
95+
method: "POST",
96+
headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
97+
redirect: "manual",
98+
});
99+
const callbackUrl = login.headers.get("location");
100+
if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
101+
return callbackUrl;
102+
});
103+
104+
scenario(
105+
"Local · a held OAuth await answers the moment the provider callback lands",
106+
{},
107+
Effect.scoped(
108+
Effect.gen(function* () {
109+
const cli = yield* Cli;
110+
const runDir = yield* RunDir;
111+
112+
const oauth = yield* serveOAuthTestServer({ scopes: ["mcp.read"] });
113+
// A real MCP upstream that accepts the AS's bearers, so the connect is a
114+
// genuine end-to-end flow, not a stub handshake.
115+
const mcp = yield* serveMcpServer(() => makeEchoMcpServer({ name: "longpoll-mcp" }), {
116+
auth: {
117+
validateAuthorization: (authorization) => oauth.acceptsAuthorizationHeader(authorization),
118+
},
119+
});
120+
121+
const suffix = randomBytes(4).toString("hex");
122+
const slug = IntegrationSlug.make(`oauth-longpoll-${suffix}`);
123+
const clientSlug = OAuthClientSlug.make(`oauth-longpoll-client-${suffix}`);
124+
125+
yield* withLocalServer(cli, runDir, (server) =>
126+
Effect.gen(function* () {
127+
const client = yield* apiClient(server);
128+
129+
yield* client.mcp.addServer({
130+
payload: {
131+
transport: "remote",
132+
name: "Longpoll MCP",
133+
endpoint: mcp.url,
134+
slug: String(slug),
135+
authenticationTemplate: [{ kind: "oauth2" }],
136+
},
137+
});
138+
yield* client.oauth.createClient({
139+
payload: {
140+
owner: "org",
141+
slug: clientSlug,
142+
grant: "authorization_code",
143+
authorizationUrl: oauth.authorizationEndpoint,
144+
tokenUrl: oauth.tokenEndpoint,
145+
clientId: "test-client",
146+
clientSecret: "test-secret",
147+
resource: oauth.mcpResourceUrl,
148+
originIntegration: slug,
149+
},
150+
});
151+
152+
const started = yield* client.oauth.start({
153+
payload: {
154+
client: clientSlug,
155+
clientOwner: "org",
156+
owner: "org",
157+
name,
158+
integration: slug,
159+
template,
160+
},
161+
});
162+
expect(started.status, "oauth.start redirects to the authorization server").toBe(
163+
"redirect",
164+
);
165+
if (started.status !== "redirect") return yield* Effect.die("no redirect");
166+
// The await sessionId is the start state — exactly what the desktop
167+
// renderer passes to openOAuthSystemBrowser.
168+
const sessionId = started.state;
169+
170+
// -- 2. Await BEFORE the callback: the requests must be held. ------
171+
const firstAwait = issueAwait(server, sessionId);
172+
const secondAwait = issueAwait(server, sessionId);
173+
const observed = yield* Effect.promise(() =>
174+
Promise.race([
175+
firstAwait.then(() => "answered" as const),
176+
secondAwait.then(() => "answered" as const),
177+
new Promise<"held">((resolve) =>
178+
setTimeout(() => resolve("held"), HELD_OBSERVATION_MS),
179+
),
180+
]),
181+
);
182+
expect(
183+
observed,
184+
"an await issued mid-flow is held open, not answered null immediately (the pre-long-poll behavior)",
185+
).toBe("held");
186+
187+
// -- 3. Complete consent and land the real callback redirect. ------
188+
const redirected = yield* resolveProviderConsent(started.authorizationUrl);
189+
// The harness boots `executor web --port 0` (OS-assigned), and the
190+
// server builds the redirect URI from the CONFIGURED port — so the
191+
// redirect reads `127.0.0.1:0`. Land the same callback path + query
192+
// (code + state, the cryptographic gate) on the server's real bound
193+
// origin; it is the same server and the same handler.
194+
const callbackUrl = new URL(redirected);
195+
expect(
196+
callbackUrl.pathname,
197+
"the provider redirects back to this server's OAuth callback",
198+
).toBe("/api/oauth/callback");
199+
const callbackStartedAt = Date.now();
200+
const callback = yield* Effect.promise(() =>
201+
fetch(`${server.origin}${callbackUrl.pathname}${callbackUrl.search}`),
202+
);
203+
const callbackHtml = yield* Effect.promise(() => callback.text());
204+
const callbackCompletedAt = Date.now();
205+
expect(callback.status, "the OAuth callback succeeds").toBe(200);
206+
expect(callbackHtml, "the callback page reports the connect succeeded").toContain(
207+
"Connected",
208+
);
209+
210+
// -- 4. Both held requests answer promptly; one carries the result.
211+
const answers = yield* Effect.promise(() => Promise.all([firstAwait, secondAwait]));
212+
for (const answer of answers) {
213+
expect(answer.status, "a held await answers 200 like an immediate poll").toBe(200);
214+
}
215+
const delivered = answers.filter((answer) => answer.body !== null);
216+
expect(delivered, "exactly one await receives the one-shot result").toHaveLength(1);
217+
expect(delivered[0]?.body).toMatchObject({ ok: true, sessionId });
218+
expect(
219+
answers.filter((answer) => answer.body === null),
220+
"the other await answers null — still pending — so a retrying client keeps working",
221+
).toHaveLength(1);
222+
223+
const lastSettledAt = Math.max(...answers.map((answer) => answer.settledAt));
224+
const sinceCallback = lastSettledAt - callbackCompletedAt;
225+
// Console output is swallowed by the runner, so park the measured
226+
// timings in the run dir as reviewable evidence.
227+
writeFileSync(
228+
join(runDir, "await-timing.json"),
229+
JSON.stringify(
230+
{
231+
heldObservationMs: HELD_OBSERVATION_MS,
232+
callbackHandlingMs: callbackCompletedAt - callbackStartedAt,
233+
awaitsSettledAfterCallbackMs: sinceCallback,
234+
budgetMs: PROMPT_RESOLUTION_BUDGET_MS,
235+
},
236+
null,
237+
2,
238+
),
239+
);
240+
expect(
241+
sinceCallback,
242+
"held awaits resolve promptly after the callback — well under the old 1s poll tick",
243+
).toBeLessThan(PROMPT_RESOLUTION_BUDGET_MS);
244+
}),
245+
);
246+
}),
247+
),
248+
);

0 commit comments

Comments
 (0)