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 });