api.test.js (10625B)
1 // test/api.test.js - the response envelope and its pagination 2 // 3 // The error names asserted here are the wire contract. They come from the 4 // class names in lib/errors.js, so a rename has to fail something rather 5 // than quietly change what clients read. 6 7 import test from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import { startHarness, data, apiError } from './helpers/harness.js'; 10 import { pageBlock, limitOf, offsetOfQuery, API_LIMIT, API_MAX_LIMIT } from '../src/lib/page.js'; 11 12 async function withApi(options, fn) { 13 const h = await startHarness(options); 14 try { 15 await fn(h); 16 } finally { 17 await h.stop(); 18 } 19 } 20 21 // --- the envelope --- 22 23 test('a success carries ok and data, and nothing else', async () => { 24 await withApi({ visibility: 'public' }, async (h) => { 25 const res = await h.app.inject({ method: 'GET', url: '/health' }); 26 27 assert.equal(res.statusCode, 200); 28 assert.deepEqual(Object.keys(res.json()).sort(), ['data', 'ok']); 29 assert.equal(res.json().ok, true); 30 }); 31 }); 32 33 test('a failure carries a name, a message and the matching status', async () => { 34 await withApi({ visibility: 'public' }, async (h) => { 35 const res = await h.app.inject({ method: 'GET', url: '/api/v1/tasks/m2y0000000000000nope' }); 36 37 assert.equal(res.statusCode, 404); 38 const error = apiError(res); 39 assert.equal(error.name, 'UnknownTask'); 40 assert.equal(typeof error.message, 'string'); 41 assert.ok(error.message.length > 0, 'a message is for a person to read'); 42 }); 43 }); 44 45 test('details are absent rather than null when there are none', async () => { 46 await withApi({ visibility: 'public' }, async (h) => { 47 const res = await h.app.inject({ method: 'GET', url: '/api/v1/tasks/m2y0000000000000nope' }); 48 49 assert.deepEqual(Object.keys(apiError(res)).sort(), ['message', 'name']); 50 }); 51 }); 52 53 test('a url under the api that matches no route is enveloped too', async () => { 54 await withApi({ visibility: 'public' }, async (h) => { 55 const res = await h.app.inject({ method: 'GET', url: '/api/v1/no/such/thing' }); 56 57 assert.equal(res.statusCode, 404); 58 assert.equal(apiError(res).name, 'NotFound'); 59 }); 60 }); 61 62 // The envelope handlers sit on the api plugin alone, or a browser asking 63 // for a page would be handed JSON. 64 test('a page request outside the api is answered with the application', async () => { 65 await withApi({ visibility: 'public' }, async (h) => { 66 const res = await h.app.inject({ 67 method: 'GET', 68 url: '/no/such/page', 69 headers: { accept: 'text/html' }, 70 }); 71 72 assert.ok(!res.body.includes('"ok":false'), 'a browser must not be handed the api envelope'); 73 }); 74 }); 75 76 test('a malformed body is refused by name', async () => { 77 await withApi({ visibility: 'public' }, async (h) => { 78 const res = await h.app.inject({ 79 method: 'POST', 80 url: '/api/v1/projects/demo/jobs', 81 headers: { 'content-type': 'application/json' }, 82 payload: '{"sha": not json}', 83 }); 84 85 assert.equal(res.statusCode, 400); 86 assert.equal(apiError(res).name, 'MalformedJson'); 87 }); 88 }); 89 90 test('the worker protocol reports its refusals by name', async () => { 91 await withApi({ visibility: 'public' }, async (h) => { 92 const res = await h.app.inject({ 93 method: 'POST', 94 url: '/api/v1/tasks/claim', 95 headers: { authorization: 'Bearer nonsense', 'content-type': 'application/json' }, 96 payload: '{}', 97 }); 98 99 assert.equal(res.statusCode, 401); 100 assert.equal(apiError(res).name, 'InvalidWorkerToken'); 101 }); 102 }); 103 104 // A stack names files, versions and the shape of the tree, and CORS here is 105 // open to any origin, so it belongs in the log and nowhere else. 106 test('no error body carries a stack', async () => { 107 await withApi({ visibility: 'public' }, async (h) => { 108 for (const url of ['/api/v1/tasks/m2y0000000000000nope', '/api/v1/no/such/thing']) { 109 const res = await h.app.inject({ method: 'GET', url }); 110 assert.ok(!/\bstack\b/i.test(res.body), `${url} leaked a stack`); 111 assert.ok(!res.body.includes('.js:'), `${url} leaked a source location`); 112 } 113 }); 114 }); 115 116 // --- pagination --- 117 118 test('a page reports where it sits and how many there are', async () => { 119 await withApi({ visibility: 'public' }, async (h) => { 120 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 121 await h.waitForJob(job); 122 123 const res = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${job}/tasks?limit=2` }); 124 assert.equal(res.statusCode, 200, res.body); 125 126 const body = res.json(); 127 assert.equal(body.data.tasks.length, 2, 'the limit is honoured'); 128 assert.equal(body.pagination.offset, 0); 129 assert.ok(body.pagination.total > 2, 'the total counts past the page'); 130 assert.match(body.pagination.next, /offset=2/); 131 assert.match(body.pagination.next, /limit=2/); 132 }); 133 }); 134 135 test('following next walks the whole collection exactly once', async () => { 136 await withApi({ visibility: 'public' }, async (h) => { 137 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 138 await h.waitForJob(job); 139 140 const seen = []; 141 let url = `/api/v1/jobs/${job}/tasks?limit=2`; 142 for (let i = 0; i < 20 && url; i += 1) { 143 const res = await h.app.inject({ method: 'GET', url }); 144 const body = res.json(); 145 seen.push(...body.data.tasks.map((t) => t.id)); 146 // next is absolute, and inject wants a path. 147 url = body.pagination.next ? new URL(body.pagination.next).pathname 148 + new URL(body.pagination.next).search : null; 149 } 150 151 const total = seen.length; 152 assert.ok(total > 2, 'the walk should have needed more than one page'); 153 assert.equal(new Set(seen).size, total, 'no task appeared on two pages'); 154 }); 155 }); 156 157 test('next is absent on the last page', async () => { 158 await withApi({ visibility: 'public' }, async (h) => { 159 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 160 await h.waitForJob(job); 161 162 const res = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${job}/tasks` }); 163 const { pagination } = res.json(); 164 165 assert.equal(pagination.offset, 0); 166 assert.ok(!('next' in pagination), 'a single page has nothing after it'); 167 }); 168 }); 169 170 test('an unusable offset or limit falls back rather than failing', async () => { 171 await withApi({ visibility: 'public' }, async (h) => { 172 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 173 await h.waitForJob(job); 174 175 const res = await h.app.inject({ 176 method: 'GET', 177 url: `/api/v1/jobs/${job}/tasks?offset=-5&limit=banana`, 178 }); 179 180 assert.equal(res.statusCode, 200, res.body); 181 assert.equal(res.json().pagination.offset, 0); 182 }); 183 }); 184 185 test('a task reports its artifacts as a page', async () => { 186 await withApi({ visibility: 'public' }, async (h) => { 187 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 188 await h.waitForJob(job); 189 const task = data(await h.claim({})).task; 190 191 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/artifacts` }); 192 assert.equal(res.statusCode, 200, res.body); 193 194 const body = res.json(); 195 assert.ok(Array.isArray(body.data)); 196 assert.equal(body.pagination.offset, 0); 197 assert.equal(body.pagination.total, body.data.length); 198 }); 199 }); 200 201 // --- what an anonymous reader sees --- 202 203 test('the lists an anonymous reader lands on hold only public rows', async () => { 204 await withApi({ visibility: 'public' }, async (h) => { 205 await h.services.projects.create({ 206 id: 'hidden', 207 name: 'Hidden', 208 repo_url: h.repoDir, 209 visibility: 'private', 210 owner_id: null, 211 }, { allowLocalPath: true }); 212 const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; 213 await h.waitForJob(job); 214 215 const projects = await h.app.inject({ method: 'GET', url: '/api/v1/projects' }); 216 assert.equal(projects.statusCode, 200, projects.body); 217 assert.deepEqual(data(projects).map((p) => p.id), ['demo']); 218 219 const jobs = await h.app.inject({ method: 'GET', url: '/api/v1/jobs' }); 220 assert.equal(jobs.statusCode, 200, jobs.body); 221 assert.ok(data(jobs).every((j) => j.project_id === 'demo')); 222 assert.ok(data(jobs).some((j) => j.id === job)); 223 }); 224 }); 225 226 test('a private project is absent rather than refused', async () => { 227 await withApi({ visibility: 'public' }, async (h) => { 228 await h.services.projects.create({ 229 id: 'hidden', 230 name: 'Hidden', 231 repo_url: h.repoDir, 232 visibility: 'private', 233 owner_id: null, 234 }, { allowLocalPath: true }); 235 236 const res = await h.app.inject({ method: 'GET', url: '/api/v1/projects/hidden' }); 237 assert.equal(res.statusCode, 404, res.body); 238 }); 239 }); 240 241 test('the endpoints behind an account say so, rather than emptying out', async () => { 242 await withApi({ visibility: 'public' }, async (h) => { 243 for (const url of ['/api/v1/workers', '/api/v1/users/me', '/api/v1/sessions']) { 244 const res = await h.app.inject({ method: 'GET', url }); 245 assert.equal(res.statusCode, 401, `${url}: ${res.body}`); 246 assert.equal(apiError(res).name, 'NotAuthenticated', url); 247 } 248 }); 249 }); 250 251 // --- the helpers underneath --- 252 253 test('a limit is clamped to the cap, and a missing one defaults', () => { 254 assert.equal(limitOf({}), API_LIMIT); 255 assert.equal(limitOf({ limit: '10' }), 10); 256 assert.equal(limitOf({ limit: '100000' }), API_MAX_LIMIT); 257 assert.equal(limitOf({ limit: '0' }), API_LIMIT); 258 assert.equal(limitOf({ limit: 'banana' }), API_LIMIT); 259 }); 260 261 test('an offset reads as zero unless it is a usable number', () => { 262 assert.equal(offsetOfQuery({}), 0); 263 assert.equal(offsetOfQuery({ offset: '25' }), 25); 264 assert.equal(offsetOfQuery({ offset: '-1' }), 0); 265 assert.equal(offsetOfQuery({ offset: 'banana' }), 0); 266 assert.ok(Number.isSafeInteger(offsetOfQuery({ offset: String(Number.MAX_VALUE) }))); 267 }); 268 269 test('next preserves the other query parameters', () => { 270 const block = pageBlock({ 271 url: 'https://ci.example.com/api/v1/tasks/t1?project=demo&offset=0&limit=2', 272 offset: 0, 273 limit: 2, 274 total: 9, 275 }); 276 277 const next = new URL(block.next); 278 assert.equal(next.searchParams.get('project'), 'demo', 'a filter must survive the page turn'); 279 assert.equal(next.searchParams.get('offset'), '2'); 280 assert.equal(next.searchParams.get('limit'), '2'); 281 }); 282 283 test('the key order is next, offset, total', () => { 284 const block = pageBlock({ url: 'https://ci.example.com/x', offset: 0, limit: 2, total: 9 }); 285 assert.deepEqual(Object.keys(block), ['next', 'offset', 'total']); 286 });