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.
20import { randomBytes } from "node:crypto";
21import { writeFileSync } from "node:fs";
22import { join } from "node:path";
23
24import { expect } from "@effect/vitest";
25import { Effect } from "effect";
26import { HttpApiClient } from "effect/unstable/httpapi";
27import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
28import { composePluginApi } from "@executor-js/api/server";
29import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
30import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
31import {
32 AuthTemplateSlug,
33 ConnectionName,
34 IntegrationSlug,
35 OAuthClientSlug,
36} from "@executor-js/sdk/shared";
37import { serveOAuthTestServer } from "@executor-js/sdk/testing";
38
39import { scenario } from "../src/scenario";
40import { Cli, RunDir } from "../src/services";
41import { withLocalServer, type ServerHandle } from "./local-server";
42
43const api = composePluginApi([mcpHttpPlugin()] as const);
44
45const name = ConnectionName.make("default");
46const 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.
52const 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.
57const PROMPT_RESOLUTION_BUDGET_MS = 900;
58
59const 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
67interface 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. */
77const 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. */
89const 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
104scenario(
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);