feat: scenario option for authorization server test (#227)
This commit is contained in:
parent
38c4852d8c
commit
fa96f3aca1
4 changed files with 216 additions and 3 deletions
21
src/index.ts
21
src/index.ts
|
|
@ -11,6 +11,7 @@ import {
|
||||||
runInteractiveMode
|
runInteractiveMode
|
||||||
} from './runner';
|
} from './runner';
|
||||||
import {
|
import {
|
||||||
|
printAuthorizationServerResults,
|
||||||
printAuthorizationServerSummary,
|
printAuthorizationServerSummary,
|
||||||
runAuthorizationServerConformanceTest
|
runAuthorizationServerConformanceTest
|
||||||
} from './runner/authorization-server';
|
} from './runner/authorization-server';
|
||||||
|
|
@ -514,20 +515,40 @@ program
|
||||||
'Run conformance tests against an authorization server implementation'
|
'Run conformance tests against an authorization server implementation'
|
||||||
)
|
)
|
||||||
.requiredOption('--url <url>', 'URL of the authorization server issuer')
|
.requiredOption('--url <url>', 'URL of the authorization server issuer')
|
||||||
|
.option('--scenario <scenario>', 'Test scenario to run')
|
||||||
.option('-o, --output-dir <path>', 'Save results to this directory')
|
.option('-o, --output-dir <path>', 'Save results to this directory')
|
||||||
.option(
|
.option(
|
||||||
'--spec-version <version>',
|
'--spec-version <version>',
|
||||||
'Filter scenarios by spec version (cumulative for date versions)'
|
'Filter scenarios by spec version (cumulative for date versions)'
|
||||||
)
|
)
|
||||||
|
.option('--verbose', 'Show verbose output (JSON instead of pretty print)')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
// Validate options with Zod
|
// Validate options with Zod
|
||||||
const validated = AuthorizationServerOptionsSchema.parse(options);
|
const validated = AuthorizationServerOptionsSchema.parse(options);
|
||||||
|
const verbose = options.verbose ?? false;
|
||||||
const outputDir = options.outputDir;
|
const outputDir = options.outputDir;
|
||||||
const specVersionFilter = options.specVersion
|
const specVersionFilter = options.specVersion
|
||||||
? resolveSpecVersion(options.specVersion)
|
? resolveSpecVersion(options.specVersion)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// If a single scenario is specified, run just that one
|
||||||
|
if (validated.scenario) {
|
||||||
|
const result = await runAuthorizationServerConformanceTest(
|
||||||
|
validated.url,
|
||||||
|
validated.scenario,
|
||||||
|
outputDir
|
||||||
|
);
|
||||||
|
|
||||||
|
const { failed } = printAuthorizationServerResults(
|
||||||
|
result.checks,
|
||||||
|
result.scenarioDescription,
|
||||||
|
verbose
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
let scenarios: string[];
|
let scenarios: string[];
|
||||||
scenarios = listClientScenariosForAuthorizationServer();
|
scenarios = listClientScenariosForAuthorizationServer();
|
||||||
if (specVersionFilter) {
|
if (specVersionFilter) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ConformanceCheck } from '../types';
|
import { ConformanceCheck } from '../types';
|
||||||
import { getClientScenarioForAuthorizationServer } from '../scenarios';
|
import { getClientScenarioForAuthorizationServer } from '../scenarios';
|
||||||
import { createResultDir } from './utils';
|
import { createResultDir, formatPrettyChecks } from './utils';
|
||||||
|
|
||||||
export async function runAuthorizationServerConformanceTest(
|
export async function runAuthorizationServerConformanceTest(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
|
|
@ -49,6 +49,49 @@ export async function runAuthorizationServerConformanceTest(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function printAuthorizationServerResults(
|
||||||
|
checks: ConformanceCheck[],
|
||||||
|
scenarioDescription: string,
|
||||||
|
verbose: boolean = false
|
||||||
|
): {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
denominator: number;
|
||||||
|
warnings: number;
|
||||||
|
} {
|
||||||
|
const denominator = checks.filter(
|
||||||
|
(c) => c.status === 'SUCCESS' || c.status === 'FAILURE'
|
||||||
|
).length;
|
||||||
|
const passed = checks.filter((c) => c.status === 'SUCCESS').length;
|
||||||
|
const failed = checks.filter((c) => c.status === 'FAILURE').length;
|
||||||
|
const warnings = checks.filter((c) => c.status === 'WARNING').length;
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(JSON.stringify(checks, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`Checks:\n${formatPrettyChecks(checks)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTest Results:`);
|
||||||
|
console.log(
|
||||||
|
`Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('\n=== Failed Checks ===');
|
||||||
|
checks
|
||||||
|
.filter((c) => c.status === 'FAILURE')
|
||||||
|
.forEach((c) => {
|
||||||
|
console.log(`\n - ${c.name}: ${c.description}`);
|
||||||
|
if (c.errorMessage) {
|
||||||
|
console.log(` Error: ${c.errorMessage}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed, failed, denominator, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
export function printAuthorizationServerSummary(
|
export function printAuthorizationServerSummary(
|
||||||
allResults: { scenario: string; checks: ConformanceCheck[] }[]
|
allResults: { scenario: string; checks: ConformanceCheck[] }[]
|
||||||
): { totalPassed: number; totalFailed: number } {
|
): { totalPassed: number; totalFailed: number } {
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,138 @@ describe('AuthorizationServerMetadataEndpointScenario', () => {
|
||||||
expect(check.errorMessage).toContain('code_challenge_methods_supported');
|
expect(check.errorMessage).toContain('code_challenge_methods_supported');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AuthorizationServerOptionsSchema', () => {
|
||||||
|
// Dynamic import to avoid circular dependency issues at module level
|
||||||
|
async function getSchema() {
|
||||||
|
const { AuthorizationServerOptionsSchema } =
|
||||||
|
await import('../../schemas.js');
|
||||||
|
return AuthorizationServerOptionsSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts a valid scenario name', async () => {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const result = schema.safeParse({
|
||||||
|
url: 'https://example.com',
|
||||||
|
scenario: 'authorization-server-metadata-endpoint'
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.scenario).toBe(
|
||||||
|
'authorization-server-metadata-endpoint'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown scenario name', async () => {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const result = schema.safeParse({
|
||||||
|
url: 'https://example.com',
|
||||||
|
scenario: 'nonexistent-scenario'
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts without scenario (optional)', async () => {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const result = schema.safeParse({
|
||||||
|
url: 'https://example.com'
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.scenario).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('printAuthorizationServerResults', () => {
|
||||||
|
async function getPrintFn() {
|
||||||
|
const { printAuthorizationServerResults } =
|
||||||
|
await import('../../runner/authorization-server.js');
|
||||||
|
return printAuthorizationServerResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCheck = {
|
||||||
|
id: 'test-check',
|
||||||
|
name: 'TestCheck',
|
||||||
|
description: 'A test check',
|
||||||
|
status: 'SUCCESS' as const,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const failureCheck = {
|
||||||
|
id: 'test-check-fail',
|
||||||
|
name: 'TestCheckFail',
|
||||||
|
description: 'A failing check',
|
||||||
|
status: 'FAILURE' as const,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errorMessage: 'Something went wrong'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prints pretty-printed output when verbose is false', async () => {
|
||||||
|
const printFn = await getPrintFn();
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = printFn([successCheck], 'Test scenario description', false);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
denominator: 1,
|
||||||
|
warnings: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should print "Checks:" header (pretty format), not raw JSON
|
||||||
|
const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||||
|
expect(allOutput).toContain('Checks:');
|
||||||
|
expect(allOutput).not.toContain('"id"');
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints JSON output when verbose is true', async () => {
|
||||||
|
const printFn = await getPrintFn();
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = printFn([successCheck], 'Test scenario description', true);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
denominator: 1,
|
||||||
|
warnings: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should print raw JSON, not "Checks:" header
|
||||||
|
const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||||
|
expect(allOutput).toContain('"id"');
|
||||||
|
expect(allOutput).toContain('"test-check"');
|
||||||
|
expect(allOutput).not.toMatch(/^Checks:/m);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failed checks with error messages', async () => {
|
||||||
|
const printFn = await getPrintFn();
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = printFn(
|
||||||
|
[successCheck, failureCheck],
|
||||||
|
'Test scenario description',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
passed: 1,
|
||||||
|
failed: 1,
|
||||||
|
denominator: 2,
|
||||||
|
warnings: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||||
|
expect(allOutput).toContain('Failed Checks');
|
||||||
|
expect(allOutput).toContain('Something went wrong');
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getScenario, getClientScenario } from './scenarios';
|
import {
|
||||||
|
getScenario,
|
||||||
|
getClientScenario,
|
||||||
|
getClientScenarioForAuthorizationServer
|
||||||
|
} from './scenarios';
|
||||||
|
|
||||||
// Client command options schema
|
// Client command options schema
|
||||||
export const ClientOptionsSchema = z.object({
|
export const ClientOptionsSchema = z.object({
|
||||||
|
|
@ -40,7 +44,17 @@ export type ServerOptions = z.infer<typeof ServerOptionsSchema>;
|
||||||
|
|
||||||
// Authorization server command options schema
|
// Authorization server command options schema
|
||||||
export const AuthorizationServerOptionsSchema = z.object({
|
export const AuthorizationServerOptionsSchema = z.object({
|
||||||
url: z.string().url('Invalid authorization server URL')
|
url: z.string().url('Invalid authorization server URL'),
|
||||||
|
scenario: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(scenario) =>
|
||||||
|
getClientScenarioForAuthorizationServer(scenario) !== undefined,
|
||||||
|
{
|
||||||
|
error: (iss) => `Unknown scenario '${iss.input}'`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AuthorizationServerOptions = z.infer<
|
export type AuthorizationServerOptions = z.infer<
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue