From ece4338a772006e931c75abda0cde39219996df1 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 18 Sep 2025 10:43:15 +0200 Subject: [PATCH 1/8] test: setup for skew protection e2e tests and first test (server actions case) --- package-lock.json | 37 ++ package.json | 2 + tests/e2e/skew-protection.test.ts | 574 ++++++++++++++++++ .../skew-protection/app/app-router/actions.js | 5 + .../app/app-router/linked/client-component.js | 12 + .../app/app-router/linked/page.js | 16 + .../skew-protection/app/app-router/page.js | 132 ++++ .../app/app-router/route-handler/route.js | 3 + .../dynamically-imported-module.js | 1 + .../app/dynamic-import/page.js | 40 ++ tests/fixtures/skew-protection/app/layout.js | 12 + .../app/middleware/[slug]/page.js | 16 + .../skew-protection/app/middleware/page.js | 125 ++++ .../app/next-config/[slug]/page.js | 18 + .../skew-protection/app/next-config/page.js | 56 ++ tests/fixtures/skew-protection/app/page.js | 30 + tests/fixtures/skew-protection/middleware.js | 32 + .../fixtures/skew-protection/next.config.mjs | 57 ++ tests/fixtures/skew-protection/package.json | 15 + .../skew-protection/pages/api/api-route.js | 3 + .../pages/pages-router/index.js | 119 ++++ .../pages-router/linked-getServerSideProps.js | 27 + .../pages-router/linked-getStaticProps.js | 24 + .../pages/pages-router/linked-static.js | 11 + .../skew-protection/public/local-image-b.png | Bin 0 -> 5894 bytes .../skew-protection/public/local-image.png | Bin 0 -> 6457 bytes .../skew-protection/public/variant-b.txt | 1 + .../skew-protection/public/variant.txt | 1 + .../skew-protection/test-variants.json | 9 + .../skew-protection/variant-config-b.mjs | 2 + .../skew-protection/variant-config.mjs | 2 + tests/utils/build-variants.mjs | 22 +- tests/utils/create-e2e-fixture.ts | 147 ++++- 33 files changed, 1542 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/skew-protection.test.ts create mode 100644 tests/fixtures/skew-protection/app/app-router/actions.js create mode 100644 tests/fixtures/skew-protection/app/app-router/linked/client-component.js create mode 100644 tests/fixtures/skew-protection/app/app-router/linked/page.js create mode 100644 tests/fixtures/skew-protection/app/app-router/page.js create mode 100644 tests/fixtures/skew-protection/app/app-router/route-handler/route.js create mode 100644 tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js create mode 100644 tests/fixtures/skew-protection/app/dynamic-import/page.js create mode 100644 tests/fixtures/skew-protection/app/layout.js create mode 100644 tests/fixtures/skew-protection/app/middleware/[slug]/page.js create mode 100644 tests/fixtures/skew-protection/app/middleware/page.js create mode 100644 tests/fixtures/skew-protection/app/next-config/[slug]/page.js create mode 100644 tests/fixtures/skew-protection/app/next-config/page.js create mode 100644 tests/fixtures/skew-protection/app/page.js create mode 100644 tests/fixtures/skew-protection/middleware.js create mode 100644 tests/fixtures/skew-protection/next.config.mjs create mode 100644 tests/fixtures/skew-protection/package.json create mode 100644 tests/fixtures/skew-protection/pages/api/api-route.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/index.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js create mode 100644 tests/fixtures/skew-protection/pages/pages-router/linked-static.js create mode 100644 tests/fixtures/skew-protection/public/local-image-b.png create mode 100644 tests/fixtures/skew-protection/public/local-image.png create mode 100644 tests/fixtures/skew-protection/public/variant-b.txt create mode 100644 tests/fixtures/skew-protection/public/variant.txt create mode 100644 tests/fixtures/skew-protection/test-variants.json create mode 100644 tests/fixtures/skew-protection/variant-config-b.mjs create mode 100644 tests/fixtures/skew-protection/variant-config.mjs diff --git a/package-lock.json b/package-lock.json index 60c49d514a..87654a0ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,10 +22,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", @@ -5812,6 +5814,16 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -6605,6 +6617,16 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -36586,6 +36608,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -37168,6 +37199,12 @@ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true }, + "adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true + }, "agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", diff --git a/package.json b/package.json index 612bf394d7..c86a1c4706 100644 --- a/package.json +++ b/package.json @@ -65,10 +65,12 @@ "@netlify/zip-it-and-ship-it": "^14.1.8", "@opentelemetry/api": "^1.8.0", "@playwright/test": "^1.43.1", + "@types/adm-zip": "^0.5.7", "@types/node": "^20.12.7", "@types/picomatch": "^3.0.0", "@types/uuid": "^10.0.0", "@vercel/nft": "^0.30.0", + "adm-zip": "^0.5.16", "cheerio": "^1.0.0-rc.12", "clean-package": "^2.2.0", "esbuild": "^0.25.0", diff --git a/tests/e2e/skew-protection.test.ts b/tests/e2e/skew-protection.test.ts new file mode 100644 index 0000000000..dfec460313 --- /dev/null +++ b/tests/e2e/skew-protection.test.ts @@ -0,0 +1,574 @@ +import { expect } from '@playwright/test' +import { execaCommand } from 'execa' +import { + createE2EFixture, + createSite, + deleteSite, + getBuildFixtureVariantCommand, + publishDeploy, +} from '../utils/create-e2e-fixture.js' +import { test as baseTest } from '../utils/playwright-helpers.js' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' + +type ExtendedFixtures = { + skewProtection: { + siteId: string + url: string + deployA: Awaited> + deployB: Awaited> + } +} + +const test = baseTest.extend< + { prepareSkewProtectionScenario: (callback: () => T) => Promise }, + ExtendedFixtures +>({ + prepareSkewProtectionScenario: async ({ skewProtection }, use) => { + const fixture = async (callback: () => T) => { + // first we will publish deployA + // then we call arbitrary callback to allow tests to load page using deployA + // and after that we will publish deployB so page loaded in browser is not using + // currently published deploy anymore, but still get results from initially published deploy + + const pollURL = `${skewProtection.url}/variant.txt` + + await publishDeploy(skewProtection.siteId, skewProtection.deployA.deployID) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('A')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + const result = await callback() + + await publishDeploy(skewProtection.siteId, skewProtection.deployB.deployID) + + // https://netlify.slack.com/archives/C098NQ4DEF6/p1758207235732189 + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // poll to ensure deploy was restored before continuing + while (true) { + const response = await fetch(pollURL) + const text = await response.text() + if (text.startsWith('B')) { + break + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + return result + } + + await use(fixture) + }, + skewProtection: [ + async ({}, use) => { + const { siteId, url } = await createSite({ + name: `next-skew-tests-${Date.now()}`, + }) + + let onBuildStart: () => void = () => {} + const waitForBuildStart = new Promise((resolve) => { + onBuildStart = () => { + resolve() + } + }) + + const deployAPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + onBuildStart, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + }) + + // we don't have to wait for deployA to finish completely before starting deployB, but we do have to wait a little bit + // to at least when build starts building, as otherwise whole deploy might be skipped and only second deploy happens + await waitForBuildStart + + const deployBPromise = createE2EFixture('skew-protection', { + siteId, + useBuildbot: true, + env: { + NETLIFY_NEXT_SKEW_PROTECTION: 'true', + }, + onPreDeploy: async (fixtureRoot) => { + await execaCommand( + `${getBuildFixtureVariantCommand('variant-b')} --apply-file-changes-only`, + { + cwd: fixtureRoot, + }, + ) + }, + }) + + const [deployA, deployB] = await Promise.all([deployAPromise, deployBPromise]) + + const fixture = { + url, + siteId, + deployA, + deployB, + + cleanup: async () => { + if (process.env.E2E_PERSIST) { + console.log( + `💾 Fixture and deployed site have been persisted. To clean up automatically, run tests without the 'E2E_PERSIST' environment variable.`, + ) + + return + } + + await deployA.cleanup() + await deployB.cleanup() + await deleteSite(siteId) + }, + } + + // for local iteration - this will print out snippet to allow to reuse previously deployed setup + // paste this at the top of `skewProtection` fixture function and this will avoid having to wait for redeploys + // keep in mind that if fixture itself require changes, you will have to redeploy + // uncomment console.log if you want to use same site/fixture and just iterate on test themselves + // and run a test with E2E_PERSIST=1 to keep site around for future runs + if (process.env.E2E_PERSIST) { + console.log( + 'You can reuse persisted site by pasting below snippet at the top of `skewProtection` fixture logic', + ) + console.log(`await use(${JSON.stringify(fixture, null, 2)})\n\nreturn`) + } + await use(fixture) + + await fixture.cleanup() + }, + { + scope: 'worker', + }, + ], +}) + +test.describe('Skew Protection', () => { + test.describe('App Router', () => { + test('should scope next/link navigation to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + + // this tests that both RSC and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-linked-page"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-server-component-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-page-client-component-current-variant')).toHaveText( + '"A"', + ) + }) + + test('should scope server actions to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('server-action-button').click() + + const element = await page.waitForSelector('[data-testid="server-action-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be either "B" (currently published deploy) + // or error about not finding server action - example of such error: + // "Error: Server Action "00a130b1673301d79679b22abb06a62c3125376d79" was not found on the server. + // Read more: https://nextjs.org/docs/messages/failed-to-find-server-action" + expect(content).toBe(`"A"`) + }) + + test('should scope route handler to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('scoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-route-handler-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope route handler to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/app-router`) + }) + + page.getByTestId('unscoped-route-handler-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-route-handler-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Pages Router', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('when linked page is fully static', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-fully-static"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-static-current-variant')).toHaveText('"A"') + }) + + test('when linked page is getStaticProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getStaticProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getStaticProps-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-getStaticProps-props-variant')).toHaveText('"A"') + }) + + test('when linked page is getServerSideProps page', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that both json page data and browser .js bundles for linked route are scoped to initial deploy + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector('[data-testid="next-link-getServerSideProps"]') + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-getServerSideProps-current-variant')).toHaveText( + '"A"', + ) + await expect(page.getByTestId('linked-getServerSideProps-props-variant')).toHaveText('"A"') + }) + }) + + test('should scope api route to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('scoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="scoped-api-route-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope api route to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/pages-router`) + }) + + page.getByTestId('unscoped-api-route-button').click() + + const element = await page.waitForSelector('[data-testid="unscoped-api-route-result"]') + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Middleware', () => { + test.describe('should scope next/link navigation to initial deploy', () => { + test('NextResponse.next()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-next"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('next') + }) + + test('NextResponse.redirect()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('NextResponse.rewrite()', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + // this tests that browser .js bundles for linked route are scoped to initial deploy (fully static pages don't have page-data json) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-middleware-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test('should scope middleware endpoint to initial deploy when manual fetch have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('scoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="scoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + + test('should NOT scope middleware endpoint to initial deploy when manual fetch does NOT have X-Deployment-Id request header', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + // this test doesn't really test skew protection, because in this scenario skew protection is not expected to kick in + // it's added here mostly to document this interaction + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/middleware`) + }) + + page.getByTestId('unscoped-middleware-endpoint-button').click() + + const element = await page.waitForSelector( + '[data-testid="unscoped-middleware-endpoint-result"]', + ) + const content = await element.textContent() + + // when fetch in not scoped, it will use currently published deploy, so "B" is expected + expect(content).toBe(`"B"`) + }) + }) + + test.describe('Next.js config rewrite and redirects', () => { + test('should scope next/link navigation to initial deploy when link target is Next.js config redirect', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-redirect"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('redirect-a') + }) + + test('should scope next/link navigation to initial deploy when link target is Next.js config rewrite', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + test.skip( + !nextVersionSatisfies('>=15.0.0'), + 'next/link navigation scoped to initial deploy is only supported in Next.js >=15.0.0', + ) + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/next-config`) + }) + + // now that other deploy was published, we can show links + page.getByTestId('next-link-expand-button').click() + + // wait for links to show + const element = await page.waitForSelector( + '[data-testid="next-link-linked-page-next-config-rewrite"]', + ) + element.click() + + // ensure expected version of a page is rendered + await expect(page.getByTestId('linked-page-current-variant')).toHaveText('"A"') + await expect(page.getByTestId('linked-page-slug')).toHaveText('rewrite-a') + }) + }) + + test.describe('Dynamic import', () => { + test('should scope dynamic import to initial deploy', async ({ + page, + skewProtection, + prepareSkewProtectionScenario, + }) => { + await prepareSkewProtectionScenario(async () => { + return await page.goto(`${skewProtection.url}/dynamic-import`) + }) + + page.getByTestId('dynamic-import-button').click() + + const element = await page.waitForSelector('[data-testid="dynamic-import-result"]') + const content = await element.textContent() + + // if skew protection does not work, this will be "B" (currently published deploy) + expect(content).toBe(`"A"`) + }) + }) +}) diff --git a/tests/fixtures/skew-protection/app/app-router/actions.js b/tests/fixtures/skew-protection/app/app-router/actions.js new file mode 100644 index 0000000000..b7f7deb422 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function testAction() { + return process.env.SKEW_VARIANT +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/client-component.js b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js new file mode 100644 index 0000000000..2006d4f1ab --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/client-component.js @@ -0,0 +1,12 @@ +'use client' + +export function ClientComponent() { + return ( +

+ Client Component - variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/linked/page.js b/tests/fixtures/skew-protection/app/app-router/linked/page.js new file mode 100644 index 0000000000..8d8d10c8d5 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/linked/page.js @@ -0,0 +1,16 @@ +import { ClientComponent } from './client-component' + +export default function Page() { + return ( + <> +

Skew Protection Testing - App Router - next/link navigation test

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+ + + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/page.js b/tests/fixtures/skew-protection/app/app-router/page.js new file mode 100644 index 0000000000..b091fb2be2 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/page.js @@ -0,0 +1,132 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +import { testAction } from './actions' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [actionResult, setActionResult] = useState(null) + const [scopedRouteHandlerResult, setScopedRouteHandlerResult] = useState(null) + const [unscopedRouteHandlerResult, setUnscopedRouteHandlerResult] = useState(null) + + return ( + <> +

Skew Protection Testing - App Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+

Server Action

+
+ + {actionResult && ( +

+ Action result: {actionResult} ( + {actionResult === process.env.SKEW_VARIANT ? 'match' : 'mismatch'}) +

+ )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of route handler as one that served initial html to the browser + } +

Fetching route-handler (scoped)

+
+ + {scopedRouteHandlerResult && ( +

+ Scoped route handler result: + {scopedRouteHandlerResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of route handler + } +

Fetching route-handler (unscoped)

+
+ + {unscopedRouteHandlerResult && ( +

+ Unscoped route handler result: + {unscopedRouteHandlerResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/app-router/route-handler/route.js b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js new file mode 100644 index 0000000000..2a526c18a9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/app-router/route-handler/route.js @@ -0,0 +1,3 @@ +export const GET = async (req) => { + return new Response(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js new file mode 100644 index 0000000000..967763eda9 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/dynamically-imported-module.js @@ -0,0 +1 @@ +export const variant = process.env.SKEW_VARIANT diff --git a/tests/fixtures/skew-protection/app/dynamic-import/page.js b/tests/fixtures/skew-protection/app/dynamic-import/page.js new file mode 100644 index 0000000000..c1f9b68044 --- /dev/null +++ b/tests/fixtures/skew-protection/app/dynamic-import/page.js @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' + +export default function Page() { + const [dynamicallyImportedValue, setDynamicallyImportedValue] = useState(null) + + return ( + <> +

Skew Protection Testing - Dynamic import

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

Dynamic import

+
+ + {dynamicallyImportedValue && ( +

+ Dynamic import result: + {dynamicallyImportedValue} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/layout.js b/tests/fixtures/skew-protection/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/skew-protection/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/[slug]/page.js b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js new file mode 100644 index 0000000000..9c5f4971ec --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/[slug]/page.js @@ -0,0 +1,16 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

Skew Protection Testing - Middleware - link target page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/middleware/page.js b/tests/fixtures/skew-protection/app/middleware/page.js new file mode 100644 index 0000000000..f8f57a53d7 --- /dev/null +++ b/tests/fixtures/skew-protection/app/middleware/page.js @@ -0,0 +1,125 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [scopedMiddlewareEndpointResult, setScopedMiddlewareEndpointResult] = useState(null) + const [unscopedMiddlewareEndpointResult, setUnscopedMiddlewareEndpointResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Middleware

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of middleware endpoint as one that served initial html to the browser + } +

Fetching middleware endpoint (scoped)

+
+ + {scopedMiddlewareEndpointResult && ( +

+ Scoped middleware endpoint result: + + {scopedMiddlewareEndpointResult} + +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of middleware endpoint + } +

Fetching middleware endpoint (unscoped)

+
+ + {unscopedMiddlewareEndpointResult && ( +

+ Unscoped middleware endpoint result: + + {unscopedMiddlewareEndpointResult} + +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/[slug]/page.js b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js new file mode 100644 index 0000000000..2bf7c3ac85 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/[slug]/page.js @@ -0,0 +1,18 @@ +export default async function Page({ params }) { + const { slug } = await params + + return ( + <> +

+ Skew Protection Testing - next.config.js - link target page +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Slug: {slug} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/app/next-config/page.js b/tests/fixtures/skew-protection/app/next-config/page.js new file mode 100644 index 0000000000..883e8a1155 --- /dev/null +++ b/tests/fixtures/skew-protection/app/next-config/page.js @@ -0,0 +1,56 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + + return ( + <> +

+ Skew Protection Testing - next.config.js +

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/app/page.js b/tests/fixtures/skew-protection/app/page.js new file mode 100644 index 0000000000..831070adbd --- /dev/null +++ b/tests/fixtures/skew-protection/app/page.js @@ -0,0 +1,30 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing

+ + + ) +} diff --git a/tests/fixtures/skew-protection/middleware.js b/tests/fixtures/skew-protection/middleware.js new file mode 100644 index 0000000000..0b354a6d55 --- /dev/null +++ b/tests/fixtures/skew-protection/middleware.js @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export function middleware(request) { + const parsedVariant = JSON.parse(process.env.SKEW_VARIANT) + + if (request.nextUrl.pathname === '/middleware/next') { + return NextResponse.next() + } + + if (request.nextUrl.pathname === '/middleware/redirect') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/redirect-${parsedVariant.toLowerCase()}` + return NextResponse.redirect(url) + } + + if (request.nextUrl.pathname === '/middleware/rewrite') { + const url = request.nextUrl.clone() + url.pathname = `/middleware/rewrite-${parsedVariant.toLowerCase()}` + return NextResponse.rewrite(url) + } + + if (request.nextUrl.pathname === '/middleware/json') { + return NextResponse.json(parsedVariant) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/tests/fixtures/skew-protection/next.config.mjs b/tests/fixtures/skew-protection/next.config.mjs new file mode 100644 index 0000000000..511d6fd2e5 --- /dev/null +++ b/tests/fixtures/skew-protection/next.config.mjs @@ -0,0 +1,57 @@ +import { remoteImage, variant } from './variant-config.mjs' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + // for next@<14.0.0 + serverActions: true, + // for next@<14.1.4 + useDeploymentId: true, + // Optionally, use with Server Actions + useDeploymentIdServerActions: true, + }, + outputFileTracingRoot: import.meta.dirname, + + // for next@<15.1.0 + webpack(config, { webpack }) { + config.plugins.push( + new webpack.DefinePlugin({ + // double JSON.stringify is intentional here - this is to keep results same as when using `compile.define` + 'process.env.SKEW_VARIANT': JSON.stringify(JSON.stringify(variant)), + }), + ) + return config + }, + + compiler: { + // this is same as above webpack config, but this will apply to turbopack builds as well + // so just future proofing it here + define: { + 'process.env.SKEW_VARIANT': JSON.stringify(variant), + }, + }, + + redirects() { + return [ + { + source: '/next-config/redirect', + destination: `/next-config/redirect-${variant.toLowerCase()}`, + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/next-config/rewrite', + destination: `/next-config/rewrite-${variant.toLowerCase()}`, + }, + ] + }, +} + +export default nextConfig diff --git a/tests/fixtures/skew-protection/package.json b/tests/fixtures/skew-protection/package.json new file mode 100644 index 0000000000..299855e023 --- /dev/null +++ b/tests/fixtures/skew-protection/package.json @@ -0,0 +1,15 @@ +{ + "name": "skew-protection", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "npm run build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/skew-protection/pages/api/api-route.js b/tests/fixtures/skew-protection/pages/api/api-route.js new file mode 100644 index 0000000000..7dc0f0c926 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/api/api-route.js @@ -0,0 +1,3 @@ +export default function handler(_req, res) { + res.send(process.env.SKEW_VARIANT) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/index.js b/tests/fixtures/skew-protection/pages/pages-router/index.js new file mode 100644 index 0000000000..364da0cccb --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/index.js @@ -0,0 +1,119 @@ +import { useState } from 'react' +import Link from 'next/link' + +export default function Page() { + const [showLinks, setShowLinks] = useState(false) + const [unscopedApiRouteResult, setUnscopedApiRouteResult] = useState(null) + const [scopedApiRouteResult, setScopedApiRouteResult] = useState(null) + + return ( + <> +

Skew Protection Testing - Pages Router

+

+ Current variant: {process.env.SKEW_VARIANT} +

+

+ next/link +

+
+ { + // Links are hidden initially, because as soon as link is in viewport, Next.js will prefetch it. + // We want to control this because we do deploy swapping, so we only want links to be in viewport + // after we do initial page load and then publish another deploy. + // Otherwise prefetch could be triggered before deploy swap which would not be testing + // skew protection. + } + + {showLinks && ( + + )} +
+ { + // scoped here means that manual fetch call does include skew protection param which should lead to using same deployment version of api route as one that served initial html to the browser + } +

Fetching API route (scoped)

+
+ + {scopedApiRouteResult && ( +

+ Scoped API route result: + {scopedApiRouteResult} +

+ )} +
+ { + // unscoped here means that manual fetch call does NOT include skew protection param which should lead to using currently published deployment version of api route + } +

Fetching API route (unscoped)

+
+ + {unscopedApiRouteResult && ( +

+ Unscoped API route result: + {unscopedApiRouteResult} +

+ )} +
+ + ) +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js new file mode 100644 index 0000000000..26cc4cd94d --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getServerSideProps.js @@ -0,0 +1,27 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getServerSideProps +

+

+ Current variant:{' '} + + {process.env.SKEW_VARIANT} + +

+

+ Variant from props:{' '} + {variant} +

+ + ) +} + +export async function getServerSideProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js new file mode 100644 index 0000000000..1e9e6dd46e --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-getStaticProps.js @@ -0,0 +1,24 @@ +export default function Page({ variant }) { + return ( + <> +

+ Skew Protection Testing - Pages Router - page with getStaticProps +

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+

+ Variant from props: {variant} +

+ + ) +} + +export async function getStaticProps() { + return { + props: { + variant: process.env.SKEW_VARIANT, + }, + } +} diff --git a/tests/fixtures/skew-protection/pages/pages-router/linked-static.js b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js new file mode 100644 index 0000000000..7eaaf5eff2 --- /dev/null +++ b/tests/fixtures/skew-protection/pages/pages-router/linked-static.js @@ -0,0 +1,11 @@ +export default function Page() { + return ( + <> +

Skew Protection Testing - Pages Router - fully static page

+

+ Current variant:{' '} + {process.env.SKEW_VARIANT} +

+ + ) +} diff --git a/tests/fixtures/skew-protection/public/local-image-b.png b/tests/fixtures/skew-protection/public/local-image-b.png new file mode 100644 index 0000000000000000000000000000000000000000..e0e353318fc29ae73949ece8370d7ba99af3b276 GIT binary patch literal 5894 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/skew-protection/public/local-image.png b/tests/fixtures/skew-protection/public/local-image.png new file mode 100644 index 0000000000000000000000000000000000000000..a282ce91bb3188c56ec00f74eb8c766e318339cf GIT binary patch literal 6457 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 2 ? argv.slice(2) : Object.keys(variants) +const variantsToBuild = + argv.length > 2 ? argv.slice(2).filter((arg) => !arg.startsWith('--')) : Object.keys(variants) + +const flags = argv.slice(2).filter((arg) => arg.startsWith('--')) /** @type {string[]} */ const notExistingVariants = [] @@ -118,12 +121,6 @@ for (const variantToBuild of variantsToBuild) { } } - const buildCommand = variant.buildCommand ?? 'next build' - const distDir = variant.distDir ?? '.next' - console.warn( - `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, - ) - for (const [target, source] of Object.entries(variant.files ?? {})) { const targetBackup = `${target}.bak` // create backup @@ -139,6 +136,17 @@ for (const variantToBuild of variantsToBuild) { }) } + if (flags.includes('--apply-file-changes-only')) { + console.warn(`[build-variants] Applied file changes for ${variantToBuild} variant`) + continue + } + + const buildCommand = variant.buildCommand ?? 'next build' + const distDir = variant.distDir ?? '.next' + console.warn( + `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, + ) + const result = await execaCommand(buildCommand, { env: { ...process.env, diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index fe2546da2e..3eebaad704 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -1,3 +1,4 @@ +import AdmZip from 'adm-zip' import { execaCommand } from 'execa' import fg from 'fast-glob' import { exec } from 'node:child_process' @@ -45,6 +46,25 @@ interface E2EConfig { * Site ID to deploy to. Defaults to the `NETLIFY_SITE_ID` environment variable or a default site. */ siteId?: string + /** + * If set to true, instead of using CLI to deploy, we will zip the source files and trigger build from zip. + */ + useBuildbot?: boolean + /** + * Runs before deploying the site if defined. + */ + onPreDeploy?: (isolatedFixtureRoot: string) => Promise + /** + * Buildbot mode specific callback that will be called once the build starts. + * Useful for scenario of triggering multiple consecutive builds, to be able to schedule builds + * before previous one finish completely. If multiple builds are scheduled at the same time, some + * of them might be skipped and this callback allows to avoid this scenario. + */ + onBuildStart?: () => Promise | void + /** + * Environment variables that will be added to `netlify.toml` if set. + */ + env?: Record } /** @@ -80,6 +100,9 @@ export const createE2EFixture = async (fixture: string, config: E2EConfig = {}) await setNextVersionInFixture(isolatedFixtureRoot, NEXT_VERSION) await installRuntime(packageName, isolatedFixtureRoot, config) await verifyFixture(isolatedFixtureRoot, config) + await config.onPreDeploy?.(isolatedFixtureRoot) + + const deploySite = config.useBuildbot ? deploySiteWithBuildbot : deploySiteWithCLI const result = await deploySite(isolatedFixtureRoot, config) @@ -157,6 +180,13 @@ async function buildAndPackRuntime( `[build] command = "${buildCommand}" publish = "${publishDirectory ?? join(siteRelDir, '.next')}" +${ + config.env + ? `[build.environment]\n${Object.entries(config.env) + .map(([key, value]) => `${key} = "${value}"`) + .join('\n')}` + : '' +} [[plugins]] package = "${name}" @@ -260,7 +290,7 @@ async function verifyFixture(isolatedFixtureRoot: string, { expectedCliVersion } } } -async function deploySite( +export async function deploySiteWithCLI( isolatedFixtureRoot: string, { packagePath, cwd = '', siteId = SITE_ID }: E2EConfig, ): Promise { @@ -293,6 +323,91 @@ async function deploySite( } } +export async function deploySiteWithBuildbot( + isolatedFixtureRoot: string, + { packagePath, siteId = SITE_ID, publishDirectory = '.next', onBuildStart }: E2EConfig, +): Promise { + if (packagePath) { + // It's likely possible to support this, just skipping implementing it until there's a need + // throwing just to be explicit that this was not done to avoid potential confusion if things + // don't work + throw new Error('packagePath is not currently supported when deploying with buildbot') + } + + if (!process.env.NETLIFY_AUTH_TOKEN) { + // we use CLI (ntl api) for most of operations, but build zip upload seems impossible with CLI + // and we do need to use API directly and we do need token for that + throw new Error('NETLIFY_AUTH_TOKEN is required for buildbot deploy, but it was not set') + } + + console.log(`🚀 Packing source files and triggering deploy`) + + const newZip = new AdmZip() + newZip.addLocalFolder(isolatedFixtureRoot, '', (entry) => { + if ( + // don't include node_modules / .git / publish dir in zip + entry.startsWith('node_modules') || + entry.startsWith('.git') || + entry.startsWith(publishDirectory) + ) { + return false + } + return true + }) + + const result = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`, + }, + // @ts-expect-error sigh, it works + body: newZip.toBuffer(), + }) + const { deploy_id } = await result.json() + + let didRunOnBuildStartCallback = false + const runOnBuildStartCallbackOnce = onBuildStart + ? () => { + if (!didRunOnBuildStartCallback) { + didRunOnBuildStartCallback = true + return onBuildStart() + } + } + : () => {} + + // poll for status + while (true) { + const { stdout } = await execaCommand( + `npx netlify api getDeploy --data=${JSON.stringify({ deploy_id })}`, + ) + const { state } = JSON.parse(stdout) + + if (state === 'error' || state === 'rejected') { + await runOnBuildStartCallbackOnce() + throw new Error( + `The deploy failed https://app.netlify.com/projects/${siteId}/deploys/${deploy_id}`, + ) + } + if (state === 'ready') { + await runOnBuildStartCallbackOnce() + break + } + + if (state === 'building') { + await runOnBuildStartCallbackOnce() + } + + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + + return { + deployID: deploy_id, + url: `https://${deploy_id}--${siteId}.netlify.app`, // this is not nice, but it does work + logs: '', + } +} + export async function deleteDeploy(deployID?: string): Promise { if (!deployID) { return @@ -312,10 +427,38 @@ async function cleanup(dest: string, deployId?: string): Promise { await Promise.allSettled([deleteDeploy(deployId), rm(dest, { recursive: true, force: true })]) } -function getBuildFixtureVariantCommand(variantName: string) { +export function getBuildFixtureVariantCommand(variantName: string) { return `node ${fileURLToPath(new URL(`./build-variants.mjs`, import.meta.url))} ${variantName}` } +export async function createSite(siteConfig?: { name: string }) { + const cmd = `npx netlify api createSiteInTeam --data=${JSON.stringify({ + account_slug: 'netlify-integration-testing', + body: siteConfig ?? {}, + })}` + + const { stdout } = await execaCommand(cmd) + const { site_id, ssl_url, admin_url } = JSON.parse(stdout) + + console.log(`🚀 Created site ${ssl_url} / ${admin_url}`) + + return { + siteId: site_id as string, + url: ssl_url as string, + adminUrl: admin_url as string, + } +} + +export async function deleteSite(siteId: string) { + const cmd = `npx netlify api deleteSite --data=${JSON.stringify({ site_id: siteId })}` + await execaCommand(cmd) +} + +export async function publishDeploy(siteId: string, deployID: string) { + const cmd = `npx netlify api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}` + await execaCommand(cmd) +} + export const fixtureFactories = { simple: () => createE2EFixture('simple'), helloWorldTurbopack: () => From 7ba519217eceea22bd7ba390ac0f00df37f736b6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 11 Sep 2025 12:18:43 +0200 Subject: [PATCH 2/8] feat: support skew protection --- src/build/plugin-context.ts | 5 + src/build/skew-protection.test.ts | 338 ++++++++++++++++++++++++++++++ src/build/skew-protection.ts | 112 ++++++++++ src/index.ts | 4 +- 4 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/build/skew-protection.test.ts create mode 100644 src/build/skew-protection.ts diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 63b09901ef..1fe088777b 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -207,6 +207,11 @@ export class PluginContext { return join(this.edgeFunctionsDir, EDGE_HANDLER_NAME) } + /** Absolute path to the skew protection config */ + get skewProtectionConfigPath(): string { + return this.resolveFromPackagePath('.netlify/v1/skew-protection.json') + } + constructor(options: NetlifyPluginOptions) { this.constants = options.constants this.featureFlags = options.featureFlags diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts new file mode 100644 index 0000000000..733d605f3d --- /dev/null +++ b/src/build/skew-protection.test.ts @@ -0,0 +1,338 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' +import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest' + +import type { PluginContext } from './plugin-context.js' +import { setSkewProtection, shouldEnableSkewProtection } from './skew-protection.js' + +// Mock fs promises +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock path +vi.mock('node:path', () => ({ + dirname: vi.fn(), +})) + +describe('shouldEnableSkewProtection', () => { + let mockCtx: PluginContext + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + } as PluginContext + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + }) + + describe('default behavior', () => { + it('should return disabled by default', () => { + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-default', + }) + }) + }) + + describe('environment variable opt-in', () => { + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: 'on-env-var', + }) + }) + + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: 'on-env-var', + }) + }) + }) + + describe('environment variable opt-out', () => { + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-env-var', + }) + }) + + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-env-var', + }) + }) + }) + + describe('feature flag opt-in', () => { + it('should enable when feature flag is set', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: 'on-ff', + }) + }) + + it('should not enable when feature flag is false', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-default', + }) + }) + }) + + describe('DEPLOY_ID validation', () => { + it('should disable when DEPLOY_ID is missing and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + delete process.env.DEPLOY_ID + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-no-valid-deploy-id', + }) + }) + + it('should disable when DEPLOY_ID is "0" and not explicitly opted in', () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + process.env.DEPLOY_ID = '0' + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-no-valid-deploy-id', + }) + }) + + it('should show specific reason when env var is set but DEPLOY_ID is invalid in local context', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-no-valid-deploy-id-env-var', + }) + }) + }) + + describe('precedence', () => { + it('should prioritize env var opt-out over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: 'off-env-var', + }) + }) + + it('should prioritize env var opt-in over feature flag', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + mockCtx.featureFlags = { 'next-runtime-skew-protection': false } + + const result = shouldEnableSkewProtection(mockCtx) + + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: 'on-env-var', + }) + }) + }) +}) + +describe('setSkewProtection', () => { + let mockCtx: PluginContext + let mockSpan: Span + let originalEnv: NodeJS.ProcessEnv + let consoleSpy: { + log: MockInstance + warn: MockInstance + } + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env } + + // Reset env vars + delete process.env.NETLIFY_NEXT_SKEW_PROTECTION + delete process.env.NEXT_DEPLOYMENT_ID + // Set valid DEPLOY_ID by default + process.env.DEPLOY_ID = 'test-deploy-id' + + mockCtx = { + featureFlags: {}, + constants: { + IS_LOCAL: false, + }, + skewProtectionConfigPath: '/test/path/skew-protection.json', + } as PluginContext + + mockSpan = { + setAttribute: vi.fn(), + } as unknown as Span + + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => { + /* no op */ + }), + warn: vi.spyOn(console, 'warn').mockImplementation(() => { + /* no op */ + }), + } + + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original env + process.env = originalEnv + consoleSpy.log.mockRestore() + consoleSpy.warn.mockRestore() + }) + + it('should set span attribute and return early when disabled', async () => { + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'off-default') + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + expect(consoleSpy.log).not.toHaveBeenCalled() + expect(consoleSpy.warn).not.toHaveBeenCalled() + }) + + it('should show warning when env var is set but no valid DEPLOY_ID', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + process.env.DEPLOY_ID = '0' + mockCtx.constants.IS_LOCAL = true + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + 'off-no-valid-deploy-id-env-var', + ) + expect(consoleSpy.warn).toHaveBeenCalledWith( + 'NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to true, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.', + ) + expect(mkdir).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + }) + + it('should set up skew protection when enabled via env var', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-env-var') + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=true environment variable.', + ) + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith( + '/test/path/skew-protection.json', + JSON.stringify( + { + patterns: ['.*'], + sources: [ + { + type: 'cookie', + name: '__vdpl', + }, + { + type: 'header', + name: 'X-Deployment-Id', + }, + { + type: 'query', + name: 'dpl', + }, + ], + }, + null, + 2, + ), + ) + }) + + it('should set up skew protection when enabled via feature flag', async () => { + mockCtx.featureFlags = { 'next-runtime-skew-protection': true } + + vi.mocked(dirname).mockReturnValue('/test/path') + + await setSkewProtection(mockCtx, mockSpan) + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-ff') + expect(consoleSpy.log).toHaveBeenCalledWith('Setting up Next.js Skew Protection.') + expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') + expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) + expect(writeFile).toHaveBeenCalledWith('/test/path/skew-protection.json', expect.any(String)) + }) + + it('should handle different env var values correctly', async () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + + await setSkewProtection(mockCtx, mockSpan) + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=1 environment variable.', + ) + }) +}) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts new file mode 100644 index 0000000000..72f3049a67 --- /dev/null +++ b/src/build/skew-protection.ts @@ -0,0 +1,112 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Span } from '@opentelemetry/api' + +import type { PluginContext } from './plugin-context.js' + +// eslint-disable-next-line no-shadow +const enum EnabledOrDisabledReason { + OPT_OUT_DEFAULT = 'off-default', + OPT_OUT_NO_VALID_DEPLOY_ID = 'off-no-valid-deploy-id', + OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR = 'off-no-valid-deploy-id-env-var', + OPT_IN_FF = 'on-ff', + OPT_IN_ENV_VAR = 'on-env-var', + OPT_OUT_ENV_VAR = 'off-env-var', +} + +const optInOptions = new Set([ + EnabledOrDisabledReason.OPT_IN_FF, + EnabledOrDisabledReason.OPT_IN_ENV_VAR, +]) + +export function shouldEnableSkewProtection(ctx: PluginContext) { + let enabledOrDisabledReason: EnabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_DEFAULT + + if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'true' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '1' + ) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_ENV_VAR + } else if ( + process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'false' || + process.env.NETLIFY_NEXT_SKEW_PROTECTION === '0' + ) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_ENV_VAR + } else if (ctx.featureFlags?.['next-runtime-skew-protection']) { + enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_FF + } + + if ( + (!process.env.DEPLOY_ID || process.env.DEPLOY_ID === '0') && + optInOptions.has(enabledOrDisabledReason) + ) { + // We can't proceed without a valid DEPLOY_ID, because Next.js does inline deploy ID at build time + // This should only be the case for CLI deploys + enabledOrDisabledReason = + enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR && ctx.constants.IS_LOCAL + ? // this case is singled out to provide visible feedback to users that env var has no effect + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR + : // this is silent disablement to avoid spam logs for users opted in via feature flag + // that don't explicitly opt in via env var + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID + } + + return { + enabled: optInOptions.has(enabledOrDisabledReason), + enabledOrDisabledReason, + } +} + +export const setSkewProtection = async (ctx: PluginContext, span: Span) => { + const { enabled, enabledOrDisabledReason } = shouldEnableSkewProtection(ctx) + + span.setAttribute('skewProtection', enabledOrDisabledReason) + + if (!enabled) { + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR) { + console.warn( + `NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to ${process.env.NETLIFY_NEXT_SKEW_PROTECTION}, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.`, + ) + } + return + } + + if (enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR) { + console.log( + `Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=${process.env.NETLIFY_NEXT_SKEW_PROTECTION} environment variable.`, + ) + } else { + console.log('Setting up Next.js Skew Protection.') + } + + process.env.NEXT_DEPLOYMENT_ID = process.env.DEPLOY_ID + + await mkdir(dirname(ctx.skewProtectionConfigPath), { + recursive: true, + }) + await writeFile( + ctx.skewProtectionConfigPath, + JSON.stringify( + { + patterns: ['.*'], + sources: [ + { + type: 'cookie', + name: '__vdpl', + }, + { + type: 'header', + name: 'X-Deployment-Id', + }, + { + type: 'query', + name: 'dpl', + }, + ], + }, + null, + 2, + ), + ) +} diff --git a/src/index.ts b/src/index.ts index 27d9c1ff7b..296da96949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/ed import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' import { setImageConfig } from './build/image-cdn.js' import { PluginContext } from './build/plugin-context.js' +import { setSkewProtection } from './build/skew-protection.js' import { verifyAdvancedAPIRoutes, verifyNetlifyFormsWorkaround, @@ -49,7 +50,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { return } - await tracer.withActiveSpan('onPreBuild', async () => { + await tracer.withActiveSpan('onPreBuild', async (span) => { // Enable Next.js standalone mode at build time process.env.NEXT_PRIVATE_STANDALONE = 'true' const ctx = new PluginContext(options) @@ -62,6 +63,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { } else { await restoreBuildCache(ctx) } + await setSkewProtection(ctx, span) }) } From fae75575a99a4a1da34873495a917d5f7aabcf9a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 2 Oct 2025 18:40:22 +0200 Subject: [PATCH 3/8] test: use enums in tests and not string values --- src/build/skew-protection.test.ts | 47 ++++++++++++++++++++----------- src/build/skew-protection.ts | 2 +- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts index 733d605f3d..98421fddb4 100644 --- a/src/build/skew-protection.test.ts +++ b/src/build/skew-protection.test.ts @@ -5,7 +5,11 @@ import type { Span } from '@opentelemetry/api' import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest' import type { PluginContext } from './plugin-context.js' -import { setSkewProtection, shouldEnableSkewProtection } from './skew-protection.js' +import { + EnabledOrDisabledReason, + setSkewProtection, + shouldEnableSkewProtection, +} from './skew-protection.js' // Mock fs promises vi.mock('node:fs/promises', () => ({ @@ -52,7 +56,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-default', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, }) }) }) @@ -65,7 +69,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: true, - enabledOrDisabledReason: 'on-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, }) }) @@ -76,7 +80,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: true, - enabledOrDisabledReason: 'on-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, }) }) }) @@ -89,7 +93,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, }) }) @@ -100,7 +104,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, }) }) }) @@ -113,7 +117,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: true, - enabledOrDisabledReason: 'on-ff', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_FF, }) }) @@ -124,7 +128,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-default', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, }) }) }) @@ -138,7 +142,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-no-valid-deploy-id', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, }) }) @@ -150,7 +154,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-no-valid-deploy-id', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, }) }) @@ -163,7 +167,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-no-valid-deploy-id-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, }) }) }) @@ -177,7 +181,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: false, - enabledOrDisabledReason: 'off-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, }) }) @@ -189,7 +193,7 @@ describe('shouldEnableSkewProtection', () => { expect(result).toEqual({ enabled: true, - enabledOrDisabledReason: 'on-env-var', + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, }) }) }) @@ -248,7 +252,10 @@ describe('setSkewProtection', () => { it('should set span attribute and return early when disabled', async () => { await setSkewProtection(mockCtx, mockSpan) - expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'off-default') + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_OUT_DEFAULT, + ) expect(mkdir).not.toHaveBeenCalled() expect(writeFile).not.toHaveBeenCalled() expect(consoleSpy.log).not.toHaveBeenCalled() @@ -264,7 +271,7 @@ describe('setSkewProtection', () => { expect(mockSpan.setAttribute).toHaveBeenCalledWith( 'skewProtection', - 'off-no-valid-deploy-id-env-var', + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR, ) expect(consoleSpy.warn).toHaveBeenCalledWith( 'NETLIFY_NEXT_SKEW_PROTECTION environment variable is set to true, but skew protection is currently unavailable for CLI deploys. Skew protection will not be enabled.', @@ -280,7 +287,10 @@ describe('setSkewProtection', () => { await setSkewProtection(mockCtx, mockSpan) - expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-env-var') + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_ENV_VAR, + ) expect(consoleSpy.log).toHaveBeenCalledWith( 'Setting up Next.js Skew Protection due to NETLIFY_NEXT_SKEW_PROTECTION=true environment variable.', ) @@ -319,7 +329,10 @@ describe('setSkewProtection', () => { await setSkewProtection(mockCtx, mockSpan) - expect(mockSpan.setAttribute).toHaveBeenCalledWith('skewProtection', 'on-ff') + expect(mockSpan.setAttribute).toHaveBeenCalledWith( + 'skewProtection', + EnabledOrDisabledReason.OPT_IN_FF, + ) expect(consoleSpy.log).toHaveBeenCalledWith('Setting up Next.js Skew Protection.') expect(process.env.NEXT_DEPLOYMENT_ID).toBe('test-deploy-id') expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts index 72f3049a67..58a5969f7e 100644 --- a/src/build/skew-protection.ts +++ b/src/build/skew-protection.ts @@ -6,7 +6,7 @@ import type { Span } from '@opentelemetry/api' import type { PluginContext } from './plugin-context.js' // eslint-disable-next-line no-shadow -const enum EnabledOrDisabledReason { +export const enum EnabledOrDisabledReason { OPT_OUT_DEFAULT = 'off-default', OPT_OUT_NO_VALID_DEPLOY_ID = 'off-no-valid-deploy-id', OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR = 'off-no-valid-deploy-id-env-var', From 92cfc9a67a3765a426612bf7a99bf32013132a71 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 2 Oct 2025 18:42:29 +0200 Subject: [PATCH 4/8] test: export skew protection config and assert that configuration file was written with it instead of inlining it again in test --- src/build/skew-protection.test.ts | 23 ++--------------- src/build/skew-protection.ts | 43 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts index 98421fddb4..1d2727e8ac 100644 --- a/src/build/skew-protection.test.ts +++ b/src/build/skew-protection.test.ts @@ -9,6 +9,7 @@ import { EnabledOrDisabledReason, setSkewProtection, shouldEnableSkewProtection, + skewProtectionConfig, } from './skew-protection.js' // Mock fs promises @@ -298,27 +299,7 @@ describe('setSkewProtection', () => { expect(mkdir).toHaveBeenCalledWith('/test/path', { recursive: true }) expect(writeFile).toHaveBeenCalledWith( '/test/path/skew-protection.json', - JSON.stringify( - { - patterns: ['.*'], - sources: [ - { - type: 'cookie', - name: '__vdpl', - }, - { - type: 'header', - name: 'X-Deployment-Id', - }, - { - type: 'query', - name: 'dpl', - }, - ], - }, - null, - 2, - ), + JSON.stringify(skewProtectionConfig), ) }) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts index 58a5969f7e..17dcc0dab7 100644 --- a/src/build/skew-protection.ts +++ b/src/build/skew-protection.ts @@ -20,6 +20,24 @@ const optInOptions = new Set([ EnabledOrDisabledReason.OPT_IN_ENV_VAR, ]) +export const skewProtectionConfig = { + patterns: ['.*'], + sources: [ + { + type: 'cookie', + name: '__vdpl', + }, + { + type: 'header', + name: 'X-Deployment-Id', + }, + { + type: 'query', + name: 'dpl', + }, + ], +} + export function shouldEnableSkewProtection(ctx: PluginContext) { let enabledOrDisabledReason: EnabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_DEFAULT @@ -85,28 +103,5 @@ export const setSkewProtection = async (ctx: PluginContext, span: Span) => { await mkdir(dirname(ctx.skewProtectionConfigPath), { recursive: true, }) - await writeFile( - ctx.skewProtectionConfigPath, - JSON.stringify( - { - patterns: ['.*'], - sources: [ - { - type: 'cookie', - name: '__vdpl', - }, - { - type: 'header', - name: 'X-Deployment-Id', - }, - { - type: 'query', - name: 'dpl', - }, - ], - }, - null, - 2, - ), - ) + await writeFile(ctx.skewProtectionConfigPath, JSON.stringify(skewProtectionConfig)) } From edaa88ff5d741fe250cf3ea47ad47fa4b8260c60 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 2 Oct 2025 18:43:17 +0200 Subject: [PATCH 5/8] refactor: use early bails in logic deciding wether to enable skew protection --- src/build/skew-protection.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/build/skew-protection.ts b/src/build/skew-protection.ts index 17dcc0dab7..150ad15f39 100644 --- a/src/build/skew-protection.ts +++ b/src/build/skew-protection.ts @@ -50,9 +50,17 @@ export function shouldEnableSkewProtection(ctx: PluginContext) { process.env.NETLIFY_NEXT_SKEW_PROTECTION === 'false' || process.env.NETLIFY_NEXT_SKEW_PROTECTION === '0' ) { - enabledOrDisabledReason = EnabledOrDisabledReason.OPT_OUT_ENV_VAR + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + } } else if (ctx.featureFlags?.['next-runtime-skew-protection']) { enabledOrDisabledReason = EnabledOrDisabledReason.OPT_IN_FF + } else { + return { + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_DEFAULT, + } } if ( @@ -61,13 +69,16 @@ export function shouldEnableSkewProtection(ctx: PluginContext) { ) { // We can't proceed without a valid DEPLOY_ID, because Next.js does inline deploy ID at build time // This should only be the case for CLI deploys - enabledOrDisabledReason = - enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR && ctx.constants.IS_LOCAL - ? // this case is singled out to provide visible feedback to users that env var has no effect - EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR - : // this is silent disablement to avoid spam logs for users opted in via feature flag - // that don't explicitly opt in via env var - EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID + return { + enabled: false, + enabledOrDisabledReason: + enabledOrDisabledReason === EnabledOrDisabledReason.OPT_IN_ENV_VAR && ctx.constants.IS_LOCAL + ? // this case is singled out to provide visible feedback to users that env var has no effect + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID_ENV_VAR + : // this is silent disablement to avoid spam logs for users opted in via feature flag + // that don't explicitly opt in via env var + EnabledOrDisabledReason.OPT_OUT_NO_VALID_DEPLOY_ID, + } } return { From b2f988323a1a57ebb0d992b0dda6e4727c74f4d7 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 2 Oct 2025 18:46:07 +0200 Subject: [PATCH 6/8] test: group env var enablement/disablement tests --- src/build/skew-protection.test.ts | 60 ++++++++++++++++--------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/src/build/skew-protection.test.ts b/src/build/skew-protection.test.ts index 1d2727e8ac..0fbc2d206a 100644 --- a/src/build/skew-protection.test.ts +++ b/src/build/skew-protection.test.ts @@ -62,50 +62,52 @@ describe('shouldEnableSkewProtection', () => { }) }) - describe('environment variable opt-in', () => { - it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => { - process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' + describe('environment variable handling', () => { + describe('opt-in', () => { + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "true"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'true' - const result = shouldEnableSkewProtection(mockCtx) + const result = shouldEnableSkewProtection(mockCtx) - expect(result).toEqual({ - enabled: true, - enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) }) - }) - it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => { - process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' + it('should enable when NETLIFY_NEXT_SKEW_PROTECTION is "1"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '1' - const result = shouldEnableSkewProtection(mockCtx) + const result = shouldEnableSkewProtection(mockCtx) - expect(result).toEqual({ - enabled: true, - enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + expect(result).toEqual({ + enabled: true, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_IN_ENV_VAR, + }) }) }) - }) - describe('environment variable opt-out', () => { - it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => { - process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' + describe('opt-out', () => { + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "false"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = 'false' - const result = shouldEnableSkewProtection(mockCtx) + const result = shouldEnableSkewProtection(mockCtx) - expect(result).toEqual({ - enabled: false, - enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) }) - }) - it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => { - process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0' + it('should disable when NETLIFY_NEXT_SKEW_PROTECTION is "0"', () => { + process.env.NETLIFY_NEXT_SKEW_PROTECTION = '0' - const result = shouldEnableSkewProtection(mockCtx) + const result = shouldEnableSkewProtection(mockCtx) - expect(result).toEqual({ - enabled: false, - enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + expect(result).toEqual({ + enabled: false, + enabledOrDisabledReason: EnabledOrDisabledReason.OPT_OUT_ENV_VAR, + }) }) }) }) From 923954221140ab53fbb6e3225ee2eda8252fdc36 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 2 Oct 2025 19:06:29 +0200 Subject: [PATCH 7/8] test: skip building skew protection fixture for integration tests --- tests/prepare.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/prepare.mjs b/tests/prepare.mjs index 164f702b17..e0e98ca425 100644 --- a/tests/prepare.mjs +++ b/tests/prepare.mjs @@ -32,6 +32,7 @@ const e2eOnlyFixtures = new Set([ 'middleware-og', 'middleware-single-matcher', 'nx-integrated', + 'skew-protection', 'turborepo', 'turborepo-npm', 'unstable-cache', From 51d6b7407f6de72b928f1f0bf73f0b9365c5e646 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 8 Oct 2025 14:27:05 +0200 Subject: [PATCH 8/8] test: set empty turbopack in config for webpack/turbopack compat --- tests/fixtures/skew-protection/next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/skew-protection/next.config.mjs b/tests/fixtures/skew-protection/next.config.mjs index 511d6fd2e5..6ada619f2e 100644 --- a/tests/fixtures/skew-protection/next.config.mjs +++ b/tests/fixtures/skew-protection/next.config.mjs @@ -26,7 +26,7 @@ const nextConfig = { ) return config }, - + turbopack: {}, compiler: { // this is same as above webpack config, but this will apply to turbopack builds as well // so just future proofing it here