management.test.js (10859B)
1 // test/management.test.js - the authenticated management surface 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 withAdmin(options, fn) { 8 const h = await startHarness({ ...options, bootstrap: true }); 9 try { 10 const { headers } = await h.login(); 11 return await fn(h, headers); 12 } finally { 13 await h.stop(); 14 } 15 } 16 17 const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); 18 19 async function claimTask(h) { 20 const triggered = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 21 await h.waitForJob(data(triggered).job_id); 22 const claimed = await h.claim({}); 23 assert.equal(claimed.statusCode, 200, claimed.body); 24 return data(claimed).task; 25 } 26 27 test('a project is created with a trigger secret that is shown once', async () => { 28 await withAdmin({}, async (h, admin) => { 29 const created = await h.app.inject({ 30 method: 'POST', 31 url: '/api/v1/projects', 32 headers: json(admin), 33 payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }), 34 }); 35 assert.equal(created.statusCode, 201, created.body); 36 const secret = data(created).trigger_secret; 37 assert.ok(secret && secret.length >= 32); 38 39 const listed = await h.app.inject({ method: 'GET', url: '/api/v1/projects', headers: admin }); 40 const project = data(listed).find((p) => p.id === 'newproj'); 41 assert.equal(project.has_trigger_secret, true); 42 assert.equal(project.trigger_secret, undefined); 43 assert.ok(!listed.body.includes(secret)); 44 45 const removed = await h.app.inject({ 46 method: 'DELETE', url: '/api/v1/projects/newproj', headers: admin, 47 }); 48 assert.equal(removed.statusCode, 200, removed.body); 49 }); 50 }); 51 52 test('a rotated trigger secret replaces the one before it', async () => { 53 await withAdmin({}, async (h, admin) => { 54 const rotated = await h.app.inject({ 55 method: 'POST', url: '/api/v1/projects/demo/trigger-secret', headers: json(admin), payload: '{}', 56 }); 57 assert.equal(rotated.statusCode, 200, rotated.body); 58 const secret = data(rotated).trigger_secret; 59 60 const stale = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'test-secret' }); 61 assert.equal(stale.statusCode, 401, stale.body); 62 63 const fresh = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret }); 64 assert.equal(fresh.statusCode, 200, fresh.body); 65 await h.waitForJob(data(fresh).job_id); 66 }); 67 }); 68 69 test('a worker token is issued once and revoking it takes effect at once', async () => { 70 await withAdmin({}, async (h, admin) => { 71 const created = await h.app.inject({ 72 method: 'POST', 73 url: '/api/v1/workers', 74 headers: json(admin), 75 payload: JSON.stringify({ name: 'builder-2' }), 76 }); 77 assert.equal(created.statusCode, 201, created.body); 78 const { id, token } = data(created); 79 80 const claim = (t) => h.app.inject({ 81 method: 'POST', 82 url: '/api/v1/tasks/claim', 83 headers: { authorization: `Bearer ${t}`, 'content-type': 'application/json' }, 84 payload: '{}', 85 }); 86 87 assert.notEqual((await claim(token)).statusCode, 401); 88 89 const listed = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: admin }); 90 assert.ok(!listed.body.includes(token)); 91 92 const disabled = await h.app.inject({ 93 method: 'PATCH', 94 url: `/api/v1/workers/${id}`, 95 headers: json(admin), 96 payload: JSON.stringify({ enabled: false }), 97 }); 98 assert.equal(disabled.statusCode, 200, disabled.body); 99 100 const after = await claim(token); 101 assert.equal(after.statusCode, 401, after.body); 102 assert.equal(apiError(after).name, 'InvalidWorkerToken'); 103 }); 104 }); 105 106 test('a project variable reaches a task environment and is never listed', async () => { 107 await withAdmin({}, async (h, admin) => { 108 const set = await h.app.inject({ 109 method: 'PUT', 110 url: '/api/v1/projects/demo/variables/DEPLOY_TOKEN', 111 headers: json(admin), 112 payload: JSON.stringify({ value: 'super-secret-value', masked: true }), 113 }); 114 assert.equal(set.statusCode, 200, set.body); 115 116 const listed = await h.app.inject({ 117 method: 'GET', url: '/api/v1/projects/demo/variables', headers: admin, 118 }); 119 const listing = data(listed); 120 assert.equal(listing[0].name, 'DEPLOY_TOKEN'); 121 assert.equal(listing[0].masked, true); 122 assert.ok(!listed.body.includes('super-secret-value')); 123 124 const task = await claimTask(h); 125 assert.equal(task.env.DEPLOY_TOKEN, 'super-secret-value'); 126 assert.deepEqual(task.masked, ['super-secret-value']); 127 }); 128 }); 129 130 test('a variable is encrypted at rest and bound to its project and name', async () => { 131 await withAdmin({}, async (h, admin) => { 132 await h.app.inject({ 133 method: 'PUT', 134 url: '/api/v1/projects/demo/variables/TOKEN', 135 headers: json(admin), 136 payload: JSON.stringify({ value: 'rest-secret-value' }), 137 }); 138 139 const row = await h.services.db.get( 140 'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}', 141 { p: 'demo', n: 'TOKEN' } 142 ); 143 assert.ok(row.value.startsWith('v1.'), 'expected an encrypted value'); 144 assert.ok(!row.value.includes('rest-secret-value')); 145 146 await h.services.db.run( 147 `INSERT INTO project_variables (project_id, name, value, masked, created_at) 148 VALUES ({p}, {n}, {v}, 1, {t})`, 149 { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() } 150 ); 151 152 const resolved = await h.services.variables.resolve('demo'); 153 assert.equal(resolved.env.TOKEN, 'rest-secret-value'); 154 assert.equal(resolved.env.MOVED, undefined, 'a relocated ciphertext must not open'); 155 }); 156 }); 157 158 test('a value that will not open is reported as broken rather than absent', async () => { 159 await withAdmin({}, async (h, admin) => { 160 await h.app.inject({ 161 method: 'PUT', 162 url: '/api/v1/projects/demo/variables/TOKEN', 163 headers: json(admin), 164 payload: JSON.stringify({ value: 'unopenable-secret-value' }), 165 }); 166 167 const row = await h.services.db.get( 168 'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}', 169 { p: 'demo', n: 'TOKEN' } 170 ); 171 await h.services.db.run( 172 `INSERT INTO project_variables (project_id, name, value, masked, created_at) 173 VALUES ({p}, {n}, {v}, 1, {t})`, 174 { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() } 175 ); 176 177 const res = await h.app.inject({ 178 url: '/api/v1/projects/demo/variables/MOVED/value', headers: admin, 179 }); 180 assert.equal(res.statusCode, 500, res.body); 181 assert.ok(!res.body.includes('unopenable-secret-value')); 182 }); 183 }); 184 185 test('a pipeline setting wins over a project variable of the same name', async () => { 186 const pipeline = ` 187 version: 1 188 defaults: 189 image: debian:bookworm-slim 190 tasks: 191 a: 192 script: ['true'] 193 env: 194 SHARED: from-pipeline 195 `; 196 await withAdmin({ pipeline }, async (h, admin) => { 197 await h.app.inject({ 198 method: 'PUT', 199 url: '/api/v1/projects/demo/variables/SHARED', 200 headers: json(admin), 201 payload: JSON.stringify({ value: 'from-variable' }), 202 }); 203 204 const task = await claimTask(h); 205 assert.equal(task.env.SHARED, 'from-pipeline'); 206 }); 207 }); 208 209 test('a masked variable is redacted from an ingested log', async () => { 210 await withAdmin({}, async (h, admin) => { 211 await h.app.inject({ 212 method: 'PUT', 213 url: '/api/v1/projects/demo/variables/LEAKY', 214 headers: json(admin), 215 payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }), 216 }); 217 218 const task = await claimTask(h); 219 const appended = await h.app.inject({ 220 method: 'POST', 221 url: `/api/v1/tasks/${encodeURIComponent(task.id)}/log`, 222 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 223 payload: Buffer.from('echo leaked-secret-value here\n'), 224 }); 225 assert.equal(appended.statusCode, 200, appended.body); 226 227 const log = await h.app.inject({ 228 method: 'GET', url: `/api/v1/tasks/${encodeURIComponent(task.id)}/log`, 229 }); 230 assert.ok(!log.body.includes('leaked-secret-value'), `log leaked: ${log.body}`); 231 assert.match(log.body, /\[masked\]/); 232 }); 233 }); 234 235 test('a job can be cancelled and retried at the same commit', async () => { 236 await withAdmin({}, async (h, admin) => { 237 const triggered = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 238 const jobId = data(triggered).job_id; 239 await h.waitForJob(jobId); 240 241 const cancelled = await h.app.inject({ 242 method: 'POST', url: `/api/v1/jobs/${jobId}/cancel`, headers: json(admin), payload: '{}', 243 }); 244 assert.equal(cancelled.statusCode, 200, cancelled.body); 245 246 const detail = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}` }); 247 assert.equal(data(detail).state, 'cancelled'); 248 249 const retried = await h.app.inject({ 250 method: 'POST', url: `/api/v1/jobs/${jobId}/retry`, headers: json(admin), payload: '{}', 251 }); 252 assert.equal(retried.statusCode, 200, retried.body); 253 assert.notEqual(data(retried).job_id, jobId); 254 await h.waitForJob(data(retried).job_id); 255 256 const fresh = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${data(retried).job_id}` }); 257 assert.equal(data(fresh).head_sha, h.sha); 258 }); 259 }); 260 261 test('an administrator cannot lock themselves out', async () => { 262 await withAdmin({}, async (h, admin) => { 263 const me = data(await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers: admin })); 264 265 const demoted = await h.app.inject({ 266 method: 'PATCH', 267 url: `/api/v1/users/${me.id}`, 268 headers: json(admin), 269 payload: JSON.stringify({ role: 'user' }), 270 }); 271 assert.equal(demoted.statusCode, 403, demoted.body); 272 273 const removed = await h.app.inject({ 274 method: 'DELETE', url: `/api/v1/users/${me.id}`, headers: admin, 275 }); 276 assert.equal(removed.statusCode, 403, removed.body); 277 }); 278 }); 279 280 test('a disabled account stops being accepted', async () => { 281 await withAdmin({}, async (h, admin) => { 282 const created = await h.app.inject({ 283 method: 'POST', 284 url: '/api/v1/users', 285 headers: json(admin), 286 payload: JSON.stringify({ username: 'temp', password: 'temp-password', role: 'user' }), 287 }); 288 const id = data(created).id; 289 const { headers } = await h.login('temp', 'temp-password'); 290 291 assert.equal( 292 (await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 200 293 ); 294 295 await h.app.inject({ 296 method: 'PATCH', 297 url: `/api/v1/users/${id}`, 298 headers: json(admin), 299 payload: JSON.stringify({ disabled: true }), 300 }); 301 302 const after = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers }); 303 assert.equal(after.statusCode, 401, after.body); 304 }); 305 });