admin.test.js (14737B)
1 // test/admin.test.js - the authenticated administration surface 2 // 3 // The recurring concern here is that secrets are write only: a trigger 4 // secret, a worker token and a variable can each be set, but only ever read 5 // back once at the moment they are created. 6 7 import test from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import { startHarness } from './helpers/harness.js'; 10 11 async function withAdmin(options, fn) { 12 const h = await startHarness({ ...options, bootstrap: true }); 13 try { 14 return await fn(h, await h.login()); 15 } finally { 16 await h.stop(); 17 } 18 } 19 20 const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); 21 22 test('login issues a token and a session cookie', async () => { 23 await withAdmin({}, async (h) => { 24 const res = await h.app.inject({ 25 method: 'POST', 26 url: '/api/auth/login', 27 headers: { 'content-type': 'application/json' }, 28 payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }), 29 }); 30 31 assert.equal(res.statusCode, 200); 32 const body = res.json(); 33 assert.equal(body.user.role, 'admin'); 34 assert.ok(body.token); 35 assert.match(res.headers['set-cookie'], /conductor_session=/); 36 assert.match(res.headers['set-cookie'], /HttpOnly/); 37 }); 38 }); 39 40 test('a wrong password and an unknown user are indistinguishable', async () => { 41 await withAdmin({}, async (h) => { 42 const wrong = await h.app.inject({ 43 method: 'POST', 44 url: '/api/auth/login', 45 headers: { 'content-type': 'application/json' }, 46 payload: JSON.stringify({ username: 'admin', password: 'nope' }), 47 }); 48 const absent = await h.app.inject({ 49 method: 'POST', 50 url: '/api/auth/login', 51 headers: { 'content-type': 'application/json' }, 52 payload: JSON.stringify({ username: 'nobody', password: 'nope' }), 53 }); 54 55 assert.equal(wrong.statusCode, 401); 56 assert.equal(absent.statusCode, 401); 57 assert.deepEqual(wrong.json(), absent.json()); 58 }); 59 }); 60 61 test('the session cookie authenticates as well as the bearer token', async () => { 62 await withAdmin({}, async (h) => { 63 const login = await h.app.inject({ 64 method: 'POST', 65 url: '/api/auth/login', 66 headers: { 'content-type': 'application/json' }, 67 payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }), 68 }); 69 const cookie = login.headers['set-cookie'].split(';')[0]; 70 71 const res = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); 72 assert.equal(res.statusCode, 200); 73 assert.equal(res.json().user.username, 'admin'); 74 }); 75 }); 76 77 test('management needs a session, and user administration needs the admin role', async () => { 78 await withAdmin({}, async (h, admin) => { 79 const anonymous = await h.app.inject({ method: 'GET', url: '/api/projects' }); 80 assert.equal(anonymous.statusCode, 401); 81 82 await h.app.inject({ 83 method: 'POST', 84 url: '/api/admin/users', 85 headers: json(admin), 86 payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password', role: 'viewer' }), 87 }); 88 89 const login = await h.app.inject({ 90 method: 'POST', 91 url: '/api/auth/login', 92 headers: { 'content-type': 'application/json' }, 93 payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password' }), 94 }); 95 const viewer = { authorization: `Bearer ${login.json().token}` }; 96 97 // An ordinary user manages their own things, and owns nothing yet. 98 const own = await h.app.inject({ method: 'GET', url: '/api/projects', headers: viewer }); 99 assert.equal(own.statusCode, 200); 100 assert.deepEqual(own.json().projects, []); 101 102 // User administration stays administrator only. 103 const forbidden = await h.app.inject({ method: 'GET', url: '/api/admin/users', headers: viewer }); 104 assert.equal(forbidden.statusCode, 403); 105 }); 106 }); 107 108 test('a disabled account stops being accepted immediately', async () => { 109 await withAdmin({}, async (h, admin) => { 110 const created = await h.app.inject({ 111 method: 'POST', 112 url: '/api/admin/users', 113 headers: json(admin), 114 payload: JSON.stringify({ username: 'temp', password: 'temp-password', role: 'viewer' }), 115 }); 116 const id = created.json().user.id; 117 118 const login = await h.app.inject({ 119 method: 'POST', 120 url: '/api/auth/login', 121 headers: { 'content-type': 'application/json' }, 122 payload: JSON.stringify({ username: 'temp', password: 'temp-password' }), 123 }); 124 const headers = { authorization: `Bearer ${login.json().token}` }; 125 assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 200); 126 127 await h.app.inject({ 128 method: 'PATCH', 129 url: `/api/admin/users/${id}`, 130 headers: json(admin), 131 payload: JSON.stringify({ disabled: true }), 132 }); 133 134 // The token has not expired, but the account is checked on every call. 135 assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 401); 136 }); 137 }); 138 139 test('the last administrator cannot be removed or demoted', async () => { 140 await withAdmin({}, async (h, admin) => { 141 const me = (await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: admin })).json().user; 142 143 const demote = await h.app.inject({ 144 method: 'PATCH', 145 url: `/api/admin/users/${me.id}`, 146 headers: json(admin), 147 payload: JSON.stringify({ role: 'viewer' }), 148 }); 149 assert.equal(demote.statusCode, 400); 150 assert.match(demote.json().error, /only administrator/); 151 152 const removed = await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${me.id}`, headers: admin }); 153 assert.equal(removed.statusCode, 400); 154 }); 155 }); 156 157 test('projects can be created, listed and removed, and the secret is shown once', async () => { 158 await withAdmin({}, async (h, admin) => { 159 const created = await h.app.inject({ 160 method: 'POST', 161 url: '/api/projects', 162 headers: json(admin), 163 payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }), 164 }); 165 assert.equal(created.statusCode, 201); 166 const secret = created.json().trigger_secret; 167 assert.ok(secret && secret.length >= 32); 168 169 const listed = await h.app.inject({ method: 'GET', url: '/api/projects', headers: admin }); 170 const project = listed.json().projects.find((p) => p.id === 'newproj'); 171 assert.equal(project.has_trigger_secret, true); 172 // Listing must never return the value itself. 173 assert.equal(project.trigger_secret, undefined); 174 assert.ok(!JSON.stringify(listed.json()).includes(secret)); 175 176 const deleted = await h.app.inject({ method: 'DELETE', url: '/api/projects/newproj', headers: admin }); 177 assert.equal(deleted.statusCode, 200); 178 }); 179 }); 180 181 test('a rotated trigger secret actually signs triggers', async () => { 182 await withAdmin({}, async (h, admin) => { 183 const rotated = await h.app.inject({ 184 method: 'POST', 185 url: '/api/projects/demo/trigger-secret', 186 headers: json(admin), 187 payload: JSON.stringify({ secret: 'rotated-secret' }), 188 }); 189 assert.equal(rotated.statusCode, 200); 190 191 // The old secret stops working, the new one starts. 192 const old = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'test-secret' }); 193 assert.equal(old.statusCode, 401); 194 195 const fresh = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'rotated-secret' }); 196 assert.equal(fresh.statusCode, 200); 197 }); 198 }); 199 200 test('worker tokens are issued once and can be revoked', async () => { 201 await withAdmin({}, async (h, admin) => { 202 const created = await h.app.inject({ 203 method: 'POST', 204 url: '/api/worker-tokens', 205 headers: json(admin), 206 payload: JSON.stringify({ name: 'builder-2' }), 207 }); 208 assert.equal(created.statusCode, 201); 209 const { token, worker_token: record } = created.json(); 210 211 // It works. 212 const poll = await h.app.inject({ 213 method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` }, 214 }); 215 assert.notEqual(poll.statusCode, 401); 216 217 // It is never listed again. 218 const listed = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: admin }); 219 assert.ok(!JSON.stringify(listed.json()).includes(token)); 220 221 // Disabling it takes effect at once. 222 await h.app.inject({ 223 method: 'PATCH', 224 url: `/api/worker-tokens/${record.id}`, 225 headers: json(admin), 226 payload: JSON.stringify({ enabled: false }), 227 }); 228 const after = await h.app.inject({ 229 method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` }, 230 }); 231 assert.equal(after.statusCode, 401); 232 }); 233 }); 234 235 test('project variables reach a job environment and are never listed', async () => { 236 await withAdmin({}, async (h, admin) => { 237 const set = await h.app.inject({ 238 method: 'PUT', 239 url: '/api/projects/demo/variables/DEPLOY_TOKEN', 240 headers: json(admin), 241 payload: JSON.stringify({ value: 'super-secret-value', masked: true }), 242 }); 243 assert.equal(set.statusCode, 200); 244 245 const listed = await h.app.inject({ 246 method: 'GET', url: '/api/projects/demo/variables', headers: admin, 247 }); 248 const listing = listed.json().variables; 249 assert.equal(listing[0].name, 'DEPLOY_TOKEN'); 250 assert.equal(listing[0].masked, true); 251 // The value must not come back out. 252 assert.ok(!JSON.stringify(listing).includes('super-secret-value')); 253 254 await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 255 const claimed = await h.poll({}); 256 const job = claimed.json().job; 257 258 assert.equal(job.env.DEPLOY_TOKEN, 'super-secret-value'); 259 assert.deepEqual(job.masked, ['super-secret-value']); 260 }); 261 }); 262 263 test('a variable is encrypted at rest and bound to its project and name', async () => { 264 await withAdmin({}, async (h, admin) => { 265 await h.app.inject({ 266 method: 'PUT', 267 url: '/api/projects/demo/variables/TOKEN', 268 headers: json(admin), 269 payload: JSON.stringify({ value: 'rest-secret-value' }), 270 }); 271 272 const row = await h.services.db.get( 273 'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}', 274 { p: 'demo', n: 'TOKEN' } 275 ); 276 assert.ok(row.value.startsWith('v1.'), 'expected an encrypted value'); 277 assert.ok(!row.value.includes('rest-secret-value')); 278 279 // The same ciphertext under a different name must not open. 280 await h.services.db.run( 281 `INSERT INTO project_variables (project_id, name, value, masked, created_at) 282 VALUES ({p}, {n}, {v}, 1, {t})`, 283 { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() } 284 ); 285 const resolved = await h.services.variables.resolve('demo'); 286 assert.equal(resolved.env.TOKEN, 'rest-secret-value'); 287 assert.equal(resolved.env.MOVED, undefined, 'a relocated ciphertext must not open'); 288 }); 289 }); 290 291 test('a pipeline setting wins over a project variable of the same name', async () => { 292 const pipeline = ` 293 version: 1 294 jobs: 295 a: 296 image: alpine 297 script: ['true'] 298 env: 299 SHARED: from-pipeline 300 `; 301 await withAdmin({ pipeline }, async (h, admin) => { 302 await h.app.inject({ 303 method: 'PUT', 304 url: '/api/projects/demo/variables/SHARED', 305 headers: json(admin), 306 payload: JSON.stringify({ value: 'from-variable' }), 307 }); 308 309 await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 310 const job = (await h.poll({})).json().job; 311 assert.equal(job.env.SHARED, 'from-pipeline'); 312 }); 313 }); 314 315 test('a masked variable is redacted from ingested logs', async () => { 316 await withAdmin({}, async (h, admin) => { 317 await h.app.inject({ 318 method: 'PUT', 319 url: '/api/projects/demo/variables/LEAKY', 320 headers: json(admin), 321 payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }), 322 }); 323 324 await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 325 const job = (await h.poll({})).json().job; 326 327 // A worker that does not mask still must not get the secret onto disk. 328 await h.app.inject({ 329 method: 'POST', 330 url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, 331 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 332 payload: Buffer.from('echo leaked-secret-value here\n'), 333 }); 334 335 const log = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); 336 assert.ok(!log.body.includes('leaked-secret-value'), `log leaked: ${log.body}`); 337 assert.match(log.body, /\[masked\]/); 338 }); 339 }); 340 341 test('a run can be cancelled and retried through the api', async () => { 342 await withAdmin({}, async (h, admin) => { 343 const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; 344 345 const cancelled = await h.app.inject({ 346 method: 'POST', url: `/api/runs/${run}/cancel`, headers: json(admin), payload: '{}', 347 }); 348 assert.equal(cancelled.statusCode, 200); 349 350 const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` }); 351 assert.equal(detail.json().run.state, 'cancelled'); 352 353 const retried = await h.app.inject({ 354 method: 'POST', url: `/api/runs/${run}/retry`, headers: json(admin), payload: '{}', 355 }); 356 assert.equal(retried.statusCode, 201); 357 assert.notEqual(retried.json().run_id, run); 358 359 const fresh = await h.app.inject({ method: 'GET', url: `/api/runs/${retried.json().run_id}` }); 360 assert.equal(fresh.json().run.state, 'running'); 361 assert.equal(fresh.json().run.head_sha, h.sha); 362 }); 363 }); 364 365 test('with oidc configured, local login is refused unless allowed', async () => { 366 const h = await startHarness({ oidcIssuer: 'https://idp.example.com/realms/ci', bootstrap: true }); 367 try { 368 assert.equal(h.cfg.auth.mode, 'oidc'); 369 const res = await h.app.inject({ 370 method: 'POST', 371 url: '/api/auth/login', 372 headers: { 'content-type': 'application/json' }, 373 payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }), 374 }); 375 assert.equal(res.statusCode, 400); 376 assert.match(res.json().error, /OIDC/); 377 378 const mode = await h.app.inject({ method: 'GET', url: '/api/auth/mode' }); 379 assert.equal(mode.json().mode, 'oidc'); 380 assert.equal(mode.json().local_login, false); 381 } finally { 382 await h.stop(); 383 } 384 }); 385 386 test('local login can be kept as a break-glass account alongside oidc', async () => { 387 const h = await startHarness({ 388 oidcIssuer: 'https://idp.example.com/realms/ci', 389 allowLocalLogin: true, 390 bootstrap: true, 391 }); 392 try { 393 const headers = await h.login(); 394 const res = await h.app.inject({ method: 'GET', url: '/api/projects', headers }); 395 assert.equal(res.statusCode, 200); 396 } finally { 397 await h.stop(); 398 } 399 });