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><script>alert(1)</script></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('"')); 45 assert.ok(out.includes('&')); 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, /<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 });