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