Unified · Split
| @@ -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 | ); | ||