diff --git a/scripts/lib/validate-awards.mjs b/scripts/lib/validate-awards.mjs new file mode 100644 index 00000000..761be714 --- /dev/null +++ b/scripts/lib/validate-awards.mjs @@ -0,0 +1,122 @@ +import { existsSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; +import { + CNCF_HOST_SUFFIXES, + checkNonEmptyArray, + checkParseableDate, + checkUrl, +} from './validate-utils.mjs'; + +// The award link fields are rendered as on /awards and in the member +// directory, so they are resolved through the URL parser rather than a string +// prefix test: `/^https:\/\//` accepts "https://cncf.io@evil.example", whose +// visible prefix and real host disagree, and accepts unparseable values such +// as "https://". checkUrl() in ./validate-utils.mjs is the shared gate; +// isHttpsUrl() in validate-architectures.mjs and websiteUrl() in +// src/lib/profile-links.mjs serve different callers (a boolean predicate and +// a runtime client-side normalizer) and are not folded in here. +// +// Beyond parsing, every rendered link is held to a host allow-list, the same +// standard scripts/validate-case-studies.mjs and validate-radar-reports.mjs +// apply to the cncf.io feeds they mirror. Winners are verified against their +// cncf.io announcement (AGENTS.md), so announcementUrl and caseStudyUrl must +// resolve on a CNCF host; talkUrl may also point at the recording's YouTube +// host. verifiedAgainst names the award history source, a CNCF property. +const LINK_HOST_SUFFIXES = { + announcementUrl: CNCF_HOST_SUFFIXES, + caseStudyUrl: CNCF_HOST_SUFFIXES, + talkUrl: [...CNCF_HOST_SUFFIXES, 'youtube.com', 'youtu.be'], +}; + +export function validateAwards(data, staticRoot) { + const errors = []; + const awardsLogoRoot = join(staticRoot, 'img', 'awards') + sep; + checkParseableDate( + errors, + 'awards.json', + 'verifiedAt', + data.verifiedAt, + 'recording the last completeness audit against verifiedAgainst', + ); + if (!data.verifiedAgainst) + errors.push({ + path: 'awards.json', + severity: 'error', + message: + 'verifiedAgainst must be an https URL to the canonical award history source', + }); + else + checkUrl(errors, 'awards.json', 'verifiedAgainst', data.verifiedAgainst, { + allowedHostSuffixes: CNCF_HOST_SUFFIXES, + }); + checkNonEmptyArray(errors, 'awards.json', 'awards', data.awards); + let lastYear = Infinity; + for (const entry of Array.isArray(data.awards) ? data.awards : []) { + const id = `${entry.year}/${entry.slug}`; + if (!Number.isInteger(entry.year) || entry.year < 2015) + errors.push({ path: id, severity: 'error', message: 'invalid year' }); + if (entry.year > lastYear) + errors.push({ + path: id, + severity: 'error', + message: 'awards must be sorted newest first', + }); + lastYear = Math.max(entry.year, 2015); + for (const field of [ + 'award', + 'awardLabel', + 'organization', + 'slug', + 'citation', + 'event', + ]) { + if (!entry[field]) + errors.push({ + path: id, + severity: 'error', + message: `missing ${field}`, + }); + } + if (!entry.announcementUrl && !entry.talkUrl) + errors.push({ + path: id, + severity: 'error', + message: 'entry needs an announcementUrl or talkUrl', + }); + for (const [field, allowedHostSuffixes] of Object.entries( + LINK_HOST_SUFFIXES, + )) { + if (entry[field]) + checkUrl(errors, id, field, entry[field], { allowedHostSuffixes }); + } + if (entry.logo) { + if (typeof entry.logo !== 'string') { + errors.push({ + path: id, + severity: 'error', + message: 'logo must be a string path under /img/awards/', + }); + } else { + // Resolve before checking containment: a raw prefix test alone is + // defeated by '..' segments, which path resolution normalises away. + const file = resolve(staticRoot, `.${entry.logo}`); + if ( + !entry.logo.startsWith('/img/awards/') || + !file.startsWith(awardsLogoRoot) + ) + errors.push({ + path: id, + severity: 'error', + message: 'logo must live under /img/awards/', + }); + else if (!existsSync(file)) + errors.push({ + path: id, + severity: 'error', + message: `logo file missing: ${entry.logo}`, + }); + } + } + } + return errors; +} diff --git a/scripts/validate-awards.mjs b/scripts/validate-awards.mjs index 667701a8..df72c988 100644 --- a/scripts/validate-awards.mjs +++ b/scripts/validate-awards.mjs @@ -1,126 +1,13 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; -import { existsSync } from 'node:fs'; -import { join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - CNCF_HOST_SUFFIXES, - checkNonEmptyArray, - checkParseableDate, - checkUrl, - reportAndExit, -} from './lib/validate-utils.mjs'; +import { validateAwards } from './lib/validate-awards.mjs'; +import { reportAndExit } from './lib/validate-utils.mjs'; const staticRoot = fileURLToPath(new URL('../static/', import.meta.url)); -const awardsLogoRoot = join(staticRoot, 'img', 'awards') + sep; const data = JSON.parse( readFileSync(new URL('../data/awards.json', import.meta.url)), ); -const errors = []; - -// The award link fields are rendered as on /awards and in the member -// directory, so they are resolved through the URL parser rather than a string -// prefix test: `/^https:\/\//` accepts "https://cncf.io@evil.example", whose -// visible prefix and real host disagree, and accepts unparseable values such -// as "https://". checkUrl() in ./lib/validate-utils.mjs is the shared gate; -// isHttpsUrl() in validate-architectures.mjs and websiteUrl() in -// src/lib/profile-links.mjs serve different callers (a boolean predicate and -// a runtime client-side normalizer) and are not folded in here. -// -// Beyond parsing, every rendered link is held to a host allow-list, the same -// standard scripts/validate-case-studies.mjs and validate-radar-reports.mjs -// apply to the cncf.io feeds they mirror. Winners are verified against their -// cncf.io announcement (AGENTS.md), so announcementUrl and caseStudyUrl must -// resolve on a CNCF host; talkUrl may also point at the recording's YouTube -// host. verifiedAgainst names the award history source, a CNCF property. -const LINK_HOST_SUFFIXES = { - announcementUrl: CNCF_HOST_SUFFIXES, - caseStudyUrl: CNCF_HOST_SUFFIXES, - talkUrl: [...CNCF_HOST_SUFFIXES, 'youtube.com', 'youtu.be'], -}; - -checkParseableDate( - errors, - 'awards.json', - 'verifiedAt', - data.verifiedAt, - 'recording the last completeness audit against verifiedAgainst', -); -if (!data.verifiedAgainst) - errors.push({ - path: 'awards.json', - severity: 'error', - message: - 'verifiedAgainst must be an https URL to the canonical award history source', - }); -else - checkUrl(errors, 'awards.json', 'verifiedAgainst', data.verifiedAgainst, { - allowedHostSuffixes: CNCF_HOST_SUFFIXES, - }); -checkNonEmptyArray(errors, 'awards.json', 'awards', data.awards); -let lastYear = Infinity; -for (const entry of Array.isArray(data.awards) ? data.awards : []) { - const id = `${entry.year}/${entry.slug}`; - if (!Number.isInteger(entry.year) || entry.year < 2015) - errors.push({ path: id, severity: 'error', message: 'invalid year' }); - if (entry.year > lastYear) - errors.push({ - path: id, - severity: 'error', - message: 'awards must be sorted newest first', - }); - lastYear = Math.max(entry.year, 2015); - for (const field of [ - 'award', - 'awardLabel', - 'organization', - 'slug', - 'citation', - 'event', - ]) { - if (!entry[field]) - errors.push({ path: id, severity: 'error', message: `missing ${field}` }); - } - if (!entry.announcementUrl && !entry.talkUrl) - errors.push({ - path: id, - severity: 'error', - message: 'entry needs an announcementUrl or talkUrl', - }); - for (const [field, allowedHostSuffixes] of Object.entries( - LINK_HOST_SUFFIXES, - )) { - if (entry[field]) - checkUrl(errors, id, field, entry[field], { allowedHostSuffixes }); - } - if (entry.logo) { - if (typeof entry.logo !== 'string') { - errors.push({ - path: id, - severity: 'error', - message: 'logo must be a string path under /img/awards/', - }); - } else { - // Resolve before checking containment: a raw prefix test alone is - // defeated by '..' segments, which path resolution normalises away. - const file = resolve(staticRoot, `.${entry.logo}`); - if ( - !entry.logo.startsWith('/img/awards/') || - !file.startsWith(awardsLogoRoot) - ) - errors.push({ - path: id, - severity: 'error', - message: 'logo must live under /img/awards/', - }); - else if (!existsSync(file)) - errors.push({ - path: id, - severity: 'error', - message: `logo file missing: ${entry.logo}`, - }); - } - } -} +const errors = validateAwards(data, staticRoot); reportAndExit(errors, 'awards'); console.log(`Validated ${data.awards.length} award entries`); diff --git a/tests/validate-awards.test.mjs b/tests/validate-awards.test.mjs index dc812675..676b931e 100644 --- a/tests/validate-awards.test.mjs +++ b/tests/validate-awards.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import test from 'node:test'; +import { validateAwards } from '../scripts/lib/validate-awards.mjs'; import { runScriptWithFixtures } from './helpers.mjs'; const SCRIPT = 'validate-awards.mjs'; @@ -26,100 +30,136 @@ function awardsFixture(awards, overrides = {}) { }; } +function validateFixture(fixtures) { + const root = mkdtempSync(join(tmpdir(), 'endusers-awards-core-')); + try { + for (const [path, content] of Object.entries(fixtures)) { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + } + return validateAwards( + JSON.parse(fixtures['data/awards.json']), + join(root, 'static'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function assertError(errors, message) { + assert.ok( + errors.some( + (error) => error.severity === 'error' && message.test(error.message), + ), + JSON.stringify(errors), + ); +} + test('accepts a valid awards file', () => { - const result = runScriptWithFixtures(SCRIPT, awardsFixture([validEntry])); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Validated 1 award/); + assert.deepEqual(validateFixture(awardsFixture([validEntry])), []); }); test('rejects an empty awards array', () => { - const result = runScriptWithFixtures(SCRIPT, awardsFixture([])); - assert.equal(result.status, 1); - assert.match(result.stderr, /non-empty array/); + assert.deepEqual(validateFixture(awardsFixture([])), [ + { + path: 'awards.json', + severity: 'error', + message: 'awards must be a non-empty array', + }, + ]); }); test('rejects a truthy non-array awards without crashing', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture({ 1: validEntry }), - ); - assert.equal(result.status, 1); - assert.match(result.stderr, /non-empty array/); - assert.doesNotMatch(result.stderr, /TypeError/); + assert.deepEqual(validateFixture(awardsFixture({ 1: validEntry })), [ + { + path: 'awards.json', + severity: 'error', + message: 'awards must be a non-empty array', + }, + ]); }); test('rejects a missing verifiedAt', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry], { verifiedAt: undefined }), + assert.deepEqual( + validateFixture(awardsFixture([validEntry], { verifiedAt: undefined })), + [ + { + path: 'awards.json', + severity: 'error', + message: + 'verifiedAt must be a parseable date recording the last completeness audit against verifiedAgainst', + }, + ], ); - assert.equal(result.status, 1); - assert.match(result.stderr, /verifiedAt must be a parseable date/); }); test('rejects a missing verifiedAgainst', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry], { verifiedAgainst: undefined }), + assert.deepEqual( + validateFixture( + awardsFixture([validEntry], { verifiedAgainst: undefined }), + ), + [ + { + path: 'awards.json', + severity: 'error', + message: + 'verifiedAgainst must be an https URL to the canonical award history source', + }, + ], ); - assert.equal(result.status, 1); - assert.match(result.stderr, /verifiedAgainst must be an https URL/); - // The absent-value arm must report the field as missing rather than fall - // through to checkHttpsUrl, which would blame the URL shape instead. - assert.doesNotMatch(result.stderr, /absolute https URL/); }); test('rejects a non-https verifiedAgainst', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry], { - verifiedAgainst: 'contribute.cncf.io/community/awards/', - }), + assert.deepEqual( + validateFixture( + awardsFixture([validEntry], { + verifiedAgainst: 'contribute.cncf.io/community/awards/', + }), + ), + [ + { + path: 'awards.json', + severity: 'error', + message: 'verifiedAgainst must be an absolute https URL', + }, + ], ); - assert.equal(result.status, 1); - assert.match(result.stderr, /verifiedAgainst must be an absolute https URL/); }); test('rejects years before 2015', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, year: 2014 }]), + assert.deepEqual( + validateFixture(awardsFixture([{ ...validEntry, year: 2014 }])), + [{ path: '2014/acme', severity: 'error', message: 'invalid year' }], ); - assert.equal(result.status, 1); - assert.match(result.stderr, /invalid year/); }); test('accepts the first supported award year', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, year: 2015 }]), + assert.deepEqual( + validateFixture(awardsFixture([{ ...validEntry, year: 2015 }])), + [], ); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Validated 1 award/); }); test('rejects entries sorted oldest first', () => { const older = { ...validEntry, year: 2022, slug: 'older' }; const newer = { ...validEntry, year: 2023, slug: 'newer' }; - const result = runScriptWithFixtures(SCRIPT, awardsFixture([older, newer])); - assert.equal(result.status, 1); - assert.match(result.stderr, /newest first/); + assertError(validateFixture(awardsFixture([older, newer])), /newest first/); }); test('accepts entries sorted newest first', () => { const newer = { ...validEntry, year: 2024, slug: 'newer' }; const older = { ...validEntry, year: 2023, slug: 'older' }; - const result = runScriptWithFixtures(SCRIPT, awardsFixture([newer, older])); - assert.equal(result.status, 0, result.stderr); + assert.deepEqual(validateFixture(awardsFixture([newer, older])), []); }); test('accepts multiple awards in the same year', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry, { ...validEntry, slug: 'second' }]), + assert.deepEqual( + validateFixture( + awardsFixture([validEntry, { ...validEntry, slug: 'second' }]), + ), + [], ); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Validated 2 award/); }); for (const [logo, file] of [ @@ -127,43 +167,38 @@ for (const [logo, file] of [ ['//img/awards/acme.svg', 'static/img/awards/acme.svg'], ]) { test(`rejects a logo outside the allowed URL/path boundary: ${logo}`, () => { - const result = runScriptWithFixtures(SCRIPT, { - ...awardsFixture([{ ...validEntry, logo }]), - [file]: '', - }); - assert.equal(result.status, 1); - assert.match(result.stderr, /logo must live under \/img\/awards\//); + assertError( + validateFixture({ + ...awardsFixture([{ ...validEntry, logo }]), + [file]: '', + }), + /logo must live under \/img\/awards\//, + ); }); } test('rejects missing required fields', () => { const { citation, ...withoutCitation } = validEntry; - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([withoutCitation]), + assertError( + validateFixture(awardsFixture([withoutCitation])), + /missing citation/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /missing citation/); }); test('rejects non-https URLs', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([ - { ...validEntry, announcementUrl: 'http://example.com/award' }, - ]), + assertError( + validateFixture( + awardsFixture([ + { ...validEntry, announcementUrl: 'http://example.com/award' }, + ]), + ), + /announcementUrl must use https, got http:/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /announcementUrl must use https, got http:/); }); -// The four link fields below are rendered as , so a value whose -// visible prefix and real authority disagree is the vector these cases pin: -// "https://cncf.io@evil.example" begins with "https://" but resolves to -// evil.example, and a string prefix test cannot tell the difference. +// The visible prefix of a userinfo URL disagrees with its real authority. test('rejects a link field carrying a userinfo component', () => { - const result = runScriptWithFixtures( - SCRIPT, + const errors = validateFixture( awardsFixture([ { ...validEntry, @@ -171,167 +206,201 @@ test('rejects a link field carrying a userinfo component', () => { }, ]), ); - assert.equal(result.status, 1); - assert.match(result.stderr, /announcementUrl must not carry a userinfo/); - assert.match(result.stderr, /evil\.example/); + assertError(errors, /announcementUrl must not carry a userinfo/); + assertError(errors, /evil\.example/); }); test('rejects a verifiedAgainst carrying a userinfo component', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry], { - verifiedAgainst: 'https://contribute.cncf.io@evil.example/awards/', - }), + assertError( + validateFixture( + awardsFixture([validEntry], { + verifiedAgainst: 'https://contribute.cncf.io@evil.example/awards/', + }), + ), + /verifiedAgainst must not carry a userinfo/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /verifiedAgainst must not carry a userinfo/); }); test('rejects an unparseable value that still begins with https://', () => { for (const announcementUrl of ['https://', 'https:// evil.example']) { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, announcementUrl }]), - ); - assert.equal( - result.status, - 1, - `accepted ${JSON.stringify(announcementUrl)}`, - ); - assert.match( - result.stderr, + assertError( + validateFixture(awardsFixture([{ ...validEntry, announcementUrl }])), /announcementUrl must be an absolute https URL/, ); } }); test('rejects a javascript: URL in an optional link field', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([ - { ...validEntry, caseStudyUrl: 'javascript:alert(document.domain)' }, - ]), + assertError( + validateFixture( + awardsFixture([ + { ...validEntry, caseStudyUrl: 'javascript:alert(document.domain)' }, + ]), + ), + /caseStudyUrl must use https, got javascript:/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /caseStudyUrl must use https, got javascript:/); }); -// The link fields render as on /community/awards. Parsing alone -// admits any https host; the allow-list holds announcements and case studies -// to cncf.io (where winners are verified) and talks to cncf.io or YouTube. test('rejects a link field whose host is outside the allow-list', () => { - for (const [field, url] of [ - ['announcementUrl', 'https://www.cncf.io.evil.example/announcements/x'], - ['caseStudyUrl', 'https://evil.example/case-studies/acme'], - ['talkUrl', 'https://vimeo.example/9'], + for (const [field, url, message] of [ + [ + 'announcementUrl', + 'https://www.cncf.io.evil.example/announcements/x', + /announcementUrl must be on /, + ], + [ + 'caseStudyUrl', + 'https://evil.example/case-studies/acme', + /caseStudyUrl must be on /, + ], + ['talkUrl', 'https://vimeo.example/9', /talkUrl must be on /], ]) { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, [field]: url }]), - ); - assert.equal(result.status, 1, `${field}: ${url} was accepted`); - assert.ok( - result.stderr.includes(field), - `${field}: error did not name the field: ${result.stderr}`, + assertError( + validateFixture(awardsFixture([{ ...validEntry, [field]: url }])), + message, ); } }); test('accepts link fields on cncf.io subdomains and a YouTube talk', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([ - { - ...validEntry, - caseStudyUrl: 'https://www.cncf.io/case-studies/acme/', - talkUrl: 'https://youtu.be/9GIQyQjs36o', - }, - ]), + assert.deepEqual( + validateFixture( + awardsFixture([ + { + ...validEntry, + caseStudyUrl: 'https://www.cncf.io/case-studies/acme/', + talkUrl: 'https://youtu.be/9GIQyQjs36o', + }, + ]), + ), + [], ); - assert.equal(result.status, 0, result.stderr); }); test('rejects a verifiedAgainst outside the CNCF hosts', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([validEntry], { - verifiedAgainst: 'https://evil.example/community/awards/', - }), + assertError( + validateFixture( + awardsFixture([validEntry], { + verifiedAgainst: 'https://evil.example/community/awards/', + }), + ), + /verifiedAgainst/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /verifiedAgainst/); }); test('rejects entries with neither announcementUrl nor talkUrl', () => { const { announcementUrl, ...withoutUrl } = validEntry; - const result = runScriptWithFixtures(SCRIPT, awardsFixture([withoutUrl])); - assert.equal(result.status, 1); - assert.match(result.stderr, /announcementUrl or talkUrl/); + assertError( + validateFixture(awardsFixture([withoutUrl])), + /announcementUrl or talkUrl/, + ); }); test('accepts a talkUrl in place of an announcementUrl', () => { const { announcementUrl, ...rest } = validEntry; - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...rest, talkUrl: 'https://www.youtube.com/watch?v=1' }]), + assert.deepEqual( + validateFixture( + awardsFixture([ + { ...rest, talkUrl: 'https://www.youtube.com/watch?v=1' }, + ]), + ), + [], ); - assert.equal(result.status, 0, result.stderr); }); test('rejects logos outside /img/awards/', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, logo: '/img/other/logo.svg' }]), + assertError( + validateFixture( + awardsFixture([{ ...validEntry, logo: '/img/other/logo.svg' }]), + ), + /\/img\/awards\//, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /\/img\/awards\//); }); test('rejects a logo that escapes /img/awards/ with .. segments', () => { - const result = runScriptWithFixtures( - SCRIPT, + const errors = validateFixture( awardsFixture([ { ...validEntry, logo: '/img/awards/../../../../../etc/hostname' }, ]), ); - assert.equal(result.status, 1); - assert.match(result.stderr, /logo must live under \/img\/awards\//); - assert.doesNotMatch(result.stderr, /logo file missing/); + assertError(errors, /logo must live under \/img\/awards\//); + assert.ok(errors.every((error) => !/logo file missing/.test(error.message))); }); test('rejects a logo that escapes static/ entirely via .. segments', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, logo: '/img/awards/../../package.json' }]), + assertError( + validateFixture( + awardsFixture([ + { ...validEntry, logo: '/img/awards/../../package.json' }, + ]), + ), + /logo must live under \/img\/awards\//, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /logo must live under \/img\/awards\//); }); test('rejects a non-string logo without crashing', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, logo: 42 }]), + assertError( + validateFixture(awardsFixture([{ ...validEntry, logo: 42 }])), + /logo must be a string path/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /logo must be a string path/); - assert.doesNotMatch(result.stderr, /TypeError/); }); test('rejects logo files missing from static/', () => { - const result = runScriptWithFixtures( - SCRIPT, - awardsFixture([{ ...validEntry, logo: '/img/awards/missing.svg' }]), + assertError( + validateFixture( + awardsFixture([{ ...validEntry, logo: '/img/awards/missing.svg' }]), + ), + /logo file missing/, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /logo file missing/); }); test('accepts a logo that exists under static/img/awards/', () => { + assert.deepEqual( + validateFixture({ + ...awardsFixture([{ ...validEntry, logo: '/img/awards/acme.svg' }]), + 'static/img/awards/acme.svg': + '', + }), + [], + ); +}); + +test('the CLI resolves input and logo fixtures and preserves its success output', () => { const result = runScriptWithFixtures(SCRIPT, { ...awardsFixture([{ ...validEntry, logo: '/img/awards/acme.svg' }]), 'static/img/awards/acme.svg': '', }); assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'Validated 1 award entries\n'); + assert.equal(result.stderr, ''); +}); + +test('the CLI preserves validation diagnostics and failure exit status', () => { + const result = runScriptWithFixtures( + SCRIPT, + awardsFixture([{ ...validEntry, year: 2014 }]), + ); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + '\n1 error(s) in awards:\n [error] 2014/acme: invalid year\n', + ); +}); + +test('the CLI reports unreadable input rather than a validation success', () => { + const result = runScriptWithFixtures(SCRIPT); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /ENOENT/); +}); + +test('the CLI reports malformed JSON rather than a validation success', () => { + const result = runScriptWithFixtures(SCRIPT, { + 'data/awards.json': '{ invalid', + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /SyntaxError/); });