conductor

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

ui.test.js (23161B)


      1 // test/ui.test.js - the server rendered interface
      2 //
      3 // The interface is rendered from the same data and the same visibility
      4 // rules as the API, so these check what a page actually contains for a
      5 // given caller rather than that a route merely answers.
      6 
      7 import test from 'node:test';
      8 import assert from 'node:assert/strict';
      9 import { startHarness } from './helpers/harness.js';
     10 import { html, esc, raw, toHtml, attrs } from '../src/conductor/ui/html.js';
     11 import { tasksTable } from '../src/conductor/ui/pages.js';
     12 
     13 async function withUi(options, fn) {
     14   const h = await startHarness({ bootstrap: true, ...options });
     15   try {
     16     return await fn(h);
     17   } finally {
     18     await h.stop();
     19   }
     20 }
     21 
     22 const get = (h, url, headers = {}) => h.app.inject({ method: 'GET', url, headers });
     23 
     24 // htmx sets this on every request it makes; mutations require it.
     25 const hx = (headers = {}) => ({ ...headers, 'hx-request': 'true' });
     26 
     27 function form(headers, fields) {
     28   return {
     29     headers: { ...hx(headers), 'content-type': 'application/x-www-form-urlencoded' },
     30     payload: new URLSearchParams(fields).toString(),
     31   };
     32 }
     33 
     34 // --- templating ---
     35 
     36 test('interpolated values are escaped by default', () => {
     37   const evil = '<script>alert(1)</script>';
     38   const out = toHtml(html`<td>${evil}</td>`);
     39   assert.equal(out, '<td>&lt;script&gt;alert(1)&lt;/script&gt;</td>');
     40 });
     41 
     42 test('quotes and ampersands are escaped inside attributes', () => {
     43   const out = toHtml(html`<input value="${'a" onload="evil() & more'}">`);
     44   assert.ok(!out.includes('onload="evil'));
     45   assert.ok(out.includes('&quot;'));
     46   assert.ok(out.includes('&amp;'));
     47 });
     48 
     49 test('nested templates and arrays are kept, raw is passed through', () => {
     50   const rows = ['a', 'b'].map((x) => html`<li>${x}</li>`);
     51   assert.equal(toHtml(html`<ul>${rows}</ul>`), '<ul><li>a</li><li>b</li></ul>');
     52   assert.equal(toHtml(html`<p>${raw('<b>bold</b>')}</p>`), '<p><b>bold</b></p>');
     53 });
     54 
     55 test('null, undefined and false render as nothing', () => {
     56   assert.equal(toHtml(html`<p>${null}${undefined}${false}</p>`), '<p></p>');
     57   assert.equal(esc(null), '');
     58 });
     59 
     60 test('attrs omits absent values and renders bare booleans', () => {
     61   assert.equal(toHtml(attrs({ id: 'x', hidden: true, skip: false, gone: null })), 'id="x" hidden');
     62 });
     63 
     64 // --- anonymous ---
     65 
     66 test('an anonymous visitor sees public jobs and an invitation to sign in', async () => {
     67   await withUi({ visibility: 'public' }, async (h) => {
     68     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
     69     await h.waitForJob(res.json().data.job_id);
     70 
     71     const page = await get(h, '/');
     72     assert.equal(page.statusCode, 200);
     73     assert.match(page.headers['content-type'], /text\/html/);
     74     assert.match(page.body, /Sign in/);
     75     assert.match(page.body, /#1<\/a>/);
     76     // No management links without a session.
     77     assert.ok(!page.body.includes('href="/projects"'));
     78     assert.ok(!page.body.includes('href="/users"'));
     79   });
     80 });
     81 
     82 test('an anonymous visitor cannot see a private job', async () => {
     83   await withUi({ visibility: 'private' }, async (h) => {
     84     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
     85     const job = res.json().data.job_id;
     86     await h.waitForJob(job);
     87 
     88     const list = await get(h, '/');
     89     assert.ok(!list.body.includes('#1</a>'));
     90 
     91     const detail = await get(h, `/jobs/${job}`);
     92     assert.match(detail.body, /No such job/);
     93   });
     94 });
     95 
     96 test('management pages redirect an anonymous visitor to sign in', async () => {
     97   await withUi({}, async (h) => {
     98     for (const url of ['/projects', '/workers', '/users']) {
     99       const res = await get(h, url);
    100       assert.equal(res.statusCode, 302, `${url} should redirect`);
    101       assert.equal(res.headers.location, '/login');
    102     }
    103   });
    104 });
    105 
    106 // --- session ---
    107 
    108 test('signing in through the form sets a session and reveals the nav', async () => {
    109   await withUi({}, async (h) => {
    110     const bad = await h.app.inject({
    111       method: 'POST',
    112       url: '/login',
    113       ...form({}, { username: 'admin', password: 'wrong' }),
    114     });
    115     assert.equal(bad.statusCode, 200);
    116     assert.match(bad.body, /Invalid username or password/);
    117 
    118     const good = await h.app.inject({
    119       method: 'POST',
    120       url: '/login',
    121       ...form({}, { username: 'admin', password: 'bootstrap-password' }),
    122     });
    123     assert.equal(good.statusCode, 204);
    124     assert.match(good.headers['set-cookie'], /conductor_session=/);
    125 
    126     const cookie = good.headers['set-cookie'].split(';')[0];
    127     const home = await get(h, '/', { cookie });
    128     assert.match(home.body, /href="\/projects"/);
    129     assert.match(home.body, /href="\/users"/);
    130     assert.match(home.body, /sign out/);
    131   });
    132 });
    133 
    134 // Signs in and returns a cookie header.
    135 async function signIn(h, username = 'admin', password = 'bootstrap-password') {
    136   const res = await h.app.inject({ method: 'POST', url: '/login', ...form({}, { username, password }) });
    137   if (res.statusCode !== 204) throw new Error(`login failed: ${res.statusCode} ${res.body}`);
    138   return { cookie: res.headers['set-cookie'].split(';')[0] };
    139 }
    140 
    141 // --- projects ---
    142 
    143 test('a user creates a project through the form and is shown the secret once', async () => {
    144   await withUi({}, async (h) => {
    145     const session = await signIn(h);
    146 
    147     const created = await h.app.inject({
    148       method: 'POST',
    149       url: '/projects',
    150       ...form(session, { id: 'from-ui', repo_url: 'https://git.example.com/ui.git', visibility: 'private' }),
    151     });
    152     assert.equal(created.statusCode, 204);
    153     assert.equal(created.headers['hx-redirect'], '/projects/from-ui?created=1');
    154 
    155     const page = await get(h, '/projects/from-ui?created=1', session);
    156     assert.match(page.body, /Trigger secret/);
    157     assert.match(page.body, /api\/v1\/projects\/from-ui\/trigger/);
    158 
    159     // The secret is only revealed on that one visit.
    160     const again = await get(h, '/projects/from-ui', session);
    161     assert.ok(!again.body.includes('Trigger secret'));
    162   });
    163 });
    164 
    165 test('a project page shows variables and never their values', async () => {
    166   await withUi({}, async (h) => {
    167     const session = await signIn(h);
    168     await h.services.variables.set('demo', 'DEPLOY_TOKEN', 'ui-secret-value');
    169 
    170     const page = await get(h, '/projects/demo', session);
    171     assert.match(page.body, /DEPLOY_TOKEN/);
    172     assert.match(page.body, /masked/);
    173     assert.ok(!page.body.includes('ui-secret-value'), 'a variable value must never be rendered');
    174   });
    175 });
    176 
    177 test('setting and removing a variable returns the updated table', async () => {
    178   await withUi({}, async (h) => {
    179     const session = await signIn(h);
    180 
    181     const set = await h.app.inject({
    182       method: 'PUT',
    183       url: '/projects/demo/variables',
    184       ...form(session, { name: 'TOKEN', value: 'abc12345', masked: 'true' }),
    185     });
    186     assert.equal(set.statusCode, 200);
    187     assert.match(set.body, /TOKEN/);
    188     assert.ok(!set.body.includes('abc12345'));
    189 
    190     const removed = await h.app.inject({
    191       method: 'DELETE', url: '/projects/demo/variables/TOKEN', headers: hx(session),
    192     });
    193     assert.equal(removed.statusCode, 200);
    194     assert.ok(!removed.body.includes('TOKEN'));
    195   });
    196 });
    197 
    198 test('a user cannot open or delete a project they do not own', async () => {
    199   await withUi({}, async (h) => {
    200     const admin = await signIn(h);
    201     await h.app.inject({
    202       method: 'POST', url: '/users', ...form(admin, { username: 'mallory', password: 'mallory-password', role: 'user' }),
    203     });
    204     const other = await signIn(h, 'mallory', 'mallory-password');
    205 
    206     const page = await get(h, '/projects/demo', other);
    207     assert.match(page.body, /No such project/);
    208 
    209     const deleted = await h.app.inject({ method: 'DELETE', url: '/projects/demo', headers: hx(other) });
    210     assert.equal(deleted.statusCode, 404);
    211     assert.ok(await h.services.projects.get('demo'), 'the project must still exist');
    212   });
    213 });
    214 
    215 // --- workers ---
    216 
    217 test('registering a worker shows the token once and lists it', async () => {
    218   await withUi({}, async (h) => {
    219     const session = await signIn(h);
    220 
    221     const created = await h.app.inject({
    222       method: 'POST', url: '/workers', ...form(session, { name: 'my-laptop' }),
    223     });
    224     assert.equal(created.statusCode, 200);
    225     assert.match(created.body, /Worker token for my-laptop/);
    226 
    227     // The token appears exactly once, in the reveal panel.
    228     const token = /value="([0-9a-f]{64})"/.exec(created.body);
    229     assert.ok(token, 'expected the token to be shown');
    230 
    231     const list = await get(h, '/workers', session);
    232     assert.match(list.body, /my-laptop/);
    233     assert.ok(!list.body.includes(token[1]), 'the token must not be listed again');
    234   });
    235 });
    236 
    237 test('the worker page lists what each machine advertises', async () => {
    238   await withUi({}, async (h) => {
    239     const session = await signIn(h);
    240     await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys', 'dind'] });
    241 
    242     const page = await get(h, `/workers/${h.worker.id}`, session);
    243     assert.equal(page.statusCode, 200);
    244     assert.match(page.body, /builder-01/);
    245     assert.match(page.body, /unos-sign-keys, dind/);
    246     assert.match(page.body, /x86_64/);
    247   });
    248 });
    249 
    250 test('machines sharing a token are listed separately', async () => {
    251   await withUi({}, async (h) => {
    252     const session = await signIn(h);
    253     await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] });
    254     await h.claim({ name: 'builder-02', features: [] });
    255 
    256     const page = await get(h, `/workers/${h.worker.id}`, session);
    257     assert.match(page.body, /builder-01/);
    258     assert.match(page.body, /builder-02/);
    259     assert.match(page.body, /machines/);
    260   });
    261 });
    262 
    263 test('the workers list links to the worker page', async () => {
    264   await withUi({}, async (h) => {
    265     const session = await signIn(h);
    266     const list = await get(h, '/workers', session);
    267     assert.match(list.body, new RegExp(`href="/workers/${h.worker.id}"`));
    268   });
    269 });
    270 
    271 test('a worker page is not served to someone who cannot manage it', async () => {
    272   await withUi({}, async (h) => {
    273     const admin = await signIn(h);
    274     await h.app.inject({
    275       method: 'POST', url: '/users', ...form(admin, { username: 'erin', password: 'erin-password', role: 'user' }),
    276     });
    277     const erin = await signIn(h, 'erin', 'erin-password');
    278 
    279     const page = await get(h, `/workers/${h.worker.id}`, erin);
    280     assert.equal(page.statusCode, 404);
    281     assert.match(page.body, /No such worker/);
    282   });
    283 });
    284 
    285 test('a feature name is escaped rather than rendered', async () => {
    286   await withUi({}, async (h) => {
    287     const session = await signIn(h);
    288     await h.claim({ name: 'builder-01', features: ['<img src=x onerror=alert(1)>'] });
    289 
    290     const page = await get(h, `/workers/${h.worker.id}`, session);
    291     assert.ok(!page.body.includes('<img src=x'), 'a feature name must not reach the page as markup');
    292     assert.match(page.body, /&lt;img src=x/);
    293   });
    294 });
    295 
    296 test('an ordinary user cannot create shared capacity from the form', async () => {
    297   await withUi({}, async (h) => {
    298     const admin = await signIn(h);
    299     await h.app.inject({
    300       method: 'POST', url: '/users', ...form(admin, { username: 'carol', password: 'carol-password', role: 'user' }),
    301     });
    302     const carol = await signIn(h, 'carol', 'carol-password');
    303 
    304     await h.app.inject({ method: 'POST', url: '/workers', ...form(carol, { name: 'sneaky', shared: 'true' }) });
    305 
    306     const rows = await h.services.db.all('SELECT name, owner_id FROM worker_tokens WHERE name = {n}', { n: 'sneaky' });
    307     assert.equal(rows.length, 1);
    308     assert.notEqual(rows[0].owner_id, null, 'an ordinary user must not create a shared worker');
    309   });
    310 });
    311 
    312 // --- users ---
    313 
    314 test('the users page is administrator only and warns about what deletion destroys', async () => {
    315   await withUi({}, async (h) => {
    316     const admin = await signIn(h);
    317     await h.app.inject({
    318       method: 'POST', url: '/users', ...form(admin, { username: 'dave', password: 'dave-password', role: 'user' }),
    319     });
    320     const dave = await h.services.users.byUsername('dave');
    321     await h.services.projects.setOwner('demo', dave.id);
    322 
    323     const page = await get(h, '/users', admin);
    324     assert.match(page.body, /dave/);
    325     // The confirmation has to say what goes with the account.
    326     assert.match(page.body, /also deletes 1 project\(s\) and all of their job history/);
    327 
    328     const asViewer = await signIn(h, 'dave', 'dave-password');
    329     const denied = await get(h, '/users', asViewer);
    330     assert.match(denied.body, /Administrator role required/);
    331   });
    332 });
    333 
    334 // --- live regions ---
    335 
    336 test('a running job polls, and a finished one stops', async () => {
    337   await withUi({ visibility: 'public' }, async (h) => {
    338     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    339     const job = res.json().data.job_id;
    340     await h.waitForJob(job);
    341 
    342     const running = await get(h, `/jobs/${job}`);
    343     assert.match(running.body, /hx-get="\/partials\/jobs\/[^"]+\/tasks"/);
    344     assert.match(running.body, /hx-trigger="every 3s"/);
    345 
    346     await h.services.scheduler.cancelJob(job);
    347 
    348     const finished = await get(h, `/jobs/${job}`);
    349     assert.ok(!finished.body.includes('/tasks" hx-trigger'), 'a settled job must stop polling');
    350   });
    351 });
    352 
    353 test('a task page streams the log and stops polling once it completes', async () => {
    354   await withUi({ visibility: 'public' }, async (h) => {
    355     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    356     await h.waitForJob(res.json().data.job_id);
    357     const task = (await h.claim({})).json().data.task;
    358 
    359     await h.app.inject({
    360       method: 'POST',
    361       url: `/api/v1/tasks/${task.id}/log`,
    362       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    363       payload: Buffer.from('compiling the thing\n'),
    364     });
    365 
    366     const live = await get(h, `/tasks/${encodeURIComponent(task.id)}`);
    367     assert.match(live.body, /compiling the thing/);
    368     assert.match(live.body, /hx-trigger="every 2s"/);
    369 
    370     await h.app.inject({
    371       method: 'POST',
    372       url: `/api/v1/tasks/${task.id}/complete`,
    373       headers: { ...h.auth, 'content-type': 'application/json' },
    374       payload: JSON.stringify({ success: true, exit_code: 0 }),
    375     });
    376 
    377     const done = await get(h, `/tasks/${encodeURIComponent(task.id)}`);
    378     assert.match(done.body, /compiling the thing/);
    379     assert.ok(!done.body.includes('hx-trigger="every 2s"'), 'a finished task must stop polling');
    380   });
    381 });
    382 
    383 test('log output is escaped, so a task cannot inject markup into the page', async () => {
    384   await withUi({ visibility: 'public' }, async (h) => {
    385     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    386     await h.waitForJob(res.json().data.job_id);
    387     const task = (await h.claim({})).json().data.task;
    388 
    389     await h.app.inject({
    390       method: 'POST',
    391       url: `/api/v1/tasks/${task.id}/log`,
    392       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    393       payload: Buffer.from('<img src=x onerror="alert(1)">\n'),
    394     });
    395 
    396     const page = await get(h, `/tasks/${encodeURIComponent(task.id)}`);
    397     assert.ok(!page.body.includes('<img src=x'), 'task output must not become markup');
    398     assert.match(page.body, /&lt;img src=x/);
    399   });
    400 });
    401 
    402 // --- csrf ---
    403 
    404 test('a mutation without the htmx header is refused', async () => {
    405   await withUi({}, async (h) => {
    406     const session = await signIn(h);
    407 
    408     // A cross site form post carries the cookie but cannot set a header.
    409     const res = await h.app.inject({
    410       method: 'POST',
    411       url: '/projects',
    412       headers: { ...session, 'content-type': 'application/x-www-form-urlencoded' },
    413       payload: new URLSearchParams({ id: 'csrf', repo_url: 'https://evil.example/x.git' }).toString(),
    414     });
    415     assert.equal(res.statusCode, 400);
    416     assert.equal(await h.services.projects.get('csrf'), undefined);
    417   });
    418 });
    419 
    420 // --- layout details ---
    421 
    422 test('the task graph is one table, so columns line up across stages', () => {
    423   const job = { id: 'j1', state: 'running' };
    424   const tasks = [
    425     { id: 't1', name: 'style', state: 'success', arch: 'x86-64', worker_name: 'w1', started_at: 1, finished_at: 2, needs: [] },
    426     { id: 't2', name: 'test', state: 'success', arch: null, worker_name: 'w2', started_at: 1, finished_at: 2, needs: [] },
    427     { id: 't3', name: 'images', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, needs: ['t1', 't2'] },
    428     { id: 't4', name: 'publish', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, needs: ['t3'] },
    429   ];
    430 
    431   const out = toHtml(tasksTable(job, tasks));
    432 
    433   // Three stages, and one table holding all of them. A table per stage
    434   // sizes its own columns, which is what put the same field in a
    435   // different place in every stage.
    436   assert.equal((out.match(/<tbody/g) ?? []).length, 3, 'expected one row group per stage');
    437   assert.equal((out.match(/<table/g) ?? []).length, 1, 'stages must share a single table');
    438   assert.match(out, /stage 1/);
    439   assert.match(out, /stage 3/);
    440 
    441   // Every task row carries the same five cells, so a column means the same
    442   // thing all the way down.
    443   for (const row of out.match(/<tr>\s*<td>.*?<\/tr>/gs) ?? []) {
    444     assert.equal((row.match(/<td/g) ?? []).length, 5, `row has the wrong cell count: ${row}`);
    445   }
    446 
    447   // The stage label spans the row, so it must track the column count.
    448   assert.match(out, /colspan="5"/);
    449   assert.doesNotMatch(out, /<th>exit<\/th>/, 'the exit code is no longer a column');
    450 });
    451 
    452 test('the sign in form is centred rather than stranded at the left edge', async () => {
    453   await withUi({}, async (h) => {
    454     const res = await get(h, '/login');
    455     assert.equal(res.statusCode, 200);
    456     assert.match(res.body, /<div class="center">\s*<div class="panel narrow">/);
    457   });
    458 });
    459 
    460 test('row actions stay ordinary table cells, so rows keep one height', async () => {
    461   await withUi({}, async (h) => {
    462     const session = await signIn(h);
    463     await h.app.inject({ method: 'POST', url: '/workers', ...form(session, { name: 'metrics-check' }) });
    464 
    465     for (const url of ['/workers', '/users']) {
    466       const body = (await get(h, url, session)).body;
    467       assert.match(body, /<td class="row-actions">/, `${url} should use row-actions`);
    468       // The flex container used for button bars would break baseline
    469       // alignment inside a row.
    470       assert.ok(!body.includes('<td class="actions">'), `${url} must not make a cell a flex container`);
    471     }
    472   });
    473 });
    474 
    475 // --- assets ---
    476 
    477 test('the stylesheet and htmx are served, and nothing else is', async () => {
    478   await withUi({}, async (h) => {
    479     assert.equal((await get(h, '/style.css')).statusCode, 200);
    480 
    481     const htmx = await get(h, '/vendor/htmx.min.js');
    482     assert.equal(htmx.statusCode, 200);
    483     assert.match(htmx.headers['content-type'], /javascript/);
    484 
    485     for (const url of ['/vendor/../package.json', '/app.js', '/index.html']) {
    486       assert.equal((await get(h, url)).statusCode, 404, `${url} must not be served`);
    487     }
    488   });
    489 });
    490 
    491 // --- retention ---
    492 
    493 test('the project page shows retention, with server defaults as placeholders', async () => {
    494   await withUi({
    495     retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 },
    496   }, async (h) => {
    497     const session = await signIn(h);
    498     const page = await get(h, `/projects/${h.project.id}`, session);
    499 
    500     assert.equal(page.statusCode, 200);
    501     assert.match(page.body, /Retention/);
    502 
    503     // An unset field shows the default it inherits rather than an empty
    504     // box that looks like nothing is configured.
    505     assert.match(page.body, /name="artifact_keep_jobs"[^>]*placeholder="8"/);
    506     assert.match(page.body, /name="artifact_keep_days"[^>]*placeholder="25"/);
    507     assert.match(page.body, /name="log_keep_days"[^>]*placeholder="11"/);
    508   });
    509 });
    510 
    511 test('saving retention through the form stores it, and empty means inherit', async () => {
    512   await withUi({
    513     retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 },
    514   }, async (h) => {
    515     const session = await signIn(h);
    516 
    517     const saved = await h.app.inject({
    518       method: 'PATCH',
    519       url: `/projects/${h.project.id}/retention`,
    520       ...form(session, { artifact_keep_jobs: '3', artifact_keep_days: '0', log_keep_days: '' }),
    521     });
    522     assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`);
    523 
    524     const project = await h.services.projects.get(h.project.id);
    525     assert.equal(project.artifact_keep_jobs, 3);
    526     // Zero and empty have to end up different, or a project cannot say
    527     // "keep forever" as distinct from "use the default".
    528     assert.equal(project.artifact_keep_days, 0);
    529     assert.equal(project.log_keep_days, null);
    530 
    531     const page = await get(h, `/projects/${h.project.id}`, session);
    532     assert.match(page.body, /name="artifact_keep_days"[^>]*value="0"/);
    533     assert.match(page.body, /name="log_keep_days"[^>]*value=""/);
    534   });
    535 });
    536 
    537 test('a negative retention value is refused by the form', async () => {
    538   await withUi({}, async (h) => {
    539     const session = await signIn(h);
    540 
    541     const res = await h.app.inject({
    542       method: 'PATCH',
    543       url: `/projects/${h.project.id}/retention`,
    544       ...form(session, { log_keep_days: '-3' }),
    545     });
    546 
    547     assert.ok(res.statusCode >= 400, 'a negative value must not be stored');
    548     const project = await h.services.projects.get(h.project.id);
    549     assert.equal(project.log_keep_days, null);
    550   });
    551 });
    552 
    553 // --- working directory ---
    554 
    555 test('the project page offers a working directory, defaulting to the server one', async () => {
    556   await withUi({}, async (h) => {
    557     const session = await signIn(h);
    558     const page = await get(h, `/projects/${h.project.id}`, session);
    559 
    560     assert.equal(page.statusCode, 200);
    561     assert.match(page.body, /name="workdir"[^>]*placeholder="\/work"/);
    562     assert.match(page.body, /name="workdir"[^>]*value=""/, 'unset means inherited');
    563   });
    564 });
    565 
    566 test('saving a working directory stores it, and empty clears it', async () => {
    567   await withUi({}, async (h) => {
    568     const session = await signIn(h);
    569 
    570     const saved = await h.app.inject({
    571       method: 'PATCH',
    572       url: `/projects/${h.project.id}`,
    573       ...form(session, { workdir: '/usr/src/app' }),
    574     });
    575     assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`);
    576     assert.equal((await h.services.projects.get(h.project.id)).workdir, '/usr/src/app');
    577 
    578     const cleared = await h.app.inject({
    579       method: 'PATCH',
    580       url: `/projects/${h.project.id}`,
    581       ...form(session, { workdir: '  ' }),
    582     });
    583     assert.ok(cleared.statusCode < 400);
    584     assert.equal((await h.services.projects.get(h.project.id)).workdir, null,
    585       'blank means follow the repository, not a directory named blank');
    586   });
    587 });
    588 
    589 test('an unusable working directory is refused by the form', async () => {
    590   await withUi({}, async (h) => {
    591     const session = await signIn(h);
    592 
    593     for (const workdir of ['relative', '/', '/a/../b']) {
    594       const res = await h.app.inject({
    595         method: 'PATCH',
    596         url: `/projects/${h.project.id}`,
    597         ...form(session, { workdir }),
    598       });
    599       assert.ok(res.statusCode >= 400, `expected ${JSON.stringify(workdir)} to be refused`);
    600       assert.equal((await h.services.projects.get(h.project.id)).workdir, null);
    601     }
    602   });
    603 });