diff --git a/.changeset/deep-states-talk.md b/.changeset/deep-states-talk.md index 95623e9e69e2..a5024c509e6c 100644 --- a/.changeset/deep-states-talk.md +++ b/.changeset/deep-states-talk.md @@ -2,4 +2,4 @@ 'astro': major --- -Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/TODO:)) +Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#changed-schema-types-are-inferred-instead-of-generated-content-loader-api)) diff --git a/.changeset/fresh-rocks-sing.md b/.changeset/fresh-rocks-sing.md index c259b2bc2e75..11589971d0e7 100644 --- a/.changeset/fresh-rocks-sing.md +++ b/.changeset/fresh-rocks-sing.md @@ -2,4 +2,4 @@ 'astro': major --- -Removes the option to define dynamic schemas in content loaders as functions and adds a new equivalent `createSchema()` property (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/TODO:)) +Removes the option to define dynamic schemas in content loaders as functions and adds a new equivalent `createSchema()` property (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#removed-schema-function-signature-content-loader-api)) diff --git a/.changeset/major-lights-kiss.md b/.changeset/major-lights-kiss.md new file mode 100644 index 000000000000..a35a1a9e3516 --- /dev/null +++ b/.changeset/major-lights-kiss.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes the links to Astro Docs to match the v6 structure. diff --git a/package.json b/package.json index 07af218309b9..9e3527dd832a 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "test:smoke:docs": "turbo run build --filter=docs", "test:check-examples": "node ./scripts/smoke/check.js", "test:vite-ci": "cd packages/astro && pnpm run test:unit && pnpm run test:integration", - "test:e2e": "cd packages/astro && pnpm playwright install chromium firefox && pnpm run test:e2e", - "test:e2e:match": "cd packages/astro && pnpm playwright install chromium firefox && pnpm run test:e2e:match", + "test:e2e": "cd packages/astro && pnpm playwright install firefox && pnpm run test:e2e", + "test:e2e:match": "cd packages/astro && pnpm playwright install firefox && pnpm run test:e2e:match", "test:e2e:hosts": "turbo run test:hosted", "benchmark": "astro-benchmark", "lint": "biome lint && knip && eslint . --report-unused-disable-directives-severity=warn --concurrency=auto", diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index ec53e202a39e..70a77fe62c7c 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -908,11 +908,11 @@ - [#14956](https://github.com/withastro/astro/pull/14956) [`0ff51df`](https://github.com/withastro/astro/commit/0ff51dfa3c6c615af54228e159f324034472b1a2) Thanks [@matthewp](https://github.com/matthewp)! - Astro v6.0 upgrades to Zod v4 for schema validation - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#zod-4)) -- [#14759](https://github.com/withastro/astro/pull/14759) [`d7889f7`](https://github.com/withastro/astro/commit/d7889f768a4d27e8c4ad3a0022099d19145a7d58) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/TODO:)) +- [#14759](https://github.com/withastro/astro/pull/14759) [`d7889f7`](https://github.com/withastro/astro/commit/d7889f768a4d27e8c4ad3a0022099d19145a7d58) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#changed-schema-types-are-inferred-instead-of-generated-content-loader-api)) - [#14306](https://github.com/withastro/astro/pull/14306) [`141c4a2`](https://github.com/withastro/astro/commit/141c4a26419fe5bb4341953ea5a0a861d9b398c0) Thanks [@ematipico](https://github.com/ematipico)! - Removes support for routes with percent-encoded percent signs (e.g. `%25`) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#removed-percent-encoding-in-routes)) -- [#14759](https://github.com/withastro/astro/pull/14759) [`d7889f7`](https://github.com/withastro/astro/commit/d7889f768a4d27e8c4ad3a0022099d19145a7d58) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Removes the option to define dynamic schemas in content loaders as functions and adds a new equivalent `createSchema()` property (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/TODO:)) +- [#14759](https://github.com/withastro/astro/pull/14759) [`d7889f7`](https://github.com/withastro/astro/commit/d7889f768a4d27e8c4ad3a0022099d19145a7d58) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Removes the option to define dynamic schemas in content loaders as functions and adds a new equivalent `createSchema()` property (Loader API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#removed-schema-function-signature-content-loader-api)) - [#14306](https://github.com/withastro/astro/pull/14306) [`141c4a2`](https://github.com/withastro/astro/commit/141c4a26419fe5bb4341953ea5a0a861d9b398c0) Thanks [@ematipico](https://github.com/ematipico)! - Removes `RouteData.generate` from the Integration API - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#removed-routedatagenerate-adapter-api)) diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index 230671fe66c0..96ba58f8fadd 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -41,7 +41,7 @@ export function injectServerIslandRoute(config: ConfigFields, routeManifest: Rou routeManifest.routes.unshift(getServerIslandRouteData(config)); } -type RenderOptions = { +export type RenderOptions = { encryptedComponentExport: string; encryptedProps: string; encryptedSlots: string; @@ -54,7 +54,7 @@ function badRequest(reason: string) { }); } -async function getRequestData(request: Request): Promise { +export async function getRequestData(request: Request): Promise { switch (request.method) { case 'GET': { const url = new URL(request.url); diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 0898f5cac082..17634cf95d0a 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -698,7 +698,7 @@ export interface AstroUserConfig< * Enabling this feature adds additional security to Astro's handling of processed and bundled scripts and styles by default, and allows you to further configure these, and additional, content types. * * This feature comes with some limitations: - * - External scripts and external styles are not supported out of the box, but you can [provide your own hashes](https://v6.docs.astro.build/en/reference/configuration-reference/#hashes). + * - External scripts and external styles are not supported out of the box, but you can [provide your own hashes](https://v6.docs.astro.build/en/reference/configuration-reference/#securitycspscriptdirectivehashes). * - [Astro's view transitions](https://v6.docs.astro.build/en/guides/view-transitions/) using the `` are not supported, but you can [consider migrating to the browser native View Transition API](https://events-3bg.pages.dev/jotter/astro-view-transitions/) instead if you are not using Astro's enhancements to the native View Transitions and Navigation APIs. * - Shiki isn't currently supported. By design, Shiki functions use inline styles that cannot work with Astro CSP implementation. Consider [using ``](https://v6.docs.astro.build/en/guides/syntax-highlighting/#prism-) when your project requires both CSP and syntax highlighting. * - `unsafe-inline` directives are incompatible with Astro's CSP implementation. By default, Astro will emit hashes for all its bundled scripts (e.g. client islands) and all modern browsers will automatically reject `unsafe-inline` when it occurs in a directive with a hash or nonce. @@ -803,7 +803,7 @@ export interface AstroUserConfig< * @version 6.0.0 * @description * - * A configuration object that allows you to override the default sources for the `style-src` directive with the [`resources`](https://v6.docs.astro.build/en/reference/configuration-reference/#resources) property, or to provide additional [hashes](https://v6.docs.astro.build/en/reference/configuration-reference#hashes) to be rendered. */ + * A configuration object that allows you to override the default sources for the `style-src` directive with the [`resources`](https://v6.docs.astro.build/en/reference/configuration-reference/#securitycspstyledirectiveresources) property, or to provide additional [hashes](https://v6.docs.astro.build/en/reference/configuration-reference#securitycspstyledirectivehashes) to be rendered. */ styleDirective?: { /** * @docs @@ -904,7 +904,7 @@ export interface AstroUserConfig< * @version 6.0.0 * @description * - * A configuration object that allows you to override the default sources for the `script-src` directive with the [`resources`](https://v6.docs.astro.build/en/reference/configuration-reference/#resources) property, or to provide additional [hashes](https://v6.docs.astro.build/en/reference/configuration-reference#hashes) to be rendered. + * A configuration object that allows you to override the default sources for the `script-src` directive with the [`resources`](https://v6.docs.astro.build/en/reference/configuration-reference/#securitycspscriptdirectiveresources) property, or to provide additional [hashes](https://v6.docs.astro.build/en/reference/configuration-reference#securitycspscriptdirectivehashes) to be rendered. */ scriptDirective?: { /** @@ -1497,7 +1497,7 @@ export interface AstroUserConfig< * An optional default time-to-live expiration period for session values, in seconds. * * By default, session values persist until they are deleted or the session is destroyed, and do not automatically expire because a particular amount of time has passed. - * Set `session.ttl` to add a default expiration period for your session values. Passing a `ttl` option to [`session.set()`](https://docs.astro.build/en/reference/api-reference/#set) will override the global default + * Set `session.ttl` to add a default expiration period for your session values. Passing a `ttl` option to [`session.set()`](https://v6.docs.astro.build/en/reference/api-reference/#sessionset) will override the global default * for that individual entry. * * ```js title="astro.config.mjs" ins={3-4} diff --git a/packages/astro/test/csp-server-islands.test.js b/packages/astro/test/csp-server-islands.test.js index 3c1e0ba98a5d..6fff5a9c22ab 100644 --- a/packages/astro/test/csp-server-islands.test.js +++ b/packages/astro/test/csp-server-islands.test.js @@ -77,77 +77,6 @@ describe('Server islands', () => { const response = await app.render(request); assert.equal(response.headers.get('x-robots-tag'), 'noindex'); }); - it('omits empty props from the query string', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/empty-props'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); - }); - it('re-encrypts props on each request', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/includeComponentWithProps/'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - const firstProps = fetchMatch[1]; - const secondRequest = new Request('http://example.com/includeComponentWithProps/'); - const secondResponse = await app.render(secondRequest); - assert.equal(secondResponse.status, 200); - const secondHtml = await secondResponse.text(); - const secondFetchMatch = secondHtml.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(secondFetchMatch.length, 2, 'should include props in the query string'); - assert.notEqual( - secondFetchMatch[1], - firstProps, - 'should re-encrypt props on each request with a different IV', - ); - }); - - it('omits empty props from the query string', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/empty-props'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); - }); - it('re-encrypts props on each request', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/includeComponentWithProps/'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - const firstProps = fetchMatch[1]; - const secondRequest = new Request('http://example.com/includeComponentWithProps/'); - const secondResponse = await app.render(secondRequest); - assert.equal(secondResponse.status, 200); - const secondHtml = await secondResponse.text(); - const secondFetchMatch = secondHtml.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(secondFetchMatch.length, 2, 'should include props in the query string'); - assert.notEqual( - secondFetchMatch[1], - firstProps, - 'should re-encrypt props on each request with a different IV', - ); - }); }); describe('Hybrid', () => { diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 5337c0fe5f3f..4c43ddce702f 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -5,328 +5,9 @@ import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; describe('CSP', () => { - let app; - /** - * @type {import('../dist/core/build/types.js').SSGManifest} - */ - let manifest; /** @type {import('./test-utils.js').Fixture} */ let fixture; - it('should contain the meta style hashes when CSS is imported from Astro component', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/csp-style-hashes', - adapter: testAdapter({ - setManifest(_manifest) { - manifest = _manifest; - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - if (manifest) { - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const $ = cheerio.load(await response.text()); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - for (const hash of manifest.csp.styleHashes) { - assert.ok(meta.attr('content').includes(hash), `Should have a CSP meta tag for ${hash}`); - } - } else { - assert.fail('Should have the manifest'); - } - }); - - it('should contain the meta script hashes when using client island', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/csp-script-hashes', - adapter: testAdapter({ - setManifest(_manifest) { - manifest = _manifest; - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - if (manifest) { - const request = new Request('http://example.com/index.html'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - for (const hash of manifest.csp.scriptHashes) { - assert.ok(meta.attr('content').includes(hash), `Should have a CSP meta tag for ${hash}`); - } - } else { - assert.fail('Should have the manifest'); - } - }); - - it('should generate the hash with the sha512 algorithm', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/sha512', - security: { - csp: { - algorithm: 'SHA-512', - }, - }, - }); - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes('sha512-')); - }); - - it('should generate the hash with the sha384 algorithm', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/sha384', - security: { - csp: { - algorithm: 'SHA-384', - }, - }, - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes('sha384-')); - }); - - it('should render hashes provided by the user', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/custom-hashes', - security: { - csp: { - styleDirective: { - hashes: ['sha512-hash1', 'sha384-hash2'], - }, - scriptDirective: { - hashes: ['sha512-hash3', 'sha384-hash4'], - }, - }, - }, - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes('sha384-hash2')); - assert.ok(meta.attr('content').toString().includes('sha384-hash4')); - assert.ok(meta.attr('content').toString().includes('sha512-hash1')); - assert.ok(meta.attr('content').toString().includes('sha512-hash3')); - }); - - it('should contain the additional directives', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/directives', - security: { - csp: { - directives: ["img-src 'self' 'https://example.com'"], - }, - }, - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes("img-src 'self' 'https://example.com'")); - }); - - it('should contain the custom resources for "script-src" and "style-src"', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/custom-resources', - security: { - csp: { - styleDirective: { - resources: ['https://cdn.example.com', 'https://styles.cdn.example.com'], - }, - scriptDirective: { - resources: ['https://cdn.example.com', 'https://scripts.cdn.example.com'], - }, - }, - }, - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok( - meta - .attr('content') - .toString() - .includes('script-src https://cdn.example.com https://scripts.cdn.example.com'), - ); - assert.ok( - meta - .attr('content') - .toString() - .includes('style-src https://cdn.example.com https://styles.cdn.example.com'), - ); - }); - - it('allows injecting custom script resources and hashes based on pages, deduplicated', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/inject-scripts/', - security: { - csp: { - directives: ["img-src 'self'"], - scriptDirective: { - resources: ['https://global.cdn.example.com'], - }, - }, - }, - }); - await fixture.build(); - - const html = await fixture.readFile('/scripts/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - // correctness for resources - assert.ok( - meta - .attr('content') - .toString() - .includes('script-src https://global.cdn.example.com https://scripts.cdn.example.com'), - ); - assert.ok(meta.attr('content').toString().includes("style-src 'self'")); - // correctness for hashes - assert.ok(meta.attr('content').toString().includes("default-src 'self';")); - assert.ok( - meta.attr('content').toString().includes("img-src 'self' https://images.cdn.example.com;"), - ); - }); - - it('allows injecting custom styles resources and hashes based on pages', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/inject-styles/', - security: { - csp: { - directives: ["img-src 'self'"], - styleDirective: { - resources: ['https://global.cdn.example.com'], - }, - }, - }, - }); - await fixture.build(); - const html = await fixture.readFile('/styles/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - // correctness for resources - assert.ok( - meta - .attr('content') - .toString() - .includes('style-src https://global.cdn.example.com https://styles.cdn.example.com'), - ); - assert.ok(meta.attr('content').toString().includes("script-src 'self'")); - // correctness for hashes - assert.ok(meta.attr('content').toString().includes("default-src 'self';")); - assert.ok( - meta.attr('content').toString().includes("img-src 'self' https://images.cdn.example.com;"), - ); - }); - - it('allows add `strict-dynamic` when enabled', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/strict-dynamic', - security: { - csp: { - scriptDirective: { - strictDynamic: true, - }, - }, - }, - }); - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes("'strict-dynamic';")); - }); - - it("allows the use of directives that don't require values, and deprecated ones", async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - outDir: './dist/no-value-directives', - security: { - csp: { - directives: [ - 'upgrade-insecure-requests', - 'sandbox', - 'trusted-types', - 'report-uri https://endpoint.example.com', - ], - }, - }, - }); - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.attr('content').toString().includes('upgrade-insecure-requests')); - assert.ok(meta.attr('content').toString().includes('sandbox')); - assert.ok(meta.attr('content').toString().includes('trusted-types')); - assert.ok(meta.attr('content').toString().includes('report-uri https://endpoint.example.com')); - }); - - it('should serve hashes via headers for dynamic pages, when the strategy is "auto"', async () => { - fixture = await loadFixture({ - root: './fixtures/csp-adapter/', - outDir: './dist/csp-headers', - adapter: testAdapter(), - security: { - csp: true, - }, - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - - const request = new Request('http://example.com/ssr'); - const response = await app.render(request); - - const header = response.headers.get('content-security-policy'); - - // correctness for resources - assert.ok(header.includes('style-src https://styles.cdn.example.com')); - assert.ok(header.includes("script-src 'self'")); - // correctness for hashes - assert.ok(header.includes("default-src 'self';")); - - const html = await response.text(); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.equal(meta.attr('content'), undefined, 'meta tag should not be present'); - }); - it('should generate hashes for inline styles', async () => { fixture = await loadFixture({ root: './fixtures/csp/', @@ -384,18 +65,6 @@ describe('CSP', () => { ]); }); - it('should not inject self by default if fonts are not used', async () => { - fixture = await loadFixture({ - root: './fixtures/csp/', - }); - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.equal(meta.attr('content').toString().includes('font-src'), false); - }); - it('should generate hashes for Image component inline styles when using layout', async () => { fixture = await loadFixture({ root: './fixtures/csp/', @@ -468,7 +137,7 @@ describe('CSP', () => { }, }); await fixture.build(); - app = await fixture.loadTestAdapterApp(); + await fixture.loadTestAdapterApp(); assert.equal(routeToHeaders.size, 4, 'expected four routes: /, /scripts, /foo, /bar'); diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js index a3558a642111..b7f90c309584 100644 --- a/packages/astro/test/middleware.test.js +++ b/packages/astro/test/middleware.test.js @@ -22,97 +22,7 @@ describe('Middleware in DEV mode', () => { await devServer.stop(); }); - it('should render locals data', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - }); - - it('should change locals data based on URL', async () => { - let html = await fixture.fetch('/').then((res) => res.text()); - let $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - - html = await fixture.fetch('/lorem').then((res) => res.text()); - $ = cheerio.load(html); - assert.equal($('p').html(), 'ipsum'); - }); - - it('should call a second middleware', async () => { - const html = await fixture.fetch('/second').then((res) => res.text()); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'second'); - }); - - it('should successfully create a new response', async () => { - const html = await fixture.fetch('/rewrite').then((res) => res.text()); - const $ = cheerio.load(html); - assert.equal($('p').html(), null); - assert.equal($('span').html(), 'New content!!'); - }); - - it('should return a new response that is a 500', async () => { - await fixture.fetch('/broken-500').then((res) => { - assert.equal(res.status, 500); - return res.text(); - }); - }); - - it('should successfully render a page if the middleware calls only next() and returns nothing', async () => { - const html = await fixture.fetch('/not-interested').then((res) => res.text()); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'Not interested'); - }); - - it("should throw an error when the middleware doesn't call next or doesn't return a response", async () => { - const html = await fixture.fetch('/does-nothing').then((res) => res.text()); - const $ = cheerio.load(html); - assert.equal($('title').html(), 'MiddlewareNoDataOrNextCalled'); - }); - - it('should return 200 if the middleware returns a 200 Response', async () => { - const response = await fixture.fetch('/no-route-but-200'); - assert.equal(response.status, 200); - const html = await response.text(); - assert.match(html, /It's OK!/); - }); - - it('should allow setting cookies', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.headers.get('set-cookie'), 'foo=bar'); - }); - - it('should be able to clone the response', async () => { - const res = await fixture.fetch('/clone'); - const html = await res.text(); - assert.equal(html.includes('it works'), true); - }); - - it('should forward cookies set in a component when the middleware returns a new response', async () => { - const res = await fixture.fetch('/return-response-cookies'); - const headers = res.headers; - assert.notEqual(headers.get('set-cookie'), null); - }); - describe('Path encoding in middleware', () => { - it('should protect /admin route with auth check', async () => { - const res = await fixture.fetch('/admin', { redirect: 'manual' }); - assert.equal(res.status, 302); - assert.equal(res.headers.get('location'), '/'); - }); - - it('should NOT allow accessing /admin with url encoding', async () => { - const res = await fixture.fetch('/%61dmin', { redirect: 'manual' }); - assert.equal(res.status, 302); - assert.equal(res.headers.get('location'), '/'); - }); - - it('should NOT allow accessing /admin with fully encoded path', async () => { - const res = await fixture.fetch('/%61%64%6d%69%6e', { redirect: 'manual' }); - assert.equal(res.status, 302); - assert.equal(res.headers.get('location'), '/'); - }); - it('should reject double-encoded paths with 404', async () => { const res = await fixture.fetch('/%2561dmin', { redirect: 'manual' }); assert.equal(res.status, 404); @@ -122,11 +32,6 @@ describe('Middleware in DEV mode', () => { const res = await fixture.fetch('/%252561dmin', { redirect: 'manual' }); assert.equal(res.status, 404); }); - - it('should allow legitimate single-encoded paths like /path%20with%20spaces', async () => { - const res = await fixture.fetch('/path%20with%20spaces'); - assert.equal(res.status, 200); - }); }); describe('Integration hooks', () => { @@ -178,34 +83,6 @@ describe('Integration hooks with no user middleware', () => { }); }); -describe('Middleware in PROD mode, SSG', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/middleware-ssg/', - }); - await fixture.build(); - }); - - it('should render locals data', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - }); - - it('should change locals data based on URL', async () => { - let html = await fixture.readFile('/index.html'); - let $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - - html = await fixture.readFile('/second/index.html'); - $ = cheerio.load(html); - assert.equal($('p').html(), 'second'); - }); -}); - describe('Middleware should not be executed or imported during', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -238,121 +115,6 @@ describe('Middleware API in PROD mode, SSR', () => { app = await fixture.loadTestAdapterApp(); }); - it('should render locals data', async () => { - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - }); - - it('should change locals data based on URL', async () => { - let response = await app.render(new Request('http://example.com/')); - let html = await response.text(); - let $ = cheerio.load(html); - assert.equal($('p').html(), 'bar'); - - response = await app.render(new Request('http://example.com/lorem')); - html = await response.text(); - $ = cheerio.load(html); - assert.equal($('p').html(), 'ipsum'); - }); - - it('should successfully redirect to another page', async () => { - const request = new Request('http://example.com/redirect'); - const response = await app.render(request); - assert.equal(response.status, 302); - }); - - it('should call a second middleware', async () => { - const response = await app.render(new Request('http://example.com/second')); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'second'); - }); - - it('should successfully create a new response', async () => { - const request = new Request('http://example.com/rewrite'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').html(), null); - assert.equal($('span').html(), 'New content!!'); - }); - - it('should return a new response that is a 500', async () => { - const request = new Request('http://example.com/broken-500'); - const response = await app.render(request); - assert.equal(response.status, 500); - }); - - it('should successfully render a page if the middleware calls only next() and returns nothing', async () => { - const request = new Request('http://example.com/not-interested'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').html(), 'Not interested'); - }); - - it("should throw an error when the middleware doesn't call next or doesn't return a response", async () => { - const request = new Request('http://example.com/does-nothing'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - assert.notEqual($('title').html(), 'MiddlewareNoDataReturned'); - }); - - it('should return 200 if the middleware returns a 200 Response', async () => { - const request = new Request('http://example.com/no-route-but-200'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - assert.match(html, /It's OK!/); - }); - - it('should correctly work for API endpoints that return a Response object', async () => { - const request = new Request('http://example.com/api/endpoint'); - const response = await app.render(request); - assert.equal(response.status, 200); - assert.equal(response.headers.get('Content-Type'), 'application/json'); - }); - - it('should correctly manipulate the response coming from API endpoints (not simple)', async () => { - const request = new Request('http://example.com/api/endpoint'); - const response = await app.render(request); - const text = await response.text(); - assert.equal(text.includes('REDACTED'), true); - }); - - it('should correctly call the middleware function for 404', async () => { - const request = new Request('http://example.com/funky-url'); - const routeData = app.match(request); - const response = await app.render(request, { routeData }); - const text = await response.text(); - assert.equal(text.includes('Error'), true); - assert.equal(text.includes('bar'), true); - }); - - it('should render 500.astro when the middleware throws an error', async () => { - const request = new Request('http://example.com/throw'); - const routeData = app.match(request); - - const response = await app.render(request, { routeData }); - assert.equal(response.status, 500); - - const text = await response.text(); - assert.equal(text.includes('

There was an error rendering the page.

'), true); - }); - - it('should correctly render the page even when custom headers are set in a middleware', async () => { - const request = new Request('http://example.com/content-policy'); - const routeData = app.match(request); - - const response = await app.render(request, { routeData }); - assert.equal(response.status, 404); - assert.equal(response.headers.get('content-type'), 'text/html'); - }); - it('can render a page that does not exist', async () => { const request = new Request('http://example.com/does-not-exist'); const routeData = app.match(request); @@ -371,34 +133,6 @@ describe('Middleware API in PROD mode, SSR', () => { }); describe('Path encoding in middleware', () => { - it('should allow accessing /admin with valid auth header', async () => { - const request = new Request('http://example.com/admin', { - headers: { Authorization: 'Bearer token123' }, - }); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - assert.equal(html.includes('Admin Panel'), true); - }); - - it('should NOT allow accessing /admin without auth header', async () => { - const request = new Request('http://example.com/admin'); - const response = await app.render(request); - assert.equal(response.status, 302); - }); - - it('should NOT allow accessing /admin with url encoding', async () => { - const request = new Request('http://example.com/%61dmin'); - const response = await app.render(request); - assert.equal(response.status, 302); - }); - - it('should NOT allow accessing /admin with fully encoded path', async () => { - const request = new Request('http://example.com/%61%64%6d%69%6e'); - const response = await app.render(request); - assert.equal(response.status, 302); - }); - it('should reject double-encoded paths with 404', async () => { const request = new Request('http://example.com/%2561dmin'); const response = await app.render(request); @@ -466,38 +200,6 @@ describe('Middleware with tailwind', () => { }); }); -describe('Middleware should support clone request', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/middleware-sequence-request-clone/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should correctly render page', async () => { - const res = await fixture.fetch('/', { - method: 'POST', - body: 'TEST BODY', - }); - const html = await res.text(); - assert.equal(html.includes('Hello Sequence and Request Clone'), true); - }); - - it('should preserve cookies set in sequence', async () => { - const res = await fixture.fetch('/'); - assert.ok(res.headers.get('set-cookie').includes('cookie1=Cookie%20from%20middleware%201')); - assert.ok(res.headers.get('set-cookie').includes('cookie2=Cookie%20from%20middleware%202')); - }); -}); - describe('Middleware sequence rewrites', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index 5d0016867703..7ca88ee2257d 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -97,37 +97,6 @@ describe('Server islands', () => { const works = res.headers.get('X-Works'); assert.equal(works, 'true', 'able to set header from server island'); }); - it('omits empty props from the query string', async () => { - const res = await fixture.fetch('/empty-props'); - assert.equal(res.status, 200); - const html = await res.text(); - const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); - }); - it('re-encrypts props on each request', async () => { - const res = await fixture.fetch('/includeComponentWithProps/'); - assert.equal(res.status, 200); - const html = await res.text(); - const fetchMatch = html.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - const firstProps = fetchMatch[1]; - const secondRes = await fixture.fetch('/includeComponentWithProps/'); - assert.equal(secondRes.status, 200); - const secondHtml = await secondRes.text(); - const secondFetchMatch = secondHtml.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(secondFetchMatch.length, 2, 'should include props in the query string'); - assert.notEqual( - secondFetchMatch[1], - firstProps, - 'should re-encrypt props on each request with a different IV', - ); - }); - it('rejects invalid props', async () => { const encryptedComponentExport = await getEncryptedComponentExport(); const res = await fixture.fetch('/_server-islands/Island', { @@ -142,31 +111,6 @@ describe('Server islands', () => { assert.equal(res.status, 400); }); - it('rejects plaintext componentExport', async () => { - const res = await fixture.fetch('/_server-islands/Island', { - method: 'POST', - body: JSON.stringify({ - componentExport: 'default', - encryptedProps: '', - encryptedSlots: '', - }), - }); - assert.equal(res.status, 400, 'should reject plaintext componentExport'); - }); - - it('rejects plaintext slots', async () => { - const encryptedComponentExport = await getEncryptedComponentExport(); - const res = await fixture.fetch('/_server-islands/Island', { - method: 'POST', - body: JSON.stringify({ - encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', - slots: { xss: '' }, - }), - }); - assert.equal(res.status, 400, 'should reject unencrypted slots'); - }); - it('accepts encrypted slots via POST', async () => { const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); const encryptedComponentExport = await encryptString(key, 'default'); @@ -308,42 +252,6 @@ describe('Server islands', () => { const response = await app.render(request); assert.equal(response.headers.get('x-robots-tag'), 'noindex'); }); - it('omits empty props from the query string', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/empty-props'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); - }); - it('re-encrypts props on each request', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/includeComponentWithProps/'); - const response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const fetchMatch = html.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(fetchMatch.length, 2, 'should include props in the query string'); - const firstProps = fetchMatch[1]; - const secondRequest = new Request('http://example.com/includeComponentWithProps/'); - const secondResponse = await app.render(secondRequest); - assert.equal(secondResponse.status, 200); - const secondHtml = await secondResponse.text(); - const secondFetchMatch = secondHtml.match( - /fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/, - ); - assert.equal(secondFetchMatch.length, 2, 'should include props in the query string'); - assert.notEqual( - secondFetchMatch[1], - firstProps, - 'should re-encrypt props on each request with a different IV', - ); - }); - it('rejects invalid props', async () => { const app = await fixture.loadTestAdapterApp(); const encryptedComponentExport = await getEncryptedComponentExport(); @@ -363,55 +271,6 @@ describe('Server islands', () => { assert.equal(response.status, 400); }); - it('rejects plaintext componentExport', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/_server-islands/Island', { - method: 'POST', - body: JSON.stringify({ - componentExport: 'default', - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', - encryptedSlots: '', - }), - headers: { - origin: 'http://example.com', - }, - }); - const response = await app.render(request); - assert.equal(response.status, 400, 'should reject plaintext componentExport'); - }); - - it('rejects plaintext slots', async () => { - const app = await fixture.loadTestAdapterApp(); - const encryptedComponentExport = await getEncryptedComponentExport(); - const request = new Request('http://example.com/_server-islands/Island', { - method: 'POST', - body: JSON.stringify({ - encryptedComponentExport, - encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD', - slots: { xss: '' }, - }), - headers: { - origin: 'http://example.com', - }, - }); - const response = await app.render(request); - assert.equal(response.status, 400, 'should reject unencrypted slots'); - }); - - it('rejects plaintext slots with XSS payload via GET', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request( - 'http://example.com/_server-islands/Island?e=file&s=%7B%22xss%22%3A%22%3Cimg%20src%3Dx%20onerror%3Dalert(0)%3E%22%7D', - { - headers: { - origin: 'http://example.com', - }, - }, - ); - const response = await app.render(request); - assert.equal(response.status, 400, 'should reject plaintext slots with XSS'); - }); - it('accepts encrypted slots via POST', async () => { const app = await fixture.loadTestAdapterApp(); const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='); diff --git a/packages/astro/test/units/csp/rendering.test.js b/packages/astro/test/units/csp/rendering.test.js new file mode 100644 index 000000000000..6fe37b85da66 --- /dev/null +++ b/packages/astro/test/units/csp/rendering.test.js @@ -0,0 +1,468 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { RenderContext } from '../../../dist/core/render-context.js'; +import { + createComponent, + maybeRenderHead, + render, + renderHead, +} from '../../../dist/runtime/server/index.js'; +import { createBasicPipeline } from '../test-utils.js'; + +// #region Test Utilities + +/** + * Creates a pipeline with CSP configuration + * @param {Partial} cspConfig + */ +function createCspPipeline(cspConfig = {}) { + const pipeline = createBasicPipeline(); + pipeline.manifest = { + ...pipeline.manifest, + shouldInjectCspMetaTags: true, + csp: { + cspDestination: cspConfig.cspDestination, + algorithm: cspConfig.algorithm || 'SHA-256', + scriptHashes: cspConfig.scriptHashes || [], + scriptResources: cspConfig.scriptResources || [], + styleHashes: cspConfig.styleHashes || [], + styleResources: cspConfig.styleResources || [], + directives: cspConfig.directives || [], + isStrictDynamic: cspConfig.isStrictDynamic || false, + }, + }; + return pipeline; +} + +/** + * Renders a page component and returns HTML and headers + * @param {any} PageComponent + * @param {any} pipeline + * @param {boolean} prerender + */ +async function renderPage(PageComponent, pipeline, prerender = true) { + const PageModule = { default: PageComponent }; + const request = new Request('http://localhost/'); + const routeData = { + type: 'page', + pathname: '/index', + component: 'src/pages/index.astro', + params: {}, + prerender, + }; + + const renderContext = await RenderContext.create({ pipeline, request, routeData }); + const response = await renderContext.render(PageModule); + const html = await response.text(); + + return { html, response }; +} + +// #endregion + +// #region Reusable Components + +/** Simple page component */ +const SimplePage = createComponent((result) => { + return render` + ${renderHead(result)} + ${maybeRenderHead(result)}

Test

+ `; +}); + +// #endregion + +// #region Tests + +describe('CSP Rendering', () => { + describe('Style Hashes', () => { + it('should contain style hashes in meta tag when CSS is imported', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha256-abc123', 'sha256-def456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('sha256-abc123'), 'Should include first style hash'); + assert.ok(content.includes('sha256-def456'), 'Should include second style hash'); + assert.ok(content.includes('style-src'), 'Should have style-src directive'); + }); + + // Note: Inline style hashing requires the full build pipeline + // and cannot be easily unit tested. This is tested in integration tests. + }); + + describe('Script Hashes', () => { + it('should contain script hashes in meta tag when using client islands', async () => { + const pipeline = createCspPipeline({ + scriptHashes: ['sha256-xyz789', 'sha256-uvw456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('sha256-xyz789'), 'Should include first script hash'); + assert.ok(content.includes('sha256-uvw456'), 'Should include second script hash'); + assert.ok(content.includes('script-src'), 'Should have script-src directive'); + }); + }); + + describe('Hash Algorithms', () => { + it('should generate hashes with SHA-512 algorithm', async () => { + const pipeline = createCspPipeline({ + algorithm: 'SHA-512', + scriptHashes: ['sha512-longhash123abc'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('sha512-'), 'Should use sha512 prefix'); + assert.ok(content.includes('sha512-longhash123abc'), 'Should include SHA-512 hash'); + }); + + it('should generate hashes with SHA-384 algorithm', async () => { + const pipeline = createCspPipeline({ + algorithm: 'SHA-384', + scriptHashes: ['sha384-mediumhash456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('sha384-'), 'Should use sha384 prefix'); + assert.ok(content.includes('sha384-mediumhash456'), 'Should include SHA-384 hash'); + }); + }); + + describe('Custom Hashes', () => { + it('should render user-provided hashes', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha512-hash1', 'sha384-hash2'], + scriptHashes: ['sha512-hash3', 'sha384-hash4'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('sha384-hash2'), 'Should include custom style hash 1'); + assert.ok(content.includes('sha384-hash4'), 'Should include custom script hash 1'); + assert.ok(content.includes('sha512-hash1'), 'Should include custom style hash 2'); + assert.ok(content.includes('sha512-hash3'), 'Should include custom script hash 2'); + }); + }); + + describe('Additional Directives', () => { + it('should include additional directives', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self' 'https://example.com'"], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok( + content.includes("img-src 'self' 'https://example.com'"), + 'Should include custom directive', + ); + }); + + it('should handle directives that do not require values', async () => { + const pipeline = createCspPipeline({ + directives: [ + 'upgrade-insecure-requests', + 'sandbox', + 'trusted-types', + 'report-uri https://endpoint.example.com', + ], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes('upgrade-insecure-requests'), 'Should include upgrade directive'); + assert.ok(content.includes('sandbox'), 'Should include sandbox directive'); + assert.ok(content.includes('trusted-types'), 'Should include trusted-types directive'); + assert.ok( + content.includes('report-uri https://endpoint.example.com'), + 'Should include report-uri directive', + ); + }); + }); + + describe('Custom Resources', () => { + it('should include custom resources for script-src and style-src', async () => { + const pipeline = createCspPipeline({ + styleResources: ['https://cdn.example.com', 'https://styles.cdn.example.com'], + scriptResources: ['https://cdn.example.com', 'https://scripts.cdn.example.com'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok( + content.includes('script-src https://cdn.example.com https://scripts.cdn.example.com'), + 'Should include script resources', + ); + assert.ok( + content.includes('style-src https://cdn.example.com https://styles.cdn.example.com'), + 'Should include style resources', + ); + }); + }); + + describe('Runtime CSP API - Astro.csp', () => { + it('should allow injecting custom script resources and hashes via Astro.csp', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'"], + scriptResources: ['https://global.cdn.example.com'], + }); + + const PageWithCspApi = createComponent((result) => { + const Astro = result.createAstro({}, {}); + + // Use runtime CSP API + Astro.csp.insertScriptResource('https://scripts.cdn.example.com'); + Astro.csp.insertScriptHash('sha256-customHash'); + Astro.csp.insertDirective("default-src 'self'"); + Astro.csp.insertDirective('img-src https://images.cdn.example.com'); + + return render` + ${renderHead(result)} + ${maybeRenderHead(result)}

Scripts

+ `; + }); + + const { html } = await renderPage(PageWithCspApi, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + // Check resources are merged and deduplicated + assert.ok( + content.includes( + 'script-src https://global.cdn.example.com https://scripts.cdn.example.com', + ), + 'Should merge script resources', + ); + assert.ok(content.includes("style-src 'self'"), 'Should have default style-src'); + + // Check hashes + assert.ok(content.includes('sha256-customHash'), 'Should include custom hash'); + + // Check directives are merged + assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); + assert.ok( + content.includes("img-src 'self' https://images.cdn.example.com"), + 'Should merge img-src directives', + ); + }); + + it('should allow injecting custom style resources and hashes via Astro.csp', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'"], + styleResources: ['https://global.cdn.example.com'], + }); + + const PageWithStyleApi = createComponent((result) => { + const Astro = result.createAstro({}, {}); + + // Use runtime CSP API for styles + Astro.csp.insertStyleResource('https://styles.cdn.example.com'); + Astro.csp.insertStyleHash('sha256-customStyleHash'); + Astro.csp.insertDirective("default-src 'self'"); + Astro.csp.insertDirective('img-src https://images.cdn.example.com'); + + return render` + ${renderHead(result)} + ${maybeRenderHead(result)}

Styles

+ `; + }); + + const { html } = await renderPage(PageWithStyleApi, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + // Check style resources are merged + assert.ok( + content.includes('style-src https://global.cdn.example.com https://styles.cdn.example.com'), + 'Should merge style resources', + ); + assert.ok(content.includes("script-src 'self'"), 'Should have default script-src'); + + // Check hashes + assert.ok(content.includes('sha256-customStyleHash'), 'Should include custom style hash'); + + // Check directives are merged + assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); + assert.ok( + content.includes("img-src 'self' https://images.cdn.example.com"), + 'Should merge img-src directives', + ); + }); + }); + + describe('Strict Dynamic', () => { + it("should add 'strict-dynamic' when enabled", async () => { + const pipeline = createCspPipeline({ + isStrictDynamic: true, + scriptHashes: ['sha256-test123'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.ok(content.includes("'strict-dynamic'"), "Should include 'strict-dynamic' keyword"); + }); + }); + + describe('CSP Delivery Methods', () => { + it('should serve CSP via meta tag for prerendered pages (default)', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha256-test123'], + }); + + const { html, response } = await renderPage(SimplePage, pipeline, true); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + assert.ok(meta.length > 0, 'Should have CSP meta tag'); + assert.equal( + response.headers.get('content-security-policy'), + null, + 'Should not have CSP header', + ); + }); + + it('should serve CSP via headers for SSR/dynamic pages', async () => { + const pipeline = createCspPipeline({ + cspDestination: 'header', + styleHashes: ['sha256-test123'], + styleResources: ['https://styles.cdn.example.com'], + }); + + const { html, response } = await renderPage(SimplePage, pipeline, false); + const $ = cheerio.load(html); + + const header = response.headers.get('content-security-policy'); + assert.ok(header, 'Should have CSP header'); + assert.ok(header.includes('style-src'), 'Header should include style-src'); + assert.ok( + header.includes('https://styles.cdn.example.com'), + 'Header should include style resources', + ); + assert.ok(header.includes("script-src 'self'"), 'Header should include script-src'); + assert.ok(header.includes('sha256-test123'), 'Header should include hash'); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + assert.equal(meta.attr('content'), undefined, 'Should not have CSP meta tag'); + }); + }); + + describe('Font Source Directive', () => { + it('should not inject font-src by default when fonts are not used', async () => { + const pipeline = createCspPipeline({ + scriptHashes: ['sha256-test123'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + assert.equal(content.includes('font-src'), false, 'Should not include font-src directive'); + }); + }); + + describe('CSP Content Parsing', () => { + it('should generate well-formed CSP content', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'", "default-src 'none'"], + scriptHashes: ['sha256-abc123'], + scriptResources: ['https://cdn.example.com'], + styleHashes: ['sha256-def456'], + styleResources: ['https://styles.example.com'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content'); + + // Parse CSP content into structured array + const parsed = content + .split(';') + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + const [directive, ...resources] = part.split(/\s+/); + return { directive, resources }; + }); + + // Check that all directives are present + const directives = parsed.map((p) => p.directive); + assert.ok(directives.includes('img-src'), 'Should have img-src'); + assert.ok(directives.includes('default-src'), 'Should have default-src'); + assert.ok(directives.includes('script-src'), 'Should have script-src'); + assert.ok(directives.includes('style-src'), 'Should have style-src'); + + // Check script-src has both resources and hashes + const scriptSrc = parsed.find((p) => p.directive === 'script-src'); + assert.ok( + scriptSrc.resources.includes('https://cdn.example.com'), + 'script-src should include resource', + ); + assert.ok( + scriptSrc.resources.some((r) => r.includes('sha256-abc123')), + 'script-src should include hash', + ); + + // Check style-src has both resources and hashes + const styleSrc = parsed.find((p) => p.directive === 'style-src'); + assert.ok( + styleSrc.resources.includes('https://styles.example.com'), + 'style-src should include resource', + ); + assert.ok( + styleSrc.resources.some((r) => r.includes('sha256-def456')), + 'style-src should include hash', + ); + }); + }); +}); + +// #endregion diff --git a/packages/astro/test/units/i18n/i18n-middleware.test.js b/packages/astro/test/units/i18n/i18n-middleware.test.js new file mode 100644 index 000000000000..11667ac25a6c --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-middleware.test.js @@ -0,0 +1,213 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; +import { createMockAPIContext } from '../middleware/test-helpers.js'; + +/** + * Creates a "page" response that mimics what the render pipeline returns. + * The `X-Astro-Route-Type: page` header is what the i18n middleware reads + * to decide whether to apply routing logic. + * + * @param {string} body + * @param {number} [status] + * @param {Record} [extraHeaders] + */ +function makePageResponse(body, status = 200, extraHeaders = {}) { + return new Response(body, { + status, + headers: { 'X-Astro-Route-Type': 'page', ...extraHeaders }, + }); +} + +/** + * Creates a minimal i18n manifest. + * @param {Partial<{ + * defaultLocale: string, + * locales: import('../../../src/types/public/config.js').Locales, + * strategy: import('../../../dist/core/app/common.js').RoutingStrategies, + * fallbackType: 'redirect' | 'rewrite', + * fallback: Record, + * domainLookupTable: Record, + * domains: Record, + * }>} [overrides] + */ +function makeI18nManifest(overrides = {}) { + return { + defaultLocale: overrides.defaultLocale ?? 'en', + locales: overrides.locales ?? ['en', 'it'], + strategy: overrides.strategy ?? 'pathname-prefix-always', + fallbackType: overrides.fallbackType ?? 'rewrite', + fallback: overrides.fallback ?? {}, + domains: overrides.domains ?? {}, + domainLookupTable: overrides.domainLookupTable ?? {}, + }; +} + +describe('createI18nMiddleware', () => { + it('returns a passthrough handler when i18n config is undefined', async () => { + const handler = createI18nMiddleware(undefined, '/', 'ignore', 'directory'); + const ctx = createMockAPIContext({ url: 'http://localhost/anything' }); + const pageResponse = makePageResponse('original'); + + const result = await handler(ctx, async () => pageResponse); + + assert.equal(result, pageResponse, 'should return the exact same response object'); + }); + + describe('pathname-prefix-always strategy', () => { + /** @type {import('astro').MiddlewareHandler} */ + let handler; + + beforeEach(() => { + handler = createI18nMiddleware( + makeI18nManifest({ strategy: 'pathname-prefix-always' }), + '/', + 'ignore', + 'directory', + ); + }); + + it('returns 404 for a non-locale-prefixed path', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); + const next = async () => makePageResponse('Blog should not render'); + + const result = await handler(ctx, next); + + assert.equal(result.status, 404); + assert.equal(await result.text(), 'Blog should not render'); + }); + + it('passes through a locale-prefixed path', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/en/start' }); + const next = async () => makePageResponse('en page'); + + const result = await handler(ctx, next); + + assert.equal(result.status, 200); + assert.equal(await result.text(), 'en page'); + }); + + it('redirects root / to /{defaultLocale}/', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/' }); + const next = async () => makePageResponse('root'); + + const result = await handler(ctx, next); + + assert.equal(result.status, 302); + assert.ok( + result.headers.get('Location')?.includes('/en'), + `expected Location to contain /en, got: ${result.headers.get('Location')}`, + ); + }); + }); + + describe('pathname-prefix-other-locales strategy', () => { + /** @type {import('astro').MiddlewareHandler} */ + let handler; + + beforeEach(() => { + handler = createI18nMiddleware( + makeI18nManifest({ strategy: 'pathname-prefix-other-locales' }), + '/', + 'ignore', + 'directory', + ); + }); + + it('passes through un-prefixed paths for the default locale', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); + const next = async () => makePageResponse('en blog'); + + const result = await handler(ctx, next); + + assert.equal(result.status, 200); + }); + + it('returns 404 when default locale prefix is used', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/en/blog' }); + const next = async () => makePageResponse('should not be visible'); + + const result = await handler(ctx, next); + + assert.equal(result.status, 404); + }); + }); + + describe('fallback routing', () => { + it('redirects to fallback locale path when fallbackType is redirect', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ + strategy: 'pathname-prefix-always', + fallbackType: 'redirect', + fallback: { it: 'en' }, + }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ url: 'http://localhost/it/start' }); + const next = async () => makePageResponse('no it page', 404); + + const result = await handler(ctx, next); + + assert.equal(result.status, 302); + assert.equal(result.headers.get('Location'), '/en/start'); + }); + + it('rewrites to fallback locale path when fallbackType is rewrite', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ + strategy: 'pathname-prefix-always', + fallbackType: 'rewrite', + fallback: { it: 'en' }, + }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ + url: 'http://localhost/it/start', + rewrite: async (path) => new Response(`rewritten to ${path}`, { status: 200 }), + }); + const next = async () => makePageResponse('no it page', 404); + + const result = await handler(ctx, next); + + assert.equal(result.status, 200); + assert.equal(await result.text(), 'rewritten to /en/start'); + }); + }); + + describe('early-return guards', () => { + it('passes through when X-Astro-Reroute is no and no fallback is configured', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ fallback: undefined }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ url: 'http://localhost/404' }); + const pageResponse = new Response('not found', { + status: 404, + headers: { 'X-Astro-Route-Type': 'page', 'X-Astro-Reroute': 'no' }, + }); + + const result = await handler(ctx, async () => pageResponse); + + assert.equal(result, pageResponse, 'should return the exact same response'); + }); + + it('passes through when route type is not page or fallback', async () => { + const handler = createI18nMiddleware(makeI18nManifest(), '/', 'ignore', 'directory'); + const ctx = createMockAPIContext({ url: 'http://localhost/api/data' }); + const endpointResponse = new Response('{"ok":true}', { + headers: { 'X-Astro-Route-Type': 'endpoint' }, + }); + + const result = await handler(ctx, async () => endpointResponse); + + assert.equal(result, endpointResponse, 'should return the exact same response'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/call-middleware.test.js b/packages/astro/test/units/middleware/call-middleware.test.js new file mode 100644 index 000000000000..b83692ad9346 --- /dev/null +++ b/packages/astro/test/units/middleware/call-middleware.test.js @@ -0,0 +1,196 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it, beforeEach } from 'node:test'; +import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; +import { createMockAPIContext, createResponseFunction } from './test-helpers.js'; + +describe('callMiddleware', () => { + /** @type {import('astro').APIContext} */ + let ctx; + const defaultResponseFn = createResponseFunction(); + + beforeEach(() => { + ctx = createMockAPIContext(); + }); + + describe('next() called', () => { + it('returns the middleware return value when next() is called and a Response is returned', async () => { + const middleware = async (_ctx, next) => { + const response = await next(); + return new Response('modified', { status: 200, headers: response.headers }); + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('original')); + + assert.equal(await response.text(), 'modified'); + }); + + it('returns the responseFunction result when next() is called but middleware returns undefined', async () => { + const middleware = async (_ctx, next) => { + await next(); + // deliberately returns undefined + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('from page')); + + assert.equal(await response.text(), 'from page'); + }); + + it('throws MiddlewareNotAResponse when next() is called but middleware returns a non-Response', async () => { + const middleware = async (_ctx, next) => { + await next(); + return 'not a response'; + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err) => { + assert.equal(err.name, 'MiddlewareNotAResponse'); + return true; + }, + ); + }); + }); + + describe('next() not called', () => { + it('returns the Response when middleware short-circuits without calling next()', async () => { + const middleware = async () => { + return new Response('short-circuit', { status: 200 }); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(await response.text(), 'short-circuit'); + assert.equal(response.status, 200); + }); + + it('returns a 500 Response when middleware short-circuits with an error status', async () => { + const middleware = async () => { + return new Response(null, { status: 500 }); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(response.status, 500); + }); + + it('throws MiddlewareNoDataOrNextCalled when middleware returns undefined without calling next()', async () => { + const middleware = async () => { + // returns undefined, never calls next + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err) => { + assert.equal(err.name, 'MiddlewareNoDataOrNextCalled'); + return true; + }, + ); + }); + + it('throws MiddlewareNotAResponse when middleware returns a non-Response without calling next()', async () => { + const middleware = async () => { + return 'not a response'; + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err) => { + assert.equal(err.name, 'MiddlewareNotAResponse'); + return true; + }, + ); + }); + }); + + describe('context mutation', () => { + it('locals mutations are visible in the response function', async () => { + const middleware = async (context, next) => { + context.locals.name = 'bar'; + return next(); + }; + const responseFn = async (apiCtx) => { + return new Response(`name=${apiCtx.locals.name}`); + }; + + const response = await callMiddleware(middleware, ctx, responseFn); + + assert.equal(await response.text(), 'name=bar'); + }); + + it('middleware can set response headers after calling next()', async () => { + const middleware = async (_context, next) => { + const response = await next(); + response.headers.set('X-Custom', 'value'); + return response; + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('OK')); + + assert.equal(response.headers.get('X-Custom'), 'value'); + }); + + it('middleware can clone the response, modify body, and return a new Response', async () => { + const middleware = async (_context, next) => { + const response = await next(); + const cloned = response.clone(); + const html = await cloned.text(); + const modified = html.replace('testing', 'it works'); + return new Response(modified, { status: 200, headers: response.headers }); + }; + + const response = await callMiddleware( + middleware, + ctx, + createResponseFunction('

testing

'), + ); + + assert.equal(await response.text(), '

it works

'); + }); + + it('middleware can intercept a JSON response, modify it, and return a new Response', async () => { + const middleware = async (_context, next) => { + const response = await next(); + const data = await response.json(); + data.name = 'REDACTED'; + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const response = await callMiddleware( + middleware, + ctx, + createResponseFunction(JSON.stringify({ name: 'secret', value: 42 }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + const body = await response.json(); + + assert.equal(body.name, 'REDACTED'); + assert.equal(body.value, 42); + }); + }); + + describe('synchronous middleware', () => { + it('works with a synchronous middleware that calls next()', async () => { + const middleware = (_context, next) => { + return next(); + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('sync OK')); + + assert.equal(await response.text(), 'sync OK'); + }); + + it('works with a synchronous middleware that returns a Response', async () => { + const middleware = () => { + return new Response('sync short-circuit'); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(await response.text(), 'sync short-circuit'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/middleware-app.test.js b/packages/astro/test/units/middleware/middleware-app.test.js new file mode 100644 index 000000000000..80013bd41892 --- /dev/null +++ b/packages/astro/test/units/middleware/middleware-app.test.js @@ -0,0 +1,611 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { App } from '../../../dist/core/app/app.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createManifest, createRouteData } from './test-helpers.js'; + +/** + * Helper: creates an App with the given middleware and routes. + * @param {object} opts + * @param {import('astro').MiddlewareHandler} opts.onRequest - The middleware handler + * @param {Array<{ routeData: any; component?: any }>} opts.routes - Route definitions + * @param {Map} opts.pageMap - Component map + * @param {string} [opts.base] + */ +function createAppWithMiddleware({ onRequest, routes, pageMap, base }) { + const manifest = createManifest({ + routes: routes.map((r) => ({ routeData: r.routeData })), + pageMap, + base, + }); + // Override the middleware field — createManifest sets it to undefined, + // but the pipeline reads it from manifest.middleware + manifest.middleware = () => ({ onRequest }); + return new App(manifest); +} + +// ----- Shared route data ----- + +const indexRouteData = createRouteData({ route: '/' }); +const loremRouteData = createRouteData({ route: '/lorem' }); +const secondRouteData = createRouteData({ route: '/second' }); +const redirectRouteData = createRouteData({ route: '/redirect' }); +const rewriteRouteData = createRouteData({ route: '/rewrite' }); +const adminRouteData = createRouteData({ route: '/admin' }); +const apiRouteData = createRouteData({ route: '/api/endpoint', type: 'endpoint' }); +const throwRouteData = createRouteData({ route: '/throw' }); +const notFoundRouteData = createRouteData({ route: '/404' }); +const serverErrorRouteData = createRouteData({ route: '/500' }); +const spacesRouteData = createRouteData({ + route: '/path with spaces', + pathname: '/path with spaces', +}); + +// ----- Shared page components ----- + +const simplePage = (localKey = 'name') => + createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + return render`

${Astro.locals[localKey]}

`; + }); + +const notFoundPage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + return render`Error

${Astro.locals.name}

`; +}); + +const serverErrorPage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + return render`500

${Astro.locals.name}

`; +}); + +const throwingPage = createComponent(() => { + throw new Error('page threw an error'); +}); + +const cookiePage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + Astro.cookies.set('from-component', 'component-value'); + return render`

cookies

`; +}); + +// ----- Tests ----- + +describe('Middleware via App.render()', () => { + describe('locals', () => { + it('should render locals data set by middleware', async () => { + const onRequest = async (ctx, next) => { + ctx.locals.name = 'bar'; + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/')); + const html = await response.text(); + + assert.match(html, /

bar<\/p>/); + }); + + it('should change locals data based on URL', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/lorem') { + ctx.locals.name = 'ipsum'; + } else { + ctx.locals.name = 'bar'; + } + return next(); + }; + const page = simplePage(); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [loremRouteData.component, async () => ({ page: async () => ({ default: page }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }, { routeData: loremRouteData }], + pageMap, + }); + + const indexRes = await app.render(new Request('http://localhost/')); + assert.match(await indexRes.text(), /

bar<\/p>/); + + const loremRes = await app.render(new Request('http://localhost/lorem')); + assert.match(await loremRes.text(), /

ipsum<\/p>/); + }); + }); + + describe('sequence', () => { + it('should call a second middleware in a sequence via manifest', async () => { + // We test sequence by making the manifest middleware itself a sequence. + // sequence() is already tested in sequence.test.js; here we verify it works + // when wired through the App pipeline. + const { sequence } = await import('../../../dist/core/middleware/sequence.js'); + + const first = async (ctx, next) => { + ctx.locals.name = 'first'; + return next(); + }; + const second = async (ctx, next) => { + if (ctx.url.pathname === '/second') { + ctx.locals.name = 'second'; + } + return next(); + }; + const combined = sequence(first, second); + const page = simplePage(); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [secondRouteData.component, async () => ({ page: async () => ({ default: page }) })], + ]); + const app = createAppWithMiddleware({ + onRequest: combined, + routes: [{ routeData: indexRouteData }, { routeData: secondRouteData }], + pageMap, + }); + + const indexRes = await app.render(new Request('http://localhost/')); + assert.match(await indexRes.text(), /

first<\/p>/); + + const secondRes = await app.render(new Request('http://localhost/second')); + assert.match(await secondRes.text(), /

second<\/p>/); + }); + }); + + describe('short-circuit responses', () => { + it('should successfully create a new response bypassing the page', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/rewrite') { + return new Response('New content!!', { status: 200 }); + } + return next(); + }; + const pageMap = new Map([ + [ + rewriteRouteData.component, + async () => ({ page: async () => ({ default: simplePage() }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: rewriteRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/rewrite')); + + assert.equal(response.status, 200); + assert.equal(await response.text(), 'New content!!'); + }); + + it('should return a new response that is a 500', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/broken-500') { + return new Response(null, { status: 500 }); + } + return next(); + }; + // We need a route that matches /broken-500 + const brokenRoute = createRouteData({ route: '/broken-500' }); + const pageMap = new Map([ + [brokenRoute.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: brokenRoute }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/broken-500')); + + assert.equal(response.status, 500); + }); + + it('should return 200 if middleware returns a 200 Response for a non-existent route', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/no-route-but-200') { + return new Response("It's OK!", { status: 200 }); + } + return next(); + }; + // No route matches /no-route-but-200, but middleware short-circuits + const pageMap = new Map([ + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/no-route-but-200')); + + assert.equal(response.status, 200); + assert.equal(await response.text(), "It's OK!"); + }); + }); + + describe('pass-through middleware', () => { + it('should render the page normally if middleware only calls next()', async () => { + const onRequest = async (_ctx, next) => { + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'passthrough' }, + }); + const html = await response.text(); + + assert.equal(response.status, 200); + assert.match(html, /

passthrough<\/p>/); + }); + }); + + describe('error handling', () => { + it('should throw when middleware returns undefined without calling next()', async () => { + const onRequest = async () => { + return undefined; + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + // In the App pipeline, errors in middleware result in a 500 response + const response = await app.render(new Request('http://localhost/')); + assert.equal(response.status, 500); + }); + + it('should render 500.astro when middleware throws an error', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/throw') { + throw new Error('middleware error'); + } + return next(); + }; + const pageMap = new Map([ + [throwRouteData.component, async () => ({ page: async () => ({ default: throwingPage }) })], + [ + serverErrorRouteData.component, + async () => ({ page: async () => ({ default: serverErrorPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: throwRouteData }, { routeData: serverErrorRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/throw')); + + assert.equal(response.status, 500); + }); + }); + + describe('redirect', () => { + it('should successfully redirect to another page', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/redirect') { + return ctx.redirect('/', 302); + } + return next(); + }; + const pageMap = new Map([ + [ + redirectRouteData.component, + async () => ({ page: async () => ({ default: simplePage() }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: redirectRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/redirect')); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/'); + }); + }); + + describe('cookies', () => { + it('should allow middleware to set cookies', async () => { + const onRequest = async (ctx, next) => { + ctx.cookies.set('foo', 'bar'); + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'test' }, + addCookieHeader: true, + }); + + const setCookie = response.headers.get('set-cookie'); + assert.ok(setCookie); + assert.match(setCookie, /foo=bar/); + }); + + it('should forward cookies set in a component when middleware returns a new response', async () => { + const onRequest = async (_ctx, next) => { + const response = await next(); + const html = await response.text(); + return new Response(html, { status: 200, headers: response.headers }); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: cookiePage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + addCookieHeader: true, + }); + const setCookie = response.headers.get('set-cookie'); + + assert.ok(setCookie); + assert.match(setCookie, /from-component=component-value/); + }); + }); + + describe('response modification', () => { + it('should be able to clone the response and modify it', async () => { + const onRequest = async (_ctx, next) => { + const response = await next(); + const cloned = response.clone(); + const html = await cloned.text(); + const modified = html.replace('testing', 'it works'); + return new Response(modified, { status: 200, headers: response.headers }); + }; + const testPage = createComponent(() => { + return render`

testing

`; + }); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: testPage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/')); + const html = await response.text(); + + assert.match(html, /it works/); + assert.ok(!html.includes('testing')); + }); + }); + + describe('API endpoints', () => { + it('should correctly work for API endpoints that return a Response object', async () => { + const onRequest = async (_ctx, next) => { + return next(); + }; + const pageMap = new Map([ + [ + apiRouteData.component, + async () => ({ + page: async () => ({ + GET: async () => + new Response(JSON.stringify({ name: 'test' }), { + headers: { 'Content-Type': 'application/json' }, + }), + }), + }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: apiRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/endpoint')); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('Content-Type'), 'application/json'); + const body = await response.json(); + assert.equal(body.name, 'test'); + }); + + it('should correctly manipulate the response coming from API endpoints', async () => { + const onRequest = async (ctx, next) => { + if (ctx.url.pathname === '/api/endpoint') { + const response = await next(); + const data = await response.json(); + data.name = 'REDACTED'; + return new Response(JSON.stringify(data), { + headers: response.headers, + }); + } + return next(); + }; + const pageMap = new Map([ + [ + apiRouteData.component, + async () => ({ + page: async () => ({ + GET: async () => + new Response(JSON.stringify({ name: 'secret', value: 42 }), { + headers: { 'Content-Type': 'application/json' }, + }), + }), + }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: apiRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/endpoint')); + const body = await response.json(); + + assert.equal(body.name, 'REDACTED'); + assert.equal(body.value, 42); + }); + }); + + describe('404 handling', () => { + it('should correctly call middleware for 404 routes', async () => { + const onRequest = async (ctx, next) => { + ctx.locals.name = 'bar'; + return next(); + }; + const pageMap = new Map([ + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: notFoundRouteData }], + pageMap, + }); + + // Request a URL that doesn't match any route — falls back to 404 + const response = await app.render(new Request('http://localhost/unknown-page')); + + assert.equal(response.status, 404); + const html = await response.text(); + assert.match(html, /bar/); + }); + }); + + describe('path encoding and auth', () => { + /** + * Auth middleware that protects /admin + */ + const authMiddleware = async (ctx, next) => { + if (ctx.url.pathname === '/admin') { + const authToken = ctx.request.headers.get('Authorization'); + if (!authToken) { + return ctx.redirect('/'); + } + } + return next(); + }; + + function createAuthApp() { + const page = simplePage(); + const pageMap = new Map([ + [adminRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + return createAppWithMiddleware({ + onRequest: authMiddleware, + routes: [ + { routeData: adminRouteData }, + { routeData: indexRouteData }, + { routeData: notFoundRouteData }, + ], + pageMap, + }); + } + + it('should allow accessing /admin with valid auth header', async () => { + const app = createAuthApp(); + const response = await app.render( + new Request('http://localhost/admin', { + headers: { Authorization: 'Bearer token123' }, + }), + { locals: { name: 'admin-content' } }, + ); + + assert.equal(response.status, 200); + }); + + it('should redirect /admin without auth header', async () => { + const app = createAuthApp(); + const response = await app.render(new Request('http://localhost/admin')); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/'); + }); + + it('should handle requests with spaces in path correctly', async () => { + const onRequest = async (_ctx, next) => { + return next(); + }; + const spacesPage = createComponent(() => { + return render`

spaces page

`; + }); + const pageMap = new Map([ + [spacesRouteData.component, async () => ({ page: async () => ({ default: spacesPage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: spacesRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/path%20with%20spaces')); + + assert.equal(response.status, 200); + }); + }); + + describe('middleware with custom headers', () => { + it('should correctly set custom headers in middleware', async () => { + const onRequest = async (_ctx, next) => { + const response = await next(); + response.headers.set('X-Custom-Header', 'custom-value'); + return response; + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'test' }, + }); + + assert.equal(response.headers.get('X-Custom-Header'), 'custom-value'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/sequence.test.js b/packages/astro/test/units/middleware/sequence.test.js new file mode 100644 index 000000000000..ac8a88909478 --- /dev/null +++ b/packages/astro/test/units/middleware/sequence.test.js @@ -0,0 +1,191 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; +import { sequence } from '../../../dist/core/middleware/sequence.js'; +import { createMockAPIContext, createResponseFunction } from './test-helpers.js'; + +describe('sequence', () => { + /** @type {import('astro').APIContext} */ + let globaCtx; + + beforeEach(() => { + globaCtx = createMockAPIContext(); + }); + + it('returns a passthrough middleware when called with no handlers', async () => { + const combined = sequence(); + const responseFn = createResponseFunction('passthrough'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'passthrough'); + }); + + it('works with a single handler', async () => { + const handler = async (ctx, next) => { + ctx.locals.touched = true; + return next(); + }; + const combined = sequence(handler); + const responseFn = createResponseFunction('single'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'single'); + assert.equal(globaCtx.locals.touched, true); + }); + + it('executes handlers in order', async () => { + const order = []; + const handler1 = async (_ctx, next) => { + order.push(1); + return next(); + }; + const handler2 = async (_ctx, next) => { + order.push(2); + return next(); + }; + const handler3 = async (_ctx, next) => { + order.push(3); + return next(); + }; + const combined = sequence(handler1, handler2, handler3); + const responseFn = createResponseFunction(); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.deepEqual(order, [1, 2, 3]); + }); + + it('propagates context mutations across handlers', async () => { + const first = async (ctx, next) => { + ctx.locals.first = 'a'; + return next(); + }; + const second = async (ctx, next) => { + // should see mutation from first + ctx.locals.second = ctx.locals.first + 'b'; + return next(); + }; + const combined = sequence(first, second); + const responseFn = async (apiCtx) => { + return new Response(`${apiCtx.locals.first}-${apiCtx.locals.second}`); + }; + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'a-ab'); + }); + + it('allows the last handler to modify the response from the page', async () => { + const handler1 = async (_ctx, next) => { + return next(); + }; + const handler2 = async (_ctx, next) => { + const response = await next(); + const text = await response.text(); + return new Response(text.toUpperCase()); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('hello world'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'HELLO WORLD'); + }); + + it('supports mixed sync and async handlers', async () => { + const syncHandler = (_ctx, next) => { + return next(); + }; + const asyncHandler = async (ctx, next) => { + ctx.locals.async = true; + return await next(); + }; + const combined = sequence(syncHandler, asyncHandler); + const responseFn = createResponseFunction('mixed'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'mixed'); + assert.equal(globaCtx.locals.async, true); + }); + + it('filters out falsy handlers', async () => { + const order = []; + const handler1 = async (_ctx, next) => { + order.push(1); + return next(); + }; + const handler2 = async (_ctx, next) => { + order.push(2); + return next(); + }; + const combined = sequence(handler1, null, undefined, handler2); + const responseFn = createResponseFunction(); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.deepEqual(order, [1, 2]); + }); + + it('allows earlier handlers to short-circuit the chain', async () => { + const order = []; + const handler1 = async () => { + order.push(1); + return new Response('short-circuit'); + }; + const handler2 = async (_ctx, next) => { + order.push(2); + return next(); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('should not reach'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'short-circuit'); + assert.deepEqual(order, [1]); // handler2 was never called + }); + + it('accumulates cookies set by multiple handlers', async () => { + const handler1 = async (ctx, next) => { + ctx.cookies.set('cookie1', 'value1'); + return next(); + }; + const handler2 = async (ctx, next) => { + ctx.cookies.set('cookie2', 'value2'); + return next(); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('OK'); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(globaCtx.cookies.get('cookie1')?.value, 'value1'); + assert.equal(globaCtx.cookies.get('cookie2')?.value, 'value2'); + }); + + it('handles a chain where middle handler returns a redirect', async () => { + const handler1 = async (ctx, next) => { + ctx.locals.beforeRedirect = true; + return next(); + }; + const handler2 = async (ctx) => { + return ctx.redirect('/login'); + }; + const handler3 = async (_ctx, next) => { + // should never be called + return next(); + }; + const combined = sequence(handler1, handler2, handler3); + const responseFn = createResponseFunction(); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/login'); + assert.equal(globaCtx.locals.beforeRedirect, true); + }); +}); diff --git a/packages/astro/test/units/middleware/test-helpers.js b/packages/astro/test/units/middleware/test-helpers.js new file mode 100644 index 000000000000..ddc9c5dfa753 --- /dev/null +++ b/packages/astro/test/units/middleware/test-helpers.js @@ -0,0 +1,96 @@ +// @ts-check +import { AstroCookies } from '../../../dist/core/cookies/index.js'; +import { makeRoute, staticPart } from '../routing/test-helpers.js'; + +export { createManifest } from '../app/test-helpers.js'; + +/** + * Creates a mock APIContext suitable for calling middleware directly via `callMiddleware()`. + * + * All fields can be overridden. The `cookies` field uses the real `AstroCookies` class + * by default to avoid mock drift. + * + * @param {Partial & { url?: string | URL }} overrides + * @returns {import('astro').APIContext} + */ +export function createMockAPIContext(overrides = {}) { + const url = + overrides.url instanceof URL ? overrides.url : new URL(overrides.url ?? 'http://localhost/'); + const request = overrides.request ?? new Request(url); + const cookies = overrides.cookies ?? new AstroCookies(request); + + return /** @type {import('astro').APIContext} */ ({ + url, + request, + locals: overrides.locals ?? {}, + params: overrides.params ?? {}, + cookies, + redirect: + overrides.redirect ?? + ((path, status = 302) => new Response(null, { status, headers: { Location: String(path) } })), + rewrite: + overrides.rewrite ?? + (() => { + throw new Error( + 'rewrite() is not mocked -- provide a mock if your middleware uses rewrite', + ); + }), + props: overrides.props ?? {}, + routePattern: overrides.routePattern ?? '', + isPrerendered: overrides.isPrerendered ?? false, + site: overrides.site, + generator: overrides.generator ?? 'astro-test', + clientAddress: overrides.clientAddress ?? '127.0.0.1', + originPathname: overrides.originPathname ?? url.pathname, + }); +} + +/** + * Creates a response function compatible with callMiddleware's third argument. + * This simulates what "rendering the page" would return. + * + * @param {string} body - The response body + * @param {ResponseInit} [init] - Optional response init (status, headers, etc.) + * @returns {(ctx: import('astro').APIContext, payload?: unknown) => Promise} + */ +export function createResponseFunction(body = 'OK', init = {}) { + return async (_ctx, _payload) => new Response(body, init); +} + +/** + * Convenience wrapper around `makeRoute` from routing test-helpers. + * Auto-generates segments from the route string for simple static routes, + * while using the real `getPattern()` for regex generation. + * + * @param {object} overrides + * @param {string} overrides.route - The route pattern (e.g. '/foo', '/api/endpoint') + * @param {'page' | 'endpoint' | 'redirect' | 'fallback'} [overrides.type] + * @param {string} [overrides.component] + * @param {boolean} [overrides.prerender] + * @param {boolean} [overrides.isIndex] + * @param {string} [overrides.pathname] + * @param {import('../../../dist/types/public/internal.js').RoutePart[][]} [overrides.segments] + * @param {'always' | 'never' | 'ignore'} [overrides.trailingSlash] + */ +export function createRouteData(overrides) { + const route = overrides.route; + const segments = + overrides.segments ?? + (route === '/' + ? [[]] + : route + .split('/') + .filter(Boolean) + .map((s) => [staticPart(s)])); + + return makeRoute({ + route, + segments, + trailingSlash: overrides.trailingSlash ?? 'ignore', + pathname: overrides.pathname ?? route, + type: overrides.type ?? 'page', + component: overrides.component ?? `src/pages${route === '/' ? '/index' : route}.astro`, + isIndex: overrides.isIndex ?? route === '/', + prerender: overrides.prerender ?? false, + }); +} diff --git a/packages/astro/test/units/server-islands/encryption.test.js b/packages/astro/test/units/server-islands/encryption.test.js new file mode 100644 index 000000000000..8d7514af4a56 --- /dev/null +++ b/packages/astro/test/units/server-islands/encryption.test.js @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createKey, + decodeKey, + decryptString, + encodeKey, + encryptString, + generateCspDigest, +} from '../../../dist/core/encryption.js'; + +describe('encryption', () => { + // #region encryptString / decryptString + describe('encryptString / decryptString', () => { + it('round-trips correctly', async () => { + const key = await createKey(); + const original = 'hello world'; + const encrypted = await encryptString(key, original); + const decrypted = await decryptString(key, encrypted); + assert.equal(decrypted, original); + }); + + it('round-trips an empty string', async () => { + const key = await createKey(); + const encrypted = await encryptString(key, ''); + const decrypted = await decryptString(key, encrypted); + assert.equal(decrypted, ''); + }); + + it('round-trips a JSON payload', async () => { + const key = await createKey(); + const original = JSON.stringify({ foo: 'bar', num: 42, nested: { a: [1, 2] } }); + const encrypted = await encryptString(key, original); + const decrypted = await decryptString(key, encrypted); + assert.equal(decrypted, original); + }); + + it('produces a different ciphertext on each call (IV randomness)', async () => { + const key = await createKey(); + const plain = 'same input'; + const first = await encryptString(key, plain); + const second = await encryptString(key, plain); + // Same plaintext — different ciphertext because each call uses a fresh IV + assert.notEqual(first, second); + }); + + it('both distinct ciphertexts decrypt to the same plaintext', async () => { + const key = await createKey(); + const plain = 'same input'; + const first = await encryptString(key, plain); + const second = await encryptString(key, plain); + assert.equal(await decryptString(key, first), plain); + assert.equal(await decryptString(key, second), plain); + }); + + it('throws when decrypting a tampered ciphertext', async () => { + const key = await createKey(); + const encrypted = await encryptString(key, 'secret'); + // Flip the last character to corrupt the ciphertext + const tampered = encrypted.slice(0, -1) + (encrypted.endsWith('A') ? 'B' : 'A'); + await assert.rejects(() => decryptString(key, tampered)); + }); + + it('throws when decrypting with the wrong key', async () => { + const keyA = await createKey(); + const keyB = await createKey(); + const encrypted = await encryptString(keyA, 'secret'); + await assert.rejects(() => decryptString(keyB, encrypted)); + }); + }); + // #endregion + + // #region encodeKey / decodeKey + describe('encodeKey / decodeKey', () => { + it('round-trips a CryptoKey through base64', async () => { + const key = await createKey(); + const encoded = await encodeKey(key); + const decoded = await decodeKey(encoded); + // Verify the decoded key works for encrypt/decrypt + const plain = 'verify key works'; + const encrypted = await encryptString(decoded, plain); + const decrypted = await decryptString(decoded, encrypted); + assert.equal(decrypted, plain); + }); + + it('produces a string from encodeKey', async () => { + const key = await createKey(); + const encoded = await encodeKey(key); + assert.equal(typeof encoded, 'string'); + assert.ok(encoded.length > 0); + }); + + it('a key encoded then decoded can decrypt ciphertexts made with the original key', async () => { + const key = await createKey(); + const plain = 'cross-key decrypt'; + const encrypted = await encryptString(key, plain); + const encoded = await encodeKey(key); + const decoded = await decodeKey(encoded); + const decrypted = await decryptString(decoded, encrypted); + assert.equal(decrypted, plain); + }); + }); + // #endregion + + // #region generateCspDigest + describe('generateCspDigest', () => { + it('produces a sha256- prefixed base64 hash for SHA-256 algorithm', async () => { + const hash = await generateCspDigest('alert(1)', 'SHA-256'); + assert.ok(hash.startsWith('sha256-'), `Expected sha256- prefix, got: ${hash}`); + }); + + it('produces a sha384- prefixed hash for SHA-384 algorithm', async () => { + const hash = await generateCspDigest('alert(1)', 'SHA-384'); + assert.ok(hash.startsWith('sha384-'), `Expected sha384- prefix, got: ${hash}`); + }); + + it('produces a sha512- prefixed hash for SHA-512 algorithm', async () => { + const hash = await generateCspDigest('alert(1)', 'SHA-512'); + assert.ok(hash.startsWith('sha512-'), `Expected sha512- prefix, got: ${hash}`); + }); + + it('produces a deterministic hash for the same input', async () => { + const a = await generateCspDigest('hello', 'SHA-256'); + const b = await generateCspDigest('hello', 'SHA-256'); + assert.equal(a, b); + }); + + it('produces different hashes for different inputs', async () => { + const a = await generateCspDigest('hello', 'SHA-256'); + const b = await generateCspDigest('world', 'SHA-256'); + assert.notEqual(a, b); + }); + }); + // #endregion +}); diff --git a/packages/astro/test/units/server-islands/endpoint.test.js b/packages/astro/test/units/server-islands/endpoint.test.js new file mode 100644 index 000000000000..a4868a77ce41 --- /dev/null +++ b/packages/astro/test/units/server-islands/endpoint.test.js @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getRequestData } from '../../../dist/core/server-islands/endpoint.js'; + +// #region Helpers + +/** + * Construct a minimal Request for testing getRequestData. + */ +function makeGetRequest(params = {}) { + const url = new URL('http://localhost/_server-islands/Island'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new Request(url.toString(), { method: 'GET' }); +} + +function makePostRequest(body) { + return new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeMethodRequest(method) { + return new Request('http://localhost/_server-islands/Island', { method }); +} + +// #endregion + +describe('getRequestData', () => { + // #region GET requests + describe('GET requests', () => { + it('returns RenderOptions when all required params are present', async () => { + const req = makeGetRequest({ s: 'slots', e: 'export', p: 'props' }); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedSlots, 'slots'); + assert.equal(result.encryptedComponentExport, 'export'); + assert.equal(result.encryptedProps, 'props'); + }); + + it('returns 400 when `s` is missing', async () => { + const req = makeGetRequest({ e: 'export', p: 'props' }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + }); + + it('returns 400 when `e` is missing', async () => { + const req = makeGetRequest({ s: 'slots', p: 'props' }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + }); + + it('returns 400 when `p` is missing', async () => { + const req = makeGetRequest({ s: 'slots', e: 'export' }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + }); + + it('returns 400 when all params are missing', async () => { + const req = makeGetRequest(); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + }); + + it('accepts empty-string param values (empty props / slots are valid)', async () => { + const req = makeGetRequest({ s: '', e: 'export', p: '' }); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedSlots, ''); + assert.equal(result.encryptedProps, ''); + }); + }); + // #endregion + + // #region POST requests + describe('POST requests', () => { + it('returns RenderOptions for a well-formed encrypted payload', async () => { + const req = makePostRequest({ + encryptedComponentExport: 'encExport', + encryptedProps: 'encProps', + encryptedSlots: 'encSlots', + }); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedComponentExport, 'encExport'); + assert.equal(result.encryptedProps, 'encProps'); + assert.equal(result.encryptedSlots, 'encSlots'); + }); + + it('returns 400 when POST body contains plaintext `slots` object', async () => { + const req = makePostRequest({ + encryptedComponentExport: 'encExport', + encryptedProps: '', + slots: { default: '

Hello

' }, + }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + assert.ok( + result.statusText.toLowerCase().includes('plaintext slots'), + `Expected 'plaintext slots' in statusText, got: ${result.statusText}`, + ); + }); + + it('returns 400 when POST body contains plaintext `componentExport` string', async () => { + const req = makePostRequest({ + componentExport: 'default', + encryptedProps: '', + encryptedSlots: '', + }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + assert.ok( + result.statusText.toLowerCase().includes('plaintext componentexport'), + `Expected 'plaintext componentExport' in statusText, got: ${result.statusText}`, + ); + }); + + it('returns 400 when POST body is malformed JSON', async () => { + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not valid json {{{', + }); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 400); + }); + + it('accepts empty strings for encryptedProps and encryptedSlots', async () => { + const req = makePostRequest({ + encryptedComponentExport: 'encExport', + encryptedProps: '', + encryptedSlots: '', + }); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedProps, ''); + assert.equal(result.encryptedSlots, ''); + }); + }); + // #endregion + + // #region Unsupported HTTP methods + describe('unsupported HTTP methods', () => { + for (const method of ['PUT', 'DELETE', 'PATCH', 'HEAD']) { + it(`returns 405 for ${method}`, async () => { + const req = makeMethodRequest(method); + const result = await getRequestData(req); + assert.ok(result instanceof Response); + assert.equal(result.status, 405); + }); + } + }); + // #endregion +}); diff --git a/packages/astro/test/units/server-islands/server-islands-render.test.js b/packages/astro/test/units/server-islands/server-islands-render.test.js new file mode 100644 index 000000000000..97880a7a5796 --- /dev/null +++ b/packages/astro/test/units/server-islands/server-islands-render.test.js @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createKey } from '../../../dist/core/encryption.js'; +import { + ServerIslandComponent, + containsServerDirective, + renderServerIslandRuntime, +} from '../../../dist/runtime/server/render/server-islands.js'; + +// #region Helpers + +/** Minimal SSRResult stub sufficient for ServerIslandComponent. */ +async function createStubResult(overrides = {}) { + const key = await createKey(); + return { + key: Promise.resolve(key), + serverIslandNameMap: new Map([ + ['src/components/Island.astro', 'Island'], + ['src/components/BigIsland.astro', 'BigIsland'], + ]), + base: '/', + trailingSlash: 'never', + _metadata: { + extraHead: [], + extraScriptHashes: [], + hasRenderedServerIslandRuntime: false, + propagators: new Set(), + }, + cspDestination: undefined, + internalFetchHeaders: {}, + ...overrides, + }; +} + +/** Collect all chunks written to a destination into a single string. */ +function createDestination() { + const chunks = []; + const destination = { + write(chunk) { + chunks.push(chunk); + }, + }; + return { + destination, + /** Returns all written chunks concatenated as a string. Call after render() completes. */ + output() { + return chunks.map((c) => String(c)).join(''); + }, + }; +} + +/** Minimal props for a server island pointing at Island.astro. */ +function islandProps(extra = {}) { + return { + 'server:component-path': 'src/components/Island.astro', + 'server:component-export': 'default', + 'server:component-directive': 'server', + 'server:defer': true, + ...extra, + }; +} + +// #endregion + +// #region containsServerDirective + +describe('containsServerDirective', () => { + it('returns true when server:component-directive is present', () => { + assert.equal(containsServerDirective({ 'server:component-directive': 'server' }), true); + }); + + it('returns false when server:component-directive is absent', () => { + assert.equal(containsServerDirective({ foo: 'bar' }), false); + }); + + it('returns false for an empty props object', () => { + assert.equal(containsServerDirective({}), false); + }); +}); + +// #endregion + +// #region renderServerIslandRuntime + +describe('renderServerIslandRuntime', () => { + it('returns a '), 'should include closing tag'); + assert.ok( + output.includes('replaceServerIsland'), + 'should include the replaceServerIsland function', + ); + }); + + it('escapes sequences inside the runtime script', () => { + const output = renderServerIslandRuntime(); + // The content between the script tags must not contain an unescaped /, '').replace(/<\/script>$/, ''); + assert.ok( + !inner.includes(' { + // #region getIslandContent() + describe('getIslandContent()', () => { + it('omits props from the URL when no user props are provided', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + // Matches `p=` with nothing after it before the next param or quote, + // e.g. `?e=...&p=&s=` (GET) or `encryptedProps: ''` (POST). + // This confirms the encrypted props value is empty when no user props are passed. + const emptyPropsPattern = /[?&]p=(?:&|')/; + assert.ok(emptyPropsPattern.test(content), `expected empty p= in URL, got: ${content}`); + }); + + it('includes encrypted props in the URL when user props are provided', async () => { + const result = await createStubResult(); + const props = islandProps({ message: 'hello' }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + const content = await component.getIslandContent(); + // p= should be non-empty when props are present + assert.ok(!content.includes("'p', ''"), 'p= should not be empty when props are present'); + }); + + it('produces different ciphertexts on each call (IV randomness)', async () => { + const result = await createStubResult(); + const propsA = islandProps({ value: 'test' }); + const propsB = islandProps({ value: 'test' }); + const compA = new ServerIslandComponent(result, propsA, {}, 'Island'); + const compB = new ServerIslandComponent(result, propsB, {}, 'Island'); + const [contentA, contentB] = await Promise.all([ + compA.getIslandContent(), + compB.getIslandContent(), + ]); + // Two separate instances with identical props should produce different encrypted values + assert.notEqual(contentA, contentB, 'encrypted values should differ due to unique IVs'); + }); + + it('uses a GET request for small payloads (under 2048 chars)', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + // A small payload should use a plain fetch() GET call (no method: 'POST') + assert.ok(!content.includes("method: 'POST'"), 'small payloads should use GET, not POST'); + assert.ok(content.includes("fetch('"), 'should use fetch()'); + }); + + it('uses a POST request for large payloads (over 2048 chars)', async () => { + const result = await createStubResult(); + // Create a large prop value to push past the 2048 character URL limit + const largeValue = 'x'.repeat(2048); + const props = islandProps({ data: largeValue }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok(content.includes("method: 'POST'"), 'large payloads should fall back to POST'); + }); + + it('builds the island URL from base + componentId', async () => { + const result = await createStubResult({ base: '/app' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('/app/_server-islands/Island'), + `island URL should include base + componentId, got: ${content}`, + ); + }); + + it('appends a trailing slash when trailingSlash is "always"', async () => { + const result = await createStubResult({ trailingSlash: 'always' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('/_server-islands/Island/'), + `should append trailing slash, got: ${content}`, + ); + }); + + it('does not append a trailing slash when trailingSlash is "never"', async () => { + const result = await createStubResult({ trailingSlash: 'never' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + !content.includes('/_server-islands/Island/'), + `should NOT append trailing slash, got: ${content}`, + ); + }); + + it('the encrypted componentExport round-trips correctly', async () => { + const key = await createKey(); + const result = await createStubResult({ key: Promise.resolve(key) }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + await component.getIslandContent(); + // Extract the encrypted export value embedded in the generated script + // The GET path embeds it in the URL; POST path embeds it in the data object. + // We verify the field is encrypted (not plaintext "default") in both cases. + const content = await component.getIslandContent(); + assert.ok( + !content.includes('"default"') && !content.includes("'default'"), + 'componentExport should be encrypted, not plaintext "default"', + ); + }); + + it('removes internal server: props before encrypting user props', async () => { + const key = await createKey(); + const result = await createStubResult({ key: Promise.resolve(key) }); + const props = islandProps({ userProp: 'visible' }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + await component.getIslandContent(); + // After getIslandContent(), the internal props should have been removed from this.props + for (const internalKey of [ + 'server:component-path', + 'server:component-export', + 'server:component-directive', + 'server:defer', + ]) { + assert.ok( + !(internalKey in component.props), + `internal prop ${internalKey} should be removed`, + ); + } + // The user prop should remain + assert.ok('userProp' in component.props, 'user prop should still be present'); + }); + + it('throws when the component path is not in serverIslandNameMap', async () => { + const result = await createStubResult(); + const props = islandProps({ 'server:component-path': 'src/components/Unknown.astro' }); + const component = new ServerIslandComponent(result, props, {}, 'Unknown'); + await assert.rejects( + () => component.getIslandContent(), + /Could not find server component name/, + ); + }); + }); + // #endregion + + // #region render() + describe('render()', () => { + it('emits the server-island-start HTML comment marker', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const dest = createDestination(); + await component.render(dest.destination); + const out = dest.output(); + assert.ok( + out.includes('server-island-start'), + `should emit server-island-start marker, got: ${out}`, + ); + }); + + it('emits a