conductor

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

admin.test.js (14737B)


      1 // test/admin.test.js - the authenticated administration surface
      2 //
      3 // The recurring concern here is that secrets are write only: a trigger
      4 // secret, a worker token and a variable can each be set, but only ever read
      5 // back once at the moment they are created.
      6 
      7 import test from 'node:test';
      8 import assert from 'node:assert/strict';
      9 import { startHarness } from './helpers/harness.js';
     10 
     11 async function withAdmin(options, fn) {
     12   const h = await startHarness({ ...options, bootstrap: true });
     13   try {
     14     return await fn(h, await h.login());
     15   } finally {
     16     await h.stop();
     17   }
     18 }
     19 
     20 const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
     21 
     22 test('login issues a token and a session cookie', async () => {
     23   await withAdmin({}, async (h) => {
     24     const res = await h.app.inject({
     25       method: 'POST',
     26       url: '/api/auth/login',
     27       headers: { 'content-type': 'application/json' },
     28       payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
     29     });
     30 
     31     assert.equal(res.statusCode, 200);
     32     const body = res.json();
     33     assert.equal(body.user.role, 'admin');
     34     assert.ok(body.token);
     35     assert.match(res.headers['set-cookie'], /conductor_session=/);
     36     assert.match(res.headers['set-cookie'], /HttpOnly/);
     37   });
     38 });
     39 
     40 test('a wrong password and an unknown user are indistinguishable', async () => {
     41   await withAdmin({}, async (h) => {
     42     const wrong = await h.app.inject({
     43       method: 'POST',
     44       url: '/api/auth/login',
     45       headers: { 'content-type': 'application/json' },
     46       payload: JSON.stringify({ username: 'admin', password: 'nope' }),
     47     });
     48     const absent = await h.app.inject({
     49       method: 'POST',
     50       url: '/api/auth/login',
     51       headers: { 'content-type': 'application/json' },
     52       payload: JSON.stringify({ username: 'nobody', password: 'nope' }),
     53     });
     54 
     55     assert.equal(wrong.statusCode, 401);
     56     assert.equal(absent.statusCode, 401);
     57     assert.deepEqual(wrong.json(), absent.json());
     58   });
     59 });
     60 
     61 test('the session cookie authenticates as well as the bearer token', async () => {
     62   await withAdmin({}, async (h) => {
     63     const login = await h.app.inject({
     64       method: 'POST',
     65       url: '/api/auth/login',
     66       headers: { 'content-type': 'application/json' },
     67       payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
     68     });
     69     const cookie = login.headers['set-cookie'].split(';')[0];
     70 
     71     const res = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } });
     72     assert.equal(res.statusCode, 200);
     73     assert.equal(res.json().user.username, 'admin');
     74   });
     75 });
     76 
     77 test('management needs a session, and user administration needs the admin role', async () => {
     78   await withAdmin({}, async (h, admin) => {
     79     const anonymous = await h.app.inject({ method: 'GET', url: '/api/projects' });
     80     assert.equal(anonymous.statusCode, 401);
     81 
     82     await h.app.inject({
     83       method: 'POST',
     84       url: '/api/admin/users',
     85       headers: json(admin),
     86       payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password', role: 'viewer' }),
     87     });
     88 
     89     const login = await h.app.inject({
     90       method: 'POST',
     91       url: '/api/auth/login',
     92       headers: { 'content-type': 'application/json' },
     93       payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password' }),
     94     });
     95     const viewer = { authorization: `Bearer ${login.json().token}` };
     96 
     97     // An ordinary user manages their own things, and owns nothing yet.
     98     const own = await h.app.inject({ method: 'GET', url: '/api/projects', headers: viewer });
     99     assert.equal(own.statusCode, 200);
    100     assert.deepEqual(own.json().projects, []);
    101 
    102     // User administration stays administrator only.
    103     const forbidden = await h.app.inject({ method: 'GET', url: '/api/admin/users', headers: viewer });
    104     assert.equal(forbidden.statusCode, 403);
    105   });
    106 });
    107 
    108 test('a disabled account stops being accepted immediately', async () => {
    109   await withAdmin({}, async (h, admin) => {
    110     const created = await h.app.inject({
    111       method: 'POST',
    112       url: '/api/admin/users',
    113       headers: json(admin),
    114       payload: JSON.stringify({ username: 'temp', password: 'temp-password', role: 'viewer' }),
    115     });
    116     const id = created.json().user.id;
    117 
    118     const login = await h.app.inject({
    119       method: 'POST',
    120       url: '/api/auth/login',
    121       headers: { 'content-type': 'application/json' },
    122       payload: JSON.stringify({ username: 'temp', password: 'temp-password' }),
    123     });
    124     const headers = { authorization: `Bearer ${login.json().token}` };
    125     assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 200);
    126 
    127     await h.app.inject({
    128       method: 'PATCH',
    129       url: `/api/admin/users/${id}`,
    130       headers: json(admin),
    131       payload: JSON.stringify({ disabled: true }),
    132     });
    133 
    134     // The token has not expired, but the account is checked on every call.
    135     assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 401);
    136   });
    137 });
    138 
    139 test('the last administrator cannot be removed or demoted', async () => {
    140   await withAdmin({}, async (h, admin) => {
    141     const me = (await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: admin })).json().user;
    142 
    143     const demote = await h.app.inject({
    144       method: 'PATCH',
    145       url: `/api/admin/users/${me.id}`,
    146       headers: json(admin),
    147       payload: JSON.stringify({ role: 'viewer' }),
    148     });
    149     assert.equal(demote.statusCode, 400);
    150     assert.match(demote.json().error, /only administrator/);
    151 
    152     const removed = await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${me.id}`, headers: admin });
    153     assert.equal(removed.statusCode, 400);
    154   });
    155 });
    156 
    157 test('projects can be created, listed and removed, and the secret is shown once', async () => {
    158   await withAdmin({}, async (h, admin) => {
    159     const created = await h.app.inject({
    160       method: 'POST',
    161       url: '/api/projects',
    162       headers: json(admin),
    163       payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }),
    164     });
    165     assert.equal(created.statusCode, 201);
    166     const secret = created.json().trigger_secret;
    167     assert.ok(secret && secret.length >= 32);
    168 
    169     const listed = await h.app.inject({ method: 'GET', url: '/api/projects', headers: admin });
    170     const project = listed.json().projects.find((p) => p.id === 'newproj');
    171     assert.equal(project.has_trigger_secret, true);
    172     // Listing must never return the value itself.
    173     assert.equal(project.trigger_secret, undefined);
    174     assert.ok(!JSON.stringify(listed.json()).includes(secret));
    175 
    176     const deleted = await h.app.inject({ method: 'DELETE', url: '/api/projects/newproj', headers: admin });
    177     assert.equal(deleted.statusCode, 200);
    178   });
    179 });
    180 
    181 test('a rotated trigger secret actually signs triggers', async () => {
    182   await withAdmin({}, async (h, admin) => {
    183     const rotated = await h.app.inject({
    184       method: 'POST',
    185       url: '/api/projects/demo/trigger-secret',
    186       headers: json(admin),
    187       payload: JSON.stringify({ secret: 'rotated-secret' }),
    188     });
    189     assert.equal(rotated.statusCode, 200);
    190 
    191     // The old secret stops working, the new one starts.
    192     const old = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'test-secret' });
    193     assert.equal(old.statusCode, 401);
    194 
    195     const fresh = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'rotated-secret' });
    196     assert.equal(fresh.statusCode, 200);
    197   });
    198 });
    199 
    200 test('worker tokens are issued once and can be revoked', async () => {
    201   await withAdmin({}, async (h, admin) => {
    202     const created = await h.app.inject({
    203       method: 'POST',
    204       url: '/api/worker-tokens',
    205       headers: json(admin),
    206       payload: JSON.stringify({ name: 'builder-2' }),
    207     });
    208     assert.equal(created.statusCode, 201);
    209     const { token, worker_token: record } = created.json();
    210 
    211     // It works.
    212     const poll = await h.app.inject({
    213       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` },
    214     });
    215     assert.notEqual(poll.statusCode, 401);
    216 
    217     // It is never listed again.
    218     const listed = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: admin });
    219     assert.ok(!JSON.stringify(listed.json()).includes(token));
    220 
    221     // Disabling it takes effect at once.
    222     await h.app.inject({
    223       method: 'PATCH',
    224       url: `/api/worker-tokens/${record.id}`,
    225       headers: json(admin),
    226       payload: JSON.stringify({ enabled: false }),
    227     });
    228     const after = await h.app.inject({
    229       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` },
    230     });
    231     assert.equal(after.statusCode, 401);
    232   });
    233 });
    234 
    235 test('project variables reach a job environment and are never listed', async () => {
    236   await withAdmin({}, async (h, admin) => {
    237     const set = await h.app.inject({
    238       method: 'PUT',
    239       url: '/api/projects/demo/variables/DEPLOY_TOKEN',
    240       headers: json(admin),
    241       payload: JSON.stringify({ value: 'super-secret-value', masked: true }),
    242     });
    243     assert.equal(set.statusCode, 200);
    244 
    245     const listed = await h.app.inject({
    246       method: 'GET', url: '/api/projects/demo/variables', headers: admin,
    247     });
    248     const listing = listed.json().variables;
    249     assert.equal(listing[0].name, 'DEPLOY_TOKEN');
    250     assert.equal(listing[0].masked, true);
    251     // The value must not come back out.
    252     assert.ok(!JSON.stringify(listing).includes('super-secret-value'));
    253 
    254     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    255     const claimed = await h.poll({});
    256     const job = claimed.json().job;
    257 
    258     assert.equal(job.env.DEPLOY_TOKEN, 'super-secret-value');
    259     assert.deepEqual(job.masked, ['super-secret-value']);
    260   });
    261 });
    262 
    263 test('a variable is encrypted at rest and bound to its project and name', async () => {
    264   await withAdmin({}, async (h, admin) => {
    265     await h.app.inject({
    266       method: 'PUT',
    267       url: '/api/projects/demo/variables/TOKEN',
    268       headers: json(admin),
    269       payload: JSON.stringify({ value: 'rest-secret-value' }),
    270     });
    271 
    272     const row = await h.services.db.get(
    273       'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}',
    274       { p: 'demo', n: 'TOKEN' }
    275     );
    276     assert.ok(row.value.startsWith('v1.'), 'expected an encrypted value');
    277     assert.ok(!row.value.includes('rest-secret-value'));
    278 
    279     // The same ciphertext under a different name must not open.
    280     await h.services.db.run(
    281       `INSERT INTO project_variables (project_id, name, value, masked, created_at)
    282        VALUES ({p}, {n}, {v}, 1, {t})`,
    283       { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() }
    284     );
    285     const resolved = await h.services.variables.resolve('demo');
    286     assert.equal(resolved.env.TOKEN, 'rest-secret-value');
    287     assert.equal(resolved.env.MOVED, undefined, 'a relocated ciphertext must not open');
    288   });
    289 });
    290 
    291 test('a pipeline setting wins over a project variable of the same name', async () => {
    292   const pipeline = `
    293 version: 1
    294 jobs:
    295   a:
    296     image: alpine
    297     script: ['true']
    298     env:
    299       SHARED: from-pipeline
    300 `;
    301   await withAdmin({ pipeline }, async (h, admin) => {
    302     await h.app.inject({
    303       method: 'PUT',
    304       url: '/api/projects/demo/variables/SHARED',
    305       headers: json(admin),
    306       payload: JSON.stringify({ value: 'from-variable' }),
    307     });
    308 
    309     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    310     const job = (await h.poll({})).json().job;
    311     assert.equal(job.env.SHARED, 'from-pipeline');
    312   });
    313 });
    314 
    315 test('a masked variable is redacted from ingested logs', async () => {
    316   await withAdmin({}, async (h, admin) => {
    317     await h.app.inject({
    318       method: 'PUT',
    319       url: '/api/projects/demo/variables/LEAKY',
    320       headers: json(admin),
    321       payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }),
    322     });
    323 
    324     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    325     const job = (await h.poll({})).json().job;
    326 
    327     // A worker that does not mask still must not get the secret onto disk.
    328     await h.app.inject({
    329       method: 'POST',
    330       url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`,
    331       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    332       payload: Buffer.from('echo leaked-secret-value here\n'),
    333     });
    334 
    335     const log = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` });
    336     assert.ok(!log.body.includes('leaked-secret-value'), `log leaked: ${log.body}`);
    337     assert.match(log.body, /\[masked\]/);
    338   });
    339 });
    340 
    341 test('a run can be cancelled and retried through the api', async () => {
    342   await withAdmin({}, async (h, admin) => {
    343     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
    344 
    345     const cancelled = await h.app.inject({
    346       method: 'POST', url: `/api/runs/${run}/cancel`, headers: json(admin), payload: '{}',
    347     });
    348     assert.equal(cancelled.statusCode, 200);
    349 
    350     const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` });
    351     assert.equal(detail.json().run.state, 'cancelled');
    352 
    353     const retried = await h.app.inject({
    354       method: 'POST', url: `/api/runs/${run}/retry`, headers: json(admin), payload: '{}',
    355     });
    356     assert.equal(retried.statusCode, 201);
    357     assert.notEqual(retried.json().run_id, run);
    358 
    359     const fresh = await h.app.inject({ method: 'GET', url: `/api/runs/${retried.json().run_id}` });
    360     assert.equal(fresh.json().run.state, 'running');
    361     assert.equal(fresh.json().run.head_sha, h.sha);
    362   });
    363 });
    364 
    365 test('with oidc configured, local login is refused unless allowed', async () => {
    366   const h = await startHarness({ oidcIssuer: 'https://idp.example.com/realms/ci', bootstrap: true });
    367   try {
    368     assert.equal(h.cfg.auth.mode, 'oidc');
    369     const res = await h.app.inject({
    370       method: 'POST',
    371       url: '/api/auth/login',
    372       headers: { 'content-type': 'application/json' },
    373       payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
    374     });
    375     assert.equal(res.statusCode, 400);
    376     assert.match(res.json().error, /OIDC/);
    377 
    378     const mode = await h.app.inject({ method: 'GET', url: '/api/auth/mode' });
    379     assert.equal(mode.json().mode, 'oidc');
    380     assert.equal(mode.json().local_login, false);
    381   } finally {
    382     await h.stop();
    383   }
    384 });
    385 
    386 test('local login can be kept as a break-glass account alongside oidc', async () => {
    387   const h = await startHarness({
    388     oidcIssuer: 'https://idp.example.com/realms/ci',
    389     allowLocalLogin: true,
    390     bootstrap: true,
    391   });
    392   try {
    393     const headers = await h.login();
    394     const res = await h.app.inject({ method: 'GET', url: '/api/projects', headers });
    395     assert.equal(res.statusCode, 200);
    396   } finally {
    397     await h.stop();
    398   }
    399 });