From 7f451f16a6bff0dc67cb148e7d35fd4ec9c2f54d Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:39:07 -0400 Subject: [PATCH] fix: handle edge cases in ranges, translate, parsefiles, and fetching - translate: reject names inherited from Object.prototype and return a copy - ranges: fall back to the other lts range for lts/active's bounds when one is empty - parsefiles: list only the id's own directory for win-*-exe, and parse osx-x86-tar - core, fetchindex: throw with the URL and status on a non-OK response - ci: use --workspaces, since npm 12 rejects -ws --- .github/workflows/ci.yml | 2 +- .github/workflows/nightly-static-build.yaml | 2 +- core/test/schedule.js | 24 ++- core/test/versions.js | 23 ++- core/util/prod/schedule.js | 5 + core/util/prod/versions.js | 5 + docs/pages/ranges.md | 10 +- fetchindex/index.js | 5 + fetchindex/test/test.js | 23 ++- parsefiles/index.js | 25 +-- parsefiles/test/data/expected.json | 10 +- parsefiles/test/parseFilename.test.js | 49 +++++ ranges/index.js | 211 ++++++-------------- ranges/test/bounds.test.js | 109 ++++++++++ translate/index.js | 7 +- translate/test/translate.test.js | 28 +++ 16 files changed, 349 insertions(+), 189 deletions(-) create mode 100644 parsefiles/test/parseFilename.test.js create mode 100644 ranges/test/bounds.test.js create mode 100644 translate/test/translate.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdec5d6..c578ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: node-version: current # the packages import each other, so link the workspaces - name: Run npm install - run: npm install -ws --include-workspace-root + run: npm install --workspaces --include-workspace-root - name: Build the types run: npm run types - name: Check the committed types match the source diff --git a/.github/workflows/nightly-static-build.yaml b/.github/workflows/nightly-static-build.yaml index 7524118..67bfd43 100644 --- a/.github/workflows/nightly-static-build.yaml +++ b/.github/workflows/nightly-static-build.yaml @@ -22,7 +22,7 @@ jobs: with: node-version: latest - name: Run npm install - run: npm install -ws --include-workspace-root + run: npm install --workspaces --include-workspace-root - name: Run build:default run: npm run build:default -w static - name: Run build:releases diff --git a/core/test/schedule.js b/core/test/schedule.js index b0491dd..1cfad7d 100644 --- a/core/test/schedule.js +++ b/core/test/schedule.js @@ -1,4 +1,4 @@ -const { match } = require('node:assert'); +const { match, rejects } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const schedule = require('../util/prod/schedule'); @@ -56,3 +56,25 @@ describe('schedule should work with optionsParser', async () => { check(data); }); }); + +describe('schedule', async () => { + it('should reject naming the URL and status when the response is not ok', async () => { + // Arrange + const url = + 'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json'; + + // Act + const result = schedule({ fetch: notFound, urls: { schedule: url } }); + + // Assert + await rejects(result, { + message: `Fetching ${url} failed: 404 Not Found`, + }); + }); +}); + +// Helpers + +async function notFound() { + return new Response('Not Found', { status: 404, statusText: 'Not Found' }); +} diff --git a/core/test/versions.js b/core/test/versions.js index 8f38b4b..b72fe3f 100644 --- a/core/test/versions.js +++ b/core/test/versions.js @@ -1,4 +1,4 @@ -const { deepStrictEqual, match } = require('node:assert'); +const { deepStrictEqual, match, rejects } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const versions = require('../util/prod/versions'); @@ -61,3 +61,24 @@ describe('versions should work with optionsParser', async () => { check(data); }); }); + +describe('versions', async () => { + it('should reject naming the URL and status when the response is not ok', async () => { + // Arrange + const url = 'https://nodejs.org/dist/index.json'; + + // Act + const result = versions({ fetch: notFound, urls: { index: url } }); + + // Assert + await rejects(result, { + message: `Fetching ${url} failed: 404 Not Found`, + }); + }); +}); + +// Helpers + +async function notFound() { + return new Response('Not Found', { status: 404, statusText: 'Not Found' }); +} diff --git a/core/util/prod/schedule.js b/core/util/prod/schedule.js index bc47c99..4f3a46b 100644 --- a/core/util/prod/schedule.js +++ b/core/util/prod/schedule.js @@ -22,6 +22,11 @@ async function schedule(options) { // fetch the url, get the json from the fetched URL that we're going to use const raw = await fetch(url); + + if (!raw.ok) { + throw new Error(`Fetching ${url} failed: ${raw.status} ${raw.statusText}`); + } + const schedule = await raw.json(); return schedule; diff --git a/core/util/prod/versions.js b/core/util/prod/versions.js index 38f0b9a..f6bce85 100644 --- a/core/util/prod/versions.js +++ b/core/util/prod/versions.js @@ -26,6 +26,11 @@ async function versions(options) { // fetch the url, get the json from the fetched URL that we're going to use const raw = await fetch(url); + + if (!raw.ok) { + throw new Error(`Fetching ${url} failed: ${raw.status} ${raw.statusText}`); + } + const versions = await raw.json(); return versions; diff --git a/docs/pages/ranges.md b/docs/pages/ranges.md index c2bf694..1af7fb4 100644 --- a/docs/pages/ranges.md +++ b/docs/pages/ranges.md @@ -94,10 +94,12 @@ The releases in `versions`, and `newest`, `oldest`, `newestLts`, and - Type: {Object} - `versions` {string[]} The releases of `lts/latest`, then those of `lts/maintenance`. - - `oldestLts` {string} The `oldestLts` of `lts/maintenance`. - - `oldestSecurity` {string} The `oldestSecurity` of `lts/maintenance`. - - `newestLts` {string} The `newestLts` of `lts/latest`. - - `newestSecurity` {string} The `newestSecurity` of `lts/latest`. + - `oldestLts` {string} The first LTS release of the oldest line, which is + in `lts/maintenance` unless that's empty. + - `oldestSecurity` {string} The oldest security release of the oldest line. + - `newestLts` {string} The newest LTS release of the newest line, which is + in `lts/latest` unless that's empty. + - `newestSecurity` {string} The newest security release of the newest line. - `newest` {string} The newest release. - `oldest` {string} The oldest release. diff --git a/fetchindex/index.js b/fetchindex/index.js index 26fc1a3..bd7e681 100644 --- a/fetchindex/index.js +++ b/fetchindex/index.js @@ -30,6 +30,11 @@ async function versions(options) { // fetch the url, get the json from the fetched URL that we're going to use const raw = await fetch(url); + + if (!raw.ok) { + throw new Error(`Fetching ${url} failed: ${raw.status} ${raw.statusText}`); + } + const versions = await raw.json(); return versions; diff --git a/fetchindex/test/test.js b/fetchindex/test/test.js index 325421f..22b85f1 100644 --- a/fetchindex/test/test.js +++ b/fetchindex/test/test.js @@ -1,4 +1,4 @@ -const { deepStrictEqual, match } = require('node:assert'); +const { deepStrictEqual, match, rejects } = require('node:assert'); const { describe, it } = require('node:test'); const { fetch: undiciFetch } = require('undici'); const fetchindex = require('../index'); @@ -61,3 +61,24 @@ describe('versions should work with optionsParser', async () => { check(data); }); }); + +describe('fetchindex', async () => { + it('should reject naming the URL and status when the response is not ok', async () => { + // Arrange + const url = 'https://nodejs.org/dist/index.json'; + + // Act + const result = fetchindex({ fetch: notFound, urls: { index: url } }); + + // Assert + await rejects(result, { + message: `Fetching ${url} failed: 404 Not Found`, + }); + }); +}); + +// Helpers + +async function notFound() { + return new Response('Not Found', { status: 404, statusText: 'Not Found' }); +} diff --git a/parsefiles/index.js b/parsefiles/index.js index 68f61ab..0d27044 100644 --- a/parsefiles/index.js +++ b/parsefiles/index.js @@ -40,15 +40,9 @@ function parseFilename(file, version) { info.files[0] = `node-v${version}.pkg`; } else if (info.files[0].includes('osx-')) { // macOS filenames, but with architectures. Different than the above. - const basemacOSString = `node-v${version}-darwin-`; - - if (info.files[0].includes('arm64')) { - info.files[0] = basemacOSString.concat('arm64.tar.gz'); - } - - if (info.files[0].includes('x64')) { - info.files[0] = basemacOSString.concat('x64.tar.gz'); - } + info.files[0] = `node-v${version}-${info.files[0] + .replace('osx-', 'darwin-') + .replace('-tar', '.tar.gz')}`; } else if (info.files[0].includes('-7z')) { // windows filenames info.files[0] = `node-v${version}-${info.files[0].replace('-7z', '.7z')}`; @@ -59,14 +53,11 @@ function parseFilename(file, version) { .replace('-msi', '.msi') .replace('win-', '')}`; } else if (info.files[0].includes('-exe')) { - info.files[0] = 'win-x64/node.exe'; - info.files.push('win-x64/node.lib'); - info.files.push('win-x64/node_pdb.7z'); - info.files.push('win-x64/node_pdb.zip'); - info.files.push('win-x86/node.exe'); - info.files.push('win-x86/node.lib'); - info.files.push('win-x86/node_pdb.7z'); - info.files.push('win-x86/node_pdb.zip'); + // windows executables, in a directory named after the architecture + const directory = info.files[0].replace('-exe', ''); + info.files = ['node.exe', 'node.lib', 'node_pdb.7z', 'node_pdb.zip'].map( + (name) => `${directory}/${name}`, + ); } else if (info.files[0] === 'src') { // source filenames. info.files[0] = `node-v${version}.tar.gz`; diff --git a/parsefiles/test/data/expected.json b/parsefiles/test/data/expected.json index 28b9358..21ae161 100644 --- a/parsefiles/test/data/expected.json +++ b/parsefiles/test/data/expected.json @@ -87,11 +87,7 @@ "win-x64/node.exe", "win-x64/node.lib", "win-x64/node_pdb.7z", - "win-x64/node_pdb.zip", - "win-x86/node.exe", - "win-x86/node.lib", - "win-x86/node_pdb.7z", - "win-x86/node_pdb.zip" + "win-x64/node_pdb.zip" ], "type": "windows", "architecture": "x64" @@ -117,10 +113,6 @@ { "id": "win-x86-exe", "files": [ - "win-x64/node.exe", - "win-x64/node.lib", - "win-x64/node_pdb.7z", - "win-x64/node_pdb.zip", "win-x86/node.exe", "win-x86/node.lib", "win-x86/node_pdb.7z", diff --git a/parsefiles/test/parseFilename.test.js b/parsefiles/test/parseFilename.test.js new file mode 100644 index 0000000..1720b3b --- /dev/null +++ b/parsefiles/test/parseFilename.test.js @@ -0,0 +1,49 @@ +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +const parseFilename = require('../index'); + +describe('parseFilename', () => { + it('should only list the win-x86 directory for win-x86-exe', () => { + // Arrange + const id = 'win-x86-exe'; + + // Act + const result = parseFilename(id, '4.0.0'); + + // Assert + assert.deepStrictEqual(result.files, [ + 'win-x86/node.exe', + 'win-x86/node.lib', + 'win-x86/node_pdb.7z', + 'win-x86/node_pdb.zip', + ]); + }); + + it('should only list the win-x64 directory for win-x64-exe', () => { + // Arrange + const id = 'win-x64-exe'; + + // Act + const result = parseFilename(id, '4.0.0'); + + // Assert + assert.deepStrictEqual(result.files, [ + 'win-x64/node.exe', + 'win-x64/node.lib', + 'win-x64/node_pdb.7z', + 'win-x64/node_pdb.zip', + ]); + }); + + it('should name the darwin-x86 tarball for osx-x86-tar', () => { + // Arrange + const id = 'osx-x86-tar'; + + // Act + const result = parseFilename(id, '4.0.0'); + + // Assert + assert.deepStrictEqual(result.files, ['node-v4.0.0-darwin-x86.tar.gz']); + }); +}); diff --git a/ranges/index.js b/ranges/index.js index 4d28827..402c0ae 100644 --- a/ranges/index.js +++ b/ranges/index.js @@ -58,15 +58,6 @@ const defaultRangesObject = { }, }; -// describing this early so it doesn't need to be commented everywhere: -// filter can accept an array or a string. Each object's building phase -// will check to make sure that the filter is valid - either as a string -// or as an array. If it's not, do nothing and move on. That specific -// string/filter logic is contained in if/else statements that get a bit -// verbose but JavaScript doesn't really have a less verbose way to do it. -// If you figure one out, or there's one in the future, please feel free -// to submit a PR to update this code. - /** * A range of versions. The fields a range doesn't have, or has nothing to * point to, are `undefined`. @@ -121,8 +112,15 @@ async function generateRanges(filter, options) { localFilter = 'all'; } - // set up the data from @nodevu/core, passing options (like `fetch` and `now`) through + // a string filter is checked as a one-item list, since `includes` on a + // string matches substrings + /** @type {readonly string[]} */ + const requested = [localFilter].flat(); + + const wants = (/** @type {string} */ alias) => + requested.includes(alias) || requested.includes('all'); + // set up the data from @nodevu/core, passing options (like `fetch` and `now`) through const data = await nodevu(options); // deep clone the default object so we don't modify it. @@ -133,185 +131,94 @@ async function generateRanges(filter, options) { const ranges = JSON.parse(JSON.stringify(defaultRangesObject)); for (const line in data) { + const phase = data[line].support?.phases.current; + for (const key in data[line].releases) { + const release = data[line].releases[key]; + // Alpha releases (`vN.0.0-alpha.X.Y.Z`) are pre-releases, so they only // go into `alpha` and are kept out of every other range - if (data[line].releases[key].alpha?.isAlpha) { - if ( - (localFilter === 'alpha' || - localFilter === 'all' || - localFilter.includes('alpha') || - localFilter.includes('all')) && - data[line].support?.phases.current === 'alpha' - ) { + if (release.alpha?.isAlpha) { + if (wants('alpha') && phase === 'alpha') { ranges.alpha.versions.push(key); } continue; } - // define `all` data - if (localFilter === 'all' || localFilter.includes('all')) { - ranges.all.versions.push(key); // put every non-Alpha release into the all array + // define `all` data, where the newest LTS and security releases are the + // first ones seen + if (wants('all')) { + ranges.all.versions.push(key); - if ( - data[line].releases[key].lts.isLts === true && - ranges.all.newestLts === undefined - ) { - ranges.all.newestLts = `${key}`; // if there's no entries yet and this iteration is an LTS release, push it into the array + if (release.lts.isLts) { + ranges.all.newestLts ??= key; } - if ( - data[line].releases[key].security.isSecurity === true && - ranges.all.newestSecurity === undefined - ) { - ranges.all.newestSecurity = `${key}`; // if there's no entries yet and this iteration is an LTS release, push it into the array + if (release.security.isSecurity) { + ranges.all.newestSecurity ??= key; } } - // define 'current' data - if ( - localFilter === 'current' || - localFilter === 'all' || - localFilter.includes('current') || - localFilter.includes('all') - ) { - if (data[line].support?.phases.current === 'start') { - ranges.current.versions.push(key); // add current iteration to the versions array if the phase is `start` which means it'll be current. + // the phase `start` means the line is Current + if (wants('current') && phase === 'start') { + ranges.current.versions.push(key); - // only the first Current line fills these in. checking the versions - // count rather than the fields, since a line without a security - // release leaves them undefined. - if (ranges.current.versions.length === 1) { - ranges.current.oldestSecurity = data[line].security.oldest; - ranges.current.newestSecurity = data[line].security.newest; - } + // only the first Current line fills these in. checking the versions + // count rather than the fields, since a line without a security + // release leaves them undefined. + if (ranges.current.versions.length === 1) { + ranges.current.oldestSecurity = data[line].security.oldest; + ranges.current.newestSecurity = data[line].security.newest; } } - // define 'lts/latest' data - if ( - localFilter === 'lts/latest' || - localFilter === 'lts/active' || - localFilter === 'all' || - localFilter.includes('lts/latest') || - localFilter.includes('lts/active') || - localFilter.includes('all') - ) { - if ( - data[line].support?.phases.current === 'lts' && - data[line].releases[key].lts.isLts - ) { - // TODO: do we want to include all versions in the release line, even prior to it being minted LTS? - ranges['lts/latest'].versions.push(key); + // lts/active is every release in lts/latest and lts/maintenance + // TODO: do we want to include all versions in the release line, even prior to it being minted LTS? + if (release.lts.isLts && (phase === 'lts' || phase === 'maintenance')) { + const phaseRange = phase === 'lts' ? 'lts/latest' : 'lts/maintenance'; - if (ranges['lts/latest'].versions.length === 1) { - // only the first LTS line fills these in, same as 'current' - ranges['lts/latest'].oldestSecurity = data[line].security.oldest; - ranges['lts/latest'].newestSecurity = data[line].security.newest; - ranges['lts/latest'].oldestLts = data[line].support.lts?.oldest; - ranges['lts/latest'].newestLts = data[line].support.lts?.newest; + for (const alias of [phaseRange, 'lts/active']) { + if (!wants(alias)) { + continue; } - } - } - // define 'lts/maintenance' data - if ( - localFilter === 'lts/maintenance' || - localFilter === 'lts/active' || - localFilter === 'all' || - localFilter.includes('lts/maintenance') || - localFilter.includes('lts/active') || - localFilter.includes('all') - ) { - if ( - data[line].support?.phases.current === 'maintenance' && - data[line].releases[key].lts.isLts - ) { - ranges['lts/maintenance'].versions.push(key); + const range = ranges[alias]; + const isFirstRelease = range.versions.push(key) === 1; - if (ranges['lts/maintenance'].versions.length === 1) { - ranges['lts/maintenance'].newestSecurity = - data[line].security.newest; - ranges['lts/maintenance'].newestLts = - data[line].support.lts?.newest; + // the newest bounds come from the range's first line, and the oldest + // from its last. lts/latest only takes its first line's, same as + // 'current'. + if (isFirstRelease) { + range.newestSecurity = data[line].security.newest; + range.newestLts = data[line].support?.lts?.newest; } - if ( - ranges['lts/maintenance'].oldestSecurity !== - data[line].security.oldest || - ranges['lts/maintenance'].oldestLts !== - data[line].support.lts?.oldest - ) { - ranges['lts/maintenance'].oldestSecurity = - data[line].security.oldest; - ranges['lts/maintenance'].oldestLts = - data[line].support.lts?.oldest; + if (isFirstRelease || alias !== 'lts/latest') { + range.oldestSecurity = data[line].security.oldest; + range.oldestLts = data[line].support?.lts?.oldest; } } } - // define 'eol' data - if ( - localFilter === 'eol' || - localFilter === 'all' || - localFilter.includes('eol') || - localFilter.includes('all') - ) { - if (data[line].support?.phases.current === 'end') { - ranges.eol.versions.push(key); - } + if (wants('eol') && phase === 'end') { + ranges.eol.versions.push(key); } } } - // define lts/active data - // - // doesn't need to be in the loops, since it's basically just a union - // of lts/latest and lts/maintenance - if ( - localFilter === 'lts/active' || - localFilter === 'all' || - localFilter.includes('lts/active') || - localFilter.includes('all') - ) { - ranges['lts/active'].versions = ranges['lts/latest'].versions.concat( - ranges['lts/maintenance'].versions, - ); - ranges['lts/active'].oldestLts = ranges['lts/maintenance'].oldestLts; // the maintenance LTS is the older LTS - ranges['lts/active'].newestLts = ranges['lts/latest'].newestLts; // the latest LTS is the newer LTS - ranges['lts/active'].oldestSecurity = - ranges['lts/maintenance'].oldestSecurity; // the maintenance security is the older security - ranges['lts/active'].newestSecurity = ranges['lts/latest'].newestSecurity; // the latest security is the newer security - } - - // some quick maths for dynamically defining newest and oldest, regardless of what ranges we have. + // define newest and oldest from each range's versions, and delete the + // ranges with none for (const alias in ranges) { - ranges[alias].newest = ranges[alias].versions[0]; // set the newest version to the first version in the array - ranges[alias].oldest = - ranges[alias].versions[ranges[alias].versions.length - 1]; // set the oldest version to the last version in the array - } + const { versions } = ranges[alias]; - // delete what we don't need - for (const property in ranges) { - if (ranges[property].versions.length === 0) { - delete ranges[property]; + if (versions.length === 0) { + delete ranges[alias]; + continue; } - } - // make sure we remove any unrequested remnants from lts/active construction. - // a string filter is checked as a one-item list, since `includes` on a - // string matches substrings - const requested = [localFilter].flat(); - - if (requested.includes('lts/active') && !requested.includes('all')) { - if (!requested.includes('lts/latest')) { - delete ranges['lts/latest']; - } - - if (!requested.includes('lts/maintenance')) { - delete ranges['lts/maintenance']; - } + ranges[alias].newest = versions[0]; + ranges[alias].oldest = versions.at(-1); } return ranges; diff --git a/ranges/test/bounds.test.js b/ranges/test/bounds.test.js new file mode 100644 index 0000000..24a7d81 --- /dev/null +++ b/ranges/test/bounds.test.js @@ -0,0 +1,109 @@ +const { deepStrictEqual } = require('node:assert'); +const ranges = require('../index'); +const fixtures = require('./fixtures'); +const { describe, it } = require('node:test'); + +// the LTS and security bounds of lts/maintenance and lts/active come from the +// newest and oldest lines in them, even when those lines have none. the +// expected values come from core's index.json and schedule.json snapshots. +describe('generateRanges', () => { + it('should take the newest bounds of "lts/active" from "lts/maintenance" when "lts/latest" is empty', async () => { + // Arrange + // v20 went into maintenance on 2024-10-22 and v22 goes LTS on 2024-10-29, + // so no line is in its LTS phase + const options = fixtures.at('2024-10-23'); + + // Act + const data = await ranges('all', options); + + // Assert + deepStrictEqual(data['lts/latest'], undefined); + deepStrictEqual(data['lts/active'].newestLts, '20.18.0'); + deepStrictEqual(data['lts/active'].newestSecurity, '20.15.1'); + }); + + it('should take the oldest bounds of "lts/active" from "lts/latest" when "lts/maintenance" is empty', async () => { + // Arrange + // v4 is the first LTS line, minted on 2015-10-12 + const options = fixtures.at('2015-10-12'); + + // Act + const data = await ranges('all', options); + + // Assert + deepStrictEqual(data['lts/maintenance'], undefined); + deepStrictEqual(data['lts/active'].oldestLts, '4.2.0'); + }); + + it('should take the newest bounds of "lts/maintenance" from its newest line and the oldest from its oldest', async () => { + // Arrange + // v20 and v18 are both in maintenance + const options = fixtures.at('2024-10-23'); + + // Act + const data = await ranges('lts/maintenance', options); + + // Assert + deepStrictEqual(summarize(data['lts/maintenance']), { + newest: 'v20.18.0', + oldest: 'v18.12.0', + newestLts: '20.18.0', + oldestLts: '18.12.0', + newestSecurity: '20.15.1', + oldestSecurity: '18.5.0', + }); + }); + + it('should leave out the newest security bound of "lts/maintenance" when its newest line has none', async () => { + // Arrange + // made-up data, since the real data has no maintenance line without a + // security release: both lines are in maintenance and only v98 has one + const index = [ + release('v99.1.0', '2099-11-01', 'Placeholder'), + release('v98.2.0', '2098-12-01', 'Placeholder', true), + release('v98.1.0', '2098-11-01', 'Placeholder', true), + ]; + + const schedule = { + v98: line('2098'), + v99: line('2099'), + }; + + const options = fixtures.at('2102-01-01', { index, schedule }); + + // Act + const data = await ranges('lts/maintenance', options); + + // Assert + deepStrictEqual(data['lts/maintenance'].versions, [ + 'v99.1.0', + 'v98.2.0', + 'v98.1.0', + ]); + deepStrictEqual(data['lts/maintenance'].newestSecurity, undefined); + deepStrictEqual(data['lts/maintenance'].oldestSecurity, '98.1.0'); + }); +}); + +// Helpers + +// a range without its versions, to compare its bounds +function summarize({ versions, ...range }) { + return range; +} + +// an index.json entry +function release(version, date, lts = false, security = false) { + return { version, date, lts, security, files: ['linux-x64', 'src'] }; +} + +// a schedule.json entry for a line that's in maintenance from 2101 to 2103 +function line(year) { + return { + start: `${year}-04-20`, + lts: `${year}-10-20`, + maintenance: '2101-01-01', + end: '2103-04-30', + codename: '', + }; +} diff --git a/translate/index.js b/translate/index.js index 8dcd2f8..7445a3c 100644 --- a/translate/index.js +++ b/translate/index.js @@ -16,11 +16,14 @@ const translations = /** @type {const} */ ({ * @returns {Promise<(typeof translations)[Term]>} */ async function translate(legacyName) { - if (!translations[legacyName]) { + // an own-property check, so names like `toString` don't resolve to + // Object.prototype's members + if (!Object.hasOwn(translations, legacyName)) { throw new Error(`Unknown value: ${legacyName}`); } - return translations[legacyName]; + // a copy, so a caller changing the result can't change later results + return [...translations[legacyName]]; } module.exports = translate; diff --git a/translate/test/translate.test.js b/translate/test/translate.test.js new file mode 100644 index 0000000..5eb8a7d --- /dev/null +++ b/translate/test/translate.test.js @@ -0,0 +1,28 @@ +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +const translate = require('../index'); + +describe('translate', () => { + it('should reject a name inherited from Object.prototype', async () => { + // Arrange + const name = 'toString'; + + // Act + const result = translate(name); + + // Assert + await assert.rejects(result, { message: 'Unknown value: toString' }); + }); + + it('should not let a caller change what later calls return', async () => { + // Arrange + (await translate('lts')).push('eol'); + + // Act + const result = await translate('lts'); + + // Assert + assert.deepStrictEqual(result, ['lts/active', 'lts/maintenance']); + }); +});