// ---------------------------------------------------------------------------// 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<string, unknown>, components: Record<string, unknown> = {},) => 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", ); }), );});
// ---------------------------------------------------------------------------
// 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<string, unknown>,
components: Record<string, unknown> = {},
) =>
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",
);
}),
);
});