conductor

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

commit e389cb209dfe2728f5f1d0fde102099a77f58aff
parent 1a5080ded7478813abcf176ecc86ee6c9fbcfcf7
Author: finwo <finwo@pm.me>
Date:   Sun, 27 Sep 2026 19:42:50 +0200

Cover owner-scoped worker dispatch and what deleting a user takes with it

Diffstat:
Atest/ownership.test.js | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtest/visibility.test.js.disabled | 319-------------------------------------------------------------------------------
2 files changed, 218 insertions(+), 319 deletions(-)

diff --git a/test/ownership.test.js b/test/ownership.test.js @@ -0,0 +1,218 @@ +// test/ownership.test.js - who owns what, and what that decides + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness, data, apiError } from './helpers/harness.js'; + +async function withHarness(options, fn) { + const h = await startHarness({ bootstrap: true, ...options }); + try { + await fn(h); + } finally { + await h.stop(); + } +} + +const json = { 'content-type': 'application/json' }; + +async function addUser(h, admin, username, role = 'user') { + const password = `${username}-password`; + const created = await h.app.inject({ + method: 'POST', + url: '/api/v1/users', + headers: { ...admin, ...json }, + payload: JSON.stringify({ username, password, role }), + }); + assert.equal(created.statusCode, 201, created.body); + + const grant = await h.login(username, password); + return { user: data(created), headers: grant.headers }; +} + +function claimAs(h, token, body = {}) { + return h.app.inject({ + method: 'POST', + url: '/api/v1/tasks/claim', + headers: { authorization: `Bearer ${token}`, ...json }, + payload: JSON.stringify(body), + }); +} + +async function queueWork(h) { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const jobId = data(res).job_id; + await h.waitForJob(jobId); + return jobId; +} + +test("a worker belonging to a user is not offered another user's work", async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const bob = await addUser(h, admin, 'bob'); + + await h.services.projects.setOwner('demo', alice.user.id); + const forAlice = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + const forBob = await h.services.workerTokens.create('bob-pi', { ownerId: bob.user.id }); + + await queueWork(h); + + assert.equal((await claimAs(h, forBob.token)).statusCode, 204); + + const claimed = await claimAs(h, forAlice.token); + assert.equal(claimed.statusCode, 200, claimed.body); + assert.equal(data(claimed).task.name, 'lint'); + }); +}); + +test('a worker with no owner is offered work from any project', async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + await h.services.projects.setOwner('demo', alice.user.id); + + const shared = await h.services.workerTokens.create('pool', { ownerId: null }); + await queueWork(h); + + const claimed = await claimAs(h, shared.token); + assert.equal(claimed.statusCode, 200, claimed.body); + }); +}); + +test('a project a user creates belongs to them and a stranger cannot touch it', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const bob = await addUser(h, admin, 'bob'); + + const created = await h.app.inject({ + method: 'POST', + url: '/api/v1/projects', + headers: { ...alice.headers, ...json }, + payload: JSON.stringify({ id: 'alice-app', repo_url: 'https://git.example.com/a.git' }), + }); + assert.equal(created.statusCode, 201, created.body); + assert.equal(data(created).owner_id, alice.user.id); + + const listed = await h.app.inject({ method: 'GET', url: '/api/v1/projects', headers: bob.headers }); + assert.ok(!data(listed).some((p) => p.id === 'alice-app')); + + const removed = await h.app.inject({ + method: 'DELETE', url: '/api/v1/projects/alice-app', headers: bob.headers, + }); + assert.equal(removed.statusCode, 404, removed.body); + }); +}); + +test('a worker a user registers is invisible to another user', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const bob = await addUser(h, admin, 'bob'); + + const token = await h.app.inject({ + method: 'POST', + url: '/api/v1/workers', + headers: { ...bob.headers, ...json }, + payload: JSON.stringify({ name: 'bob-laptop' }), + }); + assert.equal(token.statusCode, 201, token.body); + assert.equal(data(token).owner_id, bob.user.id); + + const listed = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: alice.headers }); + assert.deepEqual(data(listed).map((w) => w.name), []); + + const probed = await h.app.inject({ + method: 'GET', url: `/api/v1/workers/${data(token).id}`, headers: alice.headers, + }); + assert.equal(probed.statusCode, 404, probed.body); + }); +}); + +test('an ordinary user asking for shared capacity gets a personal worker', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + + const sneaky = await h.app.inject({ + method: 'POST', + url: '/api/v1/workers', + headers: { ...alice.headers, ...json }, + payload: JSON.stringify({ name: 'sneaky', owner_id: null }), + }); + assert.equal(data(sneaky).owner_id, alice.user.id); + + const pool = await h.app.inject({ + method: 'POST', + url: '/api/v1/workers', + headers: { ...admin, ...json }, + payload: JSON.stringify({ name: 'pool', owner_id: null }), + }); + assert.equal(data(pool).owner_id, null); + }); +}); + +test('only an administrator can reassign a project', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + await h.services.projects.setOwner('demo', alice.user.id); + + const refused = await h.app.inject({ + method: 'PATCH', + url: '/api/v1/projects/demo', + headers: { ...alice.headers, ...json }, + payload: JSON.stringify({ owner_id: null }), + }); + assert.equal(refused.statusCode, 403, refused.body); + assert.equal(apiError(refused).name, 'NotPermitted'); + + const allowed = await h.app.inject({ + method: 'PATCH', + url: '/api/v1/projects/demo', + headers: { ...admin, ...json }, + payload: JSON.stringify({ owner_id: null }), + }); + assert.equal(allowed.statusCode, 200, allowed.body); + assert.equal(data(allowed).owner_id, null); + }); +}); + +test('deleting a user takes their projects and workers with them', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + + await h.services.projects.setOwner('demo', alice.user.id); + const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + const jobId = await queueWork(h); + + const removed = await h.app.inject({ + method: 'DELETE', url: `/api/v1/users/${alice.user.id}`, headers: admin, + }); + assert.equal(removed.statusCode, 200, removed.body); + + assert.equal(await h.services.projects.get('demo'), undefined); + assert.equal(await h.services.workerTokens.get(token.id), undefined); + assert.equal(await h.services.db.get('SELECT id FROM jobs WHERE id = {id}', { id: jobId }), undefined); + }); +}); + +test('a worker token does not outlive its owner and become shared capacity', async () => { + await withHarness({}, async (h) => { + const { headers: admin } = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + + await h.app.inject({ method: 'DELETE', url: `/api/v1/users/${alice.user.id}`, headers: admin }); + + const rows = await h.services.db.all( + 'SELECT id FROM worker_tokens WHERE id = {id}', { id: token.id } + ); + assert.deepEqual(rows, []); + + const claimed = await claimAs(h, token.token); + assert.equal(claimed.statusCode, 401, claimed.body); + assert.equal(apiError(claimed).name, 'InvalidWorkerToken'); + }); +}); diff --git a/test/visibility.test.js.disabled b/test/visibility.test.js.disabled @@ -1,319 +0,0 @@ -// test/visibility.test.js - who can see and run what -// -// Two rules are enforced here, and both matter for letting strangers use -// the same installation: -// -// A run is private unless its project or its pipeline says otherwise. -// An anonymous visitor sees only public runs; a user additionally sees -// their own; an administrator sees everything. -// -// A worker registered by a user is only ever offered that user's jobs. - -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { startHarness } from './helpers/harness.js'; - -async function withHarness(options, fn) { - const h = await startHarness({ bootstrap: true, ...options }); - try { - return await fn(h); - } finally { - await h.stop(); - } -} - -// Creates a user and returns their credentials and identity. -async function addUser(h, admin, username, role = 'viewer') { - const created = await h.app.inject({ - method: 'POST', - url: '/api/admin/users', - headers: { ...admin, 'content-type': 'application/json' }, - payload: JSON.stringify({ username, password: `${username}-password`, role }), - }); - const user = created.json().user; - - const login = await h.app.inject({ - method: 'POST', - url: '/api/auth/login', - headers: { 'content-type': 'application/json' }, - payload: JSON.stringify({ username, password: `${username}-password` }), - }); - return { user, headers: { cookie: login.headers['set-cookie'].split(';')[0] } }; -} - -const runIds = (res) => res.json().runs.map((r) => r.id); - -test('a private run is invisible to anonymous callers', async () => { - await withHarness({ visibility: 'private' }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), []); - // The same answer as a run that does not exist, so nothing is leaked - // by the status code. - assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 404); - }); -}); - -test('a public run is visible to anonymous callers', async () => { - await withHarness({ visibility: 'public' }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [run]); - assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 200); - }); -}); - -test('the pipeline may override the project visibility', async () => { - const pipeline = ` -version: 1 -visibility: public -jobs: - a: - image: alpine - script: ['true'] -`; - await withHarness({ pipeline, visibility: 'private' }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - // The project is private, but this commit declared itself public. - const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` }); - assert.equal(detail.statusCode, 200); - assert.equal(detail.json().run.visibility, 'public'); - }); -}); - -test('visibility is recorded per run, so a later commit can change it', async () => { - const open = "version: 1\nvisibility: public\njobs:\n a: { image: alpine, script: ['true'] }\n"; - await withHarness({ pipeline: open, visibility: 'private' }, async (h) => { - const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - const closed = "version: 1\nvisibility: private\njobs:\n a: { image: alpine, script: ['true'] }\n"; - const sha2 = await h.commit({ '.conductor.yml': closed }, 'go private'); - const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json().run_id; - - // The old run stays public; the new one does not. - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [first]); - assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${second}` })).statusCode, 404); - }); -}); - -test('an owner sees their private runs, a stranger does not', async () => { - await withHarness({ visibility: 'private' }, async (h) => { - const admin = await h.login(); - const owner = await addUser(h, admin, 'owner'); - const stranger = await addUser(h, admin, 'stranger'); - - await h.services.projects.setOwner('demo', owner.user.id); - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: owner.headers })), [run]); - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: stranger.headers })), []); - // An administrator sees everything. - assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: admin })), [run]); - }); -}); - -test('logs and artifacts of a private run are not readable by a stranger', async () => { - await withHarness({ visibility: 'private' }, async (h) => { - const admin = await h.login(); - const owner = await addUser(h, admin, 'owner'); - await h.services.projects.setOwner('demo', owner.user.id); - - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({})).json().job; - - await h.app.inject({ - method: 'POST', - url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, - headers: { ...h.auth, 'content-type': 'application/octet-stream' }, - payload: Buffer.from('private build output\n'), - }); - await h.app.inject({ - method: 'POST', - url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, - headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'secret.bin' }, - payload: Buffer.from('private artifact'), - }); - - const jobUrl = `/api/jobs/${encodeURIComponent(job.id)}`; - assert.equal((await h.app.inject({ method: 'GET', url: jobUrl })).statusCode, 404); - assert.equal((await h.app.inject({ method: 'GET', url: `${jobUrl}/log` })).statusCode, 404); - - const asOwner = await h.app.inject({ method: 'GET', url: jobUrl, headers: owner.headers }); - assert.equal(asOwner.statusCode, 200); - - const artifactId = asOwner.json().artifacts[0].id; - assert.equal((await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}` })).statusCode, 404); - const download = await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}`, headers: owner.headers }); - assert.equal(download.body, 'private artifact'); - }); -}); - -test("a worker belonging to a user only receives that owner's projects", async () => { - await withHarness({ visibility: 'private' }, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - const bob = await addUser(h, admin, 'bob'); - - // demo belongs to alice. - await h.services.projects.setOwner('demo', alice.user.id); - - const aliceWorker = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); - const bobWorker = await h.services.workerTokens.create('bob-pi', { ownerId: bob.user.id }); - - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - - // Bob's worker must never see alice's work. - const forBob = await h.app.inject({ - method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${bobWorker.token}` }, - }); - assert.equal(forBob.statusCode, 204); - - const forAlice = await h.app.inject({ - method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${aliceWorker.token}` }, - }); - assert.equal(forAlice.statusCode, 200); - assert.equal(forAlice.json().job.project_id, 'demo'); - }); -}); - -test('a shared worker receives work from any project', async () => { - await withHarness({ visibility: 'private' }, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - await h.services.projects.setOwner('demo', alice.user.id); - - // No owner means shared capacity. - const shared = await h.services.workerTokens.create('shared', { ownerId: null }); - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - - const res = await h.app.inject({ - method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${shared.token}` }, - }); - assert.equal(res.statusCode, 200); - }); -}); - -test("a user registers their own project and worker, and cannot touch another's", async () => { - await withHarness({}, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - const bob = await addUser(h, admin, 'bob'); - const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); - - const created = await h.app.inject({ - method: 'POST', - url: '/api/projects', - headers: json(alice.headers), - payload: JSON.stringify({ id: 'alice-app', repo_url: 'https://git.example.com/a.git' }), - }); - assert.equal(created.statusCode, 201); - assert.equal(created.json().project.owner_id, alice.user.id); - - // Bob cannot see or manage it. - const bobList = await h.app.inject({ method: 'GET', url: '/api/projects', headers: bob.headers }); - assert.deepEqual(bobList.json().projects.map((p) => p.id), []); - assert.equal((await h.app.inject({ - method: 'DELETE', url: '/api/projects/alice-app', headers: bob.headers, - })).statusCode, 403); - - // A worker bob registers belongs to bob. - const token = await h.app.inject({ - method: 'POST', - url: '/api/worker-tokens', - headers: json(bob.headers), - payload: JSON.stringify({ name: 'bob-laptop' }), - }); - assert.equal(token.json().worker_token.owner_id, bob.user.id); - assert.equal(token.json().worker_token.shared, false); - - // And alice cannot see it. - const aliceWorkers = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice.headers }); - assert.deepEqual(aliceWorkers.json().worker_tokens.map((t) => t.name), []); - }); -}); - -test('only an administrator can create shared capacity or reassign a project', async () => { - await withHarness({}, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); - - // Asking for a shared worker as an ordinary user gets a personal one. - const attempt = await h.app.inject({ - method: 'POST', - url: '/api/worker-tokens', - headers: json(alice.headers), - payload: JSON.stringify({ name: 'sneaky', shared: true }), - }); - assert.equal(attempt.json().worker_token.shared, false); - assert.equal(attempt.json().worker_token.owner_id, alice.user.id); - - const asAdmin = await h.app.inject({ - method: 'POST', - url: '/api/worker-tokens', - headers: json(admin), - payload: JSON.stringify({ name: 'pool', shared: true }), - }); - assert.equal(asAdmin.json().worker_token.shared, true); - - // Reassigning an owner is administrator only. - await h.services.projects.setOwner('demo', alice.user.id); - const reassign = await h.app.inject({ - method: 'PATCH', - url: '/api/projects/demo', - headers: json(alice.headers), - payload: JSON.stringify({ owner_id: null }), - }); - assert.equal(reassign.statusCode, 400); - }); -}); - -test('deleting a user deletes what they owned, leaving nothing orphaned', async () => { - await withHarness({}, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - await h.services.projects.setOwner('demo', alice.user.id); - const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - // The caller is told what will go before it goes. - const impact = await h.app.inject({ - method: 'GET', url: `/api/admin/users/${alice.user.id}/impact`, headers: admin, - }); - assert.deepEqual(impact.json().projects, ['demo']); - assert.equal(impact.json().worker_tokens_deleted, 1); - assert.ok(impact.json().runs_deleted > 0); - - const removed = await h.app.inject({ - method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin, - }); - assert.equal(removed.statusCode, 200); - assert.deepEqual(removed.json().projects, ['demo']); - - // Nothing of theirs is left behind. - assert.equal(await h.services.projects.get('demo'), undefined); - assert.equal(await h.services.db.get('SELECT id FROM runs WHERE id = {id}', { id: run }), undefined); - assert.equal(await h.services.workerTokens.get(token.id), undefined); - }); -}); - -test('an orphaned worker token cannot appear and quietly become shared', async () => { - await withHarness({}, async (h) => { - const admin = await h.login(); - const alice = await addUser(h, admin, 'alice'); - const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); - - await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin }); - - // Were the row merely unowned, this token would now accept any - // project's jobs instead of none. - const rows = await h.services.db.all('SELECT id, owner_id FROM worker_tokens WHERE id = {id}', { id: token.id }); - assert.deepEqual(rows, []); - - const poll = await h.app.inject({ - method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token.token}` }, - }); - assert.equal(poll.statusCode, 401); - }); -});