Skip to content

Commit 3df5ca5

Browse files
committed
test: adds unit tests for spectral ruleset validator
1 parent c832d4b commit 3df5ca5

1 file changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
/* eslint-disable unicorn/no-useless-undefined */
2+
// `toArray(undefined)` is an explicit call to exercise the undefined-input code path.
3+
import { describe, expect, it } from 'vitest';
4+
5+
import {
6+
isLocalFilePath,
7+
isPrivateOrLoopbackHost,
8+
toArray,
9+
validateSpectralRuleset,
10+
} from '../spectral-ruleset-validator';
11+
12+
const expectInvalid = (content: string, errorContains?: string | RegExp): string => {
13+
const result = validateSpectralRuleset(content);
14+
expect(result.isValid).toBe(false);
15+
if (!result.isValid && errorContains) {
16+
expect(result.error).toMatch(errorContains);
17+
}
18+
return result.isValid ? '' : result.error;
19+
};
20+
21+
const expectValid = (content: string): void => {
22+
expect(validateSpectralRuleset(content)).toEqual({ isValid: true });
23+
};
24+
25+
const ruleWith = (body: string): string =>
26+
`rules:\n my-rule:\n${body
27+
.split('\n')
28+
.map(l => (l ? ` ${l}` : l))
29+
.join('\n')}`;
30+
31+
describe('isLocalFilePath()', () => {
32+
it('returns true for explicit relative prefixes', () => {
33+
expect(isLocalFilePath('./foo.yaml')).toBe(true);
34+
expect(isLocalFilePath('../foo.yaml')).toBe(true);
35+
expect(isLocalFilePath('../../shared/foo.yaml')).toBe(true);
36+
});
37+
38+
it('returns true for POSIX absolute paths', () => {
39+
expect(isLocalFilePath('/etc/spectral/rules.yaml')).toBe(true);
40+
});
41+
42+
it('returns false for bare filenames', () => {
43+
expect(isLocalFilePath('foo.yaml')).toBe(false);
44+
});
45+
46+
it('returns false for URLs', () => {
47+
expect(isLocalFilePath('https://example.com/rules.yaml')).toBe(false);
48+
expect(isLocalFilePath('http://example.com/rules.yaml')).toBe(false);
49+
});
50+
51+
it('returns false for built-in Spectral identifiers', () => {
52+
expect(isLocalFilePath('spectral:oas')).toBe(false);
53+
});
54+
});
55+
56+
describe('isPrivateOrLoopbackHost()', () => {
57+
it('returns true for localhost variants', () => {
58+
expect(isPrivateOrLoopbackHost('localhost')).toBe(true);
59+
expect(isPrivateOrLoopbackHost('foo.localhost')).toBe(true);
60+
});
61+
62+
it('returns true for loopback IPs (v4 and v6)', () => {
63+
expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true);
64+
expect(isPrivateOrLoopbackHost('::1')).toBe(true);
65+
});
66+
67+
it('returns true for RFC 1918 private ranges', () => {
68+
expect(isPrivateOrLoopbackHost('10.0.0.1')).toBe(true);
69+
expect(isPrivateOrLoopbackHost('172.16.0.1')).toBe(true);
70+
expect(isPrivateOrLoopbackHost('192.168.1.1')).toBe(true);
71+
});
72+
73+
it('returns true for link-local IPv4', () => {
74+
expect(isPrivateOrLoopbackHost('169.254.0.1')).toBe(true);
75+
});
76+
77+
it('returns true for bracketed IPv6 hostnames (as produced by new URL().hostname)', () => {
78+
expect(isPrivateOrLoopbackHost('[::1]')).toBe(true);
79+
});
80+
81+
it('returns false for public unicast IPs', () => {
82+
expect(isPrivateOrLoopbackHost('8.8.8.8')).toBe(false);
83+
expect(isPrivateOrLoopbackHost('1.1.1.1')).toBe(false);
84+
});
85+
86+
it('returns false for non-IP hostnames (DNS resolution is handled elsewhere)', () => {
87+
expect(isPrivateOrLoopbackHost('example.com')).toBe(false);
88+
});
89+
});
90+
91+
describe('toArray()', () => {
92+
it('returns [] for undefined', () => {
93+
expect(toArray(undefined)).toEqual([]);
94+
});
95+
96+
it('wraps a single value in an array', () => {
97+
expect(toArray('a')).toEqual(['a']);
98+
expect(toArray(0)).toEqual([0]);
99+
});
100+
101+
it('returns arrays unchanged', () => {
102+
expect(toArray(['a', 'b'])).toEqual(['a', 'b']);
103+
expect(toArray<number>([])).toEqual([]);
104+
});
105+
});
106+
107+
describe('validateSpectralRuleset()', () => {
108+
// Top-level shape
109+
it('rejects empty string', () => {
110+
expectInvalid('', /empty/i);
111+
});
112+
113+
it('rejects whitespace-only content', () => {
114+
expectInvalid(' \n \t\n', /empty/i);
115+
});
116+
117+
it('rejects unparseable YAML', () => {
118+
expectInvalid('rules: [unterminated', /yaml|json/i);
119+
});
120+
121+
it('rejects YAML that parses to a non-object', () => {
122+
expectInvalid('"just a string"', /object/i);
123+
expectInvalid('- a\n- b\n', /object/i);
124+
expectInvalid('null', /object/i);
125+
});
126+
127+
it('rejects an empty object', () => {
128+
expectInvalid('{}', /declare at least one/i);
129+
});
130+
131+
it('rejects unsupported top-level keys', () => {
132+
const error = expectInvalid('functions:\n - exec\n', /unsupported top-level/i);
133+
expect(error).toContain('functions');
134+
});
135+
136+
it('accepts JSON input (YAML is a superset of JSON)', () => {
137+
expectValid('{"extends": ["spectral:oas"]}');
138+
});
139+
140+
// extends — covers validateExtends() in full
141+
it('accepts every built-in extends identifier', () => {
142+
expectValid('extends:\n - spectral:oas\n - spectral:asyncapi\n - spectral:arazzo\n');
143+
});
144+
145+
it('accepts a bare-string extends identifier (single, not array)', () => {
146+
expectValid('extends: spectral:oas\n');
147+
});
148+
149+
it('accepts relative file paths in extends', () => {
150+
expectValid('extends:\n - ./rules.yaml\n');
151+
expectValid('extends:\n - ../shared/rules.yml\n');
152+
});
153+
154+
it('accepts absolute file paths in extends', () => {
155+
expectValid('extends:\n - /tmp/rules.yaml\n');
156+
});
157+
158+
it('accepts https URLs to public hosts', () => {
159+
expectValid('extends:\n - https://example.com/rules.yaml\n');
160+
});
161+
162+
it('rejects non-string extends entries', () => {
163+
expectInvalid('extends:\n - 42\n', /must be strings/i);
164+
});
165+
166+
it('rejects http URLs in extends', () => {
167+
expectInvalid('extends:\n - http://example.com/rules.yaml\n', /must use https/i);
168+
});
169+
170+
it('rejects extends URLs targeting localhost variants', () => {
171+
expectInvalid('extends:\n - https://localhost/rules.yaml\n', /disallowed host/i);
172+
expectInvalid('extends:\n - https://foo.localhost/rules.yaml\n', /disallowed host/i);
173+
});
174+
175+
it('rejects extends URLs targeting loopback IPs (v4 and v6)', () => {
176+
expectInvalid('extends:\n - https://127.0.0.1/rules.yaml\n', /disallowed host/i);
177+
expectInvalid('extends:\n - "https://[::1]/rules.yaml"\n', /disallowed host/i);
178+
});
179+
180+
it('rejects extends URLs targeting RFC 1918 private ranges', () => {
181+
expectInvalid('extends:\n - https://10.0.0.1/rules.yaml\n', /disallowed host/i);
182+
expectInvalid('extends:\n - https://192.168.1.1/rules.yaml\n', /disallowed host/i);
183+
expectInvalid('extends:\n - https://172.16.0.1/rules.yaml\n', /disallowed host/i);
184+
});
185+
186+
it('rejects extends strings that are neither identifiers nor paths nor valid URLs', () => {
187+
expectInvalid('extends:\n - not-a-real-thing\n', /not a recognized|valid URL/i);
188+
});
189+
190+
// rules + rule body + then — covers validateRules(), validateRuleBody(), validateThen()
191+
it('rejects rules that is not an object', () => {
192+
expectInvalid('rules:\n - foo\n', /"rules" must be an object/);
193+
expectInvalid('rules: "string"\n', /"rules" must be an object/);
194+
expectInvalid('rules: null\n', /"rules" must be an object/);
195+
});
196+
197+
it('rejects prototype-pollution rule names with object bodies', () => {
198+
// YAML produces an own property for these names, unlike a JS object literal.
199+
expectInvalid('"rules":\n "__proto__":\n given: $\n then:\n function: truthy\n', /not allowed/i);
200+
expectInvalid('rules:\n constructor:\n given: $\n then:\n function: truthy\n', /not allowed/i);
201+
expectInvalid('rules:\n prototype:\n given: $\n then:\n function: truthy\n', /not allowed/i);
202+
});
203+
204+
it('accepts shorthand boolean rule definitions', () => {
205+
expectValid('rules:\n my-rule: true\n');
206+
expectValid('rules:\n my-rule: false\n');
207+
});
208+
209+
it('accepts shorthand severity-string rule definitions', () => {
210+
expectValid('rules:\n my-rule: warn\n');
211+
expectValid('rules:\n my-rule: error\n');
212+
});
213+
214+
it('rejects rule bodies that are not objects, booleans, or severity strings', () => {
215+
expectInvalid('rules:\n my-rule: 42\n', /must be an object, boolean, or severity string/i);
216+
});
217+
218+
it('rejects given expressions containing each prototype-pollution token', () => {
219+
expectInvalid(ruleWith('given: "$.__proto__.x"\nthen:\n function: truthy'), /disallowed token/i);
220+
expectInvalid(ruleWith('given: "$.prototype.x"\nthen:\n function: truthy'), /disallowed token/i);
221+
expectInvalid(ruleWith('given: "$.constructor.x"\nthen:\n function: truthy'), /disallowed token/i);
222+
});
223+
224+
it('rejects when any entry of a given array is unsafe', () => {
225+
expectInvalid(ruleWith('given:\n - $.paths[*]\n - $.__proto__\nthen:\n function: truthy'), /disallowed token/i);
226+
});
227+
228+
it('accepts non-string given values (only strings are checked)', () => {
229+
expectValid(ruleWith('given: 42\nthen:\n function: truthy'));
230+
});
231+
232+
it('rejects rule documentationUrl with unsafe schemes', () => {
233+
expectInvalid(
234+
ruleWith('given: $\ndocumentationUrl: http://example.com\nthen:\n function: truthy'),
235+
/documentationUrl/i,
236+
);
237+
expectInvalid(
238+
ruleWith('given: $\ndocumentationUrl: "ftp://example.com"\nthen:\n function: truthy'),
239+
/documentationUrl/i,
240+
);
241+
expectInvalid(
242+
ruleWith('given: $\ndocumentationUrl: "javascript:alert(1)"\nthen:\n function: truthy'),
243+
/documentationUrl/i,
244+
);
245+
expectInvalid(ruleWith('given: $\ndocumentationUrl: "not a url"\nthen:\n function: truthy'), /documentationUrl/i);
246+
});
247+
248+
it('accepts rule documentationUrl that is https', () => {
249+
expectValid(ruleWith('given: $\ndocumentationUrl: https://example.com\nthen:\n function: truthy'));
250+
});
251+
252+
it('skips non-string documentationUrl (the string check is the only gate)', () => {
253+
expectValid(ruleWith('given: $\ndocumentationUrl: 42\nthen:\n function: truthy'));
254+
});
255+
256+
it('rejects then.field containing prototype-pollution tokens', () => {
257+
expectInvalid(ruleWith('given: $\nthen:\n field: __proto__\n function: truthy'), /field/i);
258+
expectInvalid(ruleWith('given: $\nthen:\n field: prototype\n function: truthy'), /field/i);
259+
expectInvalid(ruleWith('given: $\nthen:\n field: constructor\n function: truthy'), /field/i);
260+
});
261+
262+
it('rejects then.field containing path traversal characters', () => {
263+
expectInvalid(ruleWith('given: $\nthen:\n field: a.b\n function: truthy'), /field/i);
264+
expectInvalid(ruleWith('given: $\nthen:\n field: "a[0]"\n function: truthy'), /field/i);
265+
expectInvalid(ruleWith('given: $\nthen:\n field: "a]b"\n function: truthy'), /field/i);
266+
});
267+
268+
it('accepts then.field that is a plain property name', () => {
269+
expectValid(ruleWith('given: $\nthen:\n field: summary\n function: truthy'));
270+
});
271+
272+
it('rejects then.function that is not a built-in', () => {
273+
expectInvalid(ruleWith('given: $\nthen:\n function: exec'), /not an allowed/i);
274+
expectInvalid(ruleWith('given: $\nthen:\n function: arbitrary'), /not an allowed/i);
275+
});
276+
277+
it('rejects non-string then.function values', () => {
278+
expectInvalid(ruleWith('given: $\nthen:\n function: 123'), /not an allowed/i);
279+
});
280+
281+
it('accepts every documented built-in Spectral function', () => {
282+
const builtins = [
283+
'alphabetical',
284+
'casing',
285+
'defined',
286+
'enumeration',
287+
'falsy',
288+
'length',
289+
'pattern',
290+
'schema',
291+
'truthy',
292+
'typedEnum',
293+
'undefined',
294+
'unreferencedReusableObject',
295+
'or',
296+
'xor',
297+
];
298+
for (const fn of builtins) {
299+
expectValid(ruleWith(`given: $\nthen:\n function: ${fn}`));
300+
}
301+
});
302+
303+
it('iterates an array of then clauses and rejects any invalid entry', () => {
304+
expectInvalid(
305+
`rules:
306+
my-rule:
307+
given: $
308+
then:
309+
- function: truthy
310+
- function: exec
311+
`,
312+
/not an allowed/i,
313+
);
314+
});
315+
316+
it('accepts an array of then clauses when all are valid', () => {
317+
expectValid(`
318+
rules:
319+
my-rule:
320+
given: $
321+
then:
322+
- field: summary
323+
function: truthy
324+
- field: description
325+
function: truthy
326+
`);
327+
});
328+
329+
it('skips non-object entries inside a then array', () => {
330+
expectValid(`
331+
rules:
332+
my-rule:
333+
given: $
334+
then:
335+
- null
336+
- function: truthy
337+
`);
338+
});
339+
340+
it('accepts a full ruleset combining extends, rules, and a documentationUrl', () => {
341+
expectValid(`
342+
extends:
343+
- spectral:oas
344+
- ./shared.yaml
345+
rules:
346+
my-rule:
347+
description: My rule
348+
given: $.paths[*]
349+
severity: warn
350+
documentationUrl: https://example.com/docs
351+
then:
352+
field: summary
353+
function: truthy
354+
`);
355+
});
356+
});

0 commit comments

Comments
 (0)