Two things, both about not making someone redo work. One key per provider. There was a single websearch.api_key shared by all of them, so trying a different provider meant pasting a new key over the working one and pasting the old one back to return — and the keys are not interchangeable, so a wrong pairing fails as an authentication error that reads like a dead provider. Each now has its own slot. The old shared key is still read as a fallback: whatever was configured before this is the right key for whichever provider was selected at the time. The sign-in code email went out raw, while every other message this app sends goes through emailWrapper — so the one mail a person receives while locked out was the one that looked least like it came from us. It now uses the same wrapper, and the body is built around the thing the reader actually needs: the code, alone, large, monospaced so a 0 cannot be read as an O, in a box of its own. It also names the address it signs into. A code arriving at a shared mailbox, or to someone with two accounts, is otherwise a number with no indication of what it opens — and that line is the one thing that lets a person notice a sign-in they did not start. The address is escaped; it is the only part of that mail that did not come from us. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
292 lines
16 KiB
JavaScript
292 lines
16 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' }] } }],
|
|
['exa', { results: [{ title: 'T', url: 'https://a', text: '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('searching is the route\u2019s job, not something the model is asked to do', () => {
|
|
// This was a tool first. Tested live against a question explicitly about
|
|
// recent trials, the model never called it \u2014 with or without corpus
|
|
// grounding, and no matter how the description was worded, because the prompt
|
|
// ends "Output ONLY Pandoc markdown" and a model told to output only markdown
|
|
// does not emit a tool call. Calling callAI with the tool directly produced a
|
|
// correct pubmed_search call, so the plumbing was never the problem.
|
|
//
|
|
// The search only ever needed the topic, and the route knows the topic before
|
|
// it calls the model. So both searches run up front and their results go into
|
|
// the prompt as findings, the same way corpus excerpts do.
|
|
const route = read('src/routes/myResources.js');
|
|
assert.match(route, /var wantsWeb = \(String\(body\.withWebSearch\) === 'true'/);
|
|
assert.match(route, /&& await webSearch\.isAvailable\(\)/, 'and the server checks, not just the UI');
|
|
assert.match(route, /var pages = await webSearch\.search\(keywords\);/);
|
|
assert.match(route, /var papers = await pubmedSearch\.search\(keywords\);/);
|
|
// One function, so generating and modifying cannot drift into offering
|
|
// different sources or searching them differently.
|
|
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
|
assert.match(route, /var sources = await gatherSources\(topic, req\.body\);/, 'generate');
|
|
assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/,
|
|
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
|
|
|
|
// Declared before they are used. They were not, once: `var` hoisting made
|
|
// wantsPubmed undefined at the point of the test, so the block never ran and
|
|
// said nothing about it.
|
|
assert.ok(route.indexOf('var wantsPubmed =') < route.indexOf('if (wantsPubmed)'),
|
|
'declared above the branch that reads it');
|
|
assert.ok(route.indexOf('var wantsWeb =') < route.indexOf('if (wantsWeb)'));
|
|
|
|
// Neither search may fail a generation, so what happened is reported back
|
|
// instead: how many results, and why there were none.
|
|
assert.match(route, /searches\.push\(\{ tool: 'pubmed_search', query: papers\.query \|\| keywords/);
|
|
assert.match(route, /searches\.push\(\{ tool: 'web_search', query: keywords, count: pages\.results\.length, reason: pages\.reason \}\);/);
|
|
assert.match(route, /searches: searches/);
|
|
|
|
// And neither library still advertises itself as a tool.
|
|
assert.doesNotMatch(read('src/utils/webSearch.js'), /name: 'web_search'/);
|
|
assert.doesNotMatch(read('src/utils/pubmedSearch.js'), /name: 'pubmed_search'/);
|
|
});
|
|
|
|
test('a PubMed query that finds nothing is narrowed rather than given up on', () => {
|
|
const src = read('src/utils/pubmedSearch.js');
|
|
// PubMed ANDs every mapped term, so one unrecognised word zeroes the query.
|
|
// Measured against the live API: "febrile seizures" → 6 results, "febrile
|
|
// seizures in under-fives" → 0, and "the anatomy of croup: subglottic
|
|
// narrowing and the steeple sign" → 0 until it was narrowed to "anatomy
|
|
// croup", which returns 6.
|
|
assert.match(src, /function candidates\(text\)/);
|
|
assert.match(src, /var tries = candidates\(text\);/);
|
|
// Longest first, so the most specific query that works is the one used.
|
|
assert.match(src, /for \(var n = Math\.min\(words\.length, 4\); n >= 2; n--\)/);
|
|
// A subtitle is not a subject.
|
|
assert.match(src, /split\(\/\[:\\u2014\\u2013\]\|\\s-\\s\/\)\[0\]/);
|
|
|
|
// Three esearch calls back to back trips NCBI's three-a-second limit without
|
|
// a key, which turned a working search into a 429 — measured on this path.
|
|
assert.match(src, /var gap = s\.apiKey \? 120 : 380;/);
|
|
assert.match(src, /if \(t\) await new Promise/, 'and the first attempt waits for nothing');
|
|
|
|
// The screen reports what was searched, so it has to be the query that
|
|
// actually found the results.
|
|
assert.match(src, /return \{ results: results, reason: null, query: used \};/);
|
|
assert.match(read('src/routes/myResources.js'), /query: papers\.query \|\| keywords/);
|
|
});
|
|
|
|
test('a search that comes back empty is not an invitation to invent citations', () => {
|
|
const route = read('src/routes/myResources.js');
|
|
// Asked to search, found nothing, still asked for PMIDs: the model supplies
|
|
// them from memory, and a fabricated PMID looks exactly like a real one.
|
|
assert.match(route, /searchedAndFoundNothing: searches\.length > 0 && !literature && !webFindings/);
|
|
// Said on both paths: refining and generating.
|
|
assert.equal((route.match(/Do not invent a citation, a PMID/g) || []).length, 2);
|
|
});
|
|
|
|
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.
|
|
// Both keys, one rule: never send a key back, enough tail to recognise it.
|
|
assert.match(admin, /\['websearch\.api_key', 'pubmed\.api_key'\]\.forEach/);
|
|
assert.match(admin, /if \(out\[k\]\) out\[k\] = '••••••••' \+ out\[k\]\.slice\(-4\);/);
|
|
// Changing the provider must not silently wipe a working key — now literally:
|
|
// each provider has its own slot, so switching does not overwrite anything.
|
|
assert.match(admin, /if \(key && key\.indexOf\('•'\) === -1\) \{/);
|
|
assert.match(admin, /db\.setSetting\(webSearchLib\.keySetting\(provider\), key\.slice\(0, 400\)\)/);
|
|
assert.match(admin, /if \(pmKey && pmKey\.indexOf\('•'\) === -1\) await db\.setSetting\('pubmed\.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 query leaves this network/);
|
|
assert.match(mine, /keep the topic non-identifying/);
|
|
assert.match(mine, /The query goes to NCBI/);
|
|
// And it is hidden entirely when unavailable, so nobody ticks a box that
|
|
// cannot work.
|
|
// Both option groups are hidden from the same answer, so Generate and Modify
|
|
// cannot end up offering different things.
|
|
const js = read('public/js/myResources.js');
|
|
assert.match(js, /\['mr-web-row', 'web'\]/);
|
|
assert.match(js, /\['mr-modify-web-row', 'web'\]/);
|
|
assert.match(js, /\['mr-pubmed-row', 'pubmed'\]/);
|
|
assert.match(js, /\['mr-modify-pubmed-row', 'pubmed'\]/);
|
|
assert.match(js, /row\.hidden = !available\[pair\[1\]\]/);
|
|
});
|
|
|
|
// ---- Exa -------------------------------------------------------------------
|
|
// Embeddings search rather than keywords, which suits a clinical question asked
|
|
// as a question. It is the only provider that can return the page text in the
|
|
// same call, and the snippet is the part the model reads.
|
|
|
|
test('Exa asks for the text extract in the search call', async () => {
|
|
// Without contents, every result would need a second fetch to be useful.
|
|
let sent = null;
|
|
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' },
|
|
async (url, options) => { sent = { url, options }; return { ok: true, status: 200, json: async () => ({ results: [] }) }; });
|
|
await lib.search('does dexamethasone help croup');
|
|
assert.equal(sent.url, 'https://api.exa.ai/search');
|
|
const body = JSON.parse(sent.options.body);
|
|
assert.equal(body.query, 'does dexamethasone help croup');
|
|
assert.ok(body.contents && body.contents.text, 'no text extract requested');
|
|
// 'auto', not 'neural': pinning neural makes it worse at the keyword-shaped
|
|
// queries the other providers handle well.
|
|
assert.equal(body.type, 'auto');
|
|
assert.equal(sent.options.headers['x-api-key'], 'k', 'Exa authenticates with x-api-key, not a bearer token');
|
|
});
|
|
|
|
test('Exa falls back through its snippet fields rather than returning nothing', async () => {
|
|
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' },
|
|
async () => ({ ok: true, status: 200, json: async () => ({ results: [
|
|
{ title: 'A', url: 'https://a', text: 'from text' },
|
|
{ title: 'B', url: 'https://b', summary: 'from summary' },
|
|
{ title: 'C', url: 'https://c' }
|
|
] }) }));
|
|
const out = await lib.search('x');
|
|
assert.deepEqual(out.results.map(r => r.snippet), ['from text', 'from summary', '']);
|
|
});
|
|
|
|
test('Exa needs a key, like every provider but SearXNG', async () => {
|
|
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa' },
|
|
async () => { throw new Error('must not be called'); });
|
|
assert.equal(await lib.isAvailable(), false);
|
|
});
|
|
|
|
test('the admin can choose it, and the server accepts what the admin can choose', async () => {
|
|
// The dropdown and the route validate against the same list; a provider in
|
|
// one and not the other is a setting that saves and then does nothing, or an
|
|
// option that cannot be saved at all.
|
|
const lib = load(ON, async () => ({ ok: true, status: 200, json: async () => ({ results: [] }) }));
|
|
const markup = read('public/components/admin.html');
|
|
for (const provider of lib.PROVIDERS) {
|
|
assert.match(markup, new RegExp('<option value="' + provider + '"'),
|
|
provider + ' is accepted by the server but not offered in the admin');
|
|
}
|
|
const offered = [...markup.matchAll(/<option value="(\w+)">[^<]*(?:Tavily|Serper|Brave|Exa|SearXNG)/g)].map(m => m[1]);
|
|
for (const provider of offered) {
|
|
assert.ok(lib.PROVIDERS.includes(provider), provider + ' is offered in the admin but rejected by the server');
|
|
}
|
|
});
|
|
|
|
// ---- one key per provider --------------------------------------------------
|
|
// There used to be a single websearch.api_key shared by all of them, so trying
|
|
// a different provider meant pasting a new key over the working one and pasting
|
|
// the old one back to return. The keys are not interchangeable, so a wrong
|
|
// pairing fails as an authentication error that looks like a dead provider.
|
|
|
|
test('each provider keeps its own key', () => {
|
|
const lib = load({}, async () => ({ ok: true, json: async () => ({}) }));
|
|
assert.equal(lib.keySetting('exa'), 'websearch.api_key.exa');
|
|
assert.equal(lib.keySetting('tavily'), 'websearch.api_key.tavily');
|
|
assert.notEqual(lib.keySetting('exa'), lib.keySetting('tavily'));
|
|
});
|
|
|
|
test('the selected provider gets its own key, not another provider\'s', async () => {
|
|
let seen = null;
|
|
const lib = load({
|
|
'websearch.enabled': 'true', 'websearch.provider': 'exa',
|
|
'websearch.api_key.exa': 'exa-key', 'websearch.api_key.tavily': 'tavily-key'
|
|
}, async (url, options) => { seen = options; return { ok: true, status: 200, json: async () => ({ results: [] }) }; });
|
|
await lib.search('x');
|
|
assert.equal(seen.headers['x-api-key'], 'exa-key');
|
|
assert.equal((await lib.settings()).apiKey, 'exa-key');
|
|
});
|
|
|
|
test('a key saved before per-provider slots still works', async () => {
|
|
// Whatever was configured before this change is the right key for whichever
|
|
// provider was selected at the time, so the old shared slot is the fallback.
|
|
const lib = load({
|
|
'websearch.enabled': 'true', 'websearch.provider': 'tavily', 'websearch.api_key': 'legacy'
|
|
}, async () => ({ ok: true, status: 200, json: async () => ({ results: [] }) }));
|
|
assert.equal((await lib.settings()).apiKey, 'legacy');
|
|
assert.equal(await lib.isAvailable(), true);
|
|
});
|
|
|
|
test('a provider-specific key wins over the shared one', async () => {
|
|
const lib = load({
|
|
'websearch.enabled': 'true', 'websearch.provider': 'exa',
|
|
'websearch.api_key': 'legacy', 'websearch.api_key.exa': 'the-exa-one'
|
|
}, async () => ({ ok: true, status: 200, json: async () => ({ results: [] }) }));
|
|
assert.equal((await lib.settings()).apiKey, 'the-exa-one');
|
|
});
|