diff --git a/CLAUDE.md b/CLAUDE.md index e7b166e..2c5ba23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,7 +214,15 @@ new SessionAuthStrategy(new BasicAuthStrategy(), { }); ``` -When the generated client receives a status in `refreshOn`, it calls the wired refresh callback — which invalidates the cached session and re-bootstraps `/session` — then retries the original request once. Capped at one retry. Strategies that don't use `/session` are unaffected (the field is ignored). +When the generated client receives a status in `refreshOn`, it calls the wired refresh callback — which invalidates the cached session and re-bootstraps `/session` — then retries the original request once. Capped at one retry. Wrapping the strategy in `SessionAuthStrategy` requires a `session.endpoint`, but `refreshOn` itself doesn't — see below for the route that works without one. + +`refreshOn` isn't limited to `SessionAuthStrategy` — a project on a custom `AuthStrategy` (`.apijack/auth.ts`) can opt in without a `sessionAuth` block at all, via `.apijack/settings.json`: + +```json +{ "auth": { "refreshOn": [401] } } +``` + +The refresh callback re-invokes the custom strategy's `authenticate()` rather than re-bootstrapping `/session`. `settings.json` `auth.refreshOn` takes precedence over `sessionAuth.refreshOn` when both are set. ### Dropping base-strategy headers post-handshake (opt-in) diff --git a/bin/apijack.ts b/bin/apijack.ts index 09974e5..4293cfe 100755 --- a/bin/apijack.ts +++ b/bin/apijack.ts @@ -150,6 +150,7 @@ const cli = createCli({ specPath, auth: authStrategy, sessionAuth, + refreshOn: projectSettings.auth?.refreshOn, generatedDir, allowedCidrs: projectConfig?.allowedCidrs, defaultUrl: projectConfig?.defaultUrl, diff --git a/src/auth/refresh-wiring.ts b/src/auth/refresh-wiring.ts new file mode 100644 index 0000000..8b22a9c --- /dev/null +++ b/src/auth/refresh-wiring.ts @@ -0,0 +1,40 @@ +import type { SessionAuthConfig } from './types'; +import type { EnvironmentConfig } from '../config'; +import { deepMergeSessionAuth } from './config-merge'; + +export interface RefreshWiringOptions { + sessionAuth?: SessionAuthConfig; + refreshOn?: number[]; +} + +export interface RefreshWiring { + /** Only set when the merged sessionAuth block defines a handshake endpoint — + * this is what drives SessionAuthStrategy construction and resolveRequestHeaders. */ + mergedSessionAuth: SessionAuthConfig | undefined; + /** Statuses that trigger a one-shot session refresh + retry, for ANY strategy. */ + refreshOn: number[] | undefined; +} + +/** + * Decides the session-auth merge and refresh-retry wiring shared by both + * cli-builder.ts client-construction sites (the createCli routine-runtime path + * and the run() path). Kept pure so both sites stay in lockstep (#135). + * + * `options.refreshOn` (from CliOptions / .apijack/settings.json) takes + * precedence over `sessionAuth.refreshOn`, so a project can opt a custom + * AuthStrategy into refresh-on-401 without ever defining a `sessionAuth` block. + */ +export function resolveRefreshWiring( + options: RefreshWiringOptions, + envConfig: Pick | null | undefined, +): RefreshWiring { + const rawSessionAuth = options.sessionAuth + ? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth) + : undefined; + // Only a block that actually defines a handshake endpoint drives SessionAuthStrategy + // construction and request-header resolution. + const mergedSessionAuth = rawSessionAuth?.session?.endpoint ? rawSessionAuth : undefined; + const refreshOn = options.refreshOn ?? rawSessionAuth?.refreshOn; + + return { mergedSessionAuth, refreshOn }; +} diff --git a/src/cli-builder.ts b/src/cli-builder.ts index a34a875..c8c142f 100644 --- a/src/cli-builder.ts +++ b/src/cli-builder.ts @@ -30,7 +30,8 @@ import { registerRoutineCommand, loadBuiltinRoutines } from './commands/routine/ import { prompt, hiddenPrompt } from './prompt'; import { SessionAuthStrategy } from './auth/session-auth'; import { resolveRequestHeaders } from './auth/resolve-headers'; -import { deepMergeSessionAuth } from './auth/config-merge'; +import { resolveRefreshWiring } from './auth/refresh-wiring'; +import type { SessionAuthConfig } from './auth/types'; import { loadPreRequestHook } from './pre-request'; import type { RoutineResult } from './routine/executor'; import { executeRoutine } from './routine/executor'; @@ -195,9 +196,7 @@ export function createCli(options: CliOptions): Cli { // 3. Compute auth strategy + sessionMgr. const envConfig = getActiveEnvConfig(cliName, configOpts); - const mergedSessionAuth = options.sessionAuth - ? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth) - : undefined; + const { mergedSessionAuth, refreshOn } = resolveRefreshWiring(options, envConfig); const strategy = mergedSessionAuth ? new SessionAuthStrategy(options.auth, mergedSessionAuth) : options.auth; @@ -255,8 +254,8 @@ export function createCli(options: CliOptions): Cli { const client = new ApiClientClass( resolved.baseUrl ?? '', getHeaders, - mergedSessionAuth ? async () => { await ctx.refreshSession(); } : undefined, - mergedSessionAuth?.refreshOn, + async () => { await ctx.refreshSession(); }, + refreshOn, ) as Record; ctx.client = client; @@ -543,15 +542,14 @@ export function createCli(options: CliOptions): Cli { } // 5. Compute auth strategy (no network — just config) - let mergedSessionAuth: ReturnType | undefined; + let mergedSessionAuth: SessionAuthConfig | undefined; + let refreshOn: number[] | undefined; let strategy = options.auth; let sessionMgr: SessionManager | null = null; if (resolved) { const envConfig = getActiveEnvConfig(cliName, configOpts); - mergedSessionAuth = options.sessionAuth - ? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth) - : undefined; + ({ mergedSessionAuth, refreshOn } = resolveRefreshWiring(options, envConfig)); strategy = mergedSessionAuth ? new SessionAuthStrategy(options.auth, mergedSessionAuth) : options.auth; @@ -656,8 +654,8 @@ export function createCli(options: CliOptions): Cli { const client = new ApiClientClass( resolved?.baseUrl ?? '', getHeaders, - mergedSessionAuth ? async () => { await ctx!.refreshSession(); } : undefined, - mergedSessionAuth?.refreshOn, + ctx ? async () => { await ctx!.refreshSession(); } : undefined, + refreshOn, ) as Record; if (ctx) ctx.client = client; diff --git a/src/settings.ts b/src/settings.ts index d7ecb07..a9add79 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,11 @@ export interface ProjectSettings { requiresAuth?: boolean; }; }; + auth?: { + /** HTTP statuses that trigger a one-shot session refresh + retry, for + * any auth strategy. See `CliOptions.refreshOn`. */ + refreshOn?: number[]; + }; } export function loadProjectSettings(apijackDir: string): ProjectSettings { diff --git a/src/types.ts b/src/types.ts index 9d9f5dc..dbb7246 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,11 @@ export interface CliOptions { specPath: string; auth: AuthStrategy; sessionAuth?: SessionAuthConfig; + /** HTTP statuses that trigger a one-shot session refresh + retry on the + * generated client, for ANY auth strategy (not just SessionAuthStrategy). + * Takes precedence over `sessionAuth.refreshOn` when both are set. Lets a + * project with a custom AuthStrategy opt in without a `sessionAuth` block. */ + refreshOn?: number[]; outputModes?: string[]; generatedDir?: string; knownSites?: Record; diff --git a/tests/auth/refresh-wiring.test.ts b/tests/auth/refresh-wiring.test.ts new file mode 100644 index 0000000..c9acce3 --- /dev/null +++ b/tests/auth/refresh-wiring.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from 'bun:test'; +import { resolveRefreshWiring } from '../../src/auth/refresh-wiring'; +import type { SessionAuthConfig } from '../../src/auth/types'; + +const fullSessionAuth: SessionAuthConfig = { + session: { endpoint: '/session' }, + cookies: { extract: ['SESSION'], applyTo: ['POST', 'PUT', 'DELETE'] }, + refreshOn: [401, 403], +}; + +describe('resolveRefreshWiring', () => { + test('no sessionAuth, no refreshOn — both undefined', () => { + const result = resolveRefreshWiring({}, undefined); + expect(result.mergedSessionAuth).toBeUndefined(); + expect(result.refreshOn).toBeUndefined(); + }); + + test('options.refreshOn alone (no sessionAuth block) — refreshOn set, mergedSessionAuth stays undefined', () => { + const result = resolveRefreshWiring({ refreshOn: [401] }, undefined); + expect(result.mergedSessionAuth).toBeUndefined(); + expect(result.refreshOn).toEqual([401]); + }); + + test('sessionAuth with endpoint — mergedSessionAuth populated, refreshOn falls back to sessionAuth.refreshOn', () => { + const result = resolveRefreshWiring({ sessionAuth: fullSessionAuth }, undefined); + expect(result.mergedSessionAuth).toEqual(fullSessionAuth); + expect(result.refreshOn).toEqual([401, 403]); + }); + + test('options.refreshOn takes precedence over sessionAuth.refreshOn', () => { + const result = resolveRefreshWiring( + { sessionAuth: fullSessionAuth, refreshOn: [401] }, + undefined, + ); + expect(result.refreshOn).toEqual([401]); + // mergedSessionAuth is untouched by the precedence rule. + expect(result.mergedSessionAuth?.refreshOn).toEqual([401, 403]); + }); + + test('envConfig.sessionAuth merges into options.sessionAuth as usual', () => { + const result = resolveRefreshWiring( + { sessionAuth: fullSessionAuth }, + { sessionAuth: { session: { endpoint: '/auth/session' } } }, + ); + expect(result.mergedSessionAuth?.session.endpoint).toBe('/auth/session'); + expect(result.mergedSessionAuth?.cookies).toEqual(fullSessionAuth.cookies); + }); + + test('a sessionAuth block without session.endpoint does not populate mergedSessionAuth (guards resolveRequestHeaders)', () => { + // Not expressible through the SessionAuthConfig type from a fully-typed + // caller, but envConfig.sessionAuth is only a Partial — + // a JS/dynamic caller could still hand cli-builder a refreshOn-only block. + const refreshOnlySessionAuth = { refreshOn: [401] } as unknown as SessionAuthConfig; + const result = resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined); + expect(result.mergedSessionAuth).toBeUndefined(); + // refreshOn still surfaces from the raw (unguarded) merge. + expect(result.refreshOn).toEqual([401]); + }); + + test('does not mutate inputs', () => { + const sessionAuthCopy = JSON.parse(JSON.stringify(fullSessionAuth)); + resolveRefreshWiring( + { sessionAuth: fullSessionAuth }, + { sessionAuth: { cookies: { applyTo: ['*'] } } }, + ); + expect(fullSessionAuth).toEqual(sessionAuthCopy); + }); +}); diff --git a/tests/cli-builder-refresh-wiring.integration.test.ts b/tests/cli-builder-refresh-wiring.integration.test.ts new file mode 100644 index 0000000..d68e8a2 --- /dev/null +++ b/tests/cli-builder-refresh-wiring.integration.test.ts @@ -0,0 +1,501 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { createCli, type Cli } from '../src/cli-builder'; +import type { CliOptions, CliContext } from '../src/types'; +import type { AuthStrategy, AuthSession, ResolvedAuth, SessionAuthConfig } from '../src/auth/types'; +import { BasicAuthStrategy } from '../src/auth/basic'; +import { SessionManager } from '../src/session'; +import { resolveRequestHeaders } from '../src/auth/resolve-headers'; +import { generateClient } from '../src/codegen/client'; +import type { OpenApiOperation } from '../src/codegen/openapi-types'; + +/** + * Integration coverage for #135: `refreshOn` reachable for projects on a + * custom AuthStrategy, at BOTH client-construction sites in cli-builder.ts — + * the createCli/runRoutine path (~L198-260, also used for MCP tool dispatch) + * and the run() path (~L546-661, the direct CLI invocation). + * + * `resolveRefreshWiring` (src/auth/refresh-wiring.ts) itself is unit-tested in + * tests/auth/refresh-wiring.test.ts; these tests prove both cli-builder.ts + * call sites actually route through it end-to-end with a real generated + * ApiClient (mirrors the shape of the #77 integration test). + */ + +const PATHS: Record> = { + '/admin/matters/{id}': { + delete: { + operationId: 'deleteMatter', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + ], + }, + }, +}; + +function makeCustomStrategy(): { strategy: AuthStrategy; calls: { count: number } } { + const calls = { count: 0 }; + const strategy: AuthStrategy = { + authenticate: async () => { + calls.count++; + + return { headers: { Authorization: `Bearer token-${calls.count}` } } satisfies AuthSession; + }, + restore: async cached => cached, + }; + + return { strategy, calls }; +} + +function writeGeneratedFixture(generatedDir: string): void { + mkdirSync(generatedDir, { recursive: true }); + writeFileSync(join(generatedDir, 'client.ts'), generateClient(PATHS)); + // Only needs to be truthy — cli-builder gates the ApiClient import on this + // export, but neither test drives real Commander subcommands. + writeFileSync(join(generatedDir, 'commands.ts'), 'export function registerGeneratedCommands(): void {}\n'); + writeFileSync( + join(generatedDir, 'command-map.ts'), + 'export const commandMap = {\n' + + ' "admin delete": { operationId: "deleteMatter", pathParams: ["id"], queryParams: [], hasBody: false },\n' + + '};\n', + ); +} + +function writeConfig(configPath: string): void { + mkdirSync(join(configPath, '..'), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ + active: 'default', + environments: { + default: { url: 'https://api.example.com', user: 'user', password: 'pass' }, + }, + })); +} + +describe('#135 refreshOn wiring for custom AuthStrategy (both cli-builder.ts call sites)', () => { + let tmpHome: string; + let originalFetch: typeof globalThis.fetch; + let originalHome: string | undefined; + + beforeEach(() => { + tmpHome = join( + tmpdir(), + `apijack-refreshon-custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + originalFetch = globalThis.fetch; + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + + rmSync(tmpHome, { recursive: true, force: true }); + }); + + test('createCli path (runRoutine): custom strategy + options.refreshOn recovers from a stale 401 — ' + + 'authenticate() called again exactly once, request retried exactly once, no /session handshake', async () => { + const cliConfigDir = join(tmpHome, '.testcli'); + const configPath = join(cliConfigDir, 'config.json'); + writeConfig(configPath); + mkdirSync(join(cliConfigDir, 'routines'), { recursive: true }); + writeFileSync( + join(cliConfigDir, 'routines', 'delete-matter.yaml'), + 'name: delete-matter\nsteps:\n - name: delete\n command: admin delete\n args:\n --id: 5\n', + ); + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + const { strategy, calls } = makeCustomStrategy(); + const deleteCalls: { authHeader: string | undefined }[] = []; + let sessionEndpointHits = 0; + + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + const headers = init?.headers as Record | undefined; + + if (urlStr.endsWith('/session')) { + sessionEndpointHits++; + + return new Response('{}', { status: 200 }); + } + + if (urlStr.includes('/admin/matters/5')) { + deleteCalls.push({ authHeader: headers?.Authorization }); + + if (deleteCalls.length === 1) return new Response('Unauthorized', { status: 401 }); + + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const cli: Cli = createCli({ + name: 'testcli', + description: 'test', + version: '1.0.0', + specPath: '/v3/api-docs', + auth: strategy, + refreshOn: [401], + generatedDir, + configPath, + }); + + const result = await cli.runRoutine('delete-matter'); + + expect(result.status).toBe('ok'); + expect(sessionEndpointHits).toBe(0); // no double /session handshake — not wrapped in SessionAuthStrategy + expect(calls.count).toBe(2); // initial authenticate() + exactly one refresh + expect(deleteCalls).toHaveLength(2); + expect(deleteCalls[0]!.authHeader).toBe('Bearer token-1'); + expect(deleteCalls[1]!.authHeader).toBe('Bearer token-2'); + }); + + test('createCli path (runRoutine): retried request also fails — one-retry cap holds, original error propagates', async () => { + const cliConfigDir = join(tmpHome, '.testcli'); + const configPath = join(cliConfigDir, 'config.json'); + writeConfig(configPath); + mkdirSync(join(cliConfigDir, 'routines'), { recursive: true }); + writeFileSync( + join(cliConfigDir, 'routines', 'delete-matter.yaml'), + 'name: delete-matter\nsteps:\n - name: delete\n command: admin delete\n args:\n --id: 5\n', + ); + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + const { strategy, calls } = makeCustomStrategy(); + let deleteCount = 0; + + globalThis.fetch = (async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + + if (urlStr.includes('/admin/matters/5')) { + deleteCount++; + + return new Response('Unauthorized', { status: 401 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const cli: Cli = createCli({ + name: 'testcli', + description: 'test', + version: '1.0.0', + specPath: '/v3/api-docs', + auth: strategy, + refreshOn: [401], + generatedDir, + configPath, + }); + + const result = await cli.runRoutine('delete-matter'); + + expect(result.status).not.toBe('ok'); + expect(deleteCount).toBe(2); // initial + exactly one retry, no further attempts + expect(calls.count).toBe(2); // initial authenticate() + exactly one refresh attempt + }); + + test('custom-strategy path: when the refresh callback throws, original 401 is preserved with refresh error as cause (#98 regression)', async () => { + // The RoutineResult surface only carries a stringified step error, so this + // asserts on the thrown Error's {status, body, cause} directly against the + // generated ApiClient — wired exactly as cli-builder.ts wires it (custom + // AuthStrategy + SessionManager, refreshOn passed straight through, no + // SessionAuthStrategy involved). Mirrors + // tests/auth/session-auth-stale-retry.integration.test.ts's #98 case, but + // for a custom (non-session) strategy. + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + const resolved: ResolvedAuth = { baseUrl: 'https://api.example.com', username: 'user', password: 'pass' }; + const { strategy } = makeCustomStrategy(); + const sessionMgr = new SessionManager('testcli', join(tmpHome, 'session.json')); + + const originalErrorBody = JSON.stringify({ status: 401, error: 'Unauthorized', path: '/admin/matters/5' }); + let deleteCount = 0; + + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + const method = (init?.method ?? 'GET').toUpperCase(); + + if (urlStr.includes('/admin/matters/5') && method === 'DELETE') { + deleteCount++; + + return new Response(originalErrorBody, { status: 401 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + // Refresh callback that always fails — simulates creds being rotated. + const refreshFailure = new Error('refresh failed: credentials no longer valid'); + const refreshSession = async () => { + throw refreshFailure; + }; + + const { ApiClient } = await import(join(generatedDir, 'client.ts')) as { ApiClient: new ( + baseUrl: string, + getHeaders: (method: string) => Record, + onRefreshNeeded?: () => Promise, + refreshOn?: number[], + ) => { deleteMatter(id: number): Promise }; }; + + const session: AuthSession = await sessionMgr.resolve(strategy, resolved); + const getHeaders = (method: string) => resolveRequestHeaders(session, undefined, method); + const client = new ApiClient(resolved.baseUrl, getHeaders, refreshSession, [401]); + + let thrown: unknown; + + try { + await client.deleteMatter(5); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(Error); + const err = thrown as Error & { status?: number; body?: string; cause?: unknown }; + expect(err.status).toBe(401); + expect(err.body).toBe(originalErrorBody); + expect(err.cause).toBe(refreshFailure); + + // Original request was made once; no retry attempted because refresh failed. + expect(deleteCount).toBe(1); + }); + + test('createCli path (runRoutine): sessionAuth.refreshOn fallback still works when options.refreshOn is unset (#77 regression)', async () => { + const cliConfigDir = join(tmpHome, '.testcli'); + const configPath = join(cliConfigDir, 'config.json'); + writeConfig(configPath); + mkdirSync(join(cliConfigDir, 'routines'), { recursive: true }); + writeFileSync( + join(cliConfigDir, 'routines', 'delete-matter.yaml'), + 'name: delete-matter\nsteps:\n - name: delete\n command: admin delete\n args:\n --id: 5\n', + ); + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + const sessionAuth: SessionAuthConfig = { + session: { endpoint: '/session' }, + cookies: { extract: ['SESSION'], applyTo: ['DELETE'] }, + refreshOn: [401], + }; + + let sessionCount = 0; + const deletes: { cookieHeader: string | undefined }[] = []; + + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + const headers = init?.headers as Record | undefined; + + if (urlStr.endsWith('/session')) { + sessionCount++; + + return new Response('{}', { + status: 200, + headers: [['Set-Cookie', `SESSION=fresh-${sessionCount}; Path=/`]], + }); + } + + if (urlStr.includes('/admin/matters/5')) { + deletes.push({ cookieHeader: headers?.Cookie }); + + if (deletes.length === 1) return new Response('Unauthorized', { status: 401 }); + + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const cli: Cli = createCli({ + name: 'testcli', + description: 'test', + version: '1.0.0', + specPath: '/v3/api-docs', + auth: new BasicAuthStrategy(), + sessionAuth, + generatedDir, + configPath, + }); + + const result = await cli.runRoutine('delete-matter'); + + expect(result.status).toBe('ok'); + // No cached session pre-populated here (unlike #77's test), so the initial + // authenticate() also hits /session — plus exactly one refresh handshake. + expect(sessionCount).toBe(2); + expect(deletes).toHaveLength(2); + expect(deletes[0]!.cookieHeader).toContain('SESSION=fresh-1'); + expect(deletes[1]!.cookieHeader).toContain('SESSION=fresh-2'); + }); + + test('createCli path (runRoutine): options.refreshOn takes precedence over sessionAuth.refreshOn', async () => { + const cliConfigDir = join(tmpHome, '.testcli'); + const configPath = join(cliConfigDir, 'config.json'); + writeConfig(configPath); + mkdirSync(join(cliConfigDir, 'routines'), { recursive: true }); + writeFileSync( + join(cliConfigDir, 'routines', 'delete-matter.yaml'), + 'name: delete-matter\nsteps:\n - name: delete\n command: admin delete\n args:\n --id: 5\n', + ); + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + // sessionAuth.refreshOn only covers 500 — if the fallback were used, a + // 401 would NOT trigger a refresh. options.refreshOn = [401] must win. + const sessionAuth: SessionAuthConfig = { + session: { endpoint: '/session' }, + cookies: { extract: ['SESSION'], applyTo: ['DELETE'] }, + refreshOn: [500], + }; + + let sessionCount = 0; + let deleteCount = 0; + + globalThis.fetch = (async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + + if (urlStr.endsWith('/session')) { + sessionCount++; + + return new Response('{}', { + status: 200, + headers: [['Set-Cookie', `SESSION=fresh-${sessionCount}; Path=/`]], + }); + } + + if (urlStr.includes('/admin/matters/5')) { + deleteCount++; + + if (deleteCount === 1) return new Response('Unauthorized', { status: 401 }); + + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const cli: Cli = createCli({ + name: 'testcli', + description: 'test', + version: '1.0.0', + specPath: '/v3/api-docs', + auth: new BasicAuthStrategy(), + sessionAuth, + refreshOn: [401], + generatedDir, + configPath, + }); + + const result = await cli.runRoutine('delete-matter'); + + expect(result.status).toBe('ok'); + expect(deleteCount).toBe(2); + // No cached session pre-populated here, so the initial authenticate() + // also hits /session — plus exactly one refresh handshake. + expect(sessionCount).toBe(2); + }); + + test('run() path: custom strategy + options.refreshOn recovers from a stale 401 via the same wiring as the createCli path', async () => { + const cliConfigDir = join(tmpHome, '.testcli'); + const configPath = join(cliConfigDir, 'config.json'); + writeConfig(configPath); + const generatedDir = join(tmpHome, 'generated'); + writeGeneratedFixture(generatedDir); + + const { strategy, calls } = makeCustomStrategy(); + const deleteCalls: { authHeader: string | undefined }[] = []; + let sessionEndpointHits = 0; + + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' + ? url + : url instanceof URL ? url.toString() : url.url; + const headers = init?.headers as Record | undefined; + + if (urlStr.endsWith('/session')) { + sessionEndpointHits++; + + return new Response('{}', { status: 200 }); + } + + if (urlStr.includes('/admin/matters/5')) { + deleteCalls.push({ authHeader: headers?.Authorization }); + + if (deleteCalls.length === 1) return new Response('Unauthorized', { status: 401 }); + + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const options: CliOptions = { + name: 'testcli', + description: 'test', + version: '1.0.0', + specPath: '/v3/api-docs', + auth: strategy, + refreshOn: [401], + generatedDir, + configPath, + }; + const cli: Cli = createCli(options); + + // The run() path (~L546-661) builds `ctx` — including the wired + // ApiClient — synchronously during command registration, before argv + // is parsed. A consumer command registrar is the only surface that + // observes that ctx, so capture it there rather than driving a real + // generated subcommand through Commander. + let capturedCtx: CliContext | null = null; + cli.command('probe', (_program, ctx) => { + capturedCtx = ctx; + }); + + const originalArgv = process.argv; + const originalExit = process.exit; + const originalLog = console.log; + // No-args invocation prints custom help and exits — well after client + // wiring and consumer command registration have already run. + process.argv = ['node', 'testcli']; + process.exit = (() => { + throw new Error('__exit__'); + }) as never; + console.log = () => {}; + + try { + await cli.run(); + } catch (e) { + if ((e as Error).message !== '__exit__') throw e; + } finally { + process.argv = originalArgv; + process.exit = originalExit; + console.log = originalLog; + } + + expect(capturedCtx).not.toBeNull(); + const client = capturedCtx!.client as { deleteMatter(id: number): Promise }; + const result = await client.deleteMatter(5); + + expect(result).toEqual({ ok: true }); + expect(sessionEndpointHits).toBe(0); + expect(calls.count).toBe(2); + expect(deleteCalls).toHaveLength(2); + expect(deleteCalls[0]!.authHeader).toBe('Bearer token-1'); + expect(deleteCalls[1]!.authHeader).toBe('Bearer token-2'); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index bf05f60..aa65572 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -27,6 +27,17 @@ describe('loadProjectSettings()', () => { expect(settings.customCommands?.defaults?.requiresAuth).toBe(true); }); + test('reads auth.refreshOn', () => { + mkdirSync(testRoot, { recursive: true }); + writeFileSync( + join(testRoot, 'settings.json'), + JSON.stringify({ auth: { refreshOn: [401] } }), + ); + + const settings = loadProjectSettings(testRoot); + expect(settings.auth?.refreshOn).toEqual([401]); + }); + test('returns empty object on malformed JSON', () => { mkdirSync(testRoot, { recursive: true }); writeFileSync(join(testRoot, 'settings.json'), '{ not json');