conductor

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

visibility.test.js.disabled (13485B)


      1 // test/visibility.test.js - who can see and run what
      2 //
      3 // Two rules are enforced here, and both matter for letting strangers use
      4 // the same installation:
      5 //
      6 //   A run is private unless its project or its pipeline says otherwise.
      7 //   An anonymous visitor sees only public runs; a user additionally sees
      8 //   their own; an administrator sees everything.
      9 //
     10 //   A worker registered by a user is only ever offered that user's jobs.
     11 
     12 import test from 'node:test';
     13 import assert from 'node:assert/strict';
     14 import { startHarness } from './helpers/harness.js';
     15 
     16 async function withHarness(options, fn) {
     17   const h = await startHarness({ bootstrap: true, ...options });
     18   try {
     19     return await fn(h);
     20   } finally {
     21     await h.stop();
     22   }
     23 }
     24 
     25 // Creates a user and returns their credentials and identity.
     26 async function addUser(h, admin, username, role = 'viewer') {
     27   const created = await h.app.inject({
     28     method: 'POST',
     29     url: '/api/admin/users',
     30     headers: { ...admin, 'content-type': 'application/json' },
     31     payload: JSON.stringify({ username, password: `${username}-password`, role }),
     32   });
     33   const user = created.json().user;
     34 
     35   const login = await h.app.inject({
     36     method: 'POST',
     37     url: '/api/auth/login',
     38     headers: { 'content-type': 'application/json' },
     39     payload: JSON.stringify({ username, password: `${username}-password` }),
     40   });
     41   return { user, headers: { cookie: login.headers['set-cookie'].split(';')[0] } };
     42 }
     43 
     44 const runIds = (res) => res.json().runs.map((r) => r.id);
     45 
     46 test('a private run is invisible to anonymous callers', async () => {
     47   await withHarness({ visibility: 'private' }, async (h) => {
     48     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
     49 
     50     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), []);
     51     // The same answer as a run that does not exist, so nothing is leaked
     52     // by the status code.
     53     assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 404);
     54   });
     55 });
     56 
     57 test('a public run is visible to anonymous callers', async () => {
     58   await withHarness({ visibility: 'public' }, async (h) => {
     59     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
     60 
     61     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [run]);
     62     assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 200);
     63   });
     64 });
     65 
     66 test('the pipeline may override the project visibility', async () => {
     67   const pipeline = `
     68 version: 1
     69 visibility: public
     70 jobs:
     71   a:
     72     image: alpine
     73     script: ['true']
     74 `;
     75   await withHarness({ pipeline, visibility: 'private' }, async (h) => {
     76     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
     77 
     78     // The project is private, but this commit declared itself public.
     79     const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` });
     80     assert.equal(detail.statusCode, 200);
     81     assert.equal(detail.json().run.visibility, 'public');
     82   });
     83 });
     84 
     85 test('visibility is recorded per run, so a later commit can change it', async () => {
     86   const open = "version: 1\nvisibility: public\njobs:\n  a: { image: alpine, script: ['true'] }\n";
     87   await withHarness({ pipeline: open, visibility: 'private' }, async (h) => {
     88     const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
     89 
     90     const closed = "version: 1\nvisibility: private\njobs:\n  a: { image: alpine, script: ['true'] }\n";
     91     const sha2 = await h.commit({ '.conductor.yml': closed }, 'go private');
     92     const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json().run_id;
     93 
     94     // The old run stays public; the new one does not.
     95     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [first]);
     96     assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${second}` })).statusCode, 404);
     97   });
     98 });
     99 
    100 test('an owner sees their private runs, a stranger does not', async () => {
    101   await withHarness({ visibility: 'private' }, async (h) => {
    102     const admin = await h.login();
    103     const owner = await addUser(h, admin, 'owner');
    104     const stranger = await addUser(h, admin, 'stranger');
    105 
    106     await h.services.projects.setOwner('demo', owner.user.id);
    107     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
    108 
    109     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: owner.headers })), [run]);
    110     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: stranger.headers })), []);
    111     // An administrator sees everything.
    112     assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: admin })), [run]);
    113   });
    114 });
    115 
    116 test('logs and artifacts of a private run are not readable by a stranger', async () => {
    117   await withHarness({ visibility: 'private' }, async (h) => {
    118     const admin = await h.login();
    119     const owner = await addUser(h, admin, 'owner');
    120     await h.services.projects.setOwner('demo', owner.user.id);
    121 
    122     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    123     const job = (await h.poll({})).json().job;
    124 
    125     await h.app.inject({
    126       method: 'POST',
    127       url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`,
    128       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    129       payload: Buffer.from('private build output\n'),
    130     });
    131     await h.app.inject({
    132       method: 'POST',
    133       url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`,
    134       headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'secret.bin' },
    135       payload: Buffer.from('private artifact'),
    136     });
    137 
    138     const jobUrl = `/api/jobs/${encodeURIComponent(job.id)}`;
    139     assert.equal((await h.app.inject({ method: 'GET', url: jobUrl })).statusCode, 404);
    140     assert.equal((await h.app.inject({ method: 'GET', url: `${jobUrl}/log` })).statusCode, 404);
    141 
    142     const asOwner = await h.app.inject({ method: 'GET', url: jobUrl, headers: owner.headers });
    143     assert.equal(asOwner.statusCode, 200);
    144 
    145     const artifactId = asOwner.json().artifacts[0].id;
    146     assert.equal((await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}` })).statusCode, 404);
    147     const download = await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}`, headers: owner.headers });
    148     assert.equal(download.body, 'private artifact');
    149   });
    150 });
    151 
    152 test("a worker belonging to a user only receives that owner's projects", async () => {
    153   await withHarness({ visibility: 'private' }, async (h) => {
    154     const admin = await h.login();
    155     const alice = await addUser(h, admin, 'alice');
    156     const bob = await addUser(h, admin, 'bob');
    157 
    158     // demo belongs to alice.
    159     await h.services.projects.setOwner('demo', alice.user.id);
    160 
    161     const aliceWorker = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
    162     const bobWorker = await h.services.workerTokens.create('bob-pi', { ownerId: bob.user.id });
    163 
    164     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    165 
    166     // Bob's worker must never see alice's work.
    167     const forBob = await h.app.inject({
    168       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${bobWorker.token}` },
    169     });
    170     assert.equal(forBob.statusCode, 204);
    171 
    172     const forAlice = await h.app.inject({
    173       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${aliceWorker.token}` },
    174     });
    175     assert.equal(forAlice.statusCode, 200);
    176     assert.equal(forAlice.json().job.project_id, 'demo');
    177   });
    178 });
    179 
    180 test('a shared worker receives work from any project', async () => {
    181   await withHarness({ visibility: 'private' }, async (h) => {
    182     const admin = await h.login();
    183     const alice = await addUser(h, admin, 'alice');
    184     await h.services.projects.setOwner('demo', alice.user.id);
    185 
    186     // No owner means shared capacity.
    187     const shared = await h.services.workerTokens.create('shared', { ownerId: null });
    188     await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    189 
    190     const res = await h.app.inject({
    191       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${shared.token}` },
    192     });
    193     assert.equal(res.statusCode, 200);
    194   });
    195 });
    196 
    197 test("a user registers their own project and worker, and cannot touch another's", async () => {
    198   await withHarness({}, async (h) => {
    199     const admin = await h.login();
    200     const alice = await addUser(h, admin, 'alice');
    201     const bob = await addUser(h, admin, 'bob');
    202     const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
    203 
    204     const created = await h.app.inject({
    205       method: 'POST',
    206       url: '/api/projects',
    207       headers: json(alice.headers),
    208       payload: JSON.stringify({ id: 'alice-app', repo_url: 'https://git.example.com/a.git' }),
    209     });
    210     assert.equal(created.statusCode, 201);
    211     assert.equal(created.json().project.owner_id, alice.user.id);
    212 
    213     // Bob cannot see or manage it.
    214     const bobList = await h.app.inject({ method: 'GET', url: '/api/projects', headers: bob.headers });
    215     assert.deepEqual(bobList.json().projects.map((p) => p.id), []);
    216     assert.equal((await h.app.inject({
    217       method: 'DELETE', url: '/api/projects/alice-app', headers: bob.headers,
    218     })).statusCode, 403);
    219 
    220     // A worker bob registers belongs to bob.
    221     const token = await h.app.inject({
    222       method: 'POST',
    223       url: '/api/worker-tokens',
    224       headers: json(bob.headers),
    225       payload: JSON.stringify({ name: 'bob-laptop' }),
    226     });
    227     assert.equal(token.json().worker_token.owner_id, bob.user.id);
    228     assert.equal(token.json().worker_token.shared, false);
    229 
    230     // And alice cannot see it.
    231     const aliceWorkers = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice.headers });
    232     assert.deepEqual(aliceWorkers.json().worker_tokens.map((t) => t.name), []);
    233   });
    234 });
    235 
    236 test('only an administrator can create shared capacity or reassign a project', async () => {
    237   await withHarness({}, async (h) => {
    238     const admin = await h.login();
    239     const alice = await addUser(h, admin, 'alice');
    240     const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
    241 
    242     // Asking for a shared worker as an ordinary user gets a personal one.
    243     const attempt = await h.app.inject({
    244       method: 'POST',
    245       url: '/api/worker-tokens',
    246       headers: json(alice.headers),
    247       payload: JSON.stringify({ name: 'sneaky', shared: true }),
    248     });
    249     assert.equal(attempt.json().worker_token.shared, false);
    250     assert.equal(attempt.json().worker_token.owner_id, alice.user.id);
    251 
    252     const asAdmin = await h.app.inject({
    253       method: 'POST',
    254       url: '/api/worker-tokens',
    255       headers: json(admin),
    256       payload: JSON.stringify({ name: 'pool', shared: true }),
    257     });
    258     assert.equal(asAdmin.json().worker_token.shared, true);
    259 
    260     // Reassigning an owner is administrator only.
    261     await h.services.projects.setOwner('demo', alice.user.id);
    262     const reassign = await h.app.inject({
    263       method: 'PATCH',
    264       url: '/api/projects/demo',
    265       headers: json(alice.headers),
    266       payload: JSON.stringify({ owner_id: null }),
    267     });
    268     assert.equal(reassign.statusCode, 400);
    269   });
    270 });
    271 
    272 test('deleting a user deletes what they owned, leaving nothing orphaned', async () => {
    273   await withHarness({}, async (h) => {
    274     const admin = await h.login();
    275     const alice = await addUser(h, admin, 'alice');
    276     await h.services.projects.setOwner('demo', alice.user.id);
    277     const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
    278     const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
    279 
    280     // The caller is told what will go before it goes.
    281     const impact = await h.app.inject({
    282       method: 'GET', url: `/api/admin/users/${alice.user.id}/impact`, headers: admin,
    283     });
    284     assert.deepEqual(impact.json().projects, ['demo']);
    285     assert.equal(impact.json().worker_tokens_deleted, 1);
    286     assert.ok(impact.json().runs_deleted > 0);
    287 
    288     const removed = await h.app.inject({
    289       method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin,
    290     });
    291     assert.equal(removed.statusCode, 200);
    292     assert.deepEqual(removed.json().projects, ['demo']);
    293 
    294     // Nothing of theirs is left behind.
    295     assert.equal(await h.services.projects.get('demo'), undefined);
    296     assert.equal(await h.services.db.get('SELECT id FROM runs WHERE id = {id}', { id: run }), undefined);
    297     assert.equal(await h.services.workerTokens.get(token.id), undefined);
    298   });
    299 });
    300 
    301 test('an orphaned worker token cannot appear and quietly become shared', async () => {
    302   await withHarness({}, async (h) => {
    303     const admin = await h.login();
    304     const alice = await addUser(h, admin, 'alice');
    305     const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
    306 
    307     await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin });
    308 
    309     // Were the row merely unowned, this token would now accept any
    310     // project's jobs instead of none.
    311     const rows = await h.services.db.all('SELECT id, owner_id FROM worker_tokens WHERE id = {id}', { id: token.id });
    312     assert.deepEqual(rows, []);
    313 
    314     const poll = await h.app.inject({
    315       method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token.token}` },
    316     });
    317     assert.equal(poll.statusCode, 401);
    318   });
    319 });