// --------------------------------------------------------------------------- // Tests for OAuth2 flow extraction in previewSpec. Covers: // - authorizationCode flow extraction (URLs + scopes) // - clientCredentials flow extraction // - security schemes defined via $ref are no longer silently dropped // - bearerFormat / openIdConnectUrl are captured // - invalid flows (missing tokenUrl) are ignored // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { previewSpec as previewSpecRaw } from "./preview"; const previewSpec = (input: string) => previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); const PreviewGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), ); const PreviewApi = HttpApi.make("previewOauth2Test") .add(PreviewGroup) .annotateMerge( OpenApi.annotations({ title: "Test API", version: "1.0.0", servers: [{ url: "https://api.example.com" }], }), ); const minimalSpec = ( securitySchemes: Record, components: Record = {}, ) => OpenApi.fromApi( PreviewApi.annotateMerge( OpenApi.annotations({ transform: (spec) => ({ ...spec, components: { ...components, securitySchemes }, }), }), ), ); describe("previewSpec OAuth2 extraction", () => { it.effect("extracts authorizationCode flow with URLs, scopes, refreshUrl", () => Effect.gen(function* () { const spec = minimalSpec({ oauth_app: { type: "oauth2", flows: { authorizationCode: { authorizationUrl: "https://example.com/oauth/authorize", tokenUrl: "https://example.com/oauth/token", refreshUrl: "https://example.com/oauth/refresh", scopes: { read: "Read access", write: "Write access", }, }, }, }, }); const preview = yield* previewSpec(JSON.stringify(spec)); expect(preview.securitySchemes).toHaveLength(1); const scheme = preview.securitySchemes[0]!; expect(scheme.name).toBe("oauth_app"); expect(scheme.type).toBe("oauth2"); expect(Option.isSome(scheme.flows)).toBe(true); const flows = Option.getOrThrow(scheme.flows); expect(Option.isSome(flows.authorizationCode)).toBe(true); const flow = Option.getOrThrow(flows.authorizationCode); expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize"); expect(flow.tokenUrl).toBe("https://example.com/oauth/token"); expect(Option.getOrElse(flow.refreshUrl, () => "")).toBe("https://example.com/oauth/refresh"); expect(flow.scopes).toEqual({ read: "Read access", write: "Write access", }); // A preset should be generated for this flow. expect(preview.oauth2Presets).toHaveLength(1); expect(preview.headerPresets).toHaveLength(0); const preset = preview.oauth2Presets[0]!; expect(preset.flow).toBe("authorizationCode"); expect(preset.securitySchemeName).toBe("oauth_app"); expect(preset.tokenUrl).toBe("https://example.com/oauth/token"); expect(preset.label).toContain("Authorization Code"); expect(preset.label).toContain("oauth_app"); expect(preset.scopes).toEqual({ read: "Read access", write: "Write access", }); expect(preset.identityScopes).toBe("auto"); }), ); it.effect("extracts clientCredentials flow alongside authorizationCode", () => Effect.gen(function* () { const spec = minimalSpec({ oauth_app: { type: "oauth2", flows: { authorizationCode: { authorizationUrl: "https://example.com/auth", tokenUrl: "https://example.com/token", scopes: {}, }, clientCredentials: { tokenUrl: "https://example.com/token", scopes: { "admin:read": "Admin read" }, }, }, }, }); const preview = yield* previewSpec(JSON.stringify(spec)); expect(preview.oauth2Presets).toHaveLength(2); const flowKinds = preview.oauth2Presets.map((p) => p.flow).sort(); expect(flowKinds).toEqual(["authorizationCode", "clientCredentials"]); const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!; expect(Option.isNone(cc.authorizationUrl)).toBe(true); expect(cc.scopes).toEqual({ "admin:read": "Admin read" }); expect(cc.identityScopes).toBe(false); }), ); it.effect("skips authorizationCode flow missing tokenUrl or authorizationUrl", () => Effect.gen(function* () { const spec = minimalSpec({ broken: { type: "oauth2", flows: { authorizationCode: { authorizationUrl: "https://example.com/auth", // tokenUrl missing scopes: {}, }, }, }, }); const preview = yield* previewSpec(JSON.stringify(spec)); expect(preview.securitySchemes).toHaveLength(1); // Scheme is still captured but with no flows. expect(Option.isNone(preview.securitySchemes[0]!.flows)).toBe(true); expect(preview.oauth2Presets).toHaveLength(0); }), ); it.effect("resolves security schemes defined via $ref", () => Effect.gen(function* () { const spec = minimalSpec({ api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, _api_token_impl: { type: "http", scheme: "bearer", bearerFormat: "JWT", description: "Internal token scheme", }, }); // Note: `api_token` should resolve through the sibling // `_api_token_impl` scheme in `components.securitySchemes`. const preview = yield* previewSpec(JSON.stringify(spec)); // Both keys are present, but the `api_token` entry should resolve to // the http bearer scheme (previously it was silently dropped). const apiToken = preview.securitySchemes.find((s) => s.name === "api_token"); expect(apiToken).toBeDefined(); expect(apiToken!.type).toBe("http"); expect(Option.getOrElse(apiToken!.scheme, () => "")).toBe("bearer"); expect(Option.getOrElse(apiToken!.bearerFormat, () => "")).toBe("JWT"); }), ); it.effect("captures openIdConnectUrl for openIdConnect schemes", () => Effect.gen(function* () { const spec = minimalSpec({ oidc: { type: "openIdConnect", openIdConnectUrl: "https://example.com/.well-known/openid-configuration", }, }); const preview = yield* previewSpec(JSON.stringify(spec)); const scheme = preview.securitySchemes[0]!; expect(scheme.type).toBe("openIdConnect"); expect(Option.getOrElse(scheme.openIdConnectUrl, () => "")).toBe( "https://example.com/.well-known/openid-configuration", ); }), ); });