conductor

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

management.test.js (10859B)


      1 // test/management.test.js - the authenticated management surface
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import { startHarness, data, apiError } from './helpers/harness.js';
      6 
      7 async function withAdmin(options, fn) {
      8   const h = await startHarness({ ...options, bootstrap: true });
      9   try {
     10     const { headers } = await h.login();
     11     return await fn(h, headers);
     12   } finally {
     13     await h.stop();
     14   }
     15 }
     16 
     17 const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
     18 
     19 async function claimTask(h) {
     20   const triggered = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
     21   await h.waitForJob(data(triggered).job_id);
     22   const claimed = await h.claim({});
     23   assert.equal(claimed.statusCode, 200, claimed.body);
     24   return data(claimed).task;
     25 }
     26 
     27 test('a project is created with a trigger secret that is shown once', async () => {
     28   await withAdmin({}, async (h, admin) => {
     29     const created = await h.app.inject({
     30       method: 'POST',
     31       url: '/api/v1/projects',
     32       headers: json(admin),
     33       payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }),
     34     });
     35     assert.equal(created.statusCode, 201, created.body);
     36     const secret = data(created).trigger_secret;
     37     assert.ok(secret && secret.length >= 32);
     38 
     39     const listed = await h.app.inject({ method: 'GET', url: '/api/v1/projects', headers: admin });
     40     const project = data(listed).find((p) => p.id === 'newproj');
     41     assert.equal(project.has_trigger_secret, true);
     42     assert.equal(project.trigger_secret, undefined);
     43     assert.ok(!listed.body.includes(secret));
     44 
     45     const removed = await h.app.inject({
     46       method: 'DELETE', url: '/api/v1/projects/newproj', headers: admin,
     47     });
     48     assert.equal(removed.statusCode, 200, removed.body);
     49   });
     50 });
     51 
     52 test('a rotated trigger secret replaces the one before it', async () => {
     53   await withAdmin({}, async (h, admin) => {
     54     const rotated = await h.app.inject({
     55       method: 'POST', url: '/api/v1/projects/demo/trigger-secret', headers: json(admin), payload: '{}',
     56     });
     57     assert.equal(rotated.statusCode, 200, rotated.body);
     58     const secret = data(rotated).trigger_secret;
     59 
     60     const stale = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'test-secret' });
     61     assert.equal(stale.statusCode, 401, stale.body);
     62 
     63     const fresh = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret });
     64     assert.equal(fresh.statusCode, 200, fresh.body);
     65     await h.waitForJob(data(fresh).job_id);
     66   });
     67 });
     68 
     69 test('a worker token is issued once and revoking it takes effect at once', async () => {
     70   await withAdmin({}, async (h, admin) => {
     71     const created = await h.app.inject({
     72       method: 'POST',
     73       url: '/api/v1/workers',
     74       headers: json(admin),
     75       payload: JSON.stringify({ name: 'builder-2' }),
     76     });
     77     assert.equal(created.statusCode, 201, created.body);
     78     const { id, token } = data(created);
     79 
     80     const claim = (t) => h.app.inject({
     81       method: 'POST',
     82       url: '/api/v1/tasks/claim',
     83       headers: { authorization: `Bearer ${t}`, 'content-type': 'application/json' },
     84       payload: '{}',
     85     });
     86 
     87     assert.notEqual((await claim(token)).statusCode, 401);
     88 
     89     const listed = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: admin });
     90     assert.ok(!listed.body.includes(token));
     91 
     92     const disabled = await h.app.inject({
     93       method: 'PATCH',
     94       url: `/api/v1/workers/${id}`,
     95       headers: json(admin),
     96       payload: JSON.stringify({ enabled: false }),
     97     });
     98     assert.equal(disabled.statusCode, 200, disabled.body);
     99 
    100     const after = await claim(token);
    101     assert.equal(after.statusCode, 401, after.body);
    102     assert.equal(apiError(after).name, 'InvalidWorkerToken');
    103   });
    104 });
    105 
    106 test('a project variable reaches a task environment and is never listed', async () => {
    107   await withAdmin({}, async (h, admin) => {
    108     const set = await h.app.inject({
    109       method: 'PUT',
    110       url: '/api/v1/projects/demo/variables/DEPLOY_TOKEN',
    111       headers: json(admin),
    112       payload: JSON.stringify({ value: 'super-secret-value', masked: true }),
    113     });
    114     assert.equal(set.statusCode, 200, set.body);
    115 
    116     const listed = await h.app.inject({
    117       method: 'GET', url: '/api/v1/projects/demo/variables', headers: admin,
    118     });
    119     const listing = data(listed);
    120     assert.equal(listing[0].name, 'DEPLOY_TOKEN');
    121     assert.equal(listing[0].masked, true);
    122     assert.ok(!listed.body.includes('super-secret-value'));
    123 
    124     const task = await claimTask(h);
    125     assert.equal(task.env.DEPLOY_TOKEN, 'super-secret-value');
    126     assert.deepEqual(task.masked, ['super-secret-value']);
    127   });
    128 });
    129 
    130 test('a variable is encrypted at rest and bound to its project and name', async () => {
    131   await withAdmin({}, async (h, admin) => {
    132     await h.app.inject({
    133       method: 'PUT',
    134       url: '/api/v1/projects/demo/variables/TOKEN',
    135       headers: json(admin),
    136       payload: JSON.stringify({ value: 'rest-secret-value' }),
    137     });
    138 
    139     const row = await h.services.db.get(
    140       'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}',
    141       { p: 'demo', n: 'TOKEN' }
    142     );
    143     assert.ok(row.value.startsWith('v1.'), 'expected an encrypted value');
    144     assert.ok(!row.value.includes('rest-secret-value'));
    145 
    146     await h.services.db.run(
    147       `INSERT INTO project_variables (project_id, name, value, masked, created_at)
    148        VALUES ({p}, {n}, {v}, 1, {t})`,
    149       { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() }
    150     );
    151 
    152     const resolved = await h.services.variables.resolve('demo');
    153     assert.equal(resolved.env.TOKEN, 'rest-secret-value');
    154     assert.equal(resolved.env.MOVED, undefined, 'a relocated ciphertext must not open');
    155   });
    156 });
    157 
    158 test('a value that will not open is reported as broken rather than absent', async () => {
    159   await withAdmin({}, async (h, admin) => {
    160     await h.app.inject({
    161       method: 'PUT',
    162       url: '/api/v1/projects/demo/variables/TOKEN',
    163       headers: json(admin),
    164       payload: JSON.stringify({ value: 'unopenable-secret-value' }),
    165     });
    166 
    167     const row = await h.services.db.get(
    168       'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}',
    169       { p: 'demo', n: 'TOKEN' }
    170     );
    171     await h.services.db.run(
    172       `INSERT INTO project_variables (project_id, name, value, masked, created_at)
    173        VALUES ({p}, {n}, {v}, 1, {t})`,
    174       { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() }
    175     );
    176 
    177     const res = await h.app.inject({
    178       url: '/api/v1/projects/demo/variables/MOVED/value', headers: admin,
    179     });
    180     assert.equal(res.statusCode, 500, res.body);
    181     assert.ok(!res.body.includes('unopenable-secret-value'));
    182   });
    183 });
    184 
    185 test('a pipeline setting wins over a project variable of the same name', async () => {
    186   const pipeline = `
    187 version: 1
    188 defaults:
    189   image: debian:bookworm-slim
    190 tasks:
    191   a:
    192     script: ['true']
    193     env:
    194       SHARED: from-pipeline
    195 `;
    196   await withAdmin({ pipeline }, async (h, admin) => {
    197     await h.app.inject({
    198       method: 'PUT',
    199       url: '/api/v1/projects/demo/variables/SHARED',
    200       headers: json(admin),
    201       payload: JSON.stringify({ value: 'from-variable' }),
    202     });
    203 
    204     const task = await claimTask(h);
    205     assert.equal(task.env.SHARED, 'from-pipeline');
    206   });
    207 });
    208 
    209 test('a masked variable is redacted from an ingested log', async () => {
    210   await withAdmin({}, async (h, admin) => {
    211     await h.app.inject({
    212       method: 'PUT',
    213       url: '/api/v1/projects/demo/variables/LEAKY',
    214       headers: json(admin),
    215       payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }),
    216     });
    217 
    218     const task = await claimTask(h);
    219     const appended = await h.app.inject({
    220       method: 'POST',
    221       url: `/api/v1/tasks/${encodeURIComponent(task.id)}/log`,
    222       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    223       payload: Buffer.from('echo leaked-secret-value here\n'),
    224     });
    225     assert.equal(appended.statusCode, 200, appended.body);
    226 
    227     const log = await h.app.inject({
    228       method: 'GET', url: `/api/v1/tasks/${encodeURIComponent(task.id)}/log`,
    229     });
    230     assert.ok(!log.body.includes('leaked-secret-value'), `log leaked: ${log.body}`);
    231     assert.match(log.body, /\[masked\]/);
    232   });
    233 });
    234 
    235 test('a job can be cancelled and retried at the same commit', async () => {
    236   await withAdmin({}, async (h, admin) => {
    237     const triggered = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    238     const jobId = data(triggered).job_id;
    239     await h.waitForJob(jobId);
    240 
    241     const cancelled = await h.app.inject({
    242       method: 'POST', url: `/api/v1/jobs/${jobId}/cancel`, headers: json(admin), payload: '{}',
    243     });
    244     assert.equal(cancelled.statusCode, 200, cancelled.body);
    245 
    246     const detail = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}` });
    247     assert.equal(data(detail).state, 'cancelled');
    248 
    249     const retried = await h.app.inject({
    250       method: 'POST', url: `/api/v1/jobs/${jobId}/retry`, headers: json(admin), payload: '{}',
    251     });
    252     assert.equal(retried.statusCode, 200, retried.body);
    253     assert.notEqual(data(retried).job_id, jobId);
    254     await h.waitForJob(data(retried).job_id);
    255 
    256     const fresh = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${data(retried).job_id}` });
    257     assert.equal(data(fresh).head_sha, h.sha);
    258   });
    259 });
    260 
    261 test('an administrator cannot lock themselves out', async () => {
    262   await withAdmin({}, async (h, admin) => {
    263     const me = data(await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers: admin }));
    264 
    265     const demoted = await h.app.inject({
    266       method: 'PATCH',
    267       url: `/api/v1/users/${me.id}`,
    268       headers: json(admin),
    269       payload: JSON.stringify({ role: 'user' }),
    270     });
    271     assert.equal(demoted.statusCode, 403, demoted.body);
    272 
    273     const removed = await h.app.inject({
    274       method: 'DELETE', url: `/api/v1/users/${me.id}`, headers: admin,
    275     });
    276     assert.equal(removed.statusCode, 403, removed.body);
    277   });
    278 });
    279 
    280 test('a disabled account stops being accepted', async () => {
    281   await withAdmin({}, async (h, admin) => {
    282     const created = await h.app.inject({
    283       method: 'POST',
    284       url: '/api/v1/users',
    285       headers: json(admin),
    286       payload: JSON.stringify({ username: 'temp', password: 'temp-password', role: 'user' }),
    287     });
    288     const id = data(created).id;
    289     const { headers } = await h.login('temp', 'temp-password');
    290 
    291     assert.equal(
    292       (await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 200
    293     );
    294 
    295     await h.app.inject({
    296       method: 'PATCH',
    297       url: `/api/v1/users/${id}`,
    298       headers: json(admin),
    299       payload: JSON.stringify({ disabled: true }),
    300     });
    301 
    302     const after = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers });
    303     assert.equal(after.statusCode, 401, after.body);
    304   });
    305 });