Unified · Split
| @@ -13,14 +13,19 @@ · 12 unmodified lines | |||
| 13 | // --------------------------------------------------------------------------- | 13 | // --------------------------------------------------------------------------- |
| 14 | 14 | ||
| 15 | import { afterEach, expect, layer } from "@effect/vitest"; | 15 | import { afterEach, expect, layer } from "@effect/vitest"; |
| 16 | import { Effect, Layer, Schema } from "effect"; | 16 | import { Effect, Layer, Predicate, Schema } from "effect"; |
| 17 | import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; | 17 | import { |
| 18 | HttpApi, | ||
| 19 | HttpApiBuilder, | ||
| 20 | HttpApiEndpoint, | ||
| 21 | HttpApiGroup, | ||
| 22 | OpenApi, | ||
| 23 | } from "effect/unstable/httpapi"; | ||
| 18 | import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"; | 24 | import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"; |
| 19 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 25 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 20 | 26 | ||
| 21 | import { | 27 | import { |
| 22 | ConnectionId, | 28 | ConnectionId, |
| 23 | ConnectionReauthRequiredError, | ||
| 24 | CreateConnectionInput, | 29 | CreateConnectionInput, |
| 25 | ScopeId, | 30 | ScopeId, |
| 26 | SecretId, | 31 | SecretId, |
| @@ -90,23 +95,26 @@ const mockTokenFetch = ( · 63 unmodified lines | |||
| 90 | handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>, | 95 | handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>, |
| 91 | ) => { | 96 | ) => { |
| 92 | const calls: TokenCall[] = []; | 97 | const calls: TokenCall[] = []; |
| 93 | globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => { | 98 | globalThis.fetch = Object.assign( |
| 94 | const url = typeof _input === "string" ? _input : _input.toString(); | 99 | async (_input: RequestInfo | URL, init?: RequestInit) => { |
| 95 | if (!url.includes("token.example.com")) { | 100 | const url = typeof _input === "string" ? _input : _input.toString(); |
| 96 | return originalFetch(_input, init); | 101 | if (!url.includes("token.example.com")) { |
| 97 | } | 102 | return originalFetch(_input, init); |
| 98 | const bodyText = | 103 | } |
| 99 | init?.body instanceof URLSearchParams | 104 | const bodyText = |
| 100 | ? init.body.toString() | 105 | init?.body instanceof URLSearchParams |
| 101 | : typeof init?.body === "string" | 106 | ? init.body.toString() |
| 102 | ? init.body | 107 | : typeof init?.body === "string" |
| 103 | : ""; | 108 | ? init.body |
| 104 | const body = new URLSearchParams(bodyText); | 109 | : ""; |
| 105 | calls.push({ body }); | 110 | const body = new URLSearchParams(bodyText); |
| 106 | const out = handler(body); | 111 | calls.push({ body }); |
| 107 | if (Effect.isEffect(out)) return await Effect.runPromise(out); | 112 | const out = handler(body); |
| 108 | return await out; | 113 | if (Effect.isEffect(out)) return await Effect.runPromise(out); |
| 109 | }, { preconnect: originalFetch.preconnect }); | 114 | return await out; |
| 115 | }, | ||
| 116 | { preconnect: originalFetch.preconnect }, | ||
| 117 | ); | ||
| 110 | return { calls }; | 118 | return { calls }; |
| 111 | }; | 119 | }; |
| 112 | 120 | ||
| @@ -127,25 +135,26 @@ const makeExecutor = () => · 14 unmodified lines | |||
| 127 | const memoryProvider: SecretProvider = { | 135 | const memoryProvider: SecretProvider = { |
| 128 | key: "memory", | 136 | key: "memory", |
| 129 | writable: true, | 137 | writable: true, |
| 130 | get: (id, scope) => | 138 | get: (id, scope) => Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null), |
| 131 | Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null), | ||
| 132 | set: (id, value, scope) => | 139 | set: (id, value, scope) => |
| 133 | Effect.sync(() => { | 140 | Effect.sync(() => { |
| 134 | secretStore.set(keyOf(scope, id), value); | 141 | secretStore.set(keyOf(scope, id), value); |
| 135 | }), | 142 | }), |
| 136 | delete: (id, scope) => | 143 | delete: (id, scope) => Effect.sync(() => secretStore.delete(keyOf(scope, id))), |
| 137 | Effect.sync(() => secretStore.delete(keyOf(scope, id))), | ||
| 138 | }; | 144 | }; |
| 139 | const memorySecretsPlugin = definePlugin(() => ({ | 145 | const memorySecretsPlugin = definePlugin(() => ({ |
| 140 | id: "memory-secrets" as const, | 146 | id: "memory-secrets" as const, |
| 141 | storage: () => ({}), | 147 | storage: () => ({}), |
| 142 | secretProviders: [memoryProvider], | 148 | secretProviders: [memoryProvider], |
| 143 | })); | 149 | })); |
| 144 | const clientLayer = FetchHttpClient.layer; | 150 | const clientLayer = FetchHttpClient.layer; |
| 145 | const server = yield* HttpServer.HttpServer; | 151 | const server = yield* HttpServer.HttpServer; |
| 146 | const address = server.address; | 152 | const address = server.address; |
| 147 | if (address._tag !== "TcpAddress") return yield* Effect.die("test server must bind to TCP"); | 153 | if (!Predicate.isTagged("TcpAddress")(address)) { |
| 148 | const baseUrl = `http://127.0.0.1:${address.port}`; | 154 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address |
| 155 | return yield* Effect.die("test server must bind to TCP"); | ||
| 156 | } | ||
| 157 | const baseUrl = `http://127.0.0.1:${address.port}`; | ||
| 149 | const plugins = [ | 158 | const plugins = [ |
| 150 | openApiPlugin({ httpClientLayer: clientLayer }), | 159 | openApiPlugin({ httpClientLayer: clientLayer }), |
| 151 | memorySecretsPlugin(), | 160 | memorySecretsPlugin(), |
| @@ -198,11 +207,7 @@ type ExecutorValue = EffectSuccess<ReturnType<typeof makeExecutor>>["executor"]; · 46 unmodified lines | |||
| 198 | // Seed an authorizationCode Connection with an already-expired access | 207 | // Seed an authorizationCode Connection with an already-expired access |
| 199 | // token and a stored refresh token. The test's mock token endpoint | 208 | // token and a stored refresh token. The test's mock token endpoint |
| 200 | // decides what comes back on `grant_type=refresh_token`. | 209 | // decides what comes back on `grant_type=refresh_token`. |
| 201 | const seedExpiredConnection = ( | 210 | const seedExpiredConnection = (executor: ExecutorValue, scopeId: ScopeId, connectionId: string) => |
| 202 | executor: ExecutorValue, | ||
| 203 | scopeId: ScopeId, | ||
| 204 | connectionId: string, | ||
| 205 | ) => | ||
| 206 | Effect.gen(function* () { | 211 | Effect.gen(function* () { |
| 207 | yield* executor.connections.create( | 212 | yield* executor.connections.create( |
| 208 | new CreateConnectionInput({ | 213 | new CreateConnectionInput({ |
| @@ -249,169 +254,144 @@ const seedExpiredConnection = ( · 40 unmodified lines | |||
| 249 | // --------------------------------------------------------------------------- | 254 | // --------------------------------------------------------------------------- |
| 250 | 255 | ||
| 251 | layer(TestLayer)("OpenAPI oauth refresh", (it) => { | 256 | layer(TestLayer)("OpenAPI oauth refresh", (it) => { |
| 252 | it.effect( | 257 | it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () => |
| 253 | "expired access_token is refreshed via grant_type=refresh_token before invoke", | 258 | Effect.gen(function* () { |
| 254 | () => | 259 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); |
| 255 | Effect.gen(function* () { | 260 | const { calls } = mockTokenFetch(() => |
| 256 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); | 261 | Effect.succeed( |
| 257 | const { calls } = mockTokenFetch( | 262 | new Response( |
| 258 | () => | 263 | JSON.stringify({ |
| 259 | Effect.succeed( | 264 | access_token: "fresh-access-v2", |
| 260 | new Response( | 265 | token_type: "Bearer", |
| 261 | JSON.stringify({ | 266 | refresh_token: "refresh-v2", |
| 262 | access_token: "fresh-access-v2", | 267 | expires_in: 3600, |
| 263 | token_type: "Bearer", | 268 | }), |
| 264 | refresh_token: "refresh-v2", | 269 | { status: 200, headers: { "content-type": "application/json" } }, |
| 265 | expires_in: 3600, | 270 | ), |
| 266 | }), | 271 | ), |
| 267 | { status: 200, headers: { "content-type": "application/json" } }, | 272 | ); |
| 268 | ), | 273 | |
| 269 | ), | 274 | const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-ok"); |
| 270 | ); | 275 | |
| 271 | 276 | yield* executor.openapi.addSpec({ | |
| 272 | const auth = yield* seedExpiredConnection( | 277 | spec: specJson, |
| 273 | executor, | 278 | scope: String(scopeId), |
| 274 | scopeId, | 279 | namespace: "petstore", |
| 275 | "conn-refresh-ok", | 280 | baseUrl, |
| 276 | ); | 281 | oauth2: auth, |
| 277 | 282 | }); | |
| 278 | yield* executor.openapi.addSpec({ | 283 | |
| 279 | spec: specJson, | 284 | const result = (yield* executor.tools.invoke( |
| 280 | scope: String(scopeId), | 285 | "petstore.items.echoHeaders", |
| 281 | namespace: "petstore", | 286 | {}, |
| 282 | baseUrl, | 287 | autoApprove, |
| 283 | oauth2: auth, | 288 | )) as { data: { authorization?: string } | null; error: unknown }; |
| 284 | }); | 289 | |
| 285 | 290 | expect(result.error).toBeNull(); | |
| 286 | const result = (yield* executor.tools.invoke( | 291 | // Proves the refresh landed: invoke carried the fresh token, |
| 287 | "petstore.items.echoHeaders", | 292 | // not the expired one we seeded. |
| 288 | {}, | 293 | expect(result.data?.authorization).toBe("Bearer fresh-access-v2"); |
| 289 | autoApprove, | 294 | expect(calls).toHaveLength(1); |
| 290 | )) as { data: { authorization?: string } | null; error: unknown }; | 295 | expect(calls[0]!.body.get("grant_type")).toBe("refresh_token"); |
| 291 | 296 | expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1"); | |
| 292 | expect(result.error).toBeNull(); | 297 | |
| 293 | // Proves the refresh landed: invoke carried the fresh token, | 298 | // Connection row is patched with the new expiry so the next |
| 294 | // not the expired one we seeded. | 299 | // invoke in-window doesn't trip a second refresh. |
| 295 | expect(result.data?.authorization).toBe("Bearer fresh-access-v2"); | 300 | const conn = yield* executor.connections.get("conn-refresh-ok"); |
| 296 | expect(calls).toHaveLength(1); | 301 | expect(conn).not.toBeNull(); |
| 297 | expect(calls[0]!.body.get("grant_type")).toBe("refresh_token"); | 302 | expect(conn!.expiresAt).not.toBeNull(); |
| 298 | expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1"); | 303 | expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000); |
| 299 | 304 | }), | |
| 300 | // Connection row is patched with the new expiry so the next | ||
| 301 | // invoke in-window doesn't trip a second refresh. | ||
| 302 | const conn = yield* executor.connections.get("conn-refresh-ok"); | ||
| 303 | expect(conn).not.toBeNull(); | ||
| 304 | expect(conn!.expiresAt).not.toBeNull(); | ||
| 305 | expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000); | ||
| 306 | }), | ||
| 307 | ); | 305 | ); |
| 308 | 306 | ||
| 309 | it.effect( | 307 | it.effect("concurrent invokes with an expired token issue exactly one refresh", () => |
| 310 | "concurrent invokes with an expired token issue exactly one refresh", | 308 | Effect.gen(function* () { |
| 311 | () => | 309 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); |
| 312 | Effect.gen(function* () { | 310 | const { calls } = mockTokenFetch(() => |
| 313 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); | 311 | Effect.succeed( |
| 314 | const { calls } = mockTokenFetch( | 312 | new Response( |
| 315 | () => | 313 | JSON.stringify({ |
| 316 | Effect.succeed( | 314 | access_token: "fresh-access-v2", |
| 317 | new Response( | 315 | token_type: "Bearer", |
| 318 | JSON.stringify({ | 316 | refresh_token: "refresh-v2", |
| 319 | access_token: "fresh-access-v2", | 317 | expires_in: 3600, |
| 320 | token_type: "Bearer", | 318 | }), |
| 321 | refresh_token: "refresh-v2", | 319 | { status: 200, headers: { "content-type": "application/json" } }, |
| 322 | expires_in: 3600, | ||
| 323 | }), | ||
| 324 | { status: 200, headers: { "content-type": "application/json" } }, | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | ); | ||
| 328 | |||
| 329 | const auth = yield* seedExpiredConnection( | ||
| 330 | executor, | ||
| 331 | scopeId, | ||
| 332 | "conn-refresh-concurrent", | ||
| 333 | ); | ||
| 334 | |||
| 335 | yield* executor.openapi.addSpec({ | ||
| 336 | spec: specJson, | ||
| 337 | scope: String(scopeId), | ||
| 338 | namespace: "petstore", | ||
| 339 | baseUrl, | ||
| 340 | oauth2: auth, | ||
| 341 | }); | ||
| 342 | |||
| 343 | const invokes = yield* Effect.all( | ||
| 344 | [1, 2, 3, 4, 5].map(() => | ||
| 345 | executor.tools.invoke( | ||
| 346 | "petstore.items.echoHeaders", | ||
| 347 | {}, | ||
| 348 | autoApprove, | ||
| 349 | ), | ||
| 350 | ), | 320 | ), |
| 351 | { concurrency: "unbounded" }, | 321 | ), |
| 352 | ); | 322 | ); |
| 353 | 323 | ||
| 354 | for (const r of invokes) { | 324 | const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-concurrent"); |
| 355 | const res = r as { | 325 | |
| 356 | data: { authorization?: string } | null; | 326 | yield* executor.openapi.addSpec({ |
| 357 | error: unknown; | 327 | spec: specJson, |
| 358 | }; | 328 | scope: String(scopeId), |
| 359 | expect(res.error).toBeNull(); | 329 | namespace: "petstore", |
| 360 | expect(res.data?.authorization).toBe("Bearer fresh-access-v2"); | 330 | baseUrl, |
| 361 | } | 331 | oauth2: auth, |
| 362 | // Critical assertion: the SDK's dedup collapses every parallel | 332 | }); |
| 363 | // invoke into one call to the token endpoint. Anything more | 333 | |
| 364 | // means we're hammering the AS under load. | 334 | const invokes = yield* Effect.all( |
| 365 | expect(calls).toHaveLength(1); | 335 | [1, 2, 3, 4, 5].map(() => |
| 366 | }), | 336 | executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 337 | ), | ||
| 338 | { concurrency: "unbounded" }, | ||
| 339 | ); | ||
| 340 | |||
| 341 | for (const r of invokes) { | ||
| 342 | const res = r as { | ||
| 343 | data: { authorization?: string } | null; | ||
| 344 | error: unknown; | ||
| 345 | }; | ||
| 346 | expect(res.error).toBeNull(); | ||
| 347 | expect(res.data?.authorization).toBe("Bearer fresh-access-v2"); | ||
| 348 | } | ||
| 349 | // Critical assertion: the SDK's dedup collapses every parallel | ||
| 350 | // invoke into one call to the token endpoint. Anything more | ||
| 351 | // means we're hammering the AS under load. | ||
| 352 | expect(calls).toHaveLength(1); | ||
| 353 | }), | ||
| 367 | ); | 354 | ); |
| 368 | 355 | ||
| 369 | it.effect( | 356 | it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () => |
| 370 | "invalid_grant from refresh surfaces as ConnectionReauthRequiredError", | 357 | Effect.gen(function* () { |
| 371 | () => | 358 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); |
| 372 | Effect.gen(function* () { | 359 | mockTokenFetch(() => |
| 373 | const { executor, scopeId, baseUrl } = yield* makeExecutor(); | 360 | Effect.succeed( |
| 374 | mockTokenFetch( | 361 | new Response( |
| 375 | () => | 362 | JSON.stringify({ |
| 376 | Effect.succeed( | 363 | error: "invalid_grant", |
| 377 | new Response( | 364 | error_description: "Refresh token revoked", |
| 378 | JSON.stringify({ | 365 | }), |
| 379 | error: "invalid_grant", | 366 | { status: 400, headers: { "content-type": "application/json" } }, |
| 380 | error_description: "Refresh token revoked", | 367 | ), |
| 381 | }), | 368 | ), |
| 382 | { status: 400, headers: { "content-type": "application/json" } }, | 369 | ); |
| 383 | ), | 370 | |
| 384 | ), | 371 | const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-dead"); |
| 385 | ); | 372 | |
| 386 | 373 | yield* executor.openapi.addSpec({ | |
| 387 | const auth = yield* seedExpiredConnection( | 374 | spec: specJson, |
| 388 | executor, | 375 | scope: String(scopeId), |
| 389 | scopeId, | 376 | namespace: "petstore", |
| 390 | "conn-refresh-dead", | 377 | baseUrl, |
| 391 | ); | 378 | oauth2: auth, |
| 392 | 379 | }); | |
| 393 | yield* executor.openapi.addSpec({ | 380 | |
| 394 | spec: specJson, | 381 | // Tool invocation currently wraps connection errors in a |
| 395 | scope: String(scopeId), | 382 | // generic Error (see openapi invokeTool), so we assert against |
| 396 | namespace: "petstore", | 383 | // the `accessToken` call directly too — that's the surface |
| 397 | baseUrl, | 384 | // the UI bridges use to trigger re-auth. |
| 398 | oauth2: auth, | 385 | const flipped = yield* executor.connections.accessToken("conn-refresh-dead").pipe( |
| 399 | }); | 386 | Effect.flip, |
| 400 | 387 | Effect.flatMap((error) => | |
| 401 | // Tool invocation currently wraps connection errors in a | 388 | Predicate.isTagged("ConnectionReauthRequiredError")(error) |
| 402 | // generic Error (see openapi invokeTool), so we assert against | 389 | ? Effect.succeed(error) |
| 403 | // the `accessToken` call directly too — that's the surface | 390 | : Effect.fail(error), |
| 404 | // the UI bridges use to trigger re-auth. | 391 | ), |
| 405 | const flipped = yield* executor.connections | 392 | ); |
| 406 | .accessToken("conn-refresh-dead") | 393 | expect(flipped.provider).toBe("openapi:oauth2"); |
| 407 | .pipe(Effect.flip); | 394 | expect(flipped.message).toBe("OAuth refresh failed"); |
| 408 | expect(flipped._tag).toBe("ConnectionReauthRequiredError"); | 395 | }), |
| 409 | expect((flipped as ConnectionReauthRequiredError).provider).toBe( | ||
| 410 | "openapi:oauth2", | ||
| 411 | ); | ||
| 412 | expect( | ||
| 413 | (flipped as ConnectionReauthRequiredError).message, | ||
| 414 | ).toMatch(/invalid_grant|revoked/i); | ||
| 415 | }), | ||
| 416 | ); | 396 | ); |
| 417 | }); | 397 | }); |