From 1f82c66749f5c66c49349972cdc7182a297523cb Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 27 Feb 2026 15:55:54 +0000 Subject: [PATCH 1/8] refactor(csp): port some tests to unit (#15677) --- packages/astro/test/csp.test.js | 334 +------------ .../astro/test/units/csp/rendering.test.js | 468 ++++++++++++++++++ 2 files changed, 470 insertions(+), 332 deletions(-) create mode 100644 packages/astro/test/units/csp/rendering.test.js diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 5337c0fe5f3f..fbc79b933c19 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,17 +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({ @@ -468,7 +138,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/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 From 99d850aea50ac37d171edbae514b955bfd4c5be9 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 27 Feb 2026 15:57:24 +0000 Subject: [PATCH 2/8] [ci] format --- packages/astro/test/csp.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index fbc79b933c19..4c43ddce702f 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -7,7 +7,7 @@ import { loadFixture } from './test-utils.js'; describe('CSP', () => { /** @type {import('./test-utils.js').Fixture} */ let fixture; - + it('should generate hashes for inline styles', async () => { fixture = await loadFixture({ root: './fixtures/csp/', @@ -65,7 +65,6 @@ describe('CSP', () => { ]); }); - it('should generate hashes for Image component inline styles when using layout', async () => { fixture = await loadFixture({ root: './fixtures/csp/', From 67f9f9602fda040b93105d85401ad55e46909cf2 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 27 Feb 2026 17:37:07 +0000 Subject: [PATCH 3/8] refactor: server island unit tests (#15692) --- .../astro/src/core/server-islands/endpoint.ts | 4 +- .../astro/test/csp-server-islands.test.js | 71 ---- packages/astro/test/server-islands.test.js | 141 -------- .../units/server-islands/encryption.test.js | 135 +++++++ .../units/server-islands/endpoint.test.js | 164 +++++++++ .../server-islands-render.test.js | 329 ++++++++++++++++++ 6 files changed, 630 insertions(+), 214 deletions(-) create mode 100644 packages/astro/test/units/server-islands/encryption.test.js create mode 100644 packages/astro/test/units/server-islands/endpoint.test.js create mode 100644 packages/astro/test/units/server-islands/server-islands-render.test.js 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/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/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/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