From 916c78ec5d88ef36fdc5c33c365520bae873eee3 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:17:23 +0400 Subject: [PATCH 1/4] fix: send profile auth headers when fetching the OpenAPI spec --- src/cli.ts | 14 ++++++++++---- src/openapi-loader.ts | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2e88e50..b28342c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -114,7 +114,7 @@ async function runApiCommand( const { profileName: overrideName, remaining: commandArgs } = extractProfileFlag(args); const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile); + const spec = await openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(profile) }); const commands = openapiToCommands.buildCommands(spec, profile); const command = commands.find((cmd) => cmd.name === toolName); @@ -345,7 +345,7 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record): Record { +function buildProfileAuthHeaders(profile: Profile): Record { const headers: Record = {}; if (profile.customHeaders) { @@ -359,6 +359,12 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record): Record { + const headers = buildProfileAuthHeaders(profile); + const cookiePairs: string[] = []; command.options .filter((opt) => opt.location === "header" || opt.location === "cookie") @@ -619,7 +625,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { customHeaders, }; - await openapiLoader.loadSpec(profile, { refresh: true }); + await openapiLoader.loadSpec(profile, { refresh: true, headers: buildProfileAuthHeaders(profile) }); profileStore.saveProfile(cwd, profile, { makeCurrent: true }); }; @@ -753,7 +759,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { async (args) => { const overrideName = args.profile as string | undefined; const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile); + const spec = await openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(profile) }); const commands = openapiToCommands.buildCommands(spec, profile); if (commands.length === 0) { stdout(`No commands available for profile ${profile.name}\n`); diff --git a/src/openapi-loader.ts b/src/openapi-loader.ts index fa57d33..15b4096 100644 --- a/src/openapi-loader.ts +++ b/src/openapi-loader.ts @@ -27,6 +27,7 @@ export class OpenapiLoader { profile: Profile, options?: { refresh?: boolean; + headers?: Record; } ): Promise { const cachePath = profile.openapiSpecCache; @@ -36,7 +37,7 @@ export class OpenapiLoader { return JSON.parse(cached); } - const spec = await this.loadAndResolveSpec(profile.openapiSpecSource); + const spec = await this.loadAndResolveSpec(profile.openapiSpecSource, options?.headers); this.ensureCacheDir(cachePath); const serialized = JSON.stringify(spec, null, 2); @@ -45,20 +46,21 @@ export class OpenapiLoader { return spec; } - private async loadAndResolveSpec(source: string): Promise { + private async loadAndResolveSpec(source: string, headers?: Record): Promise { const rawDocCache = new Map(); - const root = await this.loadDocument(source, rawDocCache); + const root = await this.loadDocument(source, rawDocCache, headers); return this.resolveRefs(root, { currentSource: source, currentDocument: root, rawDocCache, resolvingRefs: new Set(), + headers, }); } - private async loadFromSource(source: string): Promise { + private async loadFromSource(source: string, headers?: Record): Promise { if (source.startsWith("http://") || source.startsWith("https://")) { - const response = await axios.get(source, { responseType: "text" }); + const response = await axios.get(source, { responseType: "text", headers }); return this.parseSpec(response.data, source); } @@ -66,12 +68,16 @@ export class OpenapiLoader { return this.parseSpec(raw, source); } - private async loadDocument(source: string, rawDocCache: Map): Promise { + private async loadDocument( + source: string, + rawDocCache: Map, + headers?: Record + ): Promise { if (rawDocCache.has(source)) { return rawDocCache.get(source); } - const loaded = await this.loadFromSource(source); + const loaded = await this.loadFromSource(source, headers); rawDocCache.set(source, loaded); return loaded; } @@ -93,6 +99,7 @@ export class OpenapiLoader { currentDocument: unknown; rawDocCache: Map; resolvingRefs: Set; + headers?: Record; } ): Promise { if (Array.isArray(value)) { @@ -139,6 +146,7 @@ export class OpenapiLoader { currentDocument: unknown; rawDocCache: Map; resolvingRefs: Set; + headers?: Record; } ): Promise { const { source, pointer } = this.splitRef(ref, context.currentSource); @@ -152,7 +160,7 @@ export class OpenapiLoader { const targetDocument = source === context.currentSource ? context.currentDocument - : await this.loadDocument(source, context.rawDocCache); + : await this.loadDocument(source, context.rawDocCache, context.headers); const targetValue = this.resolvePointer(targetDocument, pointer); const resolvedValue = await this.resolveRefs(targetValue, { From 7aeeb18ed8a65062a975441841084b389e5348ac Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:29:00 +0400 Subject: [PATCH 2/4] test: cover auth headers for protected spec fetching --- tests/cli.test.ts | 49 ++++++++++++++++++++++++++++++++++++ tests/openapi-loader.test.ts | 39 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5206d75..0eda205 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,9 +2,15 @@ import { ConfigLocator } from "../src/config"; import { ProfileStore } from "../src/profile-store"; import { OpenapiLoader } from "../src/openapi-loader"; import { run, HttpClient } from "../src/cli"; +import axios from "axios"; import { AxiosError } from "axios"; import { VERSION } from "../src/version"; +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), +})); + interface MemoryFsEntry { type: "file" | "dir"; content?: string; @@ -164,6 +170,49 @@ describe("cli", () => { expect(profileStore.getCurrentProfileName(cwd)).toBe("myapi"); }); + it("profiles add sends profile auth headers when fetching a protected HTTP spec", async () => { + const mockedAxios = axios as jest.Mocked; + const spec = { openapi: "3.0.0", paths: {} }; + + mockedAxios.get.mockImplementation(async (_url: string, config?: any) => { + if (config?.headers?.Authorization !== "Bearer secret123" || config?.headers?.["x-api-key"] !== "key123") { + throw new AxiosError("Request failed with status code 401", "401"); + } + return { data: spec }; + }); + + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const fs = new MemoryFs(); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs }); + + await run( + [ + "profiles", + "add", + "protected", + "--api-base-url", + "http://127.0.0.1:3000", + "--openapi-spec", + "http://127.0.0.1:3000/openapi.json", + "--api-bearer-token", + "secret123", + "--custom-headers", + '{"x-api-key":"key123"}', + ], + { cwd, profileStore, openapiLoader } + ); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(fs.existsSync(profilesPath)).toBe(true); + const profile = profileStore.getCurrentProfile(cwd); + expect(profile?.name).toBe("protected"); + expect(profile?.apiBearerToken).toBe("secret123"); + expect(profile?.customHeaders).toEqual({ "x-api-key": "key123" }); + }); + it("profiles list prints profile names", async () => { const localDir = `${cwd}/.ocli`; const iniContent = [ diff --git a/tests/openapi-loader.test.ts b/tests/openapi-loader.test.ts index f236948..8275f39 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -269,4 +269,43 @@ describe("OpenapiLoader", () => { expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); expect(loaded.paths["/jobs"].get.parameters[0].in).toBe("query"); }); + + it("passes headers to axios for the spec and remote ref documents", async () => { + mockedAxios.get.mockImplementation(async (source: string) => { + if (source === "https://example.com/root.yaml") { + return { + data: `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + }; + } + + if (source === "https://example.com/paths/jobs.yaml") { + return { + data: `jobsPath:\n get:\n summary: Get job\n`, + }; + } + + throw new Error(`Unexpected URL: ${source}`); + }); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs }); + + await loader.loadSpec(profile, { + refresh: true, + headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, + }); + + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + for (const call of mockedAxios.get.mock.calls) { + expect(call[1]).toEqual({ + responseType: "text", + headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, + }); + } + }); }); From d05673c3bcaf17e1a194a7fc2f4e782c46b4e9ec Mon Sep 17 00:00:00 2001 From: Pavel Rykov Date: Fri, 4 Sep 2026 13:03:51 +0300 Subject: [PATCH 3/4] fix: scope spec auth headers to profile origins and keep them across nested refs Follow-up to the auth-headers change from PR #22: - headers passed to OpenapiLoader.loadSpec() are sent only to the origins of --openapi-spec and --api-base-url; external $ref documents on any other host are fetched anonymously, so a spec cannot leak profile credentials - the resolve context now carries the auth scope into nested $ref documents, so a root -> A -> B chain behind auth loads instead of failing on B - OpenapiLoader accepts an injectable httpClient; tests use it instead of jest.mock("axios"), as the testing rules require - download failures surface as SpecFetchError with the URL and HTTP status; cli.ts turns 401/403 into a hint naming the auth flags Co-Authored-By: Claude Fable 5.1 --- src/cli.ts | 45 +++++-- src/openapi-loader.ts | 167 +++++++++++++++++++------- tests/cli.test.ts | 63 +++++++--- tests/openapi-loader.test.ts | 226 +++++++++++++++++++++++------------ 4 files changed, 354 insertions(+), 147 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b28342c..f79d1ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import { ConfigLocator } from "./config"; import { ProfileStore, Profile } from "./profile-store"; -import { OpenapiLoader } from "./openapi-loader"; +import { OpenapiLoader, SpecFetchError } from "./openapi-loader"; import { OpenapiToCommands, CliCommand, CliCommandOption } from "./openapi-to-commands"; import { CommandSearch } from "./command-search"; import { findUnknownFlags, formatUnknownFlagsError } from "./command-args"; @@ -114,7 +114,7 @@ async function runApiCommand( const { profileName: overrideName, remaining: commandArgs } = extractProfileFlag(args); const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(profile) }); + const spec = await loadProfileSpec(openapiLoader, profile); const commands = openapiToCommands.buildCommands(spec, profile); const command = commands.find((cmd) => cmd.name === toolName); @@ -345,6 +345,23 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record { + try { + return await openapiLoader.loadSpec(profile, { refresh: options?.refresh, headers: buildProfileAuthHeaders(profile) }); + } catch (err) { + if (err instanceof SpecFetchError && (err.status === 401 || err.status === 403)) { + throw new Error( + `${err.message}. Check --api-basic-auth, --api-bearer-token, or --custom-headers of profile ${profile.name}.` + ); + } + throw err; + } +} + function buildProfileAuthHeaders(profile: Profile): Record { const headers: Record = {}; @@ -625,7 +642,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { customHeaders, }; - await openapiLoader.loadSpec(profile, { refresh: true, headers: buildProfileAuthHeaders(profile) }); + await loadProfileSpec(openapiLoader, profile, { refresh: true }); profileStore.saveProfile(cwd, profile, { makeCurrent: true }); }; @@ -636,13 +653,25 @@ export async function run(argv: string[], options?: RunOptions): Promise { demandOption: true, description: "Base URL for API requests.", }) - .option("openapi-spec", { type: "string", demandOption: true }) - .option("api-basic-auth", { type: "string", default: "" }) - .option("api-bearer-token", { type: "string", default: "" }) + .option("openapi-spec", { + type: "string", + demandOption: true, + description: "URL or local path of the OpenAPI/Swagger document. Downloaded once and cached.", + }) + .option("api-basic-auth", { + type: "string", + default: "", + description: "user:password for Basic auth. Sent with API requests and the spec download.", + }) + .option("api-bearer-token", { + type: "string", + default: "", + description: "Bearer token. Sent with API requests and the spec download.", + }) .option("include-endpoints", { type: "string", default: "" }) .option("exclude-endpoints", { type: "string", default: "" }) .option("command-prefix", { type: "string", default: "", description: "Prefix for command names (e.g. api_ -> api_messages)" }) - .option("custom-headers", { type: "string", default: "", description: "Custom headers as JSON string, e.g. '{\"X-Tenant\":\"acme\"}'" }); + .option("custom-headers", { type: "string", default: "", description: "Custom headers as JSON string, e.g. '{\"X-Tenant\":\"acme\"}'. Sent with API requests and the spec download." }); const staticCommands = new Set(["onboard", "profiles", "use", "commands", "search", "help", "--help", "-h", "--version"]); @@ -759,7 +788,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { async (args) => { const overrideName = args.profile as string | undefined; const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(profile) }); + const spec = await loadProfileSpec(openapiLoader, profile); const commands = openapiToCommands.buildCommands(spec, profile); if (commands.length === 0) { stdout(`No commands available for profile ${profile.name}\n`); diff --git a/src/openapi-loader.ts b/src/openapi-loader.ts index 15b4096..695ee48 100644 --- a/src/openapi-loader.ts +++ b/src/openapi-loader.ts @@ -12,24 +12,66 @@ interface FileSystemForLoader { mkdirSync(pathToCreate: string, options?: { recursive?: boolean }): void; } +export interface SpecHttpClient { + get(url: string, options?: { headers?: Record }): Promise<{ data: unknown }>; +} + export interface OpenapiLoaderOptions { fs?: FileSystemForLoader; + httpClient?: SpecHttpClient; +} + +export interface LoadSpecOptions { + refresh?: boolean; + headers?: Record; +} + +interface RemoteAuth { + headers: Record; + origins: Set; +} + +interface ResolveContext { + currentSource: string; + currentDocument: unknown; + rawDocCache: Map; + resolvingRefs: Set; + remoteAuth?: RemoteAuth; } +export class SpecFetchError extends Error { + readonly url: string; + readonly status?: number; + + constructor(url: string, status: number | undefined, detail: string) { + super( + status === undefined + ? `Failed to fetch OpenAPI document ${url}: ${detail}` + : `Failed to fetch OpenAPI document ${url}: HTTP ${status}` + ); + this.name = "SpecFetchError"; + this.url = url; + this.status = status; + } +} + +const defaultHttpClient: SpecHttpClient = { + get: async (url, options) => { + const response = await axios.get(url, { responseType: "text", headers: options?.headers }); + return { data: response.data }; + }, +}; + export class OpenapiLoader { private readonly fs: FileSystemForLoader; + private readonly httpClient: SpecHttpClient; constructor(options?: OpenapiLoaderOptions) { this.fs = options?.fs ?? fsModule; + this.httpClient = options?.httpClient ?? defaultHttpClient; } - async loadSpec( - profile: Profile, - options?: { - refresh?: boolean; - headers?: Record; - } - ): Promise { + async loadSpec(profile: Profile, options?: LoadSpecOptions): Promise { const cachePath = profile.openapiSpecCache; if (!options?.refresh && this.fs.existsSync(cachePath)) { @@ -37,7 +79,7 @@ export class OpenapiLoader { return JSON.parse(cached); } - const spec = await this.loadAndResolveSpec(profile.openapiSpecSource, options?.headers); + const spec = await this.loadAndResolveSpec(profile.openapiSpecSource, this.remoteAuthFor(profile, options?.headers)); this.ensureCacheDir(cachePath); const serialized = JSON.stringify(spec, null, 2); @@ -46,21 +88,68 @@ export class OpenapiLoader { return spec; } - private async loadAndResolveSpec(source: string, headers?: Record): Promise { + private async loadAndResolveSpec(source: string, remoteAuth?: RemoteAuth): Promise { const rawDocCache = new Map(); - const root = await this.loadDocument(source, rawDocCache, headers); + const root = await this.loadDocument(source, rawDocCache, remoteAuth); return this.resolveRefs(root, { currentSource: source, currentDocument: root, rawDocCache, resolvingRefs: new Set(), - headers, + remoteAuth, }); } - private async loadFromSource(source: string, headers?: Record): Promise { - if (source.startsWith("http://") || source.startsWith("https://")) { - const response = await axios.get(source, { responseType: "text", headers }); + // Profile credentials are meant for the hosts the user configured: the spec URL and the API base URL. + // Any other origin reachable through an external $ref is fetched anonymously. + private remoteAuthFor(profile: Profile, headers?: Record): RemoteAuth | undefined { + if (!headers || Object.keys(headers).length === 0) { + return undefined; + } + + const origins = new Set(); + for (const candidate of [profile.openapiSpecSource, profile.apiBaseUrl]) { + const origin = this.originOf(candidate); + if (origin) { + origins.add(origin); + } + } + + return { headers, origins }; + } + + private headersFor(source: string, remoteAuth?: RemoteAuth): Record | undefined { + if (!remoteAuth) { + return undefined; + } + const origin = this.originOf(source); + return origin && remoteAuth.origins.has(origin) ? remoteAuth.headers : undefined; + } + + private originOf(source: string): string | undefined { + if (!this.isRemote(source)) { + return undefined; + } + try { + return new URL(source).origin; + } catch { + return undefined; + } + } + + private isRemote(source: string): boolean { + return source.startsWith("http://") || source.startsWith("https://"); + } + + private async loadFromSource(source: string, remoteAuth?: RemoteAuth): Promise { + if (this.isRemote(source)) { + const headers = this.headersFor(source, remoteAuth); + let response: { data: unknown }; + try { + response = await this.httpClient.get(source, headers ? { headers } : {}); + } catch (err) { + throw this.toFetchError(source, err); + } return this.parseSpec(response.data, source); } @@ -68,21 +157,26 @@ export class OpenapiLoader { return this.parseSpec(raw, source); } - private async loadDocument( - source: string, - rawDocCache: Map, - headers?: Record - ): Promise { + private toFetchError(url: string, err: unknown): SpecFetchError { + if (err instanceof SpecFetchError) { + return err; + } + const status = (err as { response?: { status?: unknown } } | undefined)?.response?.status; + const detail = err instanceof Error ? err.message : String(err); + return new SpecFetchError(url, typeof status === "number" ? status : undefined, detail); + } + + private async loadDocument(source: string, rawDocCache: Map, remoteAuth?: RemoteAuth): Promise { if (rawDocCache.has(source)) { return rawDocCache.get(source); } - const loaded = await this.loadFromSource(source, headers); + const loaded = await this.loadFromSource(source, remoteAuth); rawDocCache.set(source, loaded); return loaded; } - private parseSpec(content: string | object, source: string): unknown { + private parseSpec(content: unknown, source: string): unknown { if (typeof content !== "string") { return content; } @@ -92,16 +186,7 @@ export class OpenapiLoader { return JSON.parse(content); } - private async resolveRefs( - value: unknown, - context: { - currentSource: string; - currentDocument: unknown; - rawDocCache: Map; - resolvingRefs: Set; - headers?: Record; - } - ): Promise { + private async resolveRefs(value: unknown, context: ResolveContext): Promise { if (Array.isArray(value)) { const items = await Promise.all(value.map((item) => this.resolveRefs(item, context))); return items; @@ -139,16 +224,7 @@ export class OpenapiLoader { return Object.fromEntries(resolvedEntries); } - private async resolveRef( - ref: string, - context: { - currentSource: string; - currentDocument: unknown; - rawDocCache: Map; - resolvingRefs: Set; - headers?: Record; - } - ): Promise { + private async resolveRef(ref: string, context: ResolveContext): Promise { const { source, pointer } = this.splitRef(ref, context.currentSource); const cacheKey = `${source}#${pointer}`; @@ -160,14 +236,13 @@ export class OpenapiLoader { const targetDocument = source === context.currentSource ? context.currentDocument - : await this.loadDocument(source, context.rawDocCache, context.headers); + : await this.loadDocument(source, context.rawDocCache, context.remoteAuth); const targetValue = this.resolvePointer(targetDocument, pointer); const resolvedValue = await this.resolveRefs(targetValue, { + ...context, currentSource: source, currentDocument: targetDocument, - rawDocCache: context.rawDocCache, - resolvingRefs: context.resolvingRefs, }); context.resolvingRefs.delete(cacheKey); @@ -180,11 +255,11 @@ export class OpenapiLoader { return { source: currentSource, pointer }; } - if (refSource.startsWith("http://") || refSource.startsWith("https://")) { + if (this.isRemote(refSource)) { return { source: refSource, pointer }; } - if (currentSource.startsWith("http://") || currentSource.startsWith("https://")) { + if (this.isRemote(currentSource)) { return { source: new URL(refSource, currentSource).toString(), pointer }; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 0eda205..0beca84 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,15 +2,9 @@ import { ConfigLocator } from "../src/config"; import { ProfileStore } from "../src/profile-store"; import { OpenapiLoader } from "../src/openapi-loader"; import { run, HttpClient } from "../src/cli"; -import axios from "axios"; import { AxiosError } from "axios"; import { VERSION } from "../src/version"; -jest.mock("axios", () => ({ - ...jest.requireActual("axios"), - get: jest.fn(), -})); - interface MemoryFsEntry { type: "file" | "dir"; content?: string; @@ -171,22 +165,22 @@ describe("cli", () => { }); it("profiles add sends profile auth headers when fetching a protected HTTP spec", async () => { - const mockedAxios = axios as jest.Mocked; const spec = { openapi: "3.0.0", paths: {} }; - - mockedAxios.get.mockImplementation(async (_url: string, config?: any) => { - if (config?.headers?.Authorization !== "Bearer secret123" || config?.headers?.["x-api-key"] !== "key123") { - throw new AxiosError("Request failed with status code 401", "401"); - } - return { data: spec }; - }); + const specHttpClient = { + get: jest.fn(async (_url: string, options?: { headers?: Record }) => { + if (options?.headers?.Authorization !== "Bearer secret123" || options?.headers?.["x-api-key"] !== "key123") { + throw Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }); + } + return { data: spec }; + }), + }; const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; const fs = new MemoryFs(); const locator = new ConfigLocator({ fs, homeDir }); const profileStore = new ProfileStore({ fs, locator }); - const openapiLoader = new OpenapiLoader({ fs }); + const openapiLoader = new OpenapiLoader({ fs, httpClient: specHttpClient }); await run( [ @@ -205,7 +199,10 @@ describe("cli", () => { { cwd, profileStore, openapiLoader } ); - expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(specHttpClient.get).toHaveBeenCalledTimes(1); + expect(specHttpClient.get).toHaveBeenCalledWith("http://127.0.0.1:3000/openapi.json", { + headers: { Authorization: "Bearer secret123", "x-api-key": "key123" }, + }); expect(fs.existsSync(profilesPath)).toBe(true); const profile = profileStore.getCurrentProfile(cwd); expect(profile?.name).toBe("protected"); @@ -213,6 +210,40 @@ describe("cli", () => { expect(profile?.customHeaders).toEqual({ "x-api-key": "key123" }); }); + it("profiles add reports a 401 from the spec URL and points at the auth flags", async () => { + const specHttpClient = { + get: jest.fn(async () => { + throw Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }); + }), + }; + + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const fs = new MemoryFs(); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs, httpClient: specHttpClient }); + + const failure = await run( + [ + "profiles", + "add", + "protected", + "--api-base-url", + "http://127.0.0.1:3000", + "--openapi-spec", + "http://127.0.0.1:3000/openapi.json", + ], + { cwd, profileStore, openapiLoader } + ).catch((err: Error) => err); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("http://127.0.0.1:3000/openapi.json"); + expect((failure as Error).message).toContain("401"); + expect((failure as Error).message).toContain("--api-bearer-token"); + expect(fs.existsSync(profilesPath)).toBe(false); + }); + it("profiles list prints profile names", async () => { const localDir = `${cwd}/.ocli`; const iniContent = [ diff --git a/tests/openapi-loader.test.ts b/tests/openapi-loader.test.ts index 8275f39..c34d758 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -1,8 +1,5 @@ -import axios from "axios"; import { Profile } from "../src/profile-store"; -import { OpenapiLoader } from "../src/openapi-loader"; - -jest.mock("axios"); +import { OpenapiLoader, SpecFetchError, SpecHttpClient } from "../src/openapi-loader"; interface MemoryFsEntry { type: "file" | "dir"; @@ -72,9 +69,34 @@ class MemoryFs { } } -describe("OpenapiLoader", () => { - const mockedAxios = axios as jest.Mocked; +type SpecHttpGet = jest.MockedFunction; + +interface FakeSpecHttpClient extends SpecHttpClient { + get: SpecHttpGet; +} + +function createHttpClient(): FakeSpecHttpClient { + return { get: jest.fn() as SpecHttpGet }; +} + +function serveDocuments(documents: Record): SpecHttpClient["get"] { + return async (url: string) => { + if (url in documents) { + return { data: documents[url] }; + } + throw new Error(`Unexpected URL: ${url}`); + }; +} +function headersSentTo(httpClient: FakeSpecHttpClient, url: string): Record | undefined { + const call = httpClient.get.mock.calls.find(([calledUrl]) => calledUrl === url); + if (!call) { + throw new Error(`No request was made to ${url}`); + } + return call[1]?.headers; +} + +describe("OpenapiLoader", () => { const baseProfile: Profile = { name: "myapi", apiBaseUrl: "http://127.0.0.1:3000", @@ -84,20 +106,24 @@ describe("OpenapiLoader", () => { openapiSpecCache: "/home/user/.ocli/specs/myapi.json", includeEndpoints: [], excludeEndpoints: [], - commandPrefix: "", - customHeaders: {}, + commandPrefix: "", + customHeaders: {}, }; + const profileHeaders = { Authorization: "Bearer token123", "x-api-key": "key123" }; + + let httpClient: FakeSpecHttpClient; + beforeEach(() => { - mockedAxios.get.mockReset(); + httpClient = createHttpClient(); }); it("downloads spec from HTTP URL and caches it when cache is missing", async () => { const spec = { openapi: "3.0.0", info: { title: "API", version: "1.0.0" } }; - mockedAxios.get.mockResolvedValueOnce({ data: spec }); + httpClient.get.mockResolvedValueOnce({ data: spec }); const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const profile: Profile = { ...baseProfile, @@ -107,6 +133,8 @@ describe("OpenapiLoader", () => { const loaded = await loader.loadSpec(profile); expect(loaded).toEqual(spec); + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(httpClient.get.mock.calls[0][0]).toBe(profile.openapiSpecSource); expect(fs.existsSync(profile.openapiSpecCache)).toBe(true); const cachedRaw = fs.readFileSync(profile.openapiSpecCache, "utf-8"); @@ -125,13 +153,12 @@ describe("OpenapiLoader", () => { [profile.openapiSpecCache]: JSON.stringify(cachedSpec), }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile); expect(loaded).toEqual(cachedSpec); - // No HTTP call is needed when cache exists. - mockedAxios.get.mockClear(); + expect(httpClient.get).not.toHaveBeenCalled(); }); it("loads spec from local file path and writes cache", async () => { @@ -146,11 +173,12 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: JSON.stringify(sourceSpec), }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }); expect(loaded).toEqual(sourceSpec); + expect(httpClient.get).not.toHaveBeenCalled(); expect(fs.existsSync(profile.openapiSpecCache)).toBe(true); const cachedRaw = fs.readFileSync(profile.openapiSpecCache, "utf-8"); @@ -169,17 +197,17 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: yamlContent, }); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).openapi).toBe("3.0.0"); - expect((loaded as any).info.title).toBe("YAML API"); - expect((loaded as any).paths["/test"].get.summary).toBe("Test endpoint"); + expect(loaded.openapi).toBe("3.0.0"); + expect(loaded.info.title).toBe("YAML API"); + expect(loaded.paths["/test"].get.summary).toBe("Test endpoint"); }); it("loads YAML spec from HTTP URL", async () => { const yamlContent = `openapi: "3.0.0"\ninfo:\n title: Remote YAML\n version: "2.0"\npaths: {}`; - mockedAxios.get.mockResolvedValueOnce({ data: yamlContent }); + httpClient.get.mockResolvedValueOnce({ data: yamlContent }); const profile: Profile = { ...baseProfile, @@ -187,11 +215,11 @@ describe("OpenapiLoader", () => { }; const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).openapi).toBe("3.0.0"); - expect((loaded as any).info.title).toBe("Remote YAML"); + expect(loaded.openapi).toBe("3.0.0"); + expect(loaded.info.title).toBe("Remote YAML"); }); it("auto-detects YAML content even without .yaml extension", async () => { @@ -206,10 +234,10 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: yamlContent, }); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).info.title).toBe("Auto Detect"); + expect(loaded.info.title).toBe("Auto Detect"); }); it("resolves local external refs across multiple files", async () => { @@ -228,34 +256,20 @@ describe("OpenapiLoader", () => { "/project/paths/components/request-bodies.yaml": requestBodies, }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; expect(loaded.paths["/jobs"].post.requestBody.content["application/json"].schema.properties.name.type).toBe("string"); }); it("resolves remote external refs across multiple documents", async () => { - mockedAxios.get.mockImplementation(async (source: string) => { - if (source === "https://example.com/root.yaml") { - return { - data: `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, - }; - } - - if (source === "https://example.com/paths/jobs.yaml") { - return { - data: `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, - }; - } - - if (source === "https://example.com/components/params.yaml") { - return { - data: `JobId:\n name: job_id\n in: query\n required: true\n schema:\n type: string\n`, - }; - } - - throw new Error(`Unexpected URL: ${source}`); - }); + httpClient.get.mockImplementation( + serveDocuments({ + "https://example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + "https://example.com/paths/jobs.yaml": `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, + "https://example.com/components/params.yaml": `JobId:\n name: job_id\n in: query\n required: true\n schema:\n type: string\n`, + }) + ); const profile: Profile = { ...baseProfile, @@ -263,49 +277,107 @@ describe("OpenapiLoader", () => { }; const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); expect(loaded.paths["/jobs"].get.parameters[0].in).toBe("query"); + expect(headersSentTo(httpClient, "https://example.com/components/params.yaml")).toBeUndefined(); }); - it("passes headers to axios for the spec and remote ref documents", async () => { - mockedAxios.get.mockImplementation(async (source: string) => { - if (source === "https://example.com/root.yaml") { - return { - data: `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, - }; - } + describe("profile headers", () => { + it("sends the headers to the spec and to every same-origin ref document, including nested ones", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + "https://example.com/paths/jobs.yaml": `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, + "https://example.com/components/params.yaml": `JobId:\n name: job_id\n in: query\n schema:\n type: string\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); + expect(httpClient.get).toHaveBeenCalledTimes(3); + expect(headersSentTo(httpClient, "https://example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://example.com/paths/jobs.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://example.com/components/params.yaml")).toEqual(profileHeaders); + }); - if (source === "https://example.com/paths/jobs.yaml") { - return { - data: `jobsPath:\n get:\n summary: Get job\n`, - }; - } + it("does not send the headers to ref documents on another origin", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://api.example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /pets:\n get:\n responses:\n "200":\n description: ok\n content:\n application/json:\n schema:\n $ref: "https://schemas.example.org/pet.yaml#/Pet"\n`, + "https://schemas.example.org/pet.yaml": `Pet:\n type: object\n properties:\n id:\n type: integer\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + apiBaseUrl: "https://api.example.com", + openapiSpecSource: "https://api.example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/pets"].get.responses["200"].content["application/json"].schema.type).toBe("object"); + expect(headersSentTo(httpClient, "https://api.example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://schemas.example.org/pet.yaml")).toBeUndefined(); + }); - throw new Error(`Unexpected URL: ${source}`); + it("sends the headers to ref documents on the API base URL origin when the spec lives elsewhere", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://docs.example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /pets:\n $ref: "https://api.example.com/paths/pets.yaml#/petsPath"\n`, + "https://api.example.com/paths/pets.yaml": `petsPath:\n get:\n summary: List pets\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + apiBaseUrl: "https://api.example.com/v1", + openapiSpecSource: "https://docs.example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/pets"].get.summary).toBe("List pets"); + expect(headersSentTo(httpClient, "https://docs.example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://api.example.com/paths/pets.yaml")).toEqual(profileHeaders); }); + }); + + it("wraps a failed download into SpecFetchError carrying the URL and HTTP status", async () => { + httpClient.get.mockRejectedValueOnce( + Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }) + ); const profile: Profile = { ...baseProfile, - openapiSpecSource: "https://example.com/root.yaml", + openapiSpecSource: "https://api.example.com/openapi.json", }; const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); - await loader.loadSpec(profile, { - refresh: true, - headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, - }); + const failure = await loader.loadSpec(profile, { refresh: true }).catch((err: unknown) => err); - expect(mockedAxios.get).toHaveBeenCalledTimes(2); - for (const call of mockedAxios.get.mock.calls) { - expect(call[1]).toEqual({ - responseType: "text", - headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, - }); - } + expect(failure).toBeInstanceOf(SpecFetchError); + expect((failure as SpecFetchError).url).toBe("https://api.example.com/openapi.json"); + expect((failure as SpecFetchError).status).toBe(401); + expect((failure as SpecFetchError).message).toContain("https://api.example.com/openapi.json"); + expect((failure as SpecFetchError).message).toContain("401"); + expect(fs.existsSync(profile.openapiSpecCache)).toBe(false); }); }); From 77eaf406df460f6f0c91920a955b2c94a4bdaa07 Mon Sep 17 00:00:00 2001 From: Pavel Rykov Date: Fri, 4 Sep 2026 13:03:51 +0300 Subject: [PATCH 4/4] docs: describe authentication, spec download headers and the 401 hint Co-Authored-By: Claude Fable 5.1 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 10ab95f..0fc3d11 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,25 @@ ocli commands -p other --query "send message" `--profile` (short `-p`) overrides the profile selected by `ocli use` for this invocation only. It works for both dynamic API commands and `ocli commands`. Place it anywhere after the command name. When omitted, the profile set via `ocli use` is used (falling back to `default`). +### Authentication and custom headers + +A profile stores up to three credentials, set with `ocli profiles add` (or `ocli onboard`): + +- `--api-bearer-token ` sends `Authorization: Bearer ` +- `--api-basic-auth ` sends `Authorization: Basic `; when both are set, Basic wins +- `--custom-headers '{"X-Tenant":"acme"}'` adds any extra headers + +`ocli` attaches these headers to every API request and to the download of the OpenAPI spec itself, so a spec served behind the same auth as the API (for example `/openapi.json` answering 401 to anonymous requests) loads with `ocli profiles add`. The headers are sent only to the two origins named in the profile, the `--openapi-spec` URL and the `--api-base-url`. External `$ref` documents on those origins receive them too, at any nesting depth; `$ref` documents on any other host are fetched anonymously, so a spec cannot forward your credentials to a third-party host. Specs loaded from a local file path involve no request. + +When the spec download is rejected with 401 or 403, `ocli` reports the failing URL and the status and points at the three flags above: + +```bash +$ ocli profiles add myapi --api-base-url https://api.example.com --openapi-spec https://api.example.com/openapi.json +Failed to fetch OpenAPI document https://api.example.com/openapi.json: HTTP 401. Check --api-basic-auth, --api-bearer-token, or --custom-headers of profile myapi. +``` + +The spec is downloaded once and cached under `.ocli/specs/.json`. Later invocations read the cache and do not contact the spec URL. Re-run `ocli profiles add` with the same profile name to refresh it. + ### Strict flag validation `ocli` refuses to run a command with a flag the spec does not define, instead of dropping it from the request: