conductor

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

admin.test.js.disabled (14272B)


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