|
| 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