Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The one feature here that sends text outside the building, so the defaults are the careful ones: disabled unless an administrator turns it on, opt-in per generation even then, and the option is hidden entirely rather than shown as something a user can tick and be refused. Only the search query leaves. Library excerpts, the generated resource and anything about the user never do. Both screens say so plainly, because a topic typed while drafting clinical material can carry clinical detail and the provider keeps its own logs. Four providers behind one shape, so swapping changes nothing downstream: Tavily, Serper over Google, Brave, and SearXNG — the only one where the query does not reach a commercial third party at all, which is why it is worth supporting even though it needs somewhere to run. The tool description says when NOT to search, because a model handed a search tool will reach for it constantly: not for settled clinical knowledge, which is what the indexed library is for, and one search per resource. That last one is enforced in the route with toolChoice: 'none' on the continuation rather than trusted to the model. A failed search never fails a generation — same contract as corpus retrieval. The resource is written without it and the response says what was searched for and what came back, so a query that left the network is visible rather than silent. The API key is masked on read and preserved when the field is left blank, the handling the OIDC client secret already gets, so changing provider cannot silently wipe a working key. Verified on the running instance: with nothing configured, webSearchAvailable is false, and a request asking for it anyway is ignored rather than honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
122 lines
6.2 KiB
JavaScript
122 lines
6.2 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const root = path.join(__dirname, '..');
|
|
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
|
|
function load(settings, fetchImpl) {
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(read('src/utils/webSearch.js'), {
|
|
module, exports: module.exports, console: { warn() {}, info() {} },
|
|
fetch: fetchImpl, AbortSignal: { timeout: () => null },
|
|
require(name) {
|
|
if (name === '../db/database') return { getSetting: async (k, d) => (k in settings ? settings[k] : d) };
|
|
throw new Error('unexpected import: ' + name);
|
|
}
|
|
});
|
|
return module.exports;
|
|
}
|
|
|
|
const ON = {
|
|
'websearch.enabled': 'true', 'websearch.provider': 'tavily', 'websearch.api_key': 'k', 'websearch.base_url': ''
|
|
};
|
|
|
|
test('web search is off until an administrator turns it on', async () => {
|
|
// This is the only path that sends text outside the building, so the default
|
|
// has to be the safe one and nothing should be able to flip it implicitly.
|
|
const off = load({}, async () => { throw new Error('must not be called'); });
|
|
assert.equal(await off.isAvailable(), false);
|
|
const out = await off.search('anything');
|
|
assert.equal(out.results.length, 0);
|
|
assert.match(out.reason, /disabled/);
|
|
|
|
// Enabled but unconfigured is still unavailable — no silent half-state.
|
|
const noKey = load({ 'websearch.enabled': 'true', 'websearch.provider': 'tavily' },
|
|
async () => { throw new Error('must not be called'); });
|
|
assert.equal(await noKey.isAvailable(), false);
|
|
assert.match((await noKey.search('x')).reason, /not configured/);
|
|
|
|
// SearXNG needs a URL rather than a key, and is judged on that.
|
|
const searx = load({ 'websearch.enabled': 'true', 'websearch.provider': 'searxng', 'websearch.base_url': 'https://s.example' },
|
|
async () => ({ ok: true, json: async () => ({ results: [] }) }));
|
|
assert.equal(await searx.isAvailable(), true);
|
|
});
|
|
|
|
test('every provider comes back in the same shape', async () => {
|
|
const cases = [
|
|
['tavily', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }],
|
|
['serper', { organic: [{ title: 'T', link: 'https://a', snippet: 'snippet a' }] }],
|
|
['brave', { web: { results: [{ title: 'T', url: 'https://a', description: 'snippet a' }] } }],
|
|
['searxng', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }]
|
|
];
|
|
for (const [provider, payload] of cases) {
|
|
const lib = load(
|
|
{ 'websearch.enabled': 'true', 'websearch.provider': provider, 'websearch.api_key': 'k', 'websearch.base_url': 'https://s.example' },
|
|
async () => ({ ok: true, status: 200, json: async () => payload }));
|
|
const out = await lib.search('bronchiolitis');
|
|
assert.equal(out.results.length, 1, provider + ' returned a result');
|
|
assert.deepEqual(Object.keys(out.results[0]).sort(), ['snippet', 'title', 'url'],
|
|
provider + ' normalises to one shape');
|
|
assert.equal(out.provider, provider);
|
|
}
|
|
});
|
|
|
|
test('a failed search never fails the generation', async () => {
|
|
// Same contract as corpus retrieval: the resource is written without it, and
|
|
// the caller is told why rather than shown an error page.
|
|
const lib = load(ON, async () => { throw new Error('provider unreachable'); });
|
|
const out = await lib.search('bronchiolitis');
|
|
assert.equal(out.results.length, 0);
|
|
assert.match(out.reason, /provider unreachable/);
|
|
|
|
const http = load(ON, async () => ({ ok: false, status: 429, json: async () => ({}) }));
|
|
assert.match((await http.search('x')).reason, /429/);
|
|
});
|
|
|
|
test('results are bounded, and a result with no URL is dropped', async () => {
|
|
const many = Array.from({ length: 40 }, (_, i) => ({ title: 'T' + i, url: 'https://a/' + i, content: 'x'.repeat(4000) }));
|
|
many.push({ title: 'no url', url: '', content: 'y' });
|
|
const lib = load(ON, async () => ({ ok: true, json: async () => ({ results: many }) }));
|
|
const out = await lib.search('bronchiolitis');
|
|
assert.equal(out.results.length, lib.MAX_RESULTS, 'capped');
|
|
assert.ok(out.results.every(r => r.url), 'nothing without a URL');
|
|
assert.ok(out.results.every(r => r.snippet.length <= 1200), 'snippets clipped');
|
|
});
|
|
|
|
test('the tool tells the model when NOT to search', () => {
|
|
const src = read('src/utils/webSearch.js');
|
|
// A model given a search tool will reach for it constantly unless told
|
|
// otherwise, and settled clinical knowledge is what the corpus is for.
|
|
assert.match(src, /Do not use it for settled clinical knowledge/);
|
|
assert.match(src, /One search per resource at most/);
|
|
assert.match(src, /Never send patient details or anything identifying/);
|
|
|
|
// One round only, enforced in the route rather than trusted to the model.
|
|
const route = read('src/routes/myResources.js');
|
|
assert.match(route, /toolChoice: 'none'/);
|
|
assert.match(route, /var wantsWeb = \(String\(req\.body\.withWebSearch\) === 'true'/);
|
|
assert.match(route, /&& await webSearch\.isAvailable\(\)/, 'and the server checks, not just the UI');
|
|
});
|
|
|
|
test('the key is masked on read and preserved when left blank', () => {
|
|
const admin = read('src/routes/adminConfig.js');
|
|
// Same handling the OIDC client secret gets.
|
|
assert.match(admin, /out\['websearch\.api_key'\] = '••••••••' \+ out\['websearch\.api_key'\]\.slice\(-4\)/);
|
|
// Changing the provider must not silently wipe a working key.
|
|
assert.match(admin, /if \(key && key\.indexOf\('•'\) === -1\) await db\.setSetting\('websearch\.api_key'/);
|
|
assert.match(admin, /if \(providers\.indexOf\(provider\) === -1\)/, 'and the provider is validated');
|
|
});
|
|
|
|
test('both screens say plainly that a query leaves the network', () => {
|
|
assert.match(read('public/components/admin.html'), /This sends text outside the building/);
|
|
assert.match(read('public/components/admin.html'), /SearXNG is the only\s*\n?\s*option here that you host yourself/);
|
|
const mine = read('public/components/my-resources.html');
|
|
assert.match(mine, /The search query leaves this network/);
|
|
assert.match(mine, /Do not put anything identifying in the topic/);
|
|
// And it is hidden entirely when unavailable, so nobody ticks a box that
|
|
// cannot work.
|
|
assert.match(read('public/js/myResources.js'), /if \(webRow\) webRow\.hidden = !data\.webSearchAvailable;/);
|
|
});
|