conductor

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

conductor.test.js (48881B)


      1 // test/conductor.test.js - end to end behaviour of the conductor service
      2 //
      3 // Drives a real server against a real git repository: trigger, dispatch,
      4 // logs, artifacts, completion and failure handling.
      5 
      6 import test from 'node:test';
      7 import assert from 'node:assert/strict';
      8 import { startHarness, DEFAULT_PIPELINE } from './helpers/harness.js';
      9 
     10 // Tests that exercised the JSON read API, which has been removed. Kept
     11 // until the suite is rebuilt against the interface.
     12 const apiGone = { skip: 'the JSON API was removed' };
     13 
     14 // Each test gets its own harness so that ordering never matters.
     15 async function withHarness(options, fn) {
     16   const h = await startHarness(options);
     17   try {
     18     return await fn(h);
     19   } finally {
     20     await h.stop();
     21   }
     22 }
     23 
     24 function tasksByName(payload) {
     25   return Object.fromEntries(payload.tasks.map((j) => [j.name, j]));
     26 }
     27 
     28 // Reads a job and its tasks straight from the database. There is no read API;
     29 // the interface is for people, and the tests inspect the data itself.
     30 async function jobState(h, jobId) {
     31   const job = await h.services.db.get('SELECT * FROM jobs WHERE id = {id}', { id: jobId });
     32   const tasks = await h.services.db.all(
     33     'SELECT * FROM tasks WHERE job_id = {job} ORDER BY name', { job: jobId }
     34   );
     35   const deps = await h.services.db.all(
     36     `SELECT d.task_id, d.depends_on_id FROM task_deps d
     37        JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`,
     38     { job: jobId }
     39   );
     40   const needs = new Map(tasks.map((t) => [t.id, []]));
     41   for (const d of deps) needs.get(d.task_id)?.push(d.depends_on_id);
     42   return { job, tasks: tasks.map((t) => ({ ...t, needs: needs.get(t.id) ?? [] })) };
     43 }
     44 
     45 // The job a task belongs to. A worker is never told this, so a test that
     46 // needs it asks the database, exactly as the conductor does.
     47 async function jobIdOf(h, taskId) {
     48   const row = await h.services.db.get('SELECT job_id FROM tasks WHERE id = {id}', { id: taskId });
     49   return row.job_id;
     50 }
     51 
     52 // Claims everything currently eligible for a worker with the given
     53 // capabilities, keyed by task name. Tests assert on which tasks appear rather
     54 // than on the order they arrive in, which is not part of the contract.
     55 async function drain(h, query = {}) {
     56   const claimed = {};
     57   for (let i = 0; i < 50; i += 1) {
     58     const res = await h.claim(query);
     59     if (res.statusCode === 204) break;
     60     const task = res.json().task;
     61     claimed[task.name] = task;
     62   }
     63   return claimed;
     64 }
     65 
     66 async function claimNamed(h, name, query) {
     67   return (await drain(h, query))[name] ?? null;
     68 }
     69 
     70 async function finish(h, taskId, { success = true, exitCode = 0, error = null } = {}) {
     71   const res = await h.app.inject({
     72     method: 'POST',
     73     url: `/api/v1/tasks/${taskId}/complete`,
     74     headers: { ...h.auth, 'content-type': 'application/json' },
     75     payload: JSON.stringify({ success, exit_code: exitCode, error }),
     76   });
     77   return res.json();
     78 }
     79 
     80 test('health reports the selected drivers', async () => {
     81   await withHarness({}, async (h) => {
     82     const res = await h.app.inject({ method: 'GET', url: '/health' });
     83     assert.equal(res.statusCode, 200);
     84     assert.deepEqual(res.json(), {
     85       ok: true, service: 'conductor', database: 'sqlite', storage: 'local', auth: 'local',
     86     });
     87   });
     88 });
     89 
     90 test('a signed trigger creates a job and expands the pipeline', async () => {
     91   await withHarness({}, async (h) => {
     92     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' });
     93     assert.equal(res.statusCode, 200);
     94 
     95     const body = res.json();
     96     assert.equal(body.status, 'created');
     97 
     98     const job = await h.waitForJob(body.job_id);
     99     assert.equal(job.state, 'running');
    100     assert.equal(job.actor, 'alice');
    101     assert.equal(job.title, 'initial commit');
    102     assert.equal(job.number, 1);
    103 
    104     const detail = await jobState(h, body.job_id);
    105     // lint + 2 build + 2 package + publish
    106     assert.equal(detail.tasks.length, 6);
    107 
    108     // Task ids are opaque, so the edge is checked by resolving the name
    109     // rather than reconstructing an id the conductor no longer derives.
    110     const tasks = tasksByName(detail);
    111     assert.deepEqual(
    112       tasks['package:arch=x86_64,pkg=musl'].needs,
    113       [tasks['build:arch=x86_64'].id]
    114     );
    115     assert.equal(tasks.publish.needs.length, 3);
    116   });
    117 });
    118 
    119 test('an unsigned or wrongly signed trigger is refused', async () => {
    120   await withHarness({}, async (h) => {
    121     const unsigned = await h.app.inject({
    122       method: 'POST',
    123       url: '/api/v1/projects/demo/trigger',
    124       headers: { 'content-type': 'application/json' },
    125       payload: JSON.stringify({ sha: h.sha }),
    126     });
    127     assert.equal(unsigned.statusCode, 401);
    128 
    129     const wrong = await h.trigger({ sha: h.sha }, { secret: 'not-the-secret' });
    130     assert.equal(wrong.statusCode, 401);
    131   });
    132 });
    133 
    134 test('github and gitlab push payloads are understood', async () => {
    135   await withHarness({}, async (h) => {
    136     const github = await h.trigger({
    137       after: h.sha,
    138       before: '0'.repeat(40),
    139       ref: 'refs/heads/main',
    140       pusher: { name: 'octocat' },
    141     });
    142     assert.equal(github.statusCode, 200);
    143     const githubJob = await h.waitForJob(github.json().job_id);
    144     assert.equal(githubJob.state, 'running');
    145 
    146     const gitlab = await h.trigger({
    147       checkout_sha: h.sha,
    148       ref: 'refs/heads/main',
    149       user_username: 'gl-user',
    150     });
    151     assert.equal(gitlab.statusCode, 200);
    152     const gitlabJob = await h.waitForJob(gitlab.json().job_id);
    153     assert.equal(gitlabJob.state, 'running');
    154   });
    155 });
    156 
    157 test('a branch deletion is ignored rather than failing', async () => {
    158   await withHarness({}, async (h) => {
    159     const res = await h.trigger({ sha: '0'.repeat(40), ref: 'refs/heads/gone' });
    160     assert.equal(res.statusCode, 200);
    161     assert.equal(res.json().status, 'ignored');
    162   });
    163 });
    164 
    165 test('a broken pipeline is reported as a client error with detail', async () => {
    166   await withHarness({ pipeline: 'version: 1\ntasks:\n  a:\n    script: [x]\n' }, async (h) => {
    167     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    168     assert.equal(res.statusCode, 200);
    169     const job = await h.waitForJob(res.json().job_id);
    170     assert.equal(job.state, 'failed');
    171     assert.ok(job.error?.includes('tasks.a.image'));
    172   });
    173 });
    174 
    175 test('a missing pipeline file is reported clearly', async () => {
    176   await withHarness({ pipeline: null }, async (h) => {
    177     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    178     assert.equal(res.statusCode, 200);
    179     const job = await h.waitForJob(res.json().job_id);
    180     assert.equal(job.state, 'failed');
    181     assert.match(job.error ?? '', /\.conductor\.yml not found/);
    182   });
    183 });
    184 
    185 test('a pipeline may be spelled .conductor.yaml instead of .conductor.yml', async () => {
    186   await withHarness({ repoConfigPath: '.conductor.yaml' }, async (h) => {
    187     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    188     assert.equal(res.statusCode, 200);
    189     const job = await h.waitForJob(res.json().job_id);
    190     assert.equal(job.state, 'running');
    191   });
    192 });
    193 
    194 test('a project pointed at a specific file gets no fallback', async () => {
    195   await withHarness({ configPath: 'ci/build.yml', repoConfigPath: '.conductor.yml' }, async (h) => {
    196     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    197     assert.equal(res.statusCode, 200);
    198     const job = await h.waitForJob(res.json().job_id);
    199     assert.equal(job.state, 'failed');
    200     assert.match(job.error ?? '', /ci\/build\.yml not found/);
    201   });
    202 });
    203 
    204 test('a repository carrying both spellings is refused rather than guessed at', async () => {
    205   await withHarness({}, async (h) => {
    206     const sha = await h.commit({ '.conductor.yaml': DEFAULT_PIPELINE }, 'add the other spelling');
    207     const res = await h.trigger({ sha, ref: 'refs/heads/main' });
    208     assert.equal(res.statusCode, 200);
    209     const job = await h.waitForJob(res.json().job_id);
    210     assert.equal(job.state, 'failed');
    211     assert.match(job.error ?? '', /both exist/);
    212   });
    213 });
    214 
    215 test('a pipeline still using the jobs key is told what it was renamed to', async () => {
    216   const old = 'version: 1\njobs:\n  build:\n    image: alpine\n    script: [make]\n';
    217   await withHarness({}, async (h) => {
    218     const sha = await h.commit({ '.conductor.yml': old }, 'pre-rename pipeline');
    219     const res = await h.trigger({ sha, ref: 'refs/heads/main' });
    220     assert.equal(res.statusCode, 200);
    221     const job = await h.waitForJob(res.json().job_id);
    222     assert.equal(job.state, 'failed');
    223     assert.match(job.error ?? '', /was renamed to tasks/);
    224   });
    225 });
    226 
    227 test('a job can be created directly, without a push payload to imitate', async () => {
    228   await withHarness({}, async (h) => {
    229     const res = await h.createJob({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' });
    230     assert.equal(res.statusCode, 200);
    231 
    232     const body = res.json();
    233     assert.equal(body.status, 'created');
    234 
    235     const job = await h.waitForJob(body.job_id);
    236     assert.equal(job.state, 'running');
    237     assert.equal(job.actor, 'alice');
    238     assert.equal(job.trigger_type, 'api');
    239   });
    240 });
    241 
    242 test('creating a job requires the same signature as a trigger', async () => {
    243   await withHarness({}, async (h) => {
    244     const unsigned = await h.app.inject({
    245       method: 'POST',
    246       url: '/api/v1/projects/demo/jobs',
    247       headers: { 'content-type': 'application/json' },
    248       payload: JSON.stringify({ sha: h.sha }),
    249     });
    250     assert.equal(unsigned.statusCode, 401);
    251 
    252     const wrong = await h.createJob({ sha: h.sha }, { secret: 'not-the-secret' });
    253     assert.equal(wrong.statusCode, 401);
    254   });
    255 });
    256 
    257 test('creating a job insists on a real commit rather than inferring one', async () => {
    258   await withHarness({}, async (h) => {
    259     assert.equal((await h.createJob({ ref: 'refs/heads/main' })).statusCode, 400);
    260     assert.equal((await h.createJob({ sha: 'abc' })).statusCode, 400);
    261   });
    262 });
    263 
    264 test('a job can be read back, with the state of every task', async () => {
    265   await withHarness({}, async (h) => {
    266     const jobId = (await h.createJob({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    267     await h.waitForJob(jobId);
    268 
    269     const signature = h.sign('');
    270     const signed = await h.app.inject({
    271       method: 'GET',
    272       url: `/api/v1/projects/demo/jobs/${jobId}`,
    273       headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
    274     });
    275     assert.equal(signed.statusCode, 200, signed.body);
    276 
    277     const body = signed.json();
    278     assert.equal(body.job.id, jobId);
    279     assert.equal(body.job.state, 'running');
    280     assert.equal(body.tasks.length, 6);
    281     assert.ok(body.tasks.every((t) => t.state === 'queued'));
    282     assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64'));
    283   });
    284 });
    285 
    286 test('a public job is readable without any credential at all', async () => {
    287   await withHarness({}, async (h) => {
    288     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    289     await h.waitForJob(jobId);
    290 
    291     const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` });
    292     assert.equal(res.statusCode, 200, res.body);
    293     assert.equal(res.json().job.visibility, 'public');
    294   });
    295 });
    296 
    297 test('a private job needs the trigger secret, and is otherwise absent', async () => {
    298   const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n');
    299   await withHarness({ pipeline }, async (h) => {
    300     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    301     await h.waitForJob(jobId);
    302 
    303     const anon = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` });
    304     assert.equal(anon.statusCode, 404);
    305 
    306     const signed = await h.app.inject({
    307       method: 'GET',
    308       url: `/api/v1/projects/demo/jobs/${jobId}`,
    309       headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') },
    310     });
    311     assert.equal(signed.statusCode, 200, signed.body);
    312     assert.equal(signed.json().job.visibility, 'private');
    313   });
    314 });
    315 
    316 test('a job of another project cannot be read through this one', async () => {
    317   await withHarness({}, async (h) => {
    318     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    319     await h.waitForJob(jobId);
    320     const signature = h.sign('');
    321     const res = await h.app.inject({
    322       method: 'GET',
    323       url: `/api/v1/projects/absent/jobs/${jobId}`,
    324       headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
    325     });
    326     assert.equal(res.statusCode, 404);
    327   });
    328 });
    329 
    330 // --- reading a task back ---
    331 
    332 // The two paths answer identically; only what may reach a private task
    333 // differs between them.
    334 function taskUrls(taskId, project = 'demo') {
    335   return [`/api/v1/tasks/${taskId}`, `/api/v1/projects/${project}/tasks/${taskId}`];
    336 }
    337 
    338 // package needs build, so nothing downstream is claimable until the first
    339 // wave has finished.
    340 async function claimDownstream(h, name) {
    341   for (const task of Object.values(await drain(h, { arches: 'x86_64,aarch64' }))) {
    342     await finish(h, task.id);
    343   }
    344   return claimNamed(h, name, { arches: 'x86_64', features: 'sign-key' });
    345 }
    346 
    347 test('a task can be read back on its own, and by its full name', async () => {
    348   await withHarness({}, async (h) => {
    349     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    350     await h.waitForJob(res.json().job_id);
    351     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    352 
    353     const [bare, scoped] = await Promise.all(
    354       taskUrls(task.id).map((url) => h.app.inject({ method: 'GET', url }))
    355     );
    356     assert.equal(bare.statusCode, 200, bare.body);
    357     assert.equal(scoped.statusCode, 200, scoped.body);
    358     assert.deepEqual(bare.json(), scoped.json(), 'both paths describe the same task');
    359 
    360     const { task: body } = bare.json();
    361     assert.equal(body.id, task.id);
    362     assert.equal(body.name, 'build:arch=x86_64');
    363     assert.equal(body.base_name, 'build');
    364     assert.equal(body.state, 'running');
    365     assert.equal(body.arch, 'x86_64');
    366     assert.equal(body.image, 'debian:bookworm-slim');
    367     assert.equal(body.visibility, 'public');
    368     assert.equal(body.sha, h.sha);
    369     assert.equal(body.ref, 'refs/heads/main');
    370     assert.equal(body.project_id, 'demo');
    371     assert.equal(body.job_id, await jobIdOf(h, task.id));
    372     assert.equal(body.job_number, 1);
    373     assert.equal(body.allow_failure, false, 'stored as 0 or 1, reported as a boolean');
    374     assert.deepEqual(body.artifact_paths, ['dist/**'], 'what it was told to collect');
    375   });
    376 });
    377 
    378 test('reading a task back carries its dependencies and matrix', async () => {
    379   await withHarness({}, async (h) => {
    380     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    381     await h.waitForJob(tr.json().job_id);
    382     const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl');
    383 
    384     const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` });
    385     assert.equal(res.statusCode, 200, res.body);
    386 
    387     const { task: body } = res.json();
    388     assert.deepEqual(body.matrix, { pkg: 'musl' });
    389     assert.deepEqual(body.needs, ['build:arch=x86_64']);
    390     assert.equal(body.workdir, '/work', 'settled when the job was created');
    391   });
    392 });
    393 
    394 // The point of the endpoint: a task's environment can hold project
    395 // variables, and its services carry an environment of their own.
    396 test('reading a task back never exposes its environment, services or script', async () => {
    397   await withHarness({}, async (h) => {
    398     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    399     await h.waitForJob(res.json().job_id);
    400     const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl');
    401 
    402     // The claim payload has them, which is what makes their absence below
    403     // meaningful rather than incidental.
    404     assert.ok(task.env.MATRIX_PKG, 'the worker is given the environment');
    405     assert.ok(Array.isArray(task.script) && task.script.length > 0);
    406 
    407     for (const url of taskUrls(task.id)) {
    408       const res = await h.app.inject({ method: 'GET', url });
    409       assert.equal(res.statusCode, 200, res.body);
    410 
    411       const payload = res.json();
    412       for (const key of ['env', 'services', 'script', 'spec', 'worker_token_id', 'log_key', 'storage_key']) {
    413         assert.equal(payload.task[key], undefined, `${url} must not carry ${key}`);
    414       }
    415       // Belt and braces: a value smuggled in under any other name.
    416       assert.ok(!JSON.stringify(payload).includes('MATRIX_PKG'), `${url} leaked an environment name`);
    417       assert.ok(!JSON.stringify(payload).includes('./pkg.sh'), `${url} leaked the script`);
    418     }
    419   });
    420 });
    421 
    422 test('a task lists the artifacts it stored, with a usable download url', async () => {
    423   await withHarness({}, async (h) => {
    424     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    425     await h.waitForJob(tr.json().job_id);
    426     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    427 
    428     await h.app.inject({
    429       method: 'POST',
    430       url: `/api/v1/tasks/${task.id}/artifacts`,
    431       headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/app' },
    432       payload: Buffer.from('artifact bytes'),
    433     });
    434 
    435     const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` });
    436     assert.equal(res.statusCode, 200, res.body);
    437 
    438     const { artifacts } = res.json();
    439     assert.equal(artifacts.length, 1);
    440     assert.equal(artifacts[0].path, 'dist/app');
    441     assert.equal(artifacts[0].size, 14);
    442     assert.match(artifacts[0].sha256, /^[0-9a-f]{64}$/);
    443     assert.ok(artifacts[0].url.startsWith('http://conductor.test/'), artifacts[0].url);
    444 
    445     // The url is the whole point of returning one, so follow it.
    446     const download = await h.app.inject({ method: 'GET', url: new URL(artifacts[0].url).pathname });
    447     assert.equal(download.statusCode, 200);
    448     assert.equal(download.body, 'artifact bytes');
    449   });
    450 });
    451 
    452 test('reading a task back needs no worker token', async () => {
    453   await withHarness({}, async (h) => {
    454     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    455     await h.waitForJob(res.json().job_id);
    456     const task = await claimNamed(h, 'lint', {});
    457 
    458     // The worker protocol shares this prefix and puts a token hook over its
    459     // own plugin. If that hook ever reaches these routes, this fails.
    460     for (const url of taskUrls(task.id)) {
    461       const res = await h.app.inject({ method: 'GET', url });
    462       assert.equal(res.statusCode, 200, `${url} should not require a token`);
    463     }
    464   });
    465 });
    466 
    467 test('a private task is absent without a credential, and needs its project named', async () => {
    468   const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n');
    469   await withHarness({ pipeline }, async (h) => {
    470     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    471     await h.waitForJob(res.json().job_id);
    472     const task = await claimNamed(h, 'lint', {});
    473 
    474     for (const url of taskUrls(task.id)) {
    475       const anon = await h.app.inject({ method: 'GET', url });
    476       assert.equal(anon.statusCode, 404, `${url} should be absent anonymously`);
    477     }
    478 
    479     const signed = { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') };
    480 
    481     const scoped = await h.app.inject({
    482       method: 'GET',
    483       url: `/api/v1/projects/demo/tasks/${task.id}`,
    484       headers: signed,
    485     });
    486     assert.equal(scoped.statusCode, 200, scoped.body);
    487     assert.equal(scoped.json().task.visibility, 'private');
    488 
    489     // Without a project in the path there is no secret to check against, so
    490     // a signature proves nothing here.
    491     const bare = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}`, headers: signed });
    492     assert.equal(bare.statusCode, 404, 'a signature is meaningless without a project');
    493   });
    494 });
    495 
    496 test('a private task is readable by an administrator, by either path', async () => {
    497   const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n');
    498   // scrypt is slow, so the harness only creates the admin when asked.
    499   await withHarness({ pipeline, bootstrap: true }, async (h) => {
    500     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    501     await h.waitForJob(res.json().job_id);
    502     const task = await claimNamed(h, 'lint', {});
    503     const { cookie } = await h.login();
    504 
    505     for (const url of taskUrls(task.id)) {
    506       const res = await h.app.inject({ method: 'GET', url, headers: { cookie } });
    507       assert.equal(res.statusCode, 200, `${url}: ${res.body}`);
    508       assert.equal(res.json().task.id, task.id);
    509     }
    510   });
    511 });
    512 
    513 test('a task of another project cannot be read through this one', async () => {
    514   await withHarness({}, async (h) => {
    515     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    516     await h.waitForJob(res.json().job_id);
    517     const task = await claimNamed(h, 'lint', {});
    518 
    519     const res2 = await h.app.inject({
    520       method: 'GET',
    521       url: `/api/v1/projects/absent/tasks/${task.id}`,
    522       headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') },
    523     });
    524     assert.equal(res2.statusCode, 404);
    525   });
    526 });
    527 
    528 test('an unknown task is 404 by either path', async () => {
    529   await withHarness({}, async (h) => {
    530     for (const url of taskUrls('m2y0000000000000nope')) {
    531       const res = await h.app.inject({ method: 'GET', url });
    532       assert.equal(res.statusCode, 404, `${url} should be 404`);
    533       assert.equal(res.json().error, 'unknown task');
    534     }
    535   });
    536 });
    537 
    538 test('the worker api rejects missing and invalid tokens', async () => {
    539   await withHarness({}, async (h) => {
    540     const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' });
    541     assert.equal(none.statusCode, 401);
    542 
    543     const bad = await h.app.inject({
    544       method: 'POST', url: '/api/v1/tasks/claim', headers: { authorization: 'Bearer nope' },
    545     });
    546     assert.equal(bad.statusCode, 401);
    547   });
    548 });
    549 
    550 test('independent tasks dispatch concurrently', async () => {
    551   await withHarness({}, async (h) => {
    552     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    553     await h.waitForJob(res.json().job_id);
    554 
    555     // The prototype could only ever hand out one task at a time.
    556     const [a, b, c] = await Promise.all([
    557       h.claim({ arches: 'x86_64,aarch64' }),
    558       h.claim({ arches: 'x86_64,aarch64' }),
    559       h.claim({ arches: 'x86_64,aarch64' }),
    560     ]);
    561 
    562     const names = [a, b, c].map((r) => r.json().task.name);
    563     assert.equal(new Set(names).size, 3, `expected three distinct tasks, got ${names.join(', ')}`);
    564     assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']);
    565   });
    566 });
    567 
    568 test('a task is only offered to a worker that satisfies its requires', async () => {
    569   await withHarness({}, async (h) => {
    570     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    571     await h.waitForJob(res.json().job_id);
    572 
    573     // Clear the three root tasks so only package and publish remain.
    574     const roots = await drain(h, { arches: 'x86_64,aarch64' });
    575     assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']);
    576     for (const task of Object.values(roots)) await finish(h, task.id, { success: true });
    577 
    578     // package requires sign-key, which this worker does not advertise.
    579     const without = await h.claim({ arches: 'x86_64,aarch64' });
    580     assert.equal(without.statusCode, 204);
    581 
    582     const with_ = await h.claim({ arches: 'x86_64,aarch64', features: 'sign-key' });
    583     assert.equal(with_.statusCode, 200);
    584     assert.match(with_.json().task.name, /^package:/);
    585   });
    586 });
    587 
    588 test('a worker is not offered work for an architecture it cannot build', async () => {
    589   await withHarness({}, async (h) => {
    590     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    591     await h.waitForJob(res.json().job_id);
    592 
    593     const claimed = [];
    594     for (let i = 0; i < 3; i += 1) {
    595       const res = await h.claim({ arches: 'aarch64' });
    596       if (res.statusCode === 204) break;
    597       claimed.push(res.json().task);
    598     }
    599 
    600     // lint has no arch so it is eligible; the x86_64 build is not.
    601     assert.ok(!claimed.some((j) => j.arch === 'x86_64'));
    602     assert.ok(claimed.some((j) => j.name === 'build:arch=aarch64'));
    603   });
    604 });
    605 
    606 test('a claimed task carries everything the worker needs', async () => {
    607   await withHarness({}, async (h) => {
    608     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    609     await h.waitForJob(res.json().job_id);
    610     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    611 
    612     assert.equal(task.image, 'debian:bookworm-slim');
    613     assert.deepEqual(task.script, ['./build.sh $ARCH']);
    614     assert.equal(task.env.ARCH, 'x86_64');
    615     assert.equal(task.sha, h.sha);
    616     assert.match(task.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/tasks\//);
    617     assert.ok(task.endpoints.source.endsWith('/source.tar.gz'));
    618     assert.ok(task.endpoints.log.endsWith('/log'));
    619 
    620     // The worker resolves features from these, so a missing field
    621     // silently disables them.
    622     assert.deepEqual(task.requires, []);
    623     assert.ok(Number.isInteger(task.heartbeat_interval) && task.heartbeat_interval > 0);
    624     assert.deepEqual(task.masked, []);
    625 
    626     // A worker is given a context and a script. Which project or job the
    627     // work belongs to is none of its business, and the id it holds carries
    628     // no structure to read it out of either.
    629     assert.equal(task.project_id, undefined);
    630     assert.equal(task.job_id, undefined);
    631     assert.ok(!task.id.includes(':'), `task id should be opaque, got ${task.id}`);
    632   });
    633 });
    634 
    635 test('a task that requires a feature reports it to the worker', async () => {
    636   await withHarness({}, async (h) => {
    637     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    638     await h.waitForJob(res.json().job_id);
    639     const roots = await drain(h, { arches: 'x86_64,aarch64' });
    640     for (const task of Object.values(roots)) await finish(h, task.id, { success: true });
    641 
    642     const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl'];
    643     assert.ok(pkg);
    644     assert.deepEqual(pkg.requires, ['sign-key']);
    645   });
    646 });
    647 
    648 test('the standard conductor variables are present in the task environment', async () => {
    649   await withHarness({}, async (h) => {
    650     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    651     await h.waitForJob(job);
    652     const task = await claimNamed(h, 'lint', {});
    653 
    654     // The worker is not told any of this, but the script is: these reach
    655     // the container as ordinary environment, which is the only place the
    656     // build legitimately needs them.
    657     assert.equal(task.env.CONDUCTOR_PROJECT, 'demo');
    658     assert.equal(task.env.CONDUCTOR_JOB_ID, job);
    659     assert.equal(task.env.CONDUCTOR_JOB_NUMBER, '1');
    660     assert.equal(task.env.CONDUCTOR_TASK, 'lint');
    661     assert.equal(task.env.CONDUCTOR_TASK_ID, task.id);
    662     assert.equal(task.env.CONDUCTOR_SHA, h.sha);
    663     assert.equal(task.env.CONDUCTOR_REF, 'refs/heads/main');
    664     assert.equal(task.env.CONDUCTOR_ATTEMPT, '1');
    665   });
    666 });
    667 
    668 test('the source tarball is served from the mirror', async () => {
    669   await withHarness({}, async (h) => {
    670     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    671     await h.waitForJob(res.json().job_id);
    672     const task = await claimNamed(h, 'lint', {});
    673 
    674     const res2 = await h.app.inject({
    675       method: 'GET',
    676       url: `/api/v1/tasks/${task.id}/source.tar.gz`,
    677       headers: h.auth,
    678     });
    679     assert.equal(res2.statusCode, 200);
    680     assert.equal(res2.headers['content-type'], 'application/gzip');
    681     // gzip magic number
    682     assert.equal(res2.rawPayload[0], 0x1f);
    683     assert.equal(res2.rawPayload[1], 0x8b);
    684   });
    685 });
    686 
    687 test('a worker cannot touch a task it does not hold', async () => {
    688   await withHarness({}, async (h) => {
    689     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    690     await h.waitForJob(tr.json().job_id);
    691     const task = await claimNamed(h, 'lint', {});
    692 
    693     const other = await h.services.workerTokens.create('intruder');
    694     const res = await h.app.inject({
    695       method: 'POST',
    696       url: `/api/v1/tasks/${task.id}/log`,
    697       headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' },
    698       payload: Buffer.from('malicious'),
    699     });
    700     assert.equal(res.statusCode, 403);
    701   });
    702 });
    703 
    704 test('log appends are resumable and deduplicated by offset', async () => {
    705   await withHarness({}, async (h) => {
    706     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    707     await h.waitForJob(res.json().job_id);
    708     const task = await claimNamed(h, 'lint', {});
    709     const url = `/api/v1/tasks/${task.id}/log`;
    710     const headers = { ...h.auth, 'content-type': 'application/octet-stream' };
    711 
    712     const first = await h.app.inject({
    713       method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\n'),
    714     });
    715     assert.deepEqual(first.json(), { size: 6, written: 6, truncated: false });
    716 
    717     // A retry resends what was already stored plus new data.
    718     const retry = await h.app.inject({
    719       method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\nworld\n'),
    720     });
    721     assert.deepEqual(retry.json(), { size: 12, written: 6, truncated: false });
    722 
    723     // A gap is refused, and says where to resume.
    724     const gap = await h.app.inject({
    725       method: 'POST', url, headers: { ...headers, 'x-log-offset': '9999' }, payload: Buffer.from('x'),
    726     });
    727     assert.equal(gap.statusCode, 409);
    728     assert.equal(gap.json().expected_offset, 12);
    729 
    730     const tail = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 0, limit: 1024 });
    731     assert.equal(tail.data.toString('utf8'), 'hello\nworld\n');
    732     assert.equal(tail.size, 12);
    733 
    734     const partial = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 6, limit: 1024 });
    735     assert.equal(partial.data.toString('utf8'), 'world\n');
    736   });
    737 });
    738 
    739 test('an oversized log chunk is refused', async () => {
    740   await withHarness({}, async (h) => {
    741     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    742     await h.waitForJob(tr.json().job_id);
    743     const task = await claimNamed(h, 'lint', {});
    744 
    745     // Artifacts are streamed and may be large, but a single log append is
    746     // buffered, so it has to be bounded or a worker can exhaust memory.
    747     const res = await h.app.inject({
    748       method: 'POST',
    749       url: `/api/v1/tasks/${task.id}/log`,
    750       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    751       payload: Buffer.alloc(2 * 1024 * 1024, 0x41),
    752     });
    753 
    754     assert.equal(res.statusCode, 413);
    755     assert.equal(await h.services.logs.size(await jobIdOf(h, task.id), task.id), 0, 'nothing should have been written');
    756   });
    757 });
    758 
    759 test('a finished log moves to storage and is still readable', async () => {
    760   await withHarness({}, async (h) => {
    761     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    762     await h.waitForJob(res.json().job_id);
    763     const task = await claimNamed(h, 'lint', {});
    764 
    765     await h.app.inject({
    766       method: 'POST',
    767       url: `/api/v1/tasks/${task.id}/log`,
    768       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    769       payload: Buffer.from('compiling\ndone\n'),
    770     });
    771     await finish(h, task.id, { success: true });
    772 
    773     const row = await h.services.db.get('SELECT log_key FROM tasks WHERE id = {id}', { id: task.id });
    774     assert.ok(row.log_key, 'the log should have moved to storage');
    775     const object = await h.services.storage.get(row.log_key);
    776     const parts = [];
    777     for await (const part of object.stream) parts.push(part);
    778     assert.equal(Buffer.concat(parts).toString('utf8'), 'compiling\ndone\n');
    779   });
    780 });
    781 
    782 test('artifacts are stored, hashed, and stripped of traversal', async () => {
    783   await withHarness({}, async (h) => {
    784     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    785     await h.waitForJob(res.json().job_id);
    786     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    787 
    788     const upload = await h.app.inject({
    789       method: 'POST',
    790       url: `/api/v1/tasks/${task.id}/artifacts`,
    791       headers: {
    792         ...h.auth,
    793         'content-type': 'application/octet-stream',
    794         'x-artifact-path': '../../../etc/passwd',
    795       },
    796       payload: Buffer.from('artifact bytes'),
    797     });
    798     assert.equal(upload.statusCode, 200);
    799 
    800     const body = upload.json();
    801     assert.equal(body.path, 'etc/passwd', 'traversal should be stripped, not honoured');
    802     assert.equal(body.size, 14);
    803     assert.match(body.sha256, /^[0-9a-f]{64}$/);
    804 
    805     const artifacts = await h.services.db.all(
    806       'SELECT id FROM artifacts WHERE task_id = {task}', { task: task.id }
    807     );
    808     assert.equal(artifacts.length, 1);
    809 
    810     const download = await h.app.inject({
    811       method: 'GET',
    812       url: `/api/v1/projects/demo/jobs/${await jobIdOf(h, task.id)}/tasks/${task.id}/artifacts/${artifacts[0].id}`,
    813     });
    814     assert.equal(download.statusCode, 200);
    815     assert.equal(download.body, 'artifact bytes');
    816   });
    817 });
    818 
    819 test('an artifact shorter than its content-length is rejected', async () => {
    820   await withHarness({}, async (h) => {
    821     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    822     await h.waitForJob(res.json().job_id);
    823     const task = await claimNamed(h, 'lint', {});
    824 
    825     const res2 = await h.app.inject({
    826       method: 'POST',
    827       url: `/api/v1/tasks/${task.id}/artifacts`,
    828       headers: {
    829         ...h.auth,
    830         'content-type': 'application/octet-stream',
    831         'content-length': '500',
    832         'x-artifact-path': 'short.bin',
    833       },
    834       payload: Buffer.from('tiny'),
    835     });
    836     assert.equal(res2.statusCode, 400);
    837   });
    838 });
    839 
    840 test('success releases dependents, matched on shared dimensions', async () => {
    841   await withHarness({}, async (h) => {
    842     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    843     await h.waitForJob(job);
    844 
    845     const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
    846     await finish(h, roots['build:arch=x86_64'].id, { success: true });
    847 
    848     // Only the x86_64 package becomes eligible; the aarch64 one still waits
    849     // on its own architecture's build.
    850     const next = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
    851     assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released');
    852     assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting');
    853 
    854     const state = tasksByName(await jobState(h, job));
    855     assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued');
    856   });
    857 });
    858 
    859 test('failure skips transitive dependents, not just direct ones', async () => {
    860   await withHarness({}, async (h) => {
    861     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    862     await h.waitForJob(job);
    863 
    864     const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    865     const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' });
    866     assert.equal(outcome.state, 'failed');
    867 
    868     const tasks = tasksByName(await jobState(h, job));
    869     // Direct dependent.
    870     assert.equal(tasks['package:arch=x86_64,pkg=musl'].state, 'skipped');
    871     // Transitive dependent: the prototype left this queued and dispatchable.
    872     assert.equal(tasks.publish.state, 'skipped');
    873     // Unrelated branches are untouched.
    874     assert.equal(tasks['build:arch=aarch64'].state, 'queued');
    875     assert.equal(tasks['package:arch=aarch64,pkg=musl'].state, 'queued');
    876   });
    877 });
    878 
    879 test('a job settles as failed once every task is terminal', async () => {
    880   await withHarness({}, async (h) => {
    881     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    882     await h.waitForJob(job);
    883 
    884     const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
    885 
    886     // Failing the x86_64 build skips its package and, transitively, publish.
    887     await finish(h, roots['build:arch=x86_64'].id, { success: false, exitCode: 1 });
    888     await finish(h, roots['build:arch=aarch64'].id, { success: true });
    889     await finish(h, roots.lint.id, { success: true });
    890 
    891     const released = await drain(h, { arches: 'aarch64', features: 'sign-key' });
    892     const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true });
    893 
    894     assert.equal(last.job_state, 'failed');
    895     assert.equal((await jobState(h, job)).job.state, 'failed');
    896   });
    897 });
    898 
    899 test('a job settles as success when everything passes', async () => {
    900   // Quoted, because an unquoted true in YAML is a boolean, not a command.
    901   const pipeline = `
    902 version: 1
    903 tasks:
    904   a:
    905     image: alpine
    906     script: ['true']
    907   b:
    908     image: alpine
    909     script: ['true']
    910     needs: [a]
    911 `;
    912   await withHarness({ pipeline }, async (h) => {
    913     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    914     await h.waitForJob(job);
    915 
    916     const a = await claimNamed(h, 'a', {});
    917     await finish(h, a.id, { success: true });
    918     const b = await claimNamed(h, 'b', {});
    919     const last = await finish(h, b.id, { success: true });
    920 
    921     assert.equal(last.job_state, 'success');
    922     assert.equal((await jobState(h, job)).job.state, 'success');
    923   });
    924 });
    925 
    926 test('an allowed failure does not fail the job or block dependents', async () => {
    927   const pipeline = `
    928 version: 1
    929 tasks:
    930   flaky:
    931     image: alpine
    932     script: ['false']
    933     allow_failure: true
    934   after:
    935     image: alpine
    936     script: ['true']
    937     needs: [flaky]
    938 `;
    939   await withHarness({ pipeline }, async (h) => {
    940     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    941     await h.waitForJob(job);
    942 
    943     const flaky = await claimNamed(h, 'flaky', {});
    944     const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 });
    945     assert.deepEqual(outcome.skipped, []);
    946 
    947     const after = await claimNamed(h, 'after', {});
    948     assert.ok(after, 'dependent should still run after an allowed failure');
    949     const last = await finish(h, after.id, { success: true });
    950     assert.equal(last.job_state, 'success');
    951     assert.equal((await jobState(h, job)).job.state, 'success');
    952   });
    953 });
    954 
    955 test('a task with retries left is requeued instead of failing the job', async () => {
    956   const pipeline = `
    957 version: 1
    958 tasks:
    959   retried:
    960     image: alpine
    961     script: [maybe]
    962     max_attempts: 2
    963 `;
    964   await withHarness({ pipeline }, async (h) => {
    965     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    966     await h.waitForJob(job);
    967 
    968     const first = await claimNamed(h, 'retried', {});
    969     const outcome = await finish(h, first.id, { success: false, exitCode: 1 });
    970     assert.equal(outcome.state, 'queued');
    971     assert.equal(outcome.retry, true);
    972 
    973     const second = await claimNamed(h, 'retried', {});
    974     assert.equal(second.attempt, 2);
    975     const last = await finish(h, second.id, { success: false, exitCode: 1 });
    976     assert.equal(last.state, 'failed');
    977     assert.equal((await jobState(h, job)).job.state, 'failed');
    978   });
    979 });
    980 
    981 test('heartbeats keep a task alive and report cancellation', async () => {
    982   await withHarness({}, async (h) => {
    983     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
    984     await h.waitForJob(job);
    985     const task = await claimNamed(h, 'lint', {});
    986 
    987     const beat = await h.app.inject({
    988       method: 'POST',
    989       url: `/api/v1/tasks/${task.id}/heartbeat`,
    990       headers: { ...h.auth, 'content-type': 'application/json' },
    991       payload: '{}',
    992     });
    993     assert.deepEqual(beat.json(), { cancelled: false });
    994 
    995     await h.services.scheduler.cancelJob(job);
    996 
    997     const after = await h.app.inject({
    998       method: 'POST',
    999       url: `/api/v1/tasks/${task.id}/heartbeat`,
   1000       headers: { ...h.auth, 'content-type': 'application/json' },
   1001       payload: '{}',
   1002     });
   1003     assert.equal(after.json().cancelled, true);
   1004     assert.equal((await jobState(h, job)).job.state, 'cancelled');
   1005   });
   1006 });
   1007 
   1008 test('the reaper fails a task whose worker stopped reporting', async () => {
   1009   await withHarness({}, async (h) => {
   1010     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id;
   1011     await h.waitForJob(job);
   1012     const task = await claimNamed(h, 'lint', {});
   1013     assert.equal(tasksByName(await jobState(h, job)).lint.state, 'running');
   1014 
   1015     // Backdate the heartbeat rather than waiting out the real timeout.
   1016     await h.services.db.run(
   1017       'UPDATE tasks SET heartbeat_at = {then} WHERE id = {id}',
   1018       { then: Date.now() - 10 * 60 * 1000, id: task.id }
   1019     );
   1020 
   1021     const reaped = await h.services.scheduler.reap();
   1022     assert.ok(reaped.length > 0);
   1023 
   1024     const after = tasksByName(await jobState(h, job)).lint;
   1025     assert.equal(after.state, 'failed');
   1026     assert.match(after.error, /stopped reporting/);
   1027 
   1028     // The worker that lost the task can no longer complete it.
   1029     const late = await finish(h, task.id, { success: true });
   1030     assert.match(String(late.error ?? ''), /not running|another worker/);
   1031   });
   1032 });
   1033 
   1034 test('a second push produces an independent, numbered job', async () => {
   1035   await withHarness({}, async (h) => {
   1036     const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json();
   1037     await h.waitForJob(first.job_id);
   1038     const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit');
   1039     const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json();
   1040     await h.waitForJob(second.job_id);
   1041 
   1042     assert.notEqual(first.job_id, second.job_id);
   1043     const detail = await jobState(h, second.job_id);
   1044     assert.equal(detail.job.number, 2);
   1045     assert.equal(detail.job.title, 'second commit');
   1046     assert.equal(detail.job.head_sha, sha2);
   1047   });
   1048 });
   1049 
   1050 test('jobs can be listed and filtered by project', apiGone, async () => {
   1051   await withHarness({}, async (h) => {
   1052     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1053 
   1054     const all = await h.app.inject({ method: 'GET', url: '/api/jobs' });
   1055     assert.equal(all.json().jobs.length, 1);
   1056 
   1057     const mine = await h.app.inject({ method: 'GET', url: '/api/jobs?project=demo' });
   1058     assert.equal(mine.json().jobs.length, 1);
   1059 
   1060     const other = await h.app.inject({ method: 'GET', url: '/api/jobs?project=absent' });
   1061     assert.equal(other.json().jobs.length, 0);
   1062   });
   1063 });
   1064 
   1065 test('unknown jobs, tasks and artifacts return 404', apiGone, async () => {
   1066   await withHarness({}, async (h) => {
   1067     for (const url of ['/api/jobs/nope', '/api/tasks/nope', '/api/tasks/nope/log', '/api/artifacts/nope']) {
   1068       const res = await h.app.inject({ method: 'GET', url });
   1069       assert.equal(res.statusCode, 404, `${url} should be 404`);
   1070     }
   1071   });
   1072 });
   1073 
   1074 test('a disabled project refuses triggers', async () => {
   1075   await withHarness({}, async (h) => {
   1076     await h.services.projects.setEnabled('demo', false);
   1077     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1078     assert.equal(res.statusCode, 409);
   1079   });
   1080 });
   1081 
   1082 test('a trigger for an unknown project is a 404', async () => {
   1083   await withHarness({}, async (h) => {
   1084     const res = await h.app.inject({
   1085       method: 'POST',
   1086       url: '/api/v1/projects/absent/trigger',
   1087       headers: { 'content-type': 'application/json' },
   1088       payload: JSON.stringify({ sha: h.sha }),
   1089     });
   1090     assert.equal(res.statusCode, 404);
   1091   });
   1092 });
   1093 
   1094 test('a commit that is not in the repository is refused', async () => {
   1095   await withHarness({}, async (h) => {
   1096     const res = await h.trigger({ sha: 'b'.repeat(40), ref: 'refs/heads/main' });
   1097     assert.equal(res.statusCode, 200);
   1098     const job = await h.waitForJob(res.json().job_id);
   1099     assert.equal(job.state, 'failed');
   1100     assert.match(job.error ?? '', /not present in the mirror/);
   1101   });
   1102 });
   1103 
   1104 test('a task is told to fetch an archive, and never the repository itself', async () => {
   1105   await withHarness({}, async (h) => {
   1106     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1107     await h.waitForJob(res.json().job_id);
   1108     const task = await claimNamed(h, 'lint', {});
   1109 
   1110     assert.match(task.endpoints.source, /\/source\.tar\.gz$/);
   1111 
   1112     // A worker holds no repository credentials and can reach no ref other
   1113     // than the commit it was given work for, so the repository location
   1114     // has no business being in the payload.
   1115     assert.equal(task.source, undefined, 'there is one way to get the source, so there is no mode');
   1116     assert.ok(!JSON.stringify(task).includes(h.repoDir), 'the repository path must not reach the worker');
   1117   });
   1118 });
   1119 
   1120 test('the source archive is the bare tree, with no wrapping directory', async () => {
   1121   // The worker hands it to docker cp with the working directory as the
   1122   // destination, and docker creates that directory as it extracts. A
   1123   // wrapping directory in the archive would land one level too deep.
   1124   await withHarness({}, async (h) => {
   1125     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1126     await h.waitForJob(res.json().job_id);
   1127     const task = await claimNamed(h, 'lint', {});
   1128 
   1129     const archive = await h.app.inject({
   1130       method: 'GET',
   1131       url: new URL(task.endpoints.source).pathname,
   1132       headers: h.auth,
   1133     });
   1134     assert.equal(archive.statusCode, 200);
   1135 
   1136     // spawnSync rather than the promisified execFile, which has no way to
   1137     // supply stdin and simply waits for input that never comes.
   1138     const { spawnSync } = await import('node:child_process');
   1139     const listing = spawnSync('tar', ['-tzf', '-'], {
   1140       input: archive.rawPayload,
   1141       encoding: 'utf8',
   1142     });
   1143     assert.equal(listing.status, 0, `tar failed: ${listing.stderr}`);
   1144 
   1145     const entries = listing.stdout.split('\n').filter(Boolean);
   1146     assert.ok(entries.length > 0, 'the archive should not be empty');
   1147     assert.ok(
   1148       entries.includes('.conductor.yml'),
   1149       `the pipeline should sit at the root of the archive, got:\n${listing.stdout}`,
   1150     );
   1151   });
   1152 });
   1153 
   1154 test('the working directory is resolved from the pipeline, then the project', async () => {
   1155   const pipeline = [
   1156     'version: 1',
   1157     'workdir: /usr/src/app',
   1158     'defaults:',
   1159     '  image: alpine:3',
   1160     'tasks:',
   1161     '  build:',
   1162     "    script: ['make']",
   1163     '',
   1164   ].join('\n');
   1165 
   1166   await withHarness({ pipeline }, async (h) => {
   1167     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1168     await h.waitForJob(res.json().job_id);
   1169     const task = await claimNamed(h, 'build', {});
   1170     assert.equal(task.workdir, '/usr/src/app', 'the repository has the final say');
   1171   });
   1172 
   1173   // With the repository silent, the project decides.
   1174   await withHarness({}, async (h) => {
   1175     await h.services.db.run(
   1176       'UPDATE projects SET workdir = {dir} WHERE id = {id}',
   1177       { dir: '/srv/build', id: h.project.id }
   1178     );
   1179     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1180     await h.waitForJob(res.json().job_id);
   1181     const task = await claimNamed(h, 'lint', {});
   1182     assert.equal(task.workdir, '/srv/build');
   1183   });
   1184 
   1185   // With neither, there is a default.
   1186   await withHarness({}, async (h) => {
   1187     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1188     await h.waitForJob(res.json().job_id);
   1189     const task = await claimNamed(h, 'lint', {});
   1190     assert.equal(task.workdir, '/work');
   1191   });
   1192 });
   1193 
   1194 test('an unusable working directory is refused rather than sanitised', async () => {
   1195   for (const workdir of ['relative/path', '/', '/a/../b', '/has space']) {
   1196     const pipeline = [
   1197       'version: 1',
   1198       `workdir: ${JSON.stringify(workdir)}`,
   1199       'defaults:',
   1200       '  image: alpine:3',
   1201       'tasks:',
   1202       '  build:',
   1203       "    script: ['make']",
   1204       '',
   1205     ].join('\n');
   1206 
   1207     await withHarness({ pipeline }, async (h) => {
   1208       const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1209       assert.equal(res.statusCode, 200);
   1210       const job = await h.waitForJob(res.json().job_id);
   1211       assert.equal(job.state, 'failed', `expected ${JSON.stringify(workdir)} to be refused`);
   1212       assert.ok((job.error ?? '').includes('workdir'), `error should mention workdir: ${job.error}`);
   1213     });
   1214   }
   1215 });
   1216 
   1217 test('the trigger secret is not stored in the clear', async () => {
   1218   await withHarness({}, async (h) => {
   1219     const row = await h.services.db.get('SELECT trigger_secret FROM projects WHERE id = {id}', { id: 'demo' });
   1220     assert.ok(row.trigger_secret.startsWith('v1.'), 'expected an encrypted value');
   1221     assert.ok(!row.trigger_secret.includes('test-secret'));
   1222     assert.equal(h.services.projects.triggerSecret(await h.services.projects.get('demo')), 'test-secret');
   1223   });
   1224 });
   1225 
   1226 test('worker tokens are stored only as hashes', async () => {
   1227   await withHarness({}, async (h) => {
   1228     const rows = await h.services.db.all('SELECT token_hash FROM worker_tokens');
   1229     assert.ok(rows.length > 0);
   1230     for (const row of rows) {
   1231       assert.match(row.token_hash, /^[0-9a-f]{64}$/);
   1232       assert.notEqual(row.token_hash, h.worker.token);
   1233     }
   1234   });
   1235 });