Files
pnpm/resolving/npm-resolver/test/publishedBy.test.ts
Zoltan Kochan 4195766f10 feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt (#11705)
Three coordinated changes that close the silent-bypass gap in loose `minimumReleaseAge` mode AND the discover-by-loop UX problem in strict mode (#10488), plus a parallel hardening of the lockfile verifier:

1. **Auto-collect into `minimumReleaseAgeExclude` (loose mode)** — fresh resolutions that fall back to a version newer than the cutoff are auto-recorded into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists what was persisted. The workspace manifest writer dedupes against existing entries.

2. **Lockfile verifier runs in loose mode too** — `createNpmResolutionVerifier` no longer gates on `minimumReleaseAgeStrict`. With auto-collect keeping the exclude list explicit, every accepted-immature pin must be on the list — same contract strict mode enforces. Lockfiles produced under a weaker (or absent) policy that still hold immature entries are rejected the same way strict mode would.

3. **Strict mode prompts on the aggregate set instead of throwing on the first** — the resolver always collects every immature direct and transitive in one pass; the install command's `handleResolutionPolicyViolations` checkpoint decides what to do with the set. Interactive (TTY) prompts the user once with the full list (default = No) and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues, persisted at the end. Decline → resolution aborts before the lockfile, package.json, or modules dir is touched. Non-interactive (CI) keeps `ERR_PNPM_NO_MATURE_MATCHING_VERSION` as the exit code but lists every offending entry instead of just the first one the resolver happened to hit.

4. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).

The steady-state flows:

- **Loose mode, `pnpm add foo@immature`**: lockfile clean, verifier no-op, resolver picks via lowest-version fallback, `foo@immature` lands in `minimumReleaseAgeExclude`, install succeeds. Subsequent `pnpm install --frozen-lockfile` in CI verifies against the populated list and succeeds.
- **Strict mode (interactive), security bump to `next@15.5.9`**: resolver collects `next@15.5.9` AND every immature `@next/swc-*@15.5.9` shim. pnpm prompts once with the full list. User approves → install completes, all entries persisted in `pnpm-workspace.yaml`. CI then runs the populated config cleanly.
- **Strict mode (non-interactive / CI)**: aborts with `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing every immature entry's `name@version` and publish time — no more discover-by-loop dance.
- **Teammate commits a poisoned lockfile**: single-policy batches reject with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` (or `ERR_PNPM_TRUST_DOWNGRADE`); a batch that trips both policies escalates to the generic `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION` and lists each entry's per-policy code in the breakdown.

### Implementation

- The npm resolver always falls back to the lowest matching version when no mature version satisfies the range, and flags the result with `ResolveResult.policyViolation` instead of throwing `NO_MATURE_MATCHING_VERSION`. `deferImmatureDecision` and `strictPublishedByCheck` are gone — every caller (install, dlx, outdated, self-update) inspects the violation and decides what to do.
- `policyViolation` flows from `ResolveResult` → `PackageResponse.body.policyViolation` → a shared accumulator in `ResolutionContext` → the `resolutionPolicyViolations` field on `resolveDependencyTree`'s return → out through `mutateModules` / `addDependenciesToPackage` to the install command.
- The violation type lives in `@pnpm/resolving.resolver-base` as `ResolutionPolicyViolation`; the npm resolver exports the two built-in codes (`MINIMUM_RELEASE_AGE_VIOLATION_CODE`, `TRUST_DOWNGRADE_VIOLATION_CODE`) as constants so consumers reference one source of truth.
- `handleResolutionPolicyViolations` runs between `resolveDependencyTree` and `resolvePeers` — the resolver-agnostic checkpoint where the install command's plan prompts (TTY) or aborts (no-TTY) with the full violation list.
- `setupPolicyHandlers` (in `installing/commands/src/policyHandlers.ts`) composes per-policy handlers behind a uniform plan interface: each handler has its own `handleResolutionPolicyViolations` (filter by code, decide what to do) and `pickManifestUpdates` (return a typed `WorkspaceManifestPolicyUpdates` patch the install command spreads into `updateWorkspaceManifest`). Today the only registered handler is `createMinimumReleaseAgeHandler` — strict + TTY prompts via `enquirer`, strict no-TTY throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` with every entry listed, loose mode auto-persists at the tail. Strict + `--no-save` is rejected up-front via `ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE`. Future policies plug in via a sibling factory + push into the handlers list, with no changes to `installDeps.ts` / `recursive.ts`.
- `installDeps` / `recursive` drain `pickManifestUpdates` after install and spread the patch into `updateWorkspaceManifest`. Plain `pnpm install` (no `--update`, no params) now still updates the workspace manifest when any handler contributes a patch. The `install` command's CLI schema gained `save: Boolean` so `--no-save` actually flows through to `opts.save = false` instead of being silently dropped by nopt.
- `makeResolutionStrict` (in `installing/client`) wraps a `ResolveFunction` and rethrows any `policyViolation` as a `PnpmError`. Used by `dlx` and `self-update` under strict `minimumReleaseAge` OR `trustPolicy: 'no-downgrade'`, since one-shot callers have nowhere to defer a violation to. Violation-code → error-code mapping lives in one place so future violation kinds get consistent UX.
- `createNpmResolutionVerifier` extends its check to `trustPolicy: 'no-downgrade'` — same per-entry fan-out, same cache key, sharing the full-metadata fetch with the maturity check. Trust-fetch errors now propagate up so the violation reason carries the underlying message (network code, 404 detail) instead of a generic "metadata is unavailable".
- `verifyLockfileResolutions`'s aggregate throw uses the per-policy code when every violation in the batch shares it, and escalates to a generic `LOCKFILE_RESOLUTION_VERIFICATION` (with per-entry codes in the breakdown) for mixed batches.
- The pnpm agent path refuses installs under `trustPolicy: 'no-downgrade'` (`ERR_PNPM_TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`) — the agent has no server-side counterpart to that check yet, so silently allowing it would land a lockfile the local verifier would later reject. `minimumReleaseAge` is forwarded to the agent and enforced server-side, so that combination is fine.

### Pacquet parity

Pacquet only carries a stub reference to `minimumReleaseAgeExclude` (see `pacquet/crates/package-manager/src/version_policy.rs`); the broader `minimumReleaseAge` and `trustPolicy` policies aren't ported yet, so this feature is outside pacquet's current surface area. It'll come along when pacquet ports the policies.

### Closes

- Closes #10488 (resolves the discover-by-loop dance for security bumps without needing `withTransitives`).
2026-05-18 09:51:11 +02:00

514 lines
20 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { afterEach, beforeEach, expect, test } from '@jest/globals'
import { ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR } from '@pnpm/constants'
import { createFetchFromRegistry } from '@pnpm/network.fetch'
import { createNpmResolver } from '@pnpm/resolving.npm-resolver'
import { fixtures } from '@pnpm/test-fixtures'
import type { Registries } from '@pnpm/types'
import { loadJsonFileSync } from 'load-json-file'
import { temporaryDirectory } from 'tempy'
import { getMockAgent, retryLoadJsonFile, setupMockAgent, teardownMockAgent } from './utils/index.js'
const f = fixtures(import.meta.dirname)
const registries: Registries = {
default: 'https://registry.npmjs.org/',
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const badDatesMeta = loadJsonFileSync<any>(f.find('bad-dates.json'))
const isPositiveMeta = loadJsonFileSync<any>(f.find('is-positive-full.json'))
const isPositiveAbbreviatedMeta = loadJsonFileSync<any>(f.find('is-positive.json'))
/* eslint-enable @typescript-eslint/no-explicit-any */
const fetch = createFetchFromRegistry({})
const getAuthHeader = () => undefined
const createResolveFromNpm = createNpmResolver.bind(null, fetch, getAuthHeader)
afterEach(async () => {
await teardownMockAgent()
})
beforeEach(async () => {
await setupMockAgent()
})
test('fall back to a newer version if there is no version published by the given date', async () => {
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/bad-dates', method: 'GET' })
.reply(200, badDatesMeta)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
})
const resolveResult = await resolveFromNpm({ alias: 'bad-dates', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('bad-dates@1.0.0')
})
test('request metadata when the one in cache does not have a version satisfying the range', async () => {
const cacheDir = temporaryDirectory()
const cachedMeta = {
'dist-tags': {},
versions: {},
time: {},
}
fs.mkdirSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`), { recursive: true })
fs.writeFileSync(
path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org/bad-dates.jsonl`),
`${JSON.stringify({})}\n${JSON.stringify(cachedMeta)}`,
'utf8'
)
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/bad-dates', method: 'GET' })
.reply(200, badDatesMeta)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
})
const resolveResult = await resolveFromNpm({ alias: 'bad-dates', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('bad-dates@1.0.0')
})
test('reports an immature pick via policyViolation even when loaded from cache and requested by exact version', async () => {
const cacheDir = temporaryDirectory()
const fooMeta = {
'dist-tags': {},
versions: {
'1.0.0': {
name: 'foo',
version: '1.0.0',
dist: {
integrity: 'sha512-9Qa5b+9n69IEuxk4FiNcavXqkixb9lD03BLtdTeu2bbORnLZQrw+pR/exiSg7SoODeu08yxS47mdZa9ddodNwQ==',
shasum: '857db584a1ba5d1cb2980527fc3b6c435d37b0fd',
tarball: 'https://registry.npmjs.org/is-positive/-/foo-1.0.0.tgz',
},
},
},
time: {
'1.0.0': '2016-08-17T19:26:00.508Z',
},
}
fs.mkdirSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`), { recursive: true })
fs.writeFileSync(
path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org/foo.jsonl`),
`${JSON.stringify({})}\n${JSON.stringify(fooMeta)}`,
'utf8'
)
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/foo', method: 'GET' })
.reply(200, fooMeta)
// The resolver no longer throws on immature picks — it falls back to the
// lowest match in range and flags the result with `policyViolation`. The
// outer caller (install / dlx / self-update) decides what to do with it.
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
})
const result = await resolveFromNpm({ alias: 'foo', bareSpecifier: '1.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(result!.id).toBe('foo@1.0.0')
expect(result!.policyViolation).toMatchObject({
name: 'foo',
version: '1.0.0',
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
})
test('should skip time field validation for excluded packages', async () => {
const cacheDir = temporaryDirectory()
const { time: _time, ...metaWithoutTime } = isPositiveMeta
fs.mkdirSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`), { recursive: true })
fs.writeFileSync(path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org/is-positive.jsonl`), JSON.stringify(metaWithoutTime), 'utf8')
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, metaWithoutTime)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
})
const publishedByExclude = (pkgName: string) => pkgName === 'is-positive'
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: 'latest' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
publishedByExclude,
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.manifest.version).toBe('3.1.0')
})
test('use abbreviated metadata when modified date is older than publishedBy', async () => {
// is-positive abbreviated has modified: "2017-08-17T19:26:00.508Z"
// publishedBy is set to 2018, so modified < publishedBy → all versions are old enough
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveAbbreviatedMeta)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2018-01-01T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('re-fetch full metadata when abbreviated modified date is recent', async () => {
// Abbreviated has modified in the future relative to publishedBy → needs full metadata
const recentAbbreviated = {
...isPositiveAbbreviatedMeta,
modified: '2015-06-10T00:00:00.000Z',
}
const agent = getMockAgent().get(registries.default.replace(/\/$/, ''))
// First request: abbreviated
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, recentAbbreviated)
// Second request: full metadata (re-fetch)
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
// publishedBy is 2015-06-05, modified is 2015-06-10 → modified >= publishedBy → needs full
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-06-05T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
// 1.0.0 was published 2015-06-02, which is before publishedBy (2015-06-05)
expect(resolveResult!.id).toBe('is-positive@1.0.0')
})
test('ignoreMissingTimeField=true skips maturity check when full metadata has no time field', async () => {
const { time: _time, ...metaWithoutTime } = isPositiveMeta
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, metaWithoutTime)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('ignoreMissingTimeField=true still upgrades abbreviated→full when time is missing', async () => {
// With ignoreMissingTimeField=true, pnpm should still re-fetch full metadata
// when abbreviated metadata lacks time — only falling back to skip+warn if
// even the full metadata has no time field. Here the full response DOES have
// time, so the maturity check must run (and pick the old 1.0.0, not latest).
const recentAbbreviated = {
...isPositiveAbbreviatedMeta,
modified: '2015-06-10T00:00:00.000Z',
}
const agent = getMockAgent().get(registries.default.replace(/\/$/, ''))
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, recentAbbreviated)
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
ignoreMissingTimeField: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-06-05T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@1.0.0')
})
test('ignoreMissingTimeField=false throws when full metadata has no time field', async () => {
const { time: _time, ...metaWithoutTime } = isPositiveMeta
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, metaWithoutTime)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: false,
})
await expect(resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})).rejects.toThrow(/missing the "time" field/)
})
test('ignoreMissingTimeField=true skips maturity check from disk-cached metadata lacking time', async () => {
// Exercise the cached-metadata return path: write full metadata to disk
// with the `time` field stripped, and verify that resolution succeeds
// (no ERR_PNPM_MISSING_TIME) when the setting is on.
const { time: _time, ...metaWithoutTime } = isPositiveMeta
const cacheDir = temporaryDirectory()
const cacheDir2 = path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(cacheDir2, { recursive: true })
const cachePath = path.join(cacheDir2, 'is-positive.jsonl')
fs.writeFileSync(cachePath, `${JSON.stringify({})}\n${JSON.stringify(metaWithoutTime)}`, 'utf8')
// No mock agent intercepts — test would fail if a network request fired.
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: true,
offline: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('falls through to the registry fetch when cached abbreviated meta lacks time on the version-spec cache path', async () => {
// Regression test for the bug where the version-spec cache fast path
// (`!opts.includeLatestTag && spec.type === 'version'`) in pickPackage
// would rethrow ERR_PNPM_MISSING_TIME under what used to be
// strictPublishedByCheck, instead of falling through to the registry-fetch
// path like the adjacent mtime-gated cache block does. The fix makes the
// two catch blocks consistent — both now always swallow and fall through.
//
// Setup: cache abbreviated metadata (no per-version `time` field) for the
// package, then request an exact-version pin that IS present in the cached
// meta.versions. The version-spec fast path will try pickMatchingVersionFast
// against the cached meta, which throws MISSING_TIME because the abbreviated
// form lacks `time`. The catch falls through to the registry fetch, which
// returns full metadata with time, and resolution succeeds.
const cacheDir = temporaryDirectory()
const abbrevCacheDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(abbrevCacheDir, { recursive: true })
const cachePath = path.join(abbrevCacheDir, 'is-positive.jsonl')
// Strip `time` from the cached abbreviated metadata to simulate the
// real-world "registry returned abbreviated form without per-version times" case.
// Set `modified` to something recent so the mtime-gated block above doesn't
// short-circuit the resolution before we hit the version-spec cache path.
const { time: _time, ...abbreviatedWithoutTime } = isPositiveAbbreviatedMeta
const cachedMeta = {
...abbreviatedWithoutTime,
modified: new Date().toISOString(),
}
fs.writeFileSync(
cachePath,
`${JSON.stringify({ modified: cachedMeta.modified })}\n${JSON.stringify(cachedMeta)}`,
'utf8'
)
// Mock the network fetch (the path the fix lets us fall through to). Returns
// full metadata with the `time` field present.
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
ignoreMissingTimeField: true,
})
// Exact-version bareSpecifier → spec.type === 'version' → hits the cache path
// we are testing. 3.0.0 was published 2015-07-10, mature relative to publishedBy.
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.0.0')
})
test('falls through to the registry fetch even with default ignoreMissingTimeField on the version-spec cache path', async () => {
// Companion to the test above: same scenario but with the default
// ignoreMissingTimeField (false). MISSING_TIME from cached abbreviated
// meta should never escape the catch — resolution falls through to the
// registry fetch and succeeds with full (time-bearing) metadata.
const cacheDir = temporaryDirectory()
const abbrevCacheDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(abbrevCacheDir, { recursive: true })
const cachePath = path.join(abbrevCacheDir, 'is-positive.jsonl')
const { time: _time, ...abbreviatedWithoutTime } = isPositiveAbbreviatedMeta
const cachedMeta = {
...abbreviatedWithoutTime,
modified: new Date().toISOString(),
}
fs.writeFileSync(
cachePath,
`${JSON.stringify({ modified: cachedMeta.modified })}\n${JSON.stringify(cachedMeta)}`,
'utf8'
)
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.0.0')
})
test('upgrades cached abbreviated metadata to full when 304 Not Modified and publishedBy is set', async () => {
// Regression test for the misleading "missing the time field" warning.
//
// When pnpm has abbreviated metadata in its disk cache and the registry
// returns 304 Not Modified on the conditional fetch, pnpm reuses the
// cached abbreviated metadata. Abbreviated metadata has no per-version
// `time` field by registry spec, so the minimumReleaseAge check would
// fall back to its warn-and-skip path — silently bypassing the maturity
// guarantee even though the registry HAS the time data in full metadata.
//
// The fix: after a 304, if the cached metadata is abbreviated and the
// package was recently modified, re-fetch with `fullMetadata: true` to
// get per-version times and run the check properly.
const cacheDir = temporaryDirectory()
const abbrevCacheDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(abbrevCacheDir, { recursive: true })
const cachePath = path.join(abbrevCacheDir, 'is-positive.jsonl')
// Cache abbreviated metadata: no `time`, recent `modified` (so the upgrade
// condition triggers), with an etag so the conditional fetch returns 304.
const { time: _time, ...abbreviatedWithoutTime } = isPositiveAbbreviatedMeta
const cachedMeta = {
...abbreviatedWithoutTime,
modified: '2015-06-10T00:00:00.000Z',
}
const cacheHeaders = JSON.stringify({ etag: '"abc123"', modified: cachedMeta.modified })
fs.writeFileSync(cachePath, `${cacheHeaders}\n${JSON.stringify(cachedMeta)}`, 'utf8')
// First fetch: conditional request with the cached etag → 304 Not Modified.
// Second fetch: upgrade request with `fullMetadata: true` → 200 with time-bearing full metadata.
const agent = getMockAgent().get(registries.default.replace(/\/$/, ''))
agent.intercept({
path: '/is-positive',
method: 'GET',
headers: { 'if-none-match': '"abc123"' },
}).reply(304, '')
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
// publishedBy is 2015-06-05, cached meta `modified` is 2015-06-10 → upgrade triggers.
// is-positive@1.0.0 (2015-06-02) is mature; @3.1.0 (2015-08-21) is not.
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-06-05T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
// The maturity check ran properly thanks to the upgrade — picked 1.0.0, not 3.x.
expect(resolveResult!.id).toBe('is-positive@1.0.0')
// The upgraded full metadata should be persisted to disk so the next
// install doesn't re-trigger the upgrade fetch.
/* eslint-disable @typescript-eslint/no-explicit-any */
const persistedMeta = await retryLoadJsonFile<any>(cachePath)
/* eslint-enable @typescript-eslint/no-explicit-any */
expect(persistedMeta?.time).toBeDefined()
})
test('use cached metadata based on file mtime when publishedBy is set', async () => {
const cacheDir = temporaryDirectory()
// Write abbreviated metadata to the abbreviated cache dir
const cacheDir2 = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(cacheDir2, { recursive: true })
const cachePath = path.join(cacheDir2, 'is-positive.jsonl')
const headers = JSON.stringify({ modified: isPositiveAbbreviatedMeta.modified })
fs.writeFileSync(cachePath, `${headers}\n${JSON.stringify(isPositiveAbbreviatedMeta)}`, 'utf8')
// No mock agent intercepts — the test verifies no network request is made.
// If a request were attempted, it would fail.
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
// publishedBy in the past relative to file mtime (file was just written = now)
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2020-01-01T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})