openvibers/openvibe.tools Commits
Add text registry/run API tests and food upstream-failure tests (#4)
Changes
@@@ -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');}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -101,6 +101,13 @@ function mergeForm(files, job) {
@@ -101,6 +101,13 @@ function mergeForm(files, job) {
const oomRun = await r.json();
const oomRun = await r.json();
assert.strictEqual(oomRun.state, 'failed');
assert.strictEqual(oomRun.state, 'failed');
assert.strictEqual(oomRun.error.code, 'tools.input.too_large');
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 {
} finally {
for (const a of apps) await a.kill('SIGTERM');
for (const a of apps) await a.kill('SIGTERM');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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); });