openvibers/openvibe.tools Commits

Add text registry/run API tests and food upstream-failure tests (#4)

OP OpenVibers committed 2 Oct 2026 · c37436f535 · 6 files, +382 −0

local checks ✓ 2/2 · CI ✓ 3/3 · review SHIP by DeepSeek harness · 2 fix rounds

https://github.com/OpenVibers/OpenVibe.Tools/pull/4

Changes

ddiff --git a/apps/docs/test/pool.test.js b/apps/docs/test/pool.test.js
iindex 1155398..2f50e0b 100644
---- a/apps/docs/test/pool.test.js
++++ b/apps/docs/test/pool.test.js
@@@ -101,6 +101,13 @@ function mergeForm(files, job) {
const oomRun = await r.json();
assert.strictEqual(oomRun.state, 'failed');
assert.strictEqual(oomRun.error.code, 'tools.input.too_large');
++ } catch (err) {
++ // A fetch that fails with "other side closed" means the server went away: show what it said.
++ for (const a of apps) {
++ const code = await Promise.race([a.exited, sleep(200).then(() => 'still running')]);
++ console.error(`── docs ${a.base} (exit: ${code}) ──\n${a.output().slice(-4000)}`);
++ }
++ throw err;
} finally {
for (const a of apps) await a.kill('SIGTERM');
}
ddiff --git a/apps/food/test/maps-stub.js b/apps/food/test/maps-stub.js
nnew file mode 100644
iindex 0000000..1916f17
---- /dev/null
++++ b/apps/food/test/maps-stub.js
@@@ -0,0 +1,52 @@
++'use strict';
++// A stand-in for the maps satellite for the food tests (apps/food/test/*): one local HTTP server, no
++// external network and no maps process. Its mode decides what /api/* answers, and every request it
++// saw is recorded (path, query keys, headers) so a test can check what food forwarded.
++const http = require('http');
++
++const SECRET = 'sk-live-UPSTREAM-SECRET';
++const INTERNAL_URL = 'https://maps.internal:9001/internal/secret';
++
++/**
++ * @returns {Promise<{ base, setMode, requests, close }>} modes:
++ * ok — 200 { ok, items }
++ * boom — 500 JSON that carries an upstream URL and key (and an X-Upstream-Secret header)
++ * badjson — 200 whose body is not JSON
++ * hang — never answers, so food's own 15 s timeout fires
++ * skipped — 200 { ok, sources: [{ skipped: true }] }, what maps answers with no provider key set
++ */
++function startMapsStub() {
++ let mode = 'ok';
++ const requests = [];
++ const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://stub.invalid');
++ requests.push({ path: u.pathname, params: [...u.searchParams.keys()], headers: req.headers });
++ if (u.pathname === '/api/ready') { // food boots only once this answers
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ return res.end(JSON.stringify({ status: 'ok' }));
++ }
++ if (mode === 'boom') {
++ res.writeHead(500, { 'Content-Type': 'application/json', 'X-Upstream-Secret': SECRET });
++ return res.end(JSON.stringify({ error: 'upstream blew up', url: INTERNAL_URL, key: SECRET }));
++ }
++ if (mode === 'badjson') {
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ return res.end('{this is not json');
++ }
++ if (mode === 'hang') return;
++ if (mode === 'skipped') {
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ return res.end(JSON.stringify({ ok: true, sources: [{ name: 'RIDB', skipped: true, reason: 'RIDB_API_KEY is not set' }], items: [] }));
++ }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ ok: true, items: [{ name: 'rice' }] }));
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ base: `http://127.0.0.1:${server.address().port}`,
++ requests,
++ setMode(m) { mode = m; },
++ close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++module.exports = { startMapsStub, SECRET, INTERNAL_URL };
ddiff --git a/apps/food/test/maps-upstream-todo.test.js b/apps/food/test/maps-upstream-todo.test.js
nnew file mode 100644
iindex 0000000..bb73d4a
---- /dev/null
++++ b/apps/food/test/maps-upstream-todo.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// Two behaviours of food's maps proxy (apps/food/server/index.js, proxyToMaps) that are wrong today.
++// Each is written as the check it should pass; while it fails it prints the repo runner's
++// `<label>: skipped (<why>)` line — the file is reported as skipped, and under --strict it fails — so
++// the defect is visible instead of silently green. No production code is changed here (this job does
++// not touch it); when the proxy is fixed, no skip line is printed and the file passes.
++// 1. a maps 5xx with a JSON body is forwarded verbatim: food answers the upstream's status and body,
++// so an upstream URL or key in that body reaches the caller (it should be a clean 502).
++// apps/food/server/index.js:134 forwards response.status and the parsed body unchanged, and the
++// catch that answers { error: 'Backend unavailable' } (line 137) only runs when the body is not JSON.
++// 2. every query parameter is forwarded (line 129), so parameters the route does not take reach maps.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const { startMapsStub } = require('./maps-stub');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-food-upstream-todo-'));
++const ALLOWED_FOODS = new Set(['group', 'campFriendly', 'shelfStable', 'search']);
++
++(async () => {
++ const maps = await startMapsStub();
++ const food = await startApp('food', { MAPS_API: maps.base, DATA_DIR: path.join(tmp, 'food') }, undefined, { readyPath: '/api/ready' });
++ const call = async (p) => {
++ const r = await fetch(`${food.base}${p}`);
++ const text = await r.text();
++ let body = null;
++ try { body = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: r.status, headers: r.headers, text, body };
++ };
++ const todo = [];
++ // The repo runner only reads a skip line that is one line of its own, so the reason is flattened.
++ const expect = (label, fn) => {
++ try { fn(); } catch (err) { todo.push(`${label}: skipped (${String(err.message).replace(/\s+/g, ' ').slice(0, 200)})`); }
++ };
++ try {
++ // 1. maps answers 5xx with a JSON body → a clean 502 that carries nothing of the upstream
++ maps.setMode('boom');
++ const r = await call('/api/foods?search=rice');
++ expect('food maps 5xx answers a clean 502 with no upstream body', () => {
++ assert.strictEqual(r.status, 502, `food answered ${r.status} ${r.text.slice(0, 200)}`);
++ assert.deepStrictEqual(r.body, { error: 'Backend unavailable' }, 'the upstream body must not be passed through');
++ assert.ok(!/maps\.internal|sk-live|upstream blew up/.test(r.text), `the answer leaks the upstream: ${r.text.slice(0, 200)}`);
++ assert.strictEqual(r.headers.get('x-upstream-secret'), null, 'no upstream header is passed through');
++ });
++
++ // 2. only the query parameters the route allows reach maps
++ maps.setMode('ok');
++ maps.requests.length = 0;
++ const q = await call('/api/foods?search=rice&evil=1&lat=5');
++ assert.strictEqual(q.status, 200);
++ const fwd = maps.requests.filter((s) => s.path === '/api/foods').pop();
++ assert.ok(fwd, 'food forwarded the call to maps');
++ expect('food forwards only the query parameters the route allows', () => {
++ const extra = fwd.params.filter((k) => !ALLOWED_FOODS.has(k));
++ assert.deepStrictEqual(extra, [], `these parameters reached maps and are not part of the route: ${extra.join(', ')}`);
++ });
++ } finally {
++ await food.kill();
++ await maps.close();
++ }
++ for (const line of todo) console.log(line);
++ if (!todo.length) console.log('food → maps todo: the proxy now answers cleanly and forwards only the allowed parameters');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/food/test/maps-upstream.test.js b/apps/food/test/maps-upstream.test.js
nnew file mode 100644
iindex 0000000..f9a8508
---- /dev/null
++++ b/apps/food/test/maps-upstream.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// Food answers every food API call from maps (apps/food/server/index.js, proxyToMaps). This stands in
++// for the maps satellite with one local HTTP server (apps/food/test/maps-stub.js, no external network,
++// no maps process) and checks how food answers when that upstream misbehaves:
++// • maps times out or returns malformed JSON → food answers a clean 502 with nothing of the upstream
++// • with no provider key configured, maps' "source skipped" answer is passed through (200), and food
++// sends no key or credential of its own upstream
++// • the parameters the route allows are forwarded
++// The two known defects this uncovered stay as todo tests in maps-upstream-todo.test.js.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const { startMapsStub } = require('./maps-stub');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-food-upstream-'));
++
++(async () => {
++ const maps = await startMapsStub();
++ const food = await startApp('food', { MAPS_API: maps.base, DATA_DIR: path.join(tmp, 'food') }, undefined, { readyPath: '/api/ready' });
++ const call = async (p) => {
++ const r = await fetch(`${food.base}${p}`);
++ const text = await r.text();
++ let body = null;
++ try { body = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: r.status, headers: r.headers, text, body };
++ };
++ try {
++ // ── maps returns malformed JSON: a clean 502 ──
++ maps.setMode('badjson');
++ let r = await call('/api/foods?search=rice');
++ assert.strictEqual(r.status, 502, `malformed JSON: food answered ${r.status} ${r.text.slice(0, 200)}`);
++ assert.deepStrictEqual(r.body, { error: 'Backend unavailable' }, 'malformed JSON: the answer is the clean error');
++ assert.ok(!/maps\.internal|sk-live|SyntaxError|undefined/.test(r.text), `malformed JSON: the answer leaks nothing (${r.text.slice(0, 200)})`);
++
++ // ── maps times out: a clean 502 after food's own 15 s budget ──
++ maps.setMode('hang');
++ const t0 = Date.now();
++ r = await call('/api/foods?search=rice');
++ assert.strictEqual(r.status, 502, `timeout: food answered ${r.status} ${r.text.slice(0, 200)}`);
++ assert.deepStrictEqual(r.body, { error: 'Backend unavailable' }, 'timeout: the answer is the clean error');
++ assert.ok(Date.now() - t0 >= 14000, 'timeout: food waited for its own budget, not forever');
++
++ // ── with no provider key configured, maps' "source skipped" answer comes through as 200 ──
++ maps.setMode('skipped');
++ maps.requests.length = 0;
++ r = await call('/api/food-banks?lat=40.7&lon=-74');
++ assert.strictEqual(r.status, 200, `a skipped source still answers 200 (${r.status} ${r.text.slice(0, 200)})`);
++ assert.strictEqual(r.body.sources[0].skipped, true, 'the skipped source is passed through, not turned into an error');
++ const up = maps.requests.filter((s) => s.path === '/api/food-banks').pop();
++ assert.ok(up, 'food forwarded the call to maps');
++ assert.ok(!up.headers.authorization && !up.headers['x-api-key'], `food sends no credential of its own upstream (${JSON.stringify(up.headers)})`);
++
++ // ── the parameters the route allows are forwarded ──
++ maps.setMode('ok');
++ maps.requests.length = 0;
++ r = await call('/api/foods?search=rice');
++ assert.strictEqual(r.status, 200);
++ const fwd = maps.requests.filter((s) => s.path === '/api/foods').pop();
++ assert.ok(fwd, 'food forwarded the call to maps');
++ assert.ok(fwd.params.includes('search'), `search reached maps (${fwd.params.join(', ')})`);
++ } finally {
++ await food.kill();
++ await maps.close();
++ }
++ console.log('food → maps: a dead, slow or key-less upstream answers cleanly and the allowed parameters are forwarded');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/text/test/run-api.test.js b/apps/text/test/run-api.test.js
nnew file mode 100644
iindex 0000000..58f93e6
---- /dev/null
++++ b/apps/text/test/run-api.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// The run API for the text tools, end to end: POST /api/v1/tools/:id/run (tools.run-request@1 →
++// tools.run@1, ADR-027). The text satellite serves the registry (tools-api.test.js); the tools with
++// execution 'client' and api true are run by the gateway, which loads this app's engines
++// (apps/text/server/engines.js) in its engine pool (apps/gateway/server/run). This starts the gateway
++// the way its server entry runs it (apps/_shared/test/spawn) and checks:
++// • a valid run answers tools.run@1: { state: succeeded, tool, result: { text } | { data } }, and a
++// pure transform's second call is a cache hit
++// • input that does not match the tool's schema is a 422 problem+json with errors[], input over the
++// tool's byte limit a 413, an unknown id a 404 and a page-only tool a 404 not_runnable
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const contracts = require(require.resolve('openvibe-contracts', { paths: [path.join(__dirname, '..', '..', 'gateway')] }));
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-text-run-'));
++let ip = 1;
++const freshIp = () => `198.51.${(ip >> 8) & 255}.${ip++ & 255}`;
++
++(async () => {
++ const gw = await startApp('gateway', {
++ DATA_DIR: path.join(tmp, 'gw'),
++ OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains',
++ OV_REGISTRY_URL: 'http://127.0.0.1:9/registry',
++ });
++ const call = async (p, opts = {}) => {
++ const { headers = {}, ...rest } = opts;
++ const r = await fetch(`${gw.base}${p}`, { ...rest, headers: { 'X-Forwarded-For': freshIp(), ...headers } });
++ const text = await r.text();
++ let body = null;
++ try { body = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: r.status, headers: r.headers, text, body };
++ };
++ const run = (id, request) => call(`/api/v1/tools/${id}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
++ const problem = (r, status, code) => {
++ assert.strictEqual(r.status, status, `${r.text.slice(0, 250)}`);
++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/);
++ assert.strictEqual(r.body.code, code, r.text.slice(0, 250));
++ };
++ try {
++ // ── A valid run: a tool that answers JSON data ──
++ let r = await run('count', { input: { text: 'two words' } });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 250));
++ const valid = contracts.validate('tools.run@1', r.body);
++ assert.ok(valid.valid, `tools.run@1: ${JSON.stringify(valid.errors && valid.errors.slice(0, 3))}`);
++ assert.strictEqual(r.body.state, 'succeeded');
++ assert.strictEqual(r.body.tool, 'count');
++ assert.strictEqual(r.body.result.data.words, 2);
++ assert.ok(!('text' in r.body.result), 'a json tool answers { data }, not { text }');
++ assert.ok(Number.isInteger(r.body.took_ms));
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ // The same pure transform again is answered from the cache.
++ r = await run('count', { input: { text: 'two words' } });
++ assert.strictEqual(r.headers.get('x-ov-cache'), 'hit', 'a pure transform is cached');
++
++ // ── A valid run: a tool that answers text ──
++ r = await run('morse', { input: { text: 'SOS' } });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.result.text, '... --- ...');
++ assert.ok(!('data' in r.body.result), 'a text tool answers { text }, not { data }');
++
++ // ── The input schema is enforced before the tool runs ──
++ r = await run('ascii', { input: {} });
++ problem(r, 422, 'tools.input.invalid');
++ assert.deepStrictEqual(r.body.errors, [{ path: '/text', message: 'is required' }], 'errors[] names the missing field');
++ r = await run('ascii', { input: { text: 'x'.repeat(2000) } });
++ problem(r, 413, 'tools.input.too_large');
++ assert.ok(!/sk-live|maps\.internal/.test(r.text));
++
++ // ── An unknown tool, and a tool whose page only exists ──
++ problem(await run('nosuchtool', { input: {} }), 404, 'tools.tool.not_found');
++ problem(await run('logo', { input: {} }), 404, 'tools.tool.not_runnable');
++
++ // ── Only POST runs a tool ──
++ r = await call('/api/v1/tools/count/run', { method: 'GET' });
++ assert.strictEqual(r.status, 405, r.text.slice(0, 200));
++ assert.strictEqual(r.body.code, 'method_not_allowed');
++ } finally {
++ await gw.kill();
++ }
++ console.log('text run API: a valid run is tools.run@1, bad input is 422/413, an unknown or page-only tool is 404');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/text/test/tools-api.test.js b/apps/text/test/tools-api.test.js
nnew file mode 100644
iindex 0000000..1941744
---- /dev/null
++++ b/apps/text/test/tools-api.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++// Text.OpenVibe's own HTTP surface, started as its server entry runs it (server/index.js, via
++// apps/_shared/test/spawn): the tool registry the gateway and the status page read (ADR-027), the
++// health route and the page fallbacks. It checks that the text tools are advertised with the run
++// contract the gateway executes (api true + run.path), that an unknown tool is a 404 problem+json and
++// a bad filter a 400, and that the registry is the public, cacheable answer it claims to be.
++//
++// The tools themselves are run by the gateway, not by this satellite (its descriptors say execution
++// 'client': apps/gateway/server/run runs the text engines, apps/text/server/engines.js). That the
++// satellite has no run route of its own is asserted at the end, so this stays true if it changes.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-text-api-'));
++
++(async () => {
++ const text = await startApp('text', { DATA_DIR: path.join(tmp, 'text') });
++ const call = async (p, opts) => {
++ const r = await fetch(`${text.base}${p}`, opts);
++ const body = await r.text();
++ let json = null;
++ try { json = JSON.parse(body); } catch { /* not JSON */ }
++ return { status: r.status, headers: r.headers, text: body, body: json };
++ };
++ try {
++ // ── Health ──
++ let r = await call('/api/health');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.service, 'openvibe-text');
++
++ // ── The tool registry (GET /api/v1/tools) ──
++ r = await call('/api/v1/tools');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'the registry is open CORS');
++ assert.match(r.headers.get('cache-control') || '', /max-age=300/, 'the registry is cacheable');
++ assert.ok(r.headers.get('etag'), 'the registry carries an ETag');
++ const list = r.body;
++ assert.strictEqual(list.count, list.tools.length, 'count matches the list');
++ assert.ok(list.count >= 22, `the text tools are advertised (${list.count})`);
++ const byId = new Map(list.tools.map((d) => [d.id, d]));
++ for (const id of ['count', 'fancy', 'morse', 'compare']) assert.ok(byId.has(id), `${id} is in the registry`);
++ // A tool the API runs names its run route; a page-only tool (the canvas makers) does not.
++ assert.deepStrictEqual(byId.get('count').run, { method: 'POST', path: '/api/v1/tools/count/run', job: null });
++ assert.strictEqual(byId.get('count').api, true);
++ assert.strictEqual(byId.get('count').execution, 'client');
++ assert.strictEqual(byId.get('logo').api, false, 'a canvas maker has no API');
++ assert.strictEqual(byId.get('logo').run, null);
++ for (const d of list.tools.filter((t) => t.api)) {
++ assert.ok(d.run && d.run.path === `/api/v1/tools/${d.id}/run`, `${d.id}: an API tool names its run path`);
++ }
++
++ // An If-None-Match with the ETag is answered 304 (the status page polls this).
++ const again = await call('/api/v1/tools', { headers: { 'If-None-Match': r.headers.get('etag') } });
++ assert.strictEqual(again.status, 304, 'a matching ETag is 304');
++
++ // A filter narrows the list; a bad filter value is a 400 problem+json.
++ r = await call('/api/v1/tools?api=false');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.count > 0 && r.body.tools.every((d) => d.api === false), 'api=false answers only page-only tools');
++ r = await call('/api/v1/tools?status=bogus');
++ assert.strictEqual(r.status, 400);
++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/);
++ assert.strictEqual(r.body.code, 'tools.query.invalid');
++ assert.ok(r.body.error, 'the problem carries a legacy error field');
++
++ // ── One tool and its schema ──
++ r = await call('/api/v1/tools/count');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.id, 'count');
++ assert.deepStrictEqual(r.body.hosts, ['count.openvibe.tools']);
++ assert.strictEqual(r.body.auth.anonymous, true, 'an anonymous tool');
++ assert.strictEqual(r.body.output.kind, 'json');
++ assert.ok(r.body.input && r.body.input.required.includes('text'), 'the input schema is embedded');
++ r = await call('/api/v1/tools/count/schema');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(Object.keys(r.body.$defs).sort(), ['input', 'output']);
++ assert.match(r.body.$id, /\/api\/v1\/tools\/count\/schema$/);
++
++ // ── Unknown tool: a 404 problem+json ──
++ r = await call('/api/v1/tools/nosuchtool');
++ assert.strictEqual(r.status, 404);
++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/);
++ assert.strictEqual(r.body.code, 'tools.tool.not_found');
++ assert.match(r.body.detail, /No tool is called/);
++
++ // ── Pages and fallbacks ──
++ r = await call('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /^<\?xml/, 'a sitemap, not the SPA fallback');
++ r = await call('/api/does-not-exist');
++ assert.strictEqual(r.status, 404);
++ assert.deepStrictEqual(r.body, { error: 'Not found' }, 'an unknown /api/ path is JSON, never the page');
++
++ // ── The satellite does not run the tools: their run route lives on the gateway ──
++ r = await call('/api/v1/tools/count/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { text: 'x' } }) });
++ assert.strictEqual(r.status, 404, 'POST .../run is not served here (the gateway runs the text engines)');
++ assert.ok(!r.headers.get('content-type') || !/json/.test(r.headers.get('content-type')), 'it is not even a run-shaped answer');
++ } finally {
++ await text.kill();
++ }
++ console.log('text registry: tools, schemas, 404/400 problem+json and the page fallbacks all answer as specified');
++})().catch((err) => { console.error(err); process.exit(1); });