Skip to content
Merged
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
55 changes: 55 additions & 0 deletions scripts/lib/validate-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import fs from 'node:fs';

import { publishableCncfUrl, CNCF_HOST_SUFFIXES } from './url-safety.mjs';

export {
checkUrl,
isSafeUrl,
Expand Down Expand Up @@ -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<Object>} 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<Object>} 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<unknown>} 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<Object>} errors - Array of error objects with { path, severity, message }
Expand Down
17 changes: 4 additions & 13 deletions scripts/validate-case-studies.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import {
checkDuplicateOrMissingId,
checkNonEmptyArray,
checkParseableDate,
checkPublishableSourceUrl,
publishableCncfUrl,
reportAndExit,
CNCF_HOST_SUFFIXES,
Expand Down Expand Up @@ -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',
Expand All @@ -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),
Expand Down
17 changes: 4 additions & 13 deletions scripts/validate-radar-reports.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import {
checkDuplicateOrMissingId,
checkNonEmptyArray,
checkParseableDate,
checkPublishableSourceUrl,
publishableCncfUrl,
reportAndExit,
CNCF_HOST_SUFFIXES,
Expand Down Expand Up @@ -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',
Expand All @@ -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),
Expand Down
53 changes: 53 additions & 0 deletions tests/validate-utils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading