Skip to content
Closed
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"validate:radar-reports": "node scripts/validate-radar-reports.mjs",
"validate:community-people": "node scripts/validate-community-people.mjs",
"validate:community-groups": "node scripts/validate-community-groups.mjs",
"validate:projects-born": "node scripts/validate-projects-born.mjs",
"pr-queue-hygiene": "node scripts/pr-queue-hygiene.mjs",
"test:unit": "TZ=UTC node --test",
"test:unit:coverage": "TZ=UTC node tests/tools/coverage-report.mjs",
Expand Down
105 changes: 105 additions & 0 deletions scripts/validate-projects-born.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { reportAndExit } from './lib/validate-utils.mjs';

// data/projects-born.json is hand-maintained, and src/components/ProjectsBorn
// renders every entry's `url` as an <a href>. That component is mounted in
// src/theme/Footer, so these links ship on every page of the site rather than
// on one section of the homepage.
//
// It was the only data file feeding an <a href> with no validator behind it,
// which left a maintainer skimming a JSON diff as the sole gate. A scheme
// prefix test would not be one either: /^https:\/\// accepts
// "https://www.cncf.io@evil.example/", whose visible prefix and real host
// disagree, and accepts unparseable values such as "https://". Parse the URL
// instead, the same standard checkHttpsUrl() in validate-awards.mjs and
// checkUrl() in validate-metrics.mjs already apply.
//
// No host allow-list: these are legitimately third-party project sites
// (envoyproxy.io, jaegertracing.io, backstage.io), unlike the cncf.io feeds
// guarded by validate-radar-reports.mjs and validate-case-studies.mjs.

const REQUIRED_TEXT_FIELDS = ['name', 'origin', 'description'];

function checkUrl(errors, path, value) {
if (typeof value !== 'string' || !value.trim()) {
errors.push({
path,
severity: 'error',
message: 'url must be a non-empty string',
});
return;
}

let parsed;
try {
parsed = new URL(value.trim());
} catch {
errors.push({
path,
severity: 'error',
message: `url must be an absolute https URL: ${JSON.stringify(value)}`,
});
return;
}

if (parsed.protocol !== 'https:') {
errors.push({
path,
severity: 'error',
message: `url must use https, got ${parsed.protocol}`,
});
return;
}

if (parsed.username || parsed.password) {
errors.push({
path,
severity: 'error',
message: `url must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`,
});
}
}

const data = JSON.parse(
readFileSync(new URL('../data/projects-born.json', import.meta.url)),
);
const errors = [];

if (!Array.isArray(data) || !data.length) {
errors.push({
path: 'projects-born.json',
severity: 'error',
message: 'projects-born.json must be a non-empty array',
});
}

const names = new Set();
for (const entry of Array.isArray(data) ? data : []) {
const path = typeof entry?.name === 'string' ? entry.name : 'unknown';

for (const field of REQUIRED_TEXT_FIELDS) {
const value = entry?.[field];
if (typeof value !== 'string' || !value.trim()) {
errors.push({
path,
severity: 'error',
message: `${field} must be a non-empty string`,
});
}
}

if (typeof entry?.name === 'string' && names.has(entry.name)) {
errors.push({
path,
severity: 'error',
message: 'duplicate name',
});
}
names.add(entry?.name);

checkUrl(errors, path, entry?.url);
}

reportAndExit(errors, 'projects born');
console.log(`Validated ${data.length} born projects`);
166 changes: 166 additions & 0 deletions tests/validate-projects-born.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { runScriptWithFixtures } from './helpers.mjs';

const SCRIPT = 'validate-projects-born.mjs';

const validEntry = {
name: 'Envoy',
origin: 'Lyft',
description: 'Originally built at Lyft before becoming a CNCF project.',
url: 'https://www.envoyproxy.io/',
};

const validData = [
validEntry,
{
name: 'Jaeger',
origin: 'Uber',
description: 'Open sourced by Uber to make distributed tracing practical.',
url: 'https://www.jaegertracing.io/',
},
];

function fixture(data) {
return { 'data/projects-born.json': JSON.stringify(data) };
}

test('accepts a valid projects-born file', () => {
const result = runScriptWithFixtures(SCRIPT, fixture(validData));
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Validated 2 born projects/);
});

test('rejects a file that is not an array', () => {
const result = runScriptWithFixtures(SCRIPT, fixture({ projects: [] }));
assert.equal(result.status, 1);
assert.match(result.stderr, /must be a non-empty array/);
});

test('rejects an empty array', () => {
const result = runScriptWithFixtures(SCRIPT, fixture([]));
assert.equal(result.status, 1);
assert.match(result.stderr, /must be a non-empty array/);
});

// The control this validator exists for: the visible prefix reads as the
// project's own site while the request resolves to the userinfo-suffixed host.
test('rejects a url carrying a userinfo component', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([
{ ...validEntry, url: 'https://www.envoyproxy.io@evil.example/' },
]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /must not carry a userinfo component/);
assert.match(result.stderr, /evil\.example/);
});

test('rejects a non-https url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: 'http://www.envoyproxy.io/' }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /must use https, got http:/);
});

test('rejects a javascript: url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: 'javascript:alert(1)' }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /must use https, got javascript:/);
});

test('rejects an unparseable url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: 'www.envoyproxy.io' }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /must be an absolute https URL/);
});

test('rejects a missing url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: undefined }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /url must be a non-empty string/);
});

test('rejects a blank url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: ' ' }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /url must be a non-empty string/);
});

test('rejects a non-string url', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, url: 42 }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /url must be a non-empty string/);
});

for (const field of ['name', 'origin', 'description']) {
test(`rejects a missing ${field}`, () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, [field]: undefined }]),
);
assert.equal(result.status, 1);
assert.match(
result.stderr,
new RegExp(`${field} must be a non-empty string`),
);
});

test(`rejects a blank ${field}`, () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, [field]: ' ' }]),
);
assert.equal(result.status, 1);
assert.match(
result.stderr,
new RegExp(`${field} must be a non-empty string`),
);
});
}

// A non-string name still has to produce a readable error path rather than
// crashing the report, so the entry is labelled "unknown".
test('labels an entry with a non-string name as unknown', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([{ ...validEntry, name: 7 }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /unknown: name must be a non-empty string/);
});

test('rejects duplicate names', () => {
const result = runScriptWithFixtures(
SCRIPT,
fixture([validEntry, { ...validEntry }]),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /duplicate name/);
});

// A null entry must be reported rather than crash the walk on property access.
test('reports a null entry instead of throwing', () => {
const result = runScriptWithFixtures(SCRIPT, fixture([null]));
assert.equal(result.status, 1);
assert.match(result.stderr, /unknown: name must be a non-empty string/);
assert.match(result.stderr, /unknown: url must be a non-empty string/);
});
1 change: 1 addition & 0 deletions tests/validators-smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const READ_ONLY_VALIDATORS = [
'validate-radar-reports.mjs',
'validate-community-people.mjs',
'validate-community-groups.mjs',
'validate-projects-born.mjs',
];

for (const script of READ_ONLY_VALIDATORS) {
Expand Down