conductor

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

ownership.test.js (12121B)


      1 // test/ownership.test.js - who owns what, and what that decides
      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 withHarness(options, fn) {
      8   const h = await startHarness({ bootstrap: true, ...options });
      9   try {
     10     await fn(h);
     11   } finally {
     12     await h.stop();
     13   }
     14 }
     15 
     16 const json = { 'content-type': 'application/json' };
     17 
     18 async function addUser(h, admin, username, role = 'user') {
     19   const password = `${username}-password`;
     20   const created = await h.app.inject({
     21     method: 'POST',
     22     url: '/api/v1/users',
     23     headers: { ...admin, ...json },
     24     payload: JSON.stringify({ username, password, role }),
     25   });
     26   assert.equal(created.statusCode, 201, created.body);
     27 
     28   const grant = await h.login(username, password);
     29   return { user: data(created), headers: grant.headers };
     30 }
     31 
     32 function claimAs(h, token, body = {}) {
     33   return h.app.inject({
     34     method: 'POST',
     35     url: '/api/v1/tasks/claim',
     36     headers: { authorization: `Bearer ${token}`, ...json },
     37     payload: JSON.stringify(body),
     38   });
     39 }
     40 
     41 async function queueWork(h) {
     42   const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
     43   const jobId = data(res).job_id;
     44   await h.waitForJob(jobId);
     45   return jobId;
     46 }
     47 
     48 test("a worker belonging to a user is not offered another user's work", async () => {
     49   await withHarness({ visibility: 'private' }, async (h) => {
     50     const { headers: admin } = await h.login();
     51     const alice = await addUser(h, admin, 'alice');
     52     const bob = await addUser(h, admin, 'bob');
     53 
     54     await h.services.projects.setOwner('demo', alice.user.id);
     55     const forAlice = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
     56     const forBob = await h.services.workerTokens.create('bob-pi', { ownerId: bob.user.id });
     57 
     58     await queueWork(h);
     59 
     60     assert.equal((await claimAs(h, forBob.token)).statusCode, 204);
     61 
     62     const claimed = await claimAs(h, forAlice.token);
     63     assert.equal(claimed.statusCode, 200, claimed.body);
     64     assert.equal(data(claimed).task.name, 'lint');
     65   });
     66 });
     67 
     68 test('a worker with no owner is offered work from any project', async () => {
     69   await withHarness({ visibility: 'private' }, async (h) => {
     70     const { headers: admin } = await h.login();
     71     const alice = await addUser(h, admin, 'alice');
     72     await h.services.projects.setOwner('demo', alice.user.id);
     73 
     74     const shared = await h.services.workerTokens.create('pool', { ownerId: null });
     75     await queueWork(h);
     76 
     77     const claimed = await claimAs(h, shared.token);
     78     assert.equal(claimed.statusCode, 200, claimed.body);
     79   });
     80 });
     81 
     82 test('a project a user creates belongs to them and a stranger cannot touch it', async () => {
     83   await withHarness({}, async (h) => {
     84     const { headers: admin } = await h.login();
     85     const alice = await addUser(h, admin, 'alice');
     86     const bob = await addUser(h, admin, 'bob');
     87 
     88     const created = await h.app.inject({
     89       method: 'POST',
     90       url: '/api/v1/projects',
     91       headers: { ...alice.headers, ...json },
     92       payload: JSON.stringify({ id: 'alice-app', repo_url: 'https://git.example.com/a.git' }),
     93     });
     94     assert.equal(created.statusCode, 201, created.body);
     95     assert.equal(data(created).owner_id, alice.user.id);
     96 
     97     const listed = await h.app.inject({ method: 'GET', url: '/api/v1/projects', headers: bob.headers });
     98     assert.ok(!data(listed).some((p) => p.id === 'alice-app'));
     99 
    100     const removed = await h.app.inject({
    101       method: 'DELETE', url: '/api/v1/projects/alice-app', headers: bob.headers,
    102     });
    103     assert.equal(removed.statusCode, 404, removed.body);
    104   });
    105 });
    106 
    107 test('a worker a user registers is invisible to another user', async () => {
    108   await withHarness({}, async (h) => {
    109     const { headers: admin } = await h.login();
    110     const alice = await addUser(h, admin, 'alice');
    111     const bob = await addUser(h, admin, 'bob');
    112 
    113     const token = await h.app.inject({
    114       method: 'POST',
    115       url: '/api/v1/workers',
    116       headers: { ...bob.headers, ...json },
    117       payload: JSON.stringify({ name: 'bob-laptop' }),
    118     });
    119     assert.equal(token.statusCode, 201, token.body);
    120     assert.equal(data(token).owner_id, bob.user.id);
    121 
    122     const listed = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: alice.headers });
    123     assert.deepEqual(data(listed).map((w) => w.name), []);
    124 
    125     const probed = await h.app.inject({
    126       method: 'GET', url: `/api/v1/workers/${data(token).id}`, headers: alice.headers,
    127     });
    128     assert.equal(probed.statusCode, 404, probed.body);
    129   });
    130 });
    131 
    132 test('an ordinary user asking for shared capacity gets a personal worker', async () => {
    133   await withHarness({}, async (h) => {
    134     const { headers: admin } = await h.login();
    135     const alice = await addUser(h, admin, 'alice');
    136 
    137     const sneaky = await h.app.inject({
    138       method: 'POST',
    139       url: '/api/v1/workers',
    140       headers: { ...alice.headers, ...json },
    141       payload: JSON.stringify({ name: 'sneaky', owner_id: null }),
    142     });
    143     assert.equal(data(sneaky).owner_id, alice.user.id);
    144 
    145     const pool = await h.app.inject({
    146       method: 'POST',
    147       url: '/api/v1/workers',
    148       headers: { ...admin, ...json },
    149       payload: JSON.stringify({ name: 'pool', owner_id: null }),
    150     });
    151     assert.equal(data(pool).owner_id, null);
    152   });
    153 });
    154 
    155 test('only an administrator can reassign a project', async () => {
    156   await withHarness({}, async (h) => {
    157     const { headers: admin } = await h.login();
    158     const alice = await addUser(h, admin, 'alice');
    159     await h.services.projects.setOwner('demo', alice.user.id);
    160 
    161     const refused = await h.app.inject({
    162       method: 'PATCH',
    163       url: '/api/v1/projects/demo',
    164       headers: { ...alice.headers, ...json },
    165       payload: JSON.stringify({ owner_id: null }),
    166     });
    167     assert.equal(refused.statusCode, 403, refused.body);
    168     assert.equal(apiError(refused).name, 'NotPermitted');
    169 
    170     const allowed = await h.app.inject({
    171       method: 'PATCH',
    172       url: '/api/v1/projects/demo',
    173       headers: { ...admin, ...json },
    174       payload: JSON.stringify({ owner_id: null }),
    175     });
    176     assert.equal(allowed.statusCode, 200, allowed.body);
    177     assert.equal(data(allowed).owner_id, null);
    178   });
    179 });
    180 
    181 test('deleting a user takes their projects and workers with them', async () => {
    182   await withHarness({}, async (h) => {
    183     const { headers: admin } = await h.login();
    184     const alice = await addUser(h, admin, 'alice');
    185 
    186     await h.services.projects.setOwner('demo', alice.user.id);
    187     const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
    188     const jobId = await queueWork(h);
    189 
    190     const removed = await h.app.inject({
    191       method: 'DELETE', url: `/api/v1/users/${alice.user.id}`, headers: admin,
    192     });
    193     assert.equal(removed.statusCode, 200, removed.body);
    194 
    195     assert.equal(await h.services.projects.get('demo'), undefined);
    196     assert.equal(await h.services.workerTokens.get(token.id), undefined);
    197     assert.equal(await h.services.db.get('SELECT id FROM jobs WHERE id = {id}', { id: jobId }), undefined);
    198   });
    199 });
    200 
    201 test('a worker token does not outlive its owner and become shared capacity', async () => {
    202   await withHarness({}, async (h) => {
    203     const { headers: admin } = await h.login();
    204     const alice = await addUser(h, admin, 'alice');
    205     const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id });
    206 
    207     await h.app.inject({ method: 'DELETE', url: `/api/v1/users/${alice.user.id}`, headers: admin });
    208 
    209     const rows = await h.services.db.all(
    210       'SELECT id FROM worker_tokens WHERE id = {id}', { id: token.id }
    211     );
    212     assert.deepEqual(rows, []);
    213 
    214     const claimed = await claimAs(h, token.token);
    215     assert.equal(claimed.statusCode, 401, claimed.body);
    216     assert.equal(apiError(claimed).name, 'InvalidWorkerToken');
    217   });
    218 });
    219 
    220 const valueUrl = (project, name) => `/api/v1/projects/${project}/variables/${name}/value`;
    221 
    222 test('an owner reads back a variable whether or not it is masked in logs', async () => {
    223   await withHarness({}, async (h) => {
    224     const { headers: admin } = await h.login();
    225     const alice = await addUser(h, admin, 'alice');
    226     await h.services.projects.setOwner('demo', alice.user.id);
    227 
    228     await h.services.variables.set('demo', 'HIDDEN', 'hidden-value', { masked: true });
    229     await h.services.variables.set('demo', 'SHOWN', 'shown-value', { masked: false });
    230 
    231     const hidden = await h.app.inject({ url: valueUrl('demo', 'HIDDEN'), headers: alice.headers });
    232     assert.equal(hidden.statusCode, 200, hidden.body);
    233     assert.equal(data(hidden).value, 'hidden-value');
    234     assert.equal(data(hidden).masked, true);
    235 
    236     const shown = await h.app.inject({ url: valueUrl('demo', 'SHOWN'), headers: alice.headers });
    237     assert.equal(data(shown).value, 'shown-value');
    238     assert.equal(data(shown).masked, false);
    239   });
    240 });
    241 
    242 test('the listing still withholds what the value endpoint hands over', async () => {
    243   await withHarness({}, async (h) => {
    244     const { headers: admin } = await h.login();
    245     await h.services.variables.set('demo', 'TOKEN', 'listing-secret-value', { masked: true });
    246 
    247     const listed = await h.app.inject({ url: '/api/v1/projects/demo/variables', headers: admin });
    248     assert.ok(!listed.body.includes('listing-secret-value'));
    249     assert.equal(data(listed)[0].name, 'TOKEN');
    250 
    251     const revealed = await h.app.inject({ url: valueUrl('demo', 'TOKEN'), headers: admin });
    252     assert.equal(data(revealed).value, 'listing-secret-value');
    253   });
    254 });
    255 
    256 test('an administrator reads a variable of a project they do not own', async () => {
    257   await withHarness({}, async (h) => {
    258     const { headers: admin } = await h.login();
    259     const alice = await addUser(h, admin, 'alice');
    260     await h.services.projects.setOwner('demo', alice.user.id);
    261     await h.services.variables.set('demo', 'TOKEN', 'admin-readable-value');
    262 
    263     const res = await h.app.inject({ url: valueUrl('demo', 'TOKEN'), headers: admin });
    264     assert.equal(res.statusCode, 200, res.body);
    265     assert.equal(data(res).value, 'admin-readable-value');
    266   });
    267 });
    268 
    269 test('a public project does not hand its variables to every signed in user', async () => {
    270   await withHarness({ visibility: 'public' }, async (h) => {
    271     const { headers: admin } = await h.login();
    272     const alice = await addUser(h, admin, 'alice');
    273     const bob = await addUser(h, admin, 'bob');
    274     await h.services.projects.setOwner('demo', alice.user.id);
    275     await h.services.variables.set('demo', 'TOKEN', 'not-for-bob-value');
    276 
    277     const refused = await h.app.inject({ url: valueUrl('demo', 'TOKEN'), headers: bob.headers });
    278     assert.equal(refused.statusCode, 403, refused.body);
    279     assert.equal(apiError(refused).name, 'NotPermitted');
    280     assert.ok(!refused.body.includes('not-for-bob-value'));
    281 
    282     const anonymous = await h.app.inject({ url: valueUrl('demo', 'TOKEN') });
    283     assert.equal(anonymous.statusCode, 403, anonymous.body);
    284     assert.ok(!anonymous.body.includes('not-for-bob-value'));
    285   });
    286 });
    287 
    288 test('a private project answers a variable read as though it were not there', async () => {
    289   await withHarness({ visibility: 'private' }, async (h) => {
    290     const { headers: admin } = await h.login();
    291     const alice = await addUser(h, admin, 'alice');
    292     const bob = await addUser(h, admin, 'bob');
    293     await h.services.projects.setOwner('demo', alice.user.id);
    294     await h.services.variables.set('demo', 'TOKEN', 'not-for-bob-value');
    295 
    296     const stranger = await h.app.inject({ url: valueUrl('demo', 'TOKEN'), headers: bob.headers });
    297     assert.equal(stranger.statusCode, 404, stranger.body);
    298 
    299     const anonymous = await h.app.inject({ url: valueUrl('demo', 'TOKEN') });
    300     assert.equal(anonymous.statusCode, 404, anonymous.body);
    301   });
    302 });
    303 
    304 test('reading a variable that was never set is a miss, not an empty value', async () => {
    305   await withHarness({}, async (h) => {
    306     const { headers: admin } = await h.login();
    307     const res = await h.app.inject({ url: valueUrl('demo', 'ABSENT'), headers: admin });
    308     assert.equal(res.statusCode, 404, res.body);
    309   });
    310 });