static.test.js (4581B)
1 // test/static.test.js - serving the built application 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 import fs from 'node:fs/promises'; 6 import os from 'node:os'; 7 import path from 'node:path'; 8 import Fastify from 'fastify'; 9 import staticRoutes, { contentSecurityPolicy } from '../src/lib/static.js'; 10 11 async function serve(files) { 12 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-web-')); 13 for (const [name, body] of Object.entries(files)) { 14 await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true }); 15 await fs.writeFile(path.join(dir, name), body); 16 } 17 18 const app = Fastify(); 19 await app.register(staticRoutes, { dir }); 20 return { app, cleanup: async () => { await app.close(); await fs.rm(dir, { recursive: true, force: true }); } }; 21 } 22 23 const BUNDLE = { 24 'index.html': '<!DOCTYPE html><html><body><script src="main.js"></script></body></html>', 25 'main.js': 'console.log(1)\n', 26 'global.css': 'body{}\n', 27 'assets/icon.svg': '<svg></svg>', 28 }; 29 30 test('what is on disk is served, with its type and an etag', async () => { 31 const { app, cleanup } = await serve(BUNDLE); 32 33 const js = await app.inject({ method: 'GET', url: '/main.js' }); 34 assert.equal(js.statusCode, 200); 35 assert.match(js.headers['content-type'], /text\/javascript/); 36 assert.ok(js.headers.etag); 37 38 const css = await app.inject({ method: 'GET', url: '/global.css' }); 39 assert.match(css.headers['content-type'], /text\/css/); 40 41 const nested = await app.inject({ method: 'GET', url: '/assets/icon.svg' }); 42 assert.equal(nested.statusCode, 200); 43 assert.match(nested.headers['content-type'], /image\/svg/); 44 45 await cleanup(); 46 }); 47 48 test('an unchanged file is answered with a not modified', async () => { 49 const { app, cleanup } = await serve(BUNDLE); 50 51 const first = await app.inject({ method: 'GET', url: '/main.js' }); 52 const again = await app.inject({ 53 method: 'GET', url: '/main.js', headers: { 'if-none-match': first.headers.etag }, 54 }); 55 56 assert.equal(again.statusCode, 304); 57 assert.equal(again.body, ''); 58 await cleanup(); 59 }); 60 61 test('the root serves the index', async () => { 62 const { app, cleanup } = await serve(BUNDLE); 63 64 const res = await app.inject({ method: 'GET', url: '/' }); 65 assert.equal(res.statusCode, 200); 66 assert.match(res.body, /<!DOCTYPE html>/); 67 await cleanup(); 68 }); 69 70 // The routes are built from what was found, so there is no path to traverse. 71 test('nothing outside the directory can be requested', async () => { 72 const { app, cleanup } = await serve(BUNDLE); 73 74 for (const url of ['/../package.json', '/%2e%2e/package.json', '/assets/../../package.json']) { 75 const res = await app.inject({ method: 'GET', url, headers: { accept: 'application/json' } }); 76 assert.equal(res.statusCode, 404, `${url} must not resolve`); 77 } 78 await cleanup(); 79 }); 80 81 test('a deep link is answered with the index, and a missing file is not', async () => { 82 const { app, cleanup } = await serve(BUNDLE); 83 84 const page = await app.inject({ method: 'GET', url: '/jobs/abc', headers: { accept: 'text/html' } }); 85 assert.equal(page.statusCode, 200); 86 assert.match(page.body, /<!DOCTYPE html>/); 87 88 const asset = await app.inject({ method: 'GET', url: '/missing.js', headers: { accept: '*/*' } }); 89 assert.equal(asset.statusCode, 404); 90 91 const posted = await app.inject({ method: 'POST', url: '/jobs', headers: { accept: 'text/html' } }); 92 assert.equal(posted.statusCode, 404); 93 94 await cleanup(); 95 }); 96 97 test('the index carries a content security policy and other files do not', async () => { 98 const { app, cleanup } = await serve(BUNDLE); 99 100 const index = await app.inject({ method: 'GET', url: '/' }); 101 const policy = index.headers['content-security-policy']; 102 assert.ok(policy, 'the index must carry a policy'); 103 assert.match(policy, /script-src 'self'/); 104 assert.ok(!policy.includes('unsafe-inline'), 'nothing in the bundle is inline'); 105 106 const js = await app.inject({ method: 'GET', url: '/main.js' }); 107 assert.equal(js.headers['content-security-policy'], undefined); 108 109 await cleanup(); 110 }); 111 112 test('a provider origin is allowed to be connected to', () => { 113 const policy = contentSecurityPolicy(['https://idp.example.com']); 114 assert.match(policy, /connect-src 'self' https:\/\/idp\.example\.com/); 115 assert.match(policy, /frame-ancestors 'none'/); 116 }); 117 118 test('a tree with no build serves nothing rather than failing', async () => { 119 const { app, cleanup } = await serve({}); 120 121 const res = await app.inject({ method: 'GET', url: '/' }); 122 assert.equal(res.statusCode, 404); 123 await cleanup(); 124 });