conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 });