Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions src/__tests__/__helpers__/helper.mts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,34 @@ import type { IRuleResult, Ruleset, RulesetDefinition } from '@stoplight/spectra
const { fetch } = spectralRuntime;
const { Spectral, Document } = SpectralCore;
const { Yaml } = SpectralParsers;
const RULESET_PATH = path.resolve(process.cwd(), 'ukhsa.oas.rules.yml');
const DEFAULT_RULESET_PATH = path.resolve(process.cwd(), 'ukhsa.oas.rules.yml');
const rulesetCache = new Map<string, Promise<RulesetDefinition>>();

/**
* Loads the Spectral ruleset definition from a local YAML file.
*
* @throws {Error} If the ruleset file does not exist at the expected path.
* @returns The parsed ruleset definition object.
*/
async function loadRulesetFromYaml(): Promise<RulesetDefinition> {
if (!fs.existsSync(RULESET_PATH)) {
throw new Error(`Ruleset file not found at ${RULESET_PATH}`);
async function loadRulesetFromYaml(rulesetPath: string = DEFAULT_RULESET_PATH): Promise<RulesetDefinition> {
if (!fs.existsSync(rulesetPath)) {
throw new Error(`Ruleset file not found at ${rulesetPath}`);
}

return await bundleAndLoadRuleset(RULESET_PATH, { fs, fetch }, [commonjs()]);
if (!rulesetCache.has(rulesetPath)) {
rulesetCache.set(rulesetPath, bundleAndLoadRuleset(rulesetPath, { fs, fetch }, [commonjs()]));
}

return await rulesetCache.get(rulesetPath)!;

}

export type RuleName = keyof Ruleset['rules'];

type TestRuleOptions = Readonly<{
rulesetPath?: string;
}>;

type Scenario = ReadonlyArray<
Readonly<{
name: string;
Expand All @@ -49,10 +58,13 @@ type Scenario = ReadonlyArray<
* @throws {Error} If any requested rule is not found in the loaded ruleset.
* @returns A Spectral instance with the filtered ruleset.
*/
export async function createWithRules(rules: RuleName[]): Promise<SpectralCore.Spectral> {
export async function createWithRules(
rules: RuleName[],
rulesetPath: string = DEFAULT_RULESET_PATH,
): Promise<SpectralCore.Spectral> {
const s = new Spectral({ resolver: httpAndFileResolver });
// Load a fresh ruleset per Spectral instance to guarantee isolation
const freshRuleset = await loadRulesetFromYaml();
const freshRuleset = await loadRulesetFromYaml(rulesetPath);
s.setRuleset(freshRuleset as RulesetDefinition);

return s;
Expand All @@ -64,13 +76,22 @@ export async function createWithRules(rules: RuleName[]): Promise<SpectralCore.S
* @param rules - A rule name or list of rule names to test.
* @param tests - Array of test scenarios, each with a document and expected errors.
*/
export default function testRule(rules: RuleName | RuleName[], tests: Scenario): void {
export default function testRule(
rules: RuleName | RuleName[],
tests: Scenario,
options: TestRuleOptions = {},
): void {
const rulesToInclude: RuleName[] = Array.isArray(rules) ? rules : [rules];
const rulesetPath = options.rulesetPath ?? DEFAULT_RULESET_PATH;
describe(`Rule ${rulesToInclude.join(', ')}`, () => {
let spectral!: SpectralCore.Spectral;

beforeAll(async () => {
spectral = await createWithRules(rulesToInclude, rulesetPath);
});

for (const t of tests) {
it(t.name, async () => {
const spectral = await createWithRules(rulesToInclude);

const doc =
t.document instanceof Document
? t.document
Expand All @@ -91,8 +112,8 @@ export default function testRule(rules: RuleName | RuleName[], tests: Scenario):
*
* @throws {Error} If the ruleset file is missing.
*/
export function expectRulesetFileExists(): void {
if (!fs.existsSync(RULESET_PATH)) {
throw new Error(`Ruleset file not found at ${RULESET_PATH}`);
export function expectRulesetFileExists(rulesetPath: string = DEFAULT_RULESET_PATH): void {
if (!fs.existsSync(rulesetPath)) {
throw new Error(`Ruleset file not found at ${rulesetPath}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs';

describe('ruleset file', () => {
it('exists', () => expectRulesetFileExists());
});

testRule('must-always-return-json-objects-as-top-level-data-structures', [
{
name: 'valid: response schema uses object as top-level',
document: `
openapi: 3.0.0
info:
title: Example
version: 1.0.0
paths:
/items:
get:
responses:
'200':
description: ok
content:
application/json:
schema:
type: object
properties:
value:
type: string
`,
errors: [],
},
{
name: 'invalid: response schema uses array top-level',
document: `
openapi: 3.0.0
info:
title: Example
version: 1.0.0
paths:
/items:
get:
responses:
'200':
description: ok
content:
application/json:
schema:
type: array
items:
type: string
`,
errors: [
{
severity: DiagnosticSeverity.Warning,
path: ['paths', '/items', 'get', 'responses', '200', 'content', 'application/json', 'schema'],
message: 'Top-level data structure must be an object',
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs';

describe('ruleset file', () => {
it('exists', () => expectRulesetFileExists());
});

testRule('must-define-critical-problem-responses', [
{
name: 'valid: 400, 404, and 500 error responses include Problem Details examples',
document: `
openapi: 3.0.0
info:
title: Example API
version: 1.0.0
paths:
/pets:
get:
responses:
'400':
description: bad request
content:
application/problem+json:
examples:
sample:
value:
title: bad request
'404':
description: not found
content:
application/problem+json:
examples:
sample:
value:
title: not found
'500':
description: server error
content:
application/problem+json:
examples:
sample:
value:
title: server error
`,
errors: [],
},
{
name: 'invalid: missing example for 404 response',
document: `
openapi: 3.0.0
info:
title: Example API
version: 1.0.0
paths:
/pets:
get:
responses:
'400':
description: bad request
content:
application/problem+json:
examples:
sample:
value:
title: bad request
'404':
description: not found
content:
application/problem+json: {}
'500':
description: server error
content:
application/problem+json:
examples:
sample:
value:
title: server error
`,
errors: [
{
severity: DiagnosticSeverity.Warning,
path: ['paths', '/pets', 'get', 'responses'],
message:
'Each operation SHOULD define Problem Details for: 400, 404, 500. Issues: 404 (missing example).',
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs';

describe('ruleset file', () => {
it('exists', () => expectRulesetFileExists());
});

testRule('must-define-security-problem-responses', [
{
name: 'valid: secured operation defines 401 and 403 Problem Details responses',
document: `
openapi: 3.0.0
info:
title: Secure API
version: 1.0.0
paths:
/accounts:
get:
security:
- ApiKeyAuth: []
responses:
'401':
description: unauthorized
content:
application/problem+json:
examples:
unauthorized:
value:
title: unauthorized
'403':
description: forbidden
content:
application/problem+json:
examples:
forbidden:
value:
title: forbidden
`,
errors: [],
},
{
name: 'invalid: secured operation missing 401 response',
document: `
openapi: 3.0.0
info:
title: Secure API
version: 1.0.0
paths:
/accounts:
get:
security:
- ApiKeyAuth: []
responses:
'403':
description: forbidden
content:
application/problem+json:
examples:
forbidden:
value:
title: forbidden
`,
errors: [
{
severity: DiagnosticSeverity.Warning,
path: ['paths', '/accounts', 'get', 'responses'],
message:
'Each operation SHOULD define Problem Details for: 401, 403. Issues: 401 (missing response).',
},
],
},
]);
44 changes: 44 additions & 0 deletions src/__tests__/rules/must-define-security-schemes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule, { expectRulesetFileExists } from '../__helpers__/helper.mjs';

describe('ruleset file', () => {
it('exists', () => expectRulesetFileExists());
});

testRule('must-define-security-schemes', [
{
name: 'valid: components contains securitySchemes',
document: `
openapi: 3.0.0
info:
title: Example
version: 1.0.0
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-Api-Key
paths: {}
`,
errors: [],
},
{
name: 'invalid: components missing securitySchemes',
document: `
openapi: 3.0.0
info:
title: Example
version: 1.0.0
components: {}
paths: {}
`,
errors: [
{
severity: DiagnosticSeverity.Error,
path: ['components'],
message: 'All APIs MUST have a security scheme defined.',
},
],
},
]);
Loading
Loading