From 66f61c32275ce502f4e59aa06a7a3d6a138c0a4a Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Wed, 23 Sep 2026 15:20:35 -0500 Subject: [PATCH] fix: null-prototype profile bases and radar-reports fallback coverage - PROFILE_BASES no longer inherits from Object.prototype, so an untrusted type such as 'constructor' misses and falls back to the twitter base instead of interpolating an inherited value into an href; the pinned todo test now runs for real (#504) - New tests/radar-reports-fallbacks.test.mjs exercises the missing-generatedAt, missing-radarReports, and missing-publishedAt arms of RadarReports via the importWithData fixture injector (#506) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Bob Killen --- src/lib/profile-links.mjs | 7 +- tests/profile-links.test.mjs | 34 +++---- tests/radar-reports-fallbacks.test.mjs | 122 +++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 tests/radar-reports-fallbacks.test.mjs diff --git a/src/lib/profile-links.mjs b/src/lib/profile-links.mjs index 366ea129..87cfe1f0 100644 --- a/src/lib/profile-links.mjs +++ b/src/lib/profile-links.mjs @@ -10,11 +10,14 @@ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); -const PROFILE_BASES = { +// Null prototype: a lookup keyed by untrusted `type` must miss for anything +// that is not one of the three known networks, including inherited +// Object.prototype members such as `constructor` (see #504). +const PROFILE_BASES = Object.assign(Object.create(null), { github: 'https://github.com/', linkedin: 'https://www.linkedin.com/in/', twitter: 'https://twitter.com/', -}; +}); /** * Builds the profile URL for a social handle. diff --git a/tests/profile-links.test.mjs b/tests/profile-links.test.mjs index ed02315b..b66f2d16 100644 --- a/tests/profile-links.test.mjs +++ b/tests/profile-links.test.mjs @@ -99,28 +99,18 @@ test('the unrecognised-type fallback still percent-encodes the handle', () => { ); }); -// Known defect, tracked in #504: PROFILE_BASES is a plain object literal, so -// a `type` naming an Object.prototype member resolves to that inherited value -// instead of missing and falling back. `PROFILE_BASES.constructor` is truthy, -// so `??` does not fire and the function returns a string that is not a URL at -// all — today `profileUrl('castrojo', 'constructor')` yields -// "function Object() { [native code] }castrojo", which would be rendered -// straight into an href. No caller passes an inherited key today, so this is -// latent rather than exploitable; the fix is a null-prototype map or an -// explicit allow-list in src/lib/profile-links.mjs, which is production code. -test( - 'profileUrl treats an inherited Object.prototype key as unrecognised', - { todo: true }, - () => { - for (const inherited of ['constructor', 'toString', 'valueOf']) { - assert.equal( - profileUrl('castrojo', inherited), - 'https://twitter.com/castrojo', - `type="${inherited}" must not resolve through Object.prototype`, - ); - } - }, -); +// PROFILE_BASES is a null-prototype map precisely so a `type` naming an +// Object.prototype member (e.g. 'constructor') misses and falls back to the +// twitter base instead of resolving to an inherited value (#504). +test('profileUrl treats an inherited Object.prototype key as unrecognised', () => { + for (const inherited of ['constructor', 'toString', 'valueOf']) { + assert.equal( + profileUrl('castrojo', inherited), + 'https://twitter.com/castrojo', + `type="${inherited}" must not resolve through Object.prototype`, + ); + } +}); // websiteUrl guards `!url.hostname` after parsing. For the two protocols it // allows that guard is unreachable: http/https are WHATWG "special" schemes, so diff --git a/tests/radar-reports-fallbacks.test.mjs b/tests/radar-reports-fallbacks.test.mjs new file mode 100644 index 00000000..c89e8554 --- /dev/null +++ b/tests/radar-reports-fallbacks.test.mjs @@ -0,0 +1,122 @@ +// Fallback-branch coverage for src/components/RadarReports/index.js (#506). +// +// The component reads @site/data/radar-reports.json at module scope, and the +// live corpus always has generatedAt, a populated radarReports array, and a +// publishedAt on every report — so the missing-or-empty-corpus arms are +// unreachable from tests/radar-reports.test.mjs, which asserts against the +// checked-in file. These tests inject fixture corpora through +// tests/helpers-component-data.mjs instead; the live-corpus assertions stay +// where they are. + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { importWithData } from './helpers-component-data.mjs'; +import { + findAllByType, + findByType, + textOf, + walkElements, +} from './tools/react-element-tree.mjs'; + +const SOURCE = 'src/components/RadarReports/index.js'; +const DATA_SPECIFIER = '@site/data/radar-reports.json'; + +const REPORT = { + id: 'radar-2024-01', + title: 'Technology Radar: Example', + url: 'https://www.cncf.io/reports/example/', + summary: 'An example report.', + publishedAt: '2024-01-15T00:00:00.000Z', +}; + +/** Renders the nested element, or null when it returns null. */ +function renderSyncStatus(tree) { + const element = [...walkElements(tree)].find( + (node) => + typeof node.type === 'function' && node.type.name === 'SyncStatus', + ); + assert.ok(element, 'expected the component to render a '); + return element.type(element.props); +} + +test('no generatedAt renders no provenance line while the list still renders', async () => { + const { default: RadarReports, cleanup } = await importWithData(SOURCE, { + [DATA_SPECIFIER]: { + sourceUrl: 'https://www.cncf.io/reports/', + radarReports: [REPORT], + }, + }); + try { + const tree = RadarReports(); + assert.equal(renderSyncStatus(tree), null); + assert.ok(!textOf(tree).includes('Mirrored from')); + assert.ok(!textOf(tree).includes('Invalid Date')); + assert.equal(findAllByType(tree, 'li').length, 1); + } finally { + cleanup(); + } +}); + +test('a corpus with no radarReports key renders an empty labelled list without throwing', async () => { + const { default: RadarReports, cleanup } = await importWithData(SOURCE, { + [DATA_SPECIFIER]: {}, + }); + try { + const tree = RadarReports(); + assert.equal(tree.type, 'section'); + assert.equal(tree.props['aria-label'], 'CNCF Technology Radar reports'); + assert.ok(findByType(tree, 'ul'), 'expected the empty list to render'); + assert.equal(findAllByType(tree, 'li').length, 0); + } finally { + cleanup(); + } +}); + +test('a report without publishedAt renders no date row while a dated sibling keeps its date', async () => { + const undated = { ...REPORT, id: 'radar-undated', publishedAt: undefined }; + const { default: RadarReports, cleanup } = await importWithData(SOURCE, { + [DATA_SPECIFIER]: { + generatedAt: '2024-02-01T00:00:00.000Z', + sourceUrl: 'https://www.cncf.io/reports/', + radarReports: [undated, REPORT], + }, + }); + try { + const [first, second] = findAllByType(RadarReports(), 'li'); + const expected = new Date(REPORT.publishedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + assert.ok(!textOf(first).includes(expected)); + assert.ok(!textOf(first).includes('Invalid Date')); + assert.ok(textOf(second).includes(expected)); + } finally { + cleanup(); + } +}); + +test('the fixture path renders the same provenance contract as the live corpus', async () => { + const generatedAt = '2024-02-01T00:00:00.000Z'; + const sourceUrl = 'https://www.cncf.io/reports/'; + const { default: RadarReports, cleanup } = await importWithData(SOURCE, { + [DATA_SPECIFIER]: { generatedAt, sourceUrl, radarReports: [REPORT] }, + }); + try { + const status = renderSyncStatus(RadarReports()); + assert.ok(status, 'expected a provenance line'); + const anchor = findByType(status, 'a'); + assert.equal(anchor.props.href, sourceUrl); + assert.equal(anchor.props.target, '_blank'); + assert.match(anchor.props.rel, /noreferrer/); + const expected = new Date(generatedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + assert.ok(textOf(status).includes(expected)); + } finally { + cleanup(); + } +});