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