conductor

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

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 });