History

Add local e2e for the OAuth await long-poll

Rhys Sullivan committed 2 months ago

Commit bedf2801 parent 79266af1 file · +248 −0Browse files

Unified · Split

e2e/local/oauth-await-longpoll.test.ts+248−0
@@ -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+);