diff --git a/examples/authorization-server-settings.example.json b/examples/authorization-server-settings.example.json new file mode 100644 index 00000000..a511114d --- /dev/null +++ b/examples/authorization-server-settings.example.json @@ -0,0 +1,3 @@ +{ + "url": "https://auth.example.com" +} diff --git a/src/authorization-file-options.test.ts b/src/authorization-file-options.test.ts new file mode 100644 index 00000000..91762162 --- /dev/null +++ b/src/authorization-file-options.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { AuthorizationServerOptionsSchema } from './schemas'; + +// Mirrors the merge logic in src/index.ts for the `authorization` command: +// the file is validated standalone via .strict(), then CLI flags override. +// Undefined CLI values must not clobber file values. +const FileSchema = AuthorizationServerOptionsSchema.strict(); + +function merge( + fileOptions: Record, + cliOptions: Record +) { + return { + ...fileOptions, + ...Object.fromEntries( + Object.entries(cliOptions).filter(([, v]) => v !== undefined) + ) + }; +} + +describe('authorization --file validation', () => { + it('accepts a complete file', () => { + const result = FileSchema.parse({ url: 'https://file.example.com' }); + expect(result.url).toBe('https://file.example.com'); + }); + + it('rejects a file missing required url (even if --url would be supplied)', () => { + expect(() => FileSchema.parse({})).toThrow(); + }); + + it('rejects unknown keys (catches typos)', () => { + expect(() => + FileSchema.parse({ url: 'https://file.example.com', urll: 'typo' }) + ).toThrow(); + }); + + it('rejects an invalid url value', () => { + expect(() => FileSchema.parse({ url: 'not-a-url' })).toThrow(); + }); +}); + +describe('authorization --file merge precedence', () => { + it('CLI url overrides file url', () => { + const file = FileSchema.parse({ url: 'https://file.example.com' }); + const cli = { url: 'https://cli.example.com' }; + const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); + expect(result.url).toBe('https://cli.example.com'); + }); + + it('file-only url passes when CLI url is undefined', () => { + const file = FileSchema.parse({ url: 'https://file.example.com' }); + const cli = { url: undefined, outputDir: undefined }; + const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); + expect(result.url).toBe('https://file.example.com'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6612d763..1893423b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { ZodError } from 'zod'; +import { promises as fs } from 'fs'; import { runConformanceTest, printClientResults, @@ -41,6 +42,7 @@ import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; +import type { AuthorizationServerOptions } from './schemas'; import { loadExpectedFailures, evaluateBaseline, @@ -514,7 +516,11 @@ program .description( 'Run conformance tests against an authorization server implementation' ) - .requiredOption('--url ', 'URL of the authorization server issuer') + .option( + '--file ', + 'Path to JSON settings file (see examples/authorization-server-settings.example.json)' + ) + .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') .option('-o, --output-dir ', 'Save results to this directory') .option( @@ -524,8 +530,43 @@ program .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { - // Validate options with Zod - const validated = AuthorizationServerOptionsSchema.parse(options); + let fileOptions: AuthorizationServerOptions | undefined; + if (options.file) { + try { + const raw = JSON.parse(await fs.readFile(options.file, 'utf-8')); + // The file must be a complete, valid config on its own; CLI flags + // are optional overrides. .strict() rejects unknown keys so typos + // surface instead of being silently ignored. + fileOptions = AuthorizationServerOptionsSchema.strict().parse(raw); + } catch (error) { + if (error instanceof ZodError) { + const details = error.issues + .map((e) => ` ${e.path.join('.') || '(root)'}: ${e.message}`) + .join('\n'); + console.error( + `Invalid settings file '${options.file}':\n${details}` + ); + } else { + console.error( + `Failed to read settings file '${options.file}': ` + + (error instanceof Error ? error.message : String(error)) + ); + } + process.exit(1); + } + } + if (!fileOptions && !options.url) { + console.error('error: must provide --url or --file'); + process.exit(1); + } + // CLI flags override file values; undefined CLI values must not clobber file values + const merged = { + ...fileOptions, + ...Object.fromEntries( + Object.entries(options).filter(([, v]) => v !== undefined) + ) + }; + const validated = AuthorizationServerOptionsSchema.parse(merged); const verbose = options.verbose ?? false; const outputDir = options.outputDir; const specVersionFilter = options.specVersion