diff --git a/scripts/lib/validate-utils.mjs b/scripts/lib/validate-utils.mjs index 78bc4897..8375b22f 100644 --- a/scripts/lib/validate-utils.mjs +++ b/scripts/lib/validate-utils.mjs @@ -12,6 +12,8 @@ import fs from 'node:fs'; +import { publishableCncfUrl, CNCF_HOST_SUFFIXES } from './url-safety.mjs'; + export { checkUrl, isSafeUrl, @@ -239,6 +241,59 @@ export function checkNonEmptyString(errors, path, field, value) { return ok; } +/** + * Push a "sourceUrl must be an https URL..." error when `value` is not a + * CNCF-hosted, userinfo-free https URL. + * + * validate-case-studies.mjs and validate-radar-reports.mjs each carried an + * independent copy of this top-of-file `sourceUrl` gate with an identically + * worded message — the same centralize-after-drift precedent already + * applied here to checkNonEmptyArray() and checkParseableDate(). + * + * @param {Array} errors - Array to accumulate errors. + * @param {string} path - File path or identifier used as the error's path. + * @param {unknown} value - Candidate sourceUrl. + * @returns {boolean} Whether `value` passed the check. + */ +export function checkPublishableSourceUrl(errors, path, value) { + const ok = Boolean(value) && Boolean(publishableCncfUrl(value)); + if (!ok) { + errors.push({ + path, + severity: 'error', + message: `sourceUrl must be an https URL, with no userinfo, on ${CNCF_HOST_SUFFIXES.join(' or ')}`, + }); + } + return ok; +} + +/** + * Push a "duplicate or missing id" error when `id` is falsy or already in + * `seen`, then record `id` in `seen` regardless. + * + * validate-case-studies.mjs and validate-radar-reports.mjs each carried an + * independent copy of this per-entry id-dedup check, byte-identical apart + * from the `errors`/`ids` variable names. + * + * @param {Array} errors - Array to accumulate errors. + * @param {string} path - File path or identifier used as the error's path. + * @param {unknown} id - Candidate entry id (entry.id, not a display fallback). + * @param {Set} seen - Set of ids already encountered; mutated. + * @returns {boolean} Whether `id` passed the check. + */ +export function checkDuplicateOrMissingId(errors, path, id, seen) { + const ok = Boolean(id) && !seen.has(id); + if (!ok) { + errors.push({ + path, + severity: 'error', + message: 'duplicate or missing id', + }); + } + seen.add(id); + return ok; +} + /** * Report collected errors and exit if any errors exist. * @param {Array} errors - Array of error objects with { path, severity, message } diff --git a/scripts/validate-case-studies.mjs b/scripts/validate-case-studies.mjs index cea4deca..c99968f6 100644 --- a/scripts/validate-case-studies.mjs +++ b/scripts/validate-case-studies.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; import { + checkDuplicateOrMissingId, checkNonEmptyArray, checkParseableDate, + checkPublishableSourceUrl, publishableCncfUrl, reportAndExit, CNCF_HOST_SUFFIXES, @@ -30,12 +32,7 @@ checkParseableDate( 'generatedAt', data.generatedAt, ); -if (!data.sourceUrl || !publishableCncfUrl(data.sourceUrl)) - errors.push({ - path: 'case-studies.json', - severity: 'error', - message: `sourceUrl must be an https URL, with no userinfo, on ${CNCF_HOST_SUFFIXES.join(' or ')}`, - }); +checkPublishableSourceUrl(errors, 'case-studies.json', data.sourceUrl); checkNonEmptyArray( errors, 'case-studies.json', @@ -47,13 +44,7 @@ const ids = new Set(); const urls = new Set(); for (const entry of Array.isArray(data.caseStudies) ? data.caseStudies : []) { const id = entry.id ?? entry.slug ?? 'unknown'; - if (!entry.id || ids.has(entry.id)) - errors.push({ - path: String(id), - severity: 'error', - message: 'duplicate or missing id', - }); - ids.add(entry.id); + checkDuplicateOrMissingId(errors, String(id), entry.id, ids); if (!entry.organization) errors.push({ path: String(id), diff --git a/scripts/validate-radar-reports.mjs b/scripts/validate-radar-reports.mjs index 391e9e5e..e7368cd8 100644 --- a/scripts/validate-radar-reports.mjs +++ b/scripts/validate-radar-reports.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; import { + checkDuplicateOrMissingId, checkNonEmptyArray, checkParseableDate, + checkPublishableSourceUrl, publishableCncfUrl, reportAndExit, CNCF_HOST_SUFFIXES, @@ -31,12 +33,7 @@ checkParseableDate( 'generatedAt', data.generatedAt, ); -if (!data.sourceUrl || !publishableCncfUrl(data.sourceUrl)) - errors.push({ - path: 'radar-reports.json', - severity: 'error', - message: `sourceUrl must be an https URL, with no userinfo, on ${CNCF_HOST_SUFFIXES.join(' or ')}`, - }); +checkPublishableSourceUrl(errors, 'radar-reports.json', data.sourceUrl); checkNonEmptyArray( errors, 'radar-reports.json', @@ -47,13 +44,7 @@ checkNonEmptyArray( const ids = new Set(); for (const entry of Array.isArray(data.radarReports) ? data.radarReports : []) { const id = entry.id ?? entry.slug ?? 'unknown'; - if (!entry.id || ids.has(entry.id)) - errors.push({ - path: String(id), - severity: 'error', - message: 'duplicate or missing id', - }); - ids.add(entry.id); + checkDuplicateOrMissingId(errors, String(id), entry.id, ids); if (!entry.title) errors.push({ path: String(id), diff --git a/tests/validate-utils.test.mjs b/tests/validate-utils.test.mjs index 1368b4f8..8fd2a71c 100644 --- a/tests/validate-utils.test.mjs +++ b/tests/validate-utils.test.mjs @@ -4,10 +4,12 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { + checkDuplicateOrMissingId, checkIsoTimestamp, checkNonEmptyArray, checkNonEmptyString, checkParseableDate, + checkPublishableSourceUrl, checkUrl, collectError, isSafeUrl, @@ -563,6 +565,57 @@ test('checkNonEmptyArray reports an empty array, a non-array and a missing value } }); +test('checkPublishableSourceUrl accepts a trimmed https URL on a CNCF host and pushes no error', () => { + const errors = []; + const ok = checkPublishableSourceUrl( + errors, + 'case-studies.json', + 'https://www.cncf.io/case-studies/', + ); + assert.equal(ok, true); + assert.deepEqual(errors, []); +}); + +test('checkPublishableSourceUrl reports a missing or off-list sourceUrl', () => { + for (const value of [undefined, null, '', 'https://evil.example/']) { + const errors = []; + const ok = checkPublishableSourceUrl(errors, 'radar-reports.json', value); + assert.equal(ok, false, String(value)); + assert.deepEqual(errors, [ + { + path: 'radar-reports.json', + severity: 'error', + message: 'sourceUrl must be an https URL, with no userinfo, on cncf.io', + }, + ]); + } +}); + +test('checkDuplicateOrMissingId accepts a new truthy id and records it as seen', () => { + const errors = []; + const seen = new Set(); + const ok = checkDuplicateOrMissingId(errors, 'swisscom', 'swisscom', seen); + assert.equal(ok, true); + assert.deepEqual(errors, []); + assert.equal(seen.has('swisscom'), true); +}); + +test('checkDuplicateOrMissingId reports a missing id and a repeat of one already seen', () => { + const seen = new Set(['swisscom']); + for (const id of [undefined, null, '', 'swisscom']) { + const errors = []; + const ok = checkDuplicateOrMissingId(errors, 'display-path', id, seen); + assert.equal(ok, false, String(id)); + assert.deepEqual(errors, [ + { + path: 'display-path', + severity: 'error', + message: 'duplicate or missing id', + }, + ]); + } +}); + test('checkIsoTimestamp accepts a strict ISO 8601 UTC value and pushes no error', () => { const errors = []; const ok = checkIsoTimestamp(