conductor

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

ui.test.js (19231B)


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