vercel/next.js · #99222
Use focused npm advisories and nudge only for ready upgrades
evals/next-upgrade/future/assessment.mjs12 + / 3 −
@@ -11,9 +11,18 @@ const { target } = JSON.parse( globalThis.fetch = async (input, init) => { const url = String(input) let value+ let versions - if (url.startsWith('https://api.github.com/advisories?')) {- value = []+ if (url === 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk') {+ versions = JSON.parse(init.body).next+ if (+ !Array.isArray(versions) ||+ versions.length === 0 ||+ versions.some((version) => typeof version !== 'string')+ ) {+ throw new Error('Expected Next.js advisory versions')+ }+ value = {} } else if (url === 'https://registry.npmjs.org/next/latest') { value = { version: target,@@ -25,7 +34,7 @@ globalThis.fetch = async (input, init) => { appendFileSync( join(tools, 'assessment.jsonl'),- JSON.stringify({ url, value }) + '\n'+ JSON.stringify({ url, versions, value }) + '\n' ) return Response.json(value) }evals/next-upgrade/latest/assessment.mjs19 + / 10 −
@@ -10,22 +10,31 @@ const { target } = JSON.parse( globalThis.fetch = async (input, init) => { const url = String(input)+ let value+ let versions - if (url.startsWith('https://api.github.com/advisories?')) {- return Response.json([])- }-- if (url !== 'https://registry.npmjs.org/next/latest') {+ if (url === 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk') {+ versions = JSON.parse(init.body).next+ if (+ !Array.isArray(versions) ||+ versions.length === 0 ||+ versions.some((version) => typeof version !== 'string')+ ) {+ throw new Error('Expected Next.js advisory versions')+ }+ value = {}+ } else if (url === 'https://registry.npmjs.org/next/latest') {+ value = {+ version: target,+ engines: { node: '>=20.9.0' },+ }+ } else { return realFetch(input, init) } - const value = {- version: target,- engines: { node: '>=20.9.0' },- } appendFileSync( join(tools, 'assessment.jsonl'),- JSON.stringify({ url, value }) + '\n'+ JSON.stringify({ url, versions, value }) + '\n' ) return Response.json(value) }packages/next/src/lib/upgrade/nudge.ts21 + / 77 −
@@ -10,7 +10,7 @@ import type { NextConfigComplete } from '../../server/config-shared' import semver from 'next/dist/compiled/semver' import type { UpgradeAction } from './prompt' import { getAgentName } from '../../telemetry/agent-name'-import { futureDefaults, getPendingFutureDefaults } from './future-defaults'+import { getPendingFutureDefaults } from './future-defaults' import { isCI } from '../../server/ci-info' type NudgeOptions = {@@ -125,8 +125,7 @@ type UpgradeReminder = { | { kind: 'security' reference: string | null- targetVersion: string | null- unavailableReason: string | null+ targetVersion: string } | { kind: 'latest'; latestVersion: string | null; names: string[] } | { kind: 'future'; targetVersion: string; names: string[] }@@ -186,24 +185,21 @@ export async function assessUpgrade( return null } const { upgrade } = assessment+ if (upgrade.status !== 'ready') {+ // TODO: Record affected and upgrade.status in telemetry so we can see when+ // an advisory applies but no ready target was available to nudge.+ return null+ } if (assessment.affected) { return { kind: 'security', policy, installedVersion, reference: assessment.reference,- targetVersion: upgrade.status === 'ready' ? upgrade.targetVersion : null,- unavailableReason:- upgrade.status === 'ready'- ? null- : `${upgrade.status === 'unknown' ? 'Upgrade availability could not be checked.' : 'No safe newer target is available for the configured upgrade policy.'} ${upgrade.reason}`,+ targetVersion: upgrade.targetVersion, } }- if (- upgrade.status !== 'ready' ||- policy === 'security' ||- stopBefore === 'latest'- ) {+ if (policy === 'security' || stopBefore === 'latest') { return null } const latestVersion = getLatestUpgradeVersion(@@ -248,8 +244,7 @@ export async function assessUpgrade( async function nudgeUpgradeForAgent( options: NudgeOptions,- reminder: UpgradeReminder,- preview: boolean+ reminder: UpgradeReminder ): Promise<void> { let summary: string let recommendation: string@@ -271,36 +266,25 @@ async function nudgeUpgradeForAgent( recommendation = 'We recommend you adopt these Future Defaults.' break }- const unavailableReason =- reminder.kind === 'security' ? reminder.unavailableReason : null- const command = unavailableReason- ? null- : `next upgrade --ai=${reminder.policy}`+ const command = `next upgrade --ai=${reminder.policy}` const note = getRequestedUpgrade() ? `Note: This reminder is enabled by \`__NEXT_AGENTIC_AUTO_UPGRADE=${reminder.policy}\`.` : `Note: This reminder is enabled by \`experimental.agenticAutoUpgrade: '${reminder.policy}'\`.`- let message = `${summary}+ const message = `${summary} **${recommendation}** This command stopped so the reminder you configured is not missed. Retry the same command to continue the original task. When you report back to the user, include this upgrade recommendation even if the retry succeeds. -${- command- ? `At the end of the task, ask the user whether they want you to run the upgrade using the configured '${reminder.policy}' policy in the background. If the app is in a Git repository, use a separate Git worktree unless they request otherwise. If the app is not in a Git repository, upgrade it in place. Run this command from this app's directory:+At the end of the task, ask the user whether they want you to run the upgrade using the configured '${reminder.policy}' policy in the background. If the app is in a Git repository, use a separate Git worktree unless they request otherwise. If the app is not in a Git repository, upgrade it in place. Run this command from this app's directory: \`\`\` ${command}-\`\`\``- : unavailableReason-}+\`\`\` ${reference ? `Reference: ${reference}\n\n` : ''}${note}`- if (preview) {- message = `Forced preview: this reminder does not establish upgrade eligibility.\n\n${message}`- } let retryAllowed = false try { retryAllowed = await allowNudgeRetry(@@ -315,7 +299,7 @@ ${reference ? `Reference: ${reference}\n\n` : ''}${note}` } if (retryAllowed) { Log.warn(- `${summary} This command is continuing after the reminder you configured.${unavailableReason ? `\n${unavailableReason}` : ''}${reference ? `\nReference: ${reference}` : ''}`+ `${summary} This command is continuing after the reminder you configured.${reference ? `\nReference: ${reference}` : ''}` ) return }@@ -389,18 +373,15 @@ async function getUpgradeDismissal( async function nudgeUpgradeForHuman( directory: string, reminder: UpgradeReminder,- signal: AbortSignal,- preview: boolean+ signal: AbortSignal ): Promise<UpgradeAction> { if (signal.aborted) { return 'skip' } let message: string- let canUpgrade = true if (reminder.kind === 'security') { message = `⚠ Installed Next.js version ${reminder.installedVersion} is affected by a known security vulnerability.`- canUpgrade = reminder.unavailableReason === null- message += `\n\n${reminder.unavailableReason ?? `Next.js security version upgrade available: ${reminder.installedVersion} -> ${reminder.targetVersion ?? '[target version]'}`}`+ message += `\n\nNext.js security version upgrade available: ${reminder.installedVersion} -> ${reminder.targetVersion}` } else if (reminder.policy === 'future') { const targetVersion = reminder.kind === 'latest'@@ -419,11 +400,8 @@ async function nudgeUpgradeForHuman( } else { return 'skip' }- if (preview) {- message = `Forced preview: __NEXT_AGENTIC_AUTO_UPGRADE=${reminder.policy}. Upgrade eligibility has not been established.\n\n${message}`- } const { promptUpgrade } = require('./prompt') as typeof import('./prompt')- const action = await promptUpgrade(message, signal, canUpgrade)+ const action = await promptUpgrade(message, signal, true) if (signal.aborted) { return 'skip' }@@ -495,56 +473,22 @@ export async function nudgeUpgrade( return } }- let reminder = await assessUpgrade(+ const reminder = await assessUpgrade( directory, { ...config, experimental: { agenticAutoUpgrade: policy } }, installedVersion, stopBefore, requested !== null )- const preview = !reminder && requested !== null- if (preview) {- // Exercise the real template without inventing an advisory or release.- // Update still runs the normal eligibility checks against this installation.- const context = { policy, installedVersion }- switch (requested) {- case 'security':- reminder = {- ...context,- kind: 'security',- reference: null,- targetVersion: null,- unavailableReason: null,- }- break- case 'latest':- reminder = {- ...context,- kind: 'latest',- latestVersion: null,- names: [],- }- break- case 'future':- reminder = {- ...context,- kind: 'future',- targetVersion: installedVersion,- names: futureDefaults.map((entry) => entry.name),- }- break- }- } if (!reminder || signal?.aborted) { return } if (agent) { await nudgeUpgradeForAgent( { directory, distDir: config.distDir, command },- reminder,- preview+ reminder ) } else if (signal) {- return nudgeUpgradeForHuman(directory, reminder, signal, preview)+ return nudgeUpgradeForHuman(directory, reminder, signal) } }packages/next/src/lib/upgrade/prepare-upgrade.ts85 + / 273 −
@@ -129,8 +129,24 @@ export async function getUpgradeAssessment( } // Prerelease version ordering does not establish which security fixes it // contains, but stable promotion targets still need advisory validation.- const snapshot =- channel === 'canary' ? null : await readAdvisorySnapshot(installedVersion)+ let snapshot: { ranges: string[]; reference: string } | null = null+ if (channel !== 'canary') {+ try {+ snapshot = {+ ranges: await readNpmAdvisories([installedVersion]),+ reference: NPM_ADVISORIES,+ }+ } catch {+ return {+ affected: null,+ reference: null,+ upgrade: {+ status: 'unknown',+ reason: 'Could not check for security updates. Please try again.',+ },+ }+ }+ } const affected = snapshot && !channel ? snapshot.ranges.some((range) =>@@ -139,13 +155,6 @@ export async function getUpgradeAssessment( : null const assessment = { affected, reference: snapshot?.reference ?? null } - if (snapshot && snapshot.targetError !== null) {- return {- ...assessment,- upgrade: { status: 'unknown', reason: snapshot.targetError },- }- }- // A dismissed release reminder still checks advisories, but does not need // target metadata unless an advisory applies. if ((policy === 'security' || onlyIfAffected) && !affected) {@@ -164,15 +173,22 @@ export async function getUpgradeAssessment( let references: string[] if (policy === 'security' && snapshot) { const registryURL = `${NPM_REGISTRY}next`- const registry = snapshot.registry ?? (await fetchJSON(registryURL)).value+ const registry = (await fetchJSON(registryURL)).value const releases = parseReleases(registry) references = [snapshot.reference, registryURL]+ const candidates = securityCandidates(installedVersion, releases)+ const ranges = [+ ...snapshot.ranges,+ ...(candidates.length+ ? await readNpmAdvisories(candidates.map(({ version }) => version))+ : []),+ ] try {- targetVersion = selectSecurityTarget(installedVersion, {- ranges: snapshot.ranges,+ targetVersion = selectSecurityTarget(+ installedVersion, releases,- references,- })!.version+ ranges+ )!.version } catch (error) { return { ...assessment,@@ -206,29 +222,12 @@ export async function getUpgradeAssessment( } } - // The tag can advance after npm's published-version snapshot was read.- // Include that exact target in the fallback query as well.+ const targetRanges =+ snapshot && targetVersion !== installedVersion+ ? await readNpmAdvisories([targetVersion])+ : snapshot?.ranges if (- snapshot &&- snapshot.registry !== null &&- !advisoryVersions(snapshot.registry, installedVersion).includes(- targetVersion- )- ) {- snapshot.ranges = affectedRanges(- await readNpmAdvisories([- ...advisoryVersions(snapshot.registry, installedVersion),- targetVersion,- ])- )- if (!channel) {- assessment.affected ||= snapshot.ranges.some((range) =>- semver.satisfies(installedVersion, range)- )- }- }- if (- snapshot?.ranges.some((range) => semver.satisfies(targetVersion, range))+ targetRanges?.some((range) => semver.satisfies(targetVersion, range)) ) { return { ...assessment,@@ -268,33 +267,17 @@ export async function getUpgradeAssessment( } } -type Advisory = {- withdrawn_at: string | null- vulnerabilities: {- package: { ecosystem: string; name: string }- vulnerable_version_range: string- }[]-}- type PackageRelease = { version: string } -type SecuritySnapshot = {- ranges: string[]- releases: PackageRelease[]- references: string[]-}--const ADVISORIES =- 'https://api.github.com/advisories?ecosystem=npm&affects=next&type=reviewed&per_page=100' const NPM_REGISTRY = 'https://registry.npmjs.org/' const NPM_ADVISORIES = `${NPM_REGISTRY}-/npm/v1/security/advisories/bulk` async function fetchJSON( url: string, init: RequestInit | undefined = undefined-): Promise<{ value: unknown; headers: Headers }> {+): Promise<{ value: unknown }> { try { const response = await fetch(url, { ...init,@@ -307,7 +290,7 @@ async function fetchJSON( throw new Error(`HTTP ${response.status}`) } - return { value: await response.json(), headers: response.headers }+ return { value: await response.json() } } catch (error) { throw new Error('Could not fetch upgrade metadata. Please try again.', { cause: error,@@ -325,7 +308,7 @@ export function getPrereleaseChannel(version: string): string | null { : null } -function parseReleases(value: unknown): SecuritySnapshot['releases'] {+function parseReleases(value: unknown): PackageRelease[] { const data = value as { versions: | Record<@@ -354,58 +337,6 @@ function parseReleases(value: unknown): SecuritySnapshot['releases'] { }) } -function affectedRanges(advisories: Advisory[]): string[] {- const ranges: string[] = []-- for (const advisory of advisories) {- if (- !advisory ||- !Array.isArray(advisory.vulnerabilities) ||- !('withdrawn_at' in advisory)- ) {- throw new Error('Could not check for security updates.')- }-- if (advisory.withdrawn_at) {- continue- }-- for (const finding of advisory.vulnerabilities) {- if (- !finding.package ||- typeof finding.package.name !== 'string' ||- typeof finding.package.ecosystem !== 'string'- ) {- throw new Error('Could not check for security updates.')- }-- if (- finding.package.ecosystem !== 'npm' ||- finding.package.name !== 'next'- ) {- continue- }-- if (- typeof finding.vulnerable_version_range !== 'string' ||- !finding.vulnerable_version_range.trim()- ) {- throw new Error('Could not check for security updates.')- }-- const range = finding.vulnerable_version_range.replace(/,\s*/g, ' ')-- if (!semver.validRange(range)) {- throw new Error('Could not check for security updates.')- }-- ranges.push(range)- }- }-- return ranges-}- export function getLatestUpgradeVersion( version: string, targetVersion: string@@ -459,61 +390,23 @@ async function fetchLatestRelease(installedVersion: string): Promise<{ return { version: release.version, reference } } -async function readGitHubAdvisories(version: string | null) {- const advisories: Advisory[] = []- const visited = new Set<string>()- const affects = version === null ? 'next' : `next@${version}`- const firstPage = new URL(ADVISORIES)- firstPage.searchParams.set('affects', affects)- let url: string | undefined = firstPage.href-- for (let page = 0; url; page++) {- if (page === 100) {- throw new Error('Could not check for security updates.')- }-- visited.add(url)- const { value, headers } = await fetchJSON(url)-- if (!Array.isArray(value)) {- throw new Error('Could not check for security updates.')- }-- advisories.push(...value)- const next = headers- .get('link')- ?.split(',')- .find((part) => /rel="next"/.test(part))- ?.match(/<([^>]+)>/)?.[1]-- if (next) {- const parsed = new URL(next)-- if (- parsed.origin !== 'https://api.github.com' ||- parsed.pathname !== '/advisories' ||- parsed.searchParams.get('ecosystem') !== 'npm' ||- parsed.searchParams.get('affects') !== affects ||- parsed.searchParams.get('type') !== 'reviewed' ||- visited.has(next)- ) {- throw new Error('Could not check for security updates.')- }- }-- url = next+// TODO: Record whether each advisory lookup succeeded or failed in upgrade telemetry.+async function readNpmAdvisories(versions: string[]): Promise<string[]> {+ let value: unknown+ try {+ value = (+ await fetchJSON(NPM_ADVISORIES, {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ next: versions }),+ })+ ).value+ } catch (error) {+ throw new Error('Could not check for security updates. Please try again.', {+ cause: error,+ }) } - return { advisories, reference: firstPage.href }-}--async function readNpmAdvisories(versions: string[]): Promise<Advisory[]> {- const { value } = await fetchJSON(NPM_ADVISORIES, {- method: 'POST',- headers: { 'Content-Type': 'application/json' },- body: JSON.stringify({ next: versions }),- })- if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Could not check for security updates.') }@@ -533,136 +426,55 @@ async function readNpmAdvisories(versions: string[]): Promise<Advisory[]> { throw new Error('Could not check for security updates.') } - const advisories: Advisory[] = data.next.map((finding) => {- if (!finding || typeof finding.vulnerable_versions !== 'string') {+ return data.next.map((finding) => {+ if (+ !finding ||+ typeof finding.vulnerable_versions !== 'string' ||+ !finding.vulnerable_versions.trim()+ ) { throw new Error('Could not check for security updates.') }-- return {- // npm's active advisory feed does not expose withdrawal metadata.- withdrawn_at: null,- vulnerabilities: [- {- package: { ecosystem: 'npm', name: 'next' },- vulnerable_version_range: finding.vulnerable_versions,- },- ],+ const range = finding.vulnerable_versions.replace(/,\s*/g, ' ')+ if (!semver.validRange(range)) {+ throw new Error('Could not check for security updates.') }+ return range })- return advisories-}--function advisoryVersions(value: unknown, checkedVersion: string): string[] {- const data = value as { versions: Record<string, unknown> | undefined }- if (- !data?.versions ||- typeof data.versions !== 'object' ||- Array.isArray(data.versions)- ) {- throw new Error('Could not check for security updates.')- }-- // Include all published versions to retrieve advisories affecting candidates,- // not only the installed version.- const versions = Object.keys(data.versions).filter((version) =>- semver.valid(version)- )- if (!versions.includes(checkedVersion)) {- versions.push(checkedVersion)- }- return versions-}--// TODO: Replace provider-specific requests with a Next.js-maintained endpoint-// that returns advisory ranges and exact safe targets for each major.-async function readAdvisorySnapshot(installedVersion: string): Promise<{- ranges: string[]- reference: string- registry: unknown | null- targetError: string | null-}> {- // Accept GitHub evidence only after every page and range validates. On- // failure, npm replaces the entire set, including candidate advisories.- try {- const { advisories, reference } = await readGitHubAdvisories(null)- return {- ranges: affectedRanges(advisories),- reference,- registry: null,- targetError: null,- }- } catch (githubFailure) {- try {- const { value } = await fetchJSON(`${NPM_REGISTRY}next`)- const ranges = affectedRanges(- await readNpmAdvisories(advisoryVersions(value, installedVersion))- )- return {- ranges,- reference: NPM_ADVISORIES,- registry: value,- targetError: null,- }- } catch (error) {- // A source-only response can still establish a warning, but cannot- // establish target safety.- try {- const ranges = affectedRanges(- await readNpmAdvisories([installedVersion])- )- if (ranges.some((range) => semver.satisfies(installedVersion, range))) {- return {- ranges,- reference: NPM_ADVISORIES,- registry: null,- targetError: 'Could not assess upgrade targets. Please try again.',- }- }- } catch {- // Neither provider could establish even a source-only warning.- }- throw new Error(- 'Could not check for security updates. Please try again.',- { cause: [githubFailure, error] }- )- }- } } -function selectSecurityTarget(+function securityCandidates( source: string,- snapshot: SecuritySnapshot-): PackageRelease | undefined {- const ranges = snapshot.ranges-- if (!ranges.some((range) => semver.satisfies(source, range))) {- return- }-- const releases = snapshot.releases- // Consider only the latest stable release of each major, not an older safe patch.+ releases: PackageRelease[]+): PackageRelease[] {+ // Only the newest stable release of each eligible major is considered. const latest = new Map<number, PackageRelease>()- for (const release of releases) { const major = semver.major(release.version) const previous = latest.get(major)- if (!previous || semver.gt(release.version, previous.version)) { latest.set(major, release) } }+ return [...latest.entries()]+ .sort(([a], [b]) => a - b)+ .filter(+ ([major, release]) =>+ major >= semver.major(source) && semver.gt(release.version, source)+ )+ .map(([, release]) => release)+} - for (const major of [...latest.keys()].sort((a, b) => a - b)) {- if (major < semver.major(source)) {- continue- }-- const candidate = latest.get(major)!+function selectSecurityTarget(+ source: string,+ releases: PackageRelease[],+ ranges: string[]+): PackageRelease | undefined {+ if (!ranges.some((range) => semver.satisfies(source, range))) {+ return+ } - if (- semver.gt(candidate.version, source) &&- !ranges.some((range) => semver.satisfies(candidate.version, range))- ) {+ for (const candidate of securityCandidates(source, releases)) {+ if (!ranges.some((range) => semver.satisfies(candidate.version, range))) { return candidate } }test/unit/prepare-latest-upgrade.test.ts216 + / 201 −
@@ -32,21 +32,16 @@ describe('prepare latest upgrade', () => { } function mockLatestVersion(version: string) {- global.fetch = jest.fn(async (input) =>- String(input).startsWith('https://api.github.com/')- ? Response.json([])- : new Response(- JSON.stringify({- version,- engines: { node: '>=18' },- }),- { status: 200 }- )- )+ global.fetch = jest.fn(async (input) => {+ if (String(input).includes('/security/advisories/bulk')) {+ return Response.json({})+ }+ return Response.json({ version, engines: { node: '>=18' } })+ }) } function mockFutureMetadata(vulnerableVersions: string) {- global.fetch = jest.fn(async (input) => {+ global.fetch = jest.fn(async (input, init) => { const url = String(input) if (url === 'https://registry.npmjs.org/next/latest') {@@ -56,31 +51,25 @@ describe('prepare latest upgrade', () => { }) } - if (url.startsWith('https://api.github.com/advisories?')) {- return new Response(null, { status: 500 })- }-- if (url === 'https://registry.npmjs.org/next') {- return Response.json({- versions: {- '16.2.0': { version: '16.2.0' },- '16.4.0': { version: '16.4.0' },- },- })- }- if ( url === 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk' ) {+ const { next: versions } = JSON.parse(String(init?.body)) as {+ next: string[]+ } return Response.json({- next: [- {- id: 1,- url: 'https://github.com/advisories/GHSA-next-test',- severity: 'high',- vulnerable_versions: vulnerableVersions,- },- ],+ next: versions.some((version) =>+ semver.satisfies(version, vulnerableVersions)+ )+ ? [+ {+ id: 1,+ url: 'https://github.com/advisories/GHSA-next-test',+ severity: 'high',+ vulnerable_versions: vulnerableVersions,+ },+ ]+ : [], }) } @@ -98,26 +87,10 @@ describe('prepare latest upgrade', () => { ranges = ['>=17.2.0-canary.0 <17.2.0-canary.5'], target = '17.2.0-canary.5' as string | null, published = ['17.2.0', '17.2.0-canary.5', '17.3.0-canary.0'],- fallback = false, npmFailure = false, } = {}) { global.fetch = jest.fn(async (input, init) => { const url = String(input)- if (url.startsWith('https://api.github.com/advisories?')) {- return fallback- ? new Response(null, { status: 500 })- : Response.json(- ranges.map((range) => ({- withdrawn_at: null,- vulnerabilities: [- {- package: { ecosystem: 'npm', name: 'next' },- vulnerable_version_range: range,- },- ],- }))- )- } if (url === 'https://registry.npmjs.org/next') { return Response.json({ 'dist-tags': target === null ? {} : { canary: target },@@ -175,10 +148,15 @@ describe('prepare latest upgrade', () => { expect(global.fetch).toHaveBeenCalledTimes(0) }) - it('does not treat failed stable advisory providers as an unaffected version', async () => {+ it('does not treat a failed advisory request as an unaffected version', async () => { const directory = await createApp('17.2.0')- mockSecurityMetadata({ fallback: true, npmFailure: true })- await expect(getUpgradeAssessment('17.2.0', 'security')).rejects.toThrow()+ mockSecurityMetadata({ npmFailure: true })+ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ affected: null,+ upgrade: { status: 'unknown' },+ }) await expect(prepareUpgrade(directory, 'security')).rejects.toThrow( 'Could not check for security updates.' )@@ -219,7 +197,7 @@ describe('prepare latest upgrade', () => { }) expect( jest.mocked(global.fetch).mock.calls.map(([url]) => String(url))- ).toEqual([expect.stringContaining('https://api.github.com/advisories?')])+ ).toEqual(['https://registry.npmjs.org/-/npm/v1/security/advisories/bulk']) }) describe('shared stable eligibility', () => {@@ -297,7 +275,7 @@ describe('prepare latest upgrade', () => { 'retains npm advisory warnings without release metadata for %s', async (policy) => { const directory = await createApp(installed)- mockSecurityMetadata({ fallback: true, ranges: [installed] })+ mockSecurityMetadata({ ranges: [installed] }) const fetchMetadata = global.fetch global.fetch = jest.fn(async (input, init) => { if (String(input).startsWith('https://registry.npmjs.org/next')) {@@ -314,17 +292,16 @@ describe('prepare latest upgrade', () => { upgrade: { status: 'unknown' }, }) await expect(prepareUpgrade(directory, policy)).rejects.toThrow(- 'Could not assess upgrade targets.'+ 'Could not fetch upgrade metadata.' ) } ) }) - it('includes a newly published tag in npm fallback assessment', async () => {+ it('checks a newly published target tag', async () => { const target = '17.3.0' const directory = await createApp('17.2.0') mockSecurityMetadata({- fallback: true, ranges: [target], target, published: ['17.2.0'],@@ -343,31 +320,27 @@ describe('prepare latest upgrade', () => { ) }) - it.each([false, true])(- 'assesses the configured policy rather than a different safe security target, fallback=%s',- async (fallback) => {- const directory = await createApp('17.2.0')- mockSecurityMetadata({- fallback,- ranges: ['17.2.0', '17.3.0'],- target: '17.3.0',- published: ['17.2.0', '17.3.0', '18.0.0'],- })+ it('assesses the configured policy rather than a different safe security target', async () => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({+ ranges: ['17.2.0', '17.3.0'],+ target: '17.3.0',+ published: ['17.2.0', '17.3.0', '18.0.0'],+ })+ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ upgrade: { status: 'ready', targetVersion: '18.0.0' },+ })+ for (const policy of ['latest', 'future'] as const) { await expect(- getUpgradeAssessment('17.2.0', 'security')- ).resolves.toMatchObject({- upgrade: { status: 'ready', targetVersion: '18.0.0' },- })- for (const policy of ['latest', 'future'] as const) {- await expect(- getUpgradeAssessment('17.2.0', policy)- ).resolves.toMatchObject({ upgrade: { status: 'blocked' } })- await expect(prepareUpgrade(directory, policy)).rejects.toThrow(- '17.3.0 is affected by an active advisory'- )- }+ getUpgradeAssessment('17.2.0', policy)+ ).resolves.toMatchObject({ upgrade: { status: 'blocked' } })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ '17.3.0 is affected by an active advisory'+ ) }- )+ }) it('preserves stable security target selection', async () => { const directory = await createApp('17.2.0')@@ -380,6 +353,69 @@ describe('prepare latest upgrade', () => { ) }) + it('queries only the installed version and newest eligible release per major', async () => {+ mockSecurityMetadata({+ ranges: ['17.2.0', '17.3.0'],+ published: [+ '16.9.0',+ '17.2.0',+ '17.2.1',+ '17.3.0',+ '18.0.0',+ '18.0.1',+ '18.1.0-canary.0',+ ],+ })++ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ affected: true,+ upgrade: { status: 'ready', targetVersion: '18.0.1' },+ })++ const requests = jest.mocked(global.fetch).mock.calls+ expect(requests.map(([url]) => String(url))).toEqual([+ 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',+ 'https://registry.npmjs.org/next',+ 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',+ ])+ expect(JSON.parse(String(requests[0][1]?.body))).toEqual({+ next: ['17.2.0'],+ })+ expect(JSON.parse(String(requests[2][1]?.body))).toEqual({+ next: ['17.3.0', '18.0.1'],+ })+ })++ it('keeps a confirmed warning when the candidate advisory request fails', async () => {+ mockSecurityMetadata({+ ranges: ['17.2.0'],+ published: ['17.2.0', '17.2.1'],+ })+ const fetchMetadata = global.fetch+ let advisoryRequests = 0+ global.fetch = jest.fn(async (input, init) => {+ if (String(input).includes('/security/advisories/bulk')) {+ advisoryRequests+++ if (advisoryRequests === 2) {+ return new Response(null, { status: 503 })+ }+ }+ return fetchMetadata(input, init)+ })++ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ affected: true,+ upgrade: {+ status: 'unknown',+ reason: 'Could not check for security updates. Please try again.',+ },+ })+ })+ it.each(['17.2.0-rc.1', '17.2.0-beta.1', '17.2.0-preview.1'])( 'blocks security upgrades for prereleases without querying advisories: %s', async (version) => {@@ -448,13 +484,7 @@ describe('prepare latest upgrade', () => { async (channel, installed) => { const target = '17.2.0' const directory = await createApp(installed)- global.fetch = jest.fn(async (input) => {- if (String(input).startsWith('https://api.github.com/advisories?')) {- return Response.json([])- }- expect(String(input)).toBe('https://registry.npmjs.org/next/latest')- return Response.json({ version: target })- })+ mockLatestVersion(target) await expect( getUpgradeAssessment(installed, 'latest') ).resolves.toMatchObject({@@ -469,44 +499,54 @@ describe('prepare latest upgrade', () => { references: ['https://registry.npmjs.org/next/latest'], }) )- expect(global.fetch).toHaveBeenCalledTimes(4)+ const advisoryRequests = jest+ .mocked(global.fetch)+ .mock.calls.filter(([url]) =>+ String(url).includes('/security/advisories/bulk')+ )+ expect(+ advisoryRequests.map(([, init]) => JSON.parse(String(init?.body)))+ ).toEqual([+ { next: [installed] },+ { next: [target] },+ { next: [installed] },+ { next: [target] },+ ]) } ) - it.each([false, true])(- 'blocks an advised stable target for a prerelease with npm fallback=%s',- async (fallback) => {- const installed = '17.2.0-rc.1'- const target = '17.2.0'- const directory = await createApp(installed)- mockSecurityMetadata({- fallback,- ranges: [target],- target,- published: fallback ? [installed] : [installed, target],- })- await expect(- getUpgradeAssessment(installed, 'latest')- ).resolves.toMatchObject({- affected: null,- upgrade: {- status: 'blocked',- reason: `Next.js ${target} is affected by an active advisory.`,- },- })- await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(- `Next.js ${target} is affected by an active advisory.`- )- }- )+ it('blocks an advised stable target for a prerelease', async () => {+ const installed = '17.2.0-rc.1'+ const target = '17.2.0'+ const directory = await createApp(installed)+ mockSecurityMetadata({ ranges: [target], target })+ await expect(+ getUpgradeAssessment(installed, 'latest')+ ).resolves.toMatchObject({+ affected: null,+ upgrade: {+ status: 'blocked',+ reason: `Next.js ${target} is affected by an active advisory.`,+ },+ })+ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ `Next.js ${target} is affected by an active advisory.`+ )+ }) it('does not offer stable latest when advisory validation fails for a prerelease', async () => { const installed = '17.2.0-rc.1' const directory = await createApp(installed)- mockSecurityMetadata({ fallback: true, npmFailure: true })- await expect(getUpgradeAssessment(installed, 'latest')).rejects.toThrow(- 'Could not check for security updates.'- )+ mockSecurityMetadata({ npmFailure: true })+ await expect(+ getUpgradeAssessment(installed, 'latest')+ ).resolves.toMatchObject({+ affected: null,+ upgrade: {+ status: 'unknown',+ reason: 'Could not check for security updates. Please try again.',+ },+ }) await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow( 'Could not check for security updates.' )@@ -520,13 +560,7 @@ describe('prepare latest upgrade', () => { 'invalid', ])('rejects a prerelease or invalid stable dist-tag: %s', async (target) => { const directory = await createApp('17.2.0-rc.1')- global.fetch = jest.fn(async (input) =>- Response.json(- String(input).startsWith('https://api.github.com/advisories?')- ? []- : { version: target }- )- )+ mockLatestVersion(target) await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow( 'Could not determine the latest stable Next.js version.' )@@ -536,13 +570,7 @@ describe('prepare latest upgrade', () => { 'does not downgrade %s when stable latest is older', async (installed) => { const directory = await createApp(installed)- global.fetch = jest.fn(async (input) => {- if (String(input).startsWith('https://api.github.com/advisories?')) {- return Response.json([])- }- expect(String(input)).toBe('https://registry.npmjs.org/next/latest')- return Response.json({ version: '17.2.0' })- })+ mockLatestVersion('17.2.0') await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual({ status: 'unaffected', reason: `Next.js ${installed} is newer than the latest stable release 17.2.0.`,@@ -669,27 +697,23 @@ describe('prepare latest upgrade', () => { ) }) - it.each([false, true])(- 'assesses a retained Future version missing from registry metadata with npm fallback=%s',- async (fallback) => {- const directory = await createApp('17.2.9')- mockSecurityMetadata({- fallback,- ranges: ['17.2.9'],- target: '17.2.1',- published: ['17.2.0', '17.2.1'],- })- await expect(prepareUpgrade(directory, 'future')).rejects.toThrow(- '17.2.9 is affected by an active advisory.'- )- }- )+ it('assesses a retained Future version missing from registry metadata', async () => {+ const directory = await createApp('17.2.9')+ mockSecurityMetadata({+ ranges: ['17.2.9'],+ target: '17.2.1',+ published: ['17.2.0', '17.2.1'],+ })+ await expect(prepareUpgrade(directory, 'future')).rejects.toThrow(+ '17.2.9 is affected by an active advisory.'+ )+ }) it.each(['latest', 'future'] as const)(- 'fails a %s assessment when both advisory providers are unavailable',+ 'fails a %s assessment when the advisory endpoint is unavailable', async (policy) => { const directory = await createApp('17.2.0')- mockSecurityMetadata({ fallback: true, npmFailure: true })+ mockSecurityMetadata({ npmFailure: true }) await expect(prepareUpgrade(directory, policy)).rejects.toThrow( 'Could not check for security updates.' )@@ -708,7 +732,7 @@ describe('prepare latest upgrade', () => { references: ['https://registry.npmjs.org/next/latest'], }) )- expect(global.fetch).toHaveBeenCalledTimes(2)+ expect(global.fetch).toHaveBeenCalledTimes(3) expect(global.fetch).toHaveBeenCalledWith( 'https://registry.npmjs.org/next/latest', expect.any(Object)@@ -730,9 +754,9 @@ describe('prepare latest upgrade', () => { async (metadata) => { const directory = await createApp('17.1.0') global.fetch = jest.fn(async (input) =>- Response.json(- String(input).startsWith('https://api.github.com/') ? [] : metadata- )+ String(input).includes('/security/advisories/bulk')+ ? Response.json({})+ : Response.json(metadata) ) await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(@@ -750,8 +774,8 @@ describe('prepare latest upgrade', () => { it('preserves target lookup failures in the shared assessment and command', async () => { const directory = await createApp('17.1.0') global.fetch = jest.fn(async (input) => {- if (String(input).startsWith('https://api.github.com/')) {- return Response.json([])+ if (String(input).includes('/security/advisories/bulk')) {+ return Response.json({}) } throw new Error('Network unavailable') })@@ -789,55 +813,46 @@ describe('prepare latest upgrade', () => { }) }) - it.each([false, true])(- 'ignores an affected older latest target with npm fallback=%s',- async (fallback) => {- const directory = await createApp('17.3.0')- mockSecurityMetadata({- fallback,- ranges: ['17.2.0'],- target: '17.2.0',- published: ['17.2.0', '17.3.0'],- })+ it('ignores an affected older latest target', async () => {+ const directory = await createApp('17.3.0')+ mockSecurityMetadata({+ ranges: ['17.2.0'],+ target: '17.2.0',+ published: ['17.2.0', '17.3.0'],+ }) - await expect(- getUpgradeAssessment('17.3.0', 'latest')- ).resolves.toMatchObject({- affected: false,- upgrade: { status: 'unaffected' },- })- await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual({- status: 'unaffected',- reason:- 'Next.js 17.3.0 is newer than the latest stable release 17.2.0.',- })- }- )+ await expect(+ getUpgradeAssessment('17.3.0', 'latest')+ ).resolves.toMatchObject({+ affected: false,+ upgrade: { status: 'unaffected' },+ })+ await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual({+ status: 'unaffected',+ reason: 'Next.js 17.3.0 is newer than the latest stable release 17.2.0.',+ })+ }) - it.each([false, true])(- 'still blocks an affected equal latest target with npm fallback=%s',- async (fallback) => {- const directory = await createApp('17.3.0')- mockSecurityMetadata({- fallback,- ranges: ['17.3.0'],- target: '17.3.0',- published: ['17.3.0'],- })+ it('still blocks an affected equal latest target', async () => {+ const directory = await createApp('17.3.0')+ mockSecurityMetadata({+ ranges: ['17.3.0'],+ target: '17.3.0',+ published: ['17.3.0'],+ }) - await expect(- getUpgradeAssessment('17.3.0', 'latest')- ).resolves.toMatchObject({- affected: true,- upgrade: { status: 'blocked' },- })- await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(- 'Next.js 17.3.0 is affected by an active advisory.'- )- }- )+ await expect(+ getUpgradeAssessment('17.3.0', 'latest')+ ).resolves.toMatchObject({+ affected: true,+ upgrade: { status: 'blocked' },+ })+ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ 'Next.js 17.3.0 is affected by an active advisory.'+ )+ }) - it('allows a Future target outside npm fallback advisory ranges', async () => {+ it('allows a Future target outside npm advisory ranges', async () => { const directory = await createApp('16.2.0') mockFutureMetadata('<16.3.0') @@ -850,7 +865,7 @@ describe('prepare latest upgrade', () => { ) }) - it('blocks a Future target inside npm fallback advisory ranges', async () => {+ it('blocks a Future target inside npm advisory ranges', async () => { const directory = await createApp('16.2.0') mockFutureMetadata('<=16.4.0') test/unit/security-upgrade-nudge.test.ts45 + / 43 −
@@ -192,33 +192,15 @@ describe('security upgrade nudge', () => { }) it.each(['blocked', 'unknown'] as const)(- 'preserves the advisory and retry behavior when target eligibility is %s',+ 'skips the security nudge when target eligibility is %s', async (status) => { jest.mocked(getUpgradeAssessment).mockResolvedValue({ reference: 'https://api.github.com/advisories?affects=next', affected: true, upgrade: { status, reason: 'Target assessment detail.' }, })- let message = ''- try {- await run()- } catch (error) {- expect(error).toMatchObject({ name: 'SecurityFatalError', exitCode: 1 })- message = (error as Error).message- }- expect(message).toContain('affected by a published security advisory')- expect(message).toContain('Target assessment detail.')- expect(message.includes('next upgrade --ai')).toBe(false)- expect(message.includes('can be automatically upgraded')).toBe(false)- expect(message).toContain(- status === 'unknown'- ? 'Upgrade availability could not be checked.'- : 'No safe newer target is available'- ) await expect(run()).resolves.toBeUndefined()- expect(warn).toHaveBeenCalledWith(- expect.stringContaining('Target assessment detail.')- )+ expect(warn).not.toHaveBeenCalled() } ) @@ -353,7 +335,9 @@ describe('latest nudge release selection', () => { ['17.2.0-canary.4', '17.3.0-rc.1', null], ['17.2.0-rc.1', '17.3.0', '17.3.0'], ['17.2.0-rc.1', '17.2.0', '17.2.0'],+ ['17.2.0-rc.1', '17.2.0-rc.2', null], ['17.2.0-beta.1', '17.2.0', '17.2.0'],+ ['17.2.0-beta.1', '18.0.0-beta.1', null], ['17.2.0-preview.1', '17.2.0', '17.2.0'], ['17.2.0-rc.1', '17.3.0-rc.1', null], ['17.2.0-beta.1', '17.3.0-beta.1', null],@@ -400,7 +384,7 @@ describe('latest upgrade nudge', () => { ) it.each(['rc', 'beta', 'preview'] as const)(- 'links a configured %s reminder to stable latest',+ 'links a verified %s latest reminder to stable latest', async (channel) => { process.env.__NEXT_VERSION = `17.2.0-${channel}.1` mockUpgrade('17.2.0')@@ -412,6 +396,11 @@ describe('latest upgrade nudge', () => { 'Reference: https://registry.npmjs.org/next/latest' ), })+ expect(getUpgradeAssessment).toHaveBeenCalledWith(+ `17.2.0-${channel}.1`,+ 'latest',+ false+ ) } ) @@ -786,36 +775,37 @@ describe('human upgrade nudge', () => { ) it.each(['security', 'latest', 'future'] as const)(- 'forces a %s request without changing installed-version eligibility',+ 'does not force a %s nudge without a valid installed version', async (policy) => { process.env.__NEXT_AGENTIC_AUTO_UPGRADE = policy- process.env.__NEXT_VERSION = '16.4.0-preview-test'+ process.env.__NEXT_VERSION = 'not-a-version' processEnv([], directory) updateInitialEnv({ __NEXT_AGENTIC_AUTO_UPGRADE: policy })- jest.mocked(promptUpgrade).mockResolvedValue('update') for (const configured of [false, 'future'] as const) { const original = config(configured) const context = getUpgradeContext(original) expect(context.experimental.agenticAutoUpgrade).toBe(policy) await expect( nudgeUpgrade(directory, context, 'dev', new AbortController().signal)- ).resolves.toBe('update')+ ).resolves.toBeUndefined() }- expect(promptUpgrade).toHaveBeenCalledWith(- expect.stringContaining(`__NEXT_AGENTIC_AUTO_UPGRADE=${policy}`),- expect.any(AbortSignal),- true- )+ expect(promptUpgrade).not.toHaveBeenCalled() expect(getUpgradeAssessment).not.toHaveBeenCalled()- const message = jest.mocked(promptUpgrade).mock.calls[0][0]- expect(message).toContain('Forced preview:')- expect(message).not.toContain(`next upgrade --ai`)- expect(message).not.toContain('You requested') jest.mocked(getAgentName).mockResolvedValue('codex')- await expect(run(policy)).rejects.toMatchObject({- message: expect.stringContaining(`next upgrade --ai=${policy}`),- })+ await expect(run(policy)).resolves.toBeUndefined()+ }+ ) + it.each(['security', 'latest', 'future'] as const)(+ 'runs an explicitly requested %s upgrade',+ async (policy) => {+ process.env.__NEXT_AGENTIC_AUTO_UPGRADE = policy+ process.env.__NEXT_VERSION = '16.4.0-preview-test'+ processEnv([], directory)+ updateInitialEnv({+ __NEXT_AGENTIC_AUTO_UPGRADE: policy,+ __NEXT_VERSION: '16.4.0-preview-test',+ }) jest.mocked(spawnNextUpgrade).mockImplementationOnce(async () => { expect(process.env.__NEXT_AGENTIC_AUTO_UPGRADE).toBeUndefined() // Future upgrade preparation reloads config and resets the environment.@@ -832,6 +822,22 @@ describe('human upgrade nudge', () => { } ) + it.each(['blocked', 'unknown'] as const)(+ 'does not force a nudge when the target is %s',+ async (status) => {+ process.env.__NEXT_AGENTIC_AUTO_UPGRADE = 'security'+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ reference:+ 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',+ upgrade: { status, reason: 'No verified target.' },+ })+ await expect(run('security')).resolves.toBeUndefined()+ expect(promptUpgrade).not.toHaveBeenCalled()+ expect(warn).not.toHaveBeenCalled()+ }+ )+ it('lets an explicit request bypass a saved dismissal', async () => { jest.mocked(promptUpgrade).mockResolvedValue('dismiss') await run('security')@@ -1030,18 +1036,14 @@ describe('human upgrade nudge', () => { }) it.each(['blocked', 'unknown'] as const)(- 'omits Update when security target availability is %s',+ 'does not prompt when security target availability is %s', async (status) => { jest.mocked(getUpgradeAssessment).mockResolvedValue({ ...securityAssessment, upgrade: { status, reason: 'No eligible target.' }, }) await run()- expect(promptUpgrade).toHaveBeenCalledWith(- expect.stringContaining('No eligible target.'),- expect.any(AbortSignal),- false- )+ expect(promptUpgrade).not.toHaveBeenCalled() } )