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();
+ }
+});