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