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
178 lines
10 KiB
JavaScript
178 lines
10 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const root = path.join(__dirname, '..');
|
||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||
|
||
test('a person’s own resources are a separate pathway from Learning', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
const learning = read('src/routes/learningAI.js');
|
||
|
||
// Learning stays moderator-owned. This exists so that not being a moderator
|
||
// no longer means not being able to generate anything at all.
|
||
assert.match(learning, /router\.use\(moderatorMiddleware\)/, 'Learning is unchanged');
|
||
assert.doesNotMatch(route, /moderatorMiddleware/, 'and this one never mentions it');
|
||
assert.match(route, /router\.use\('\/my-resources', authMiddleware\)/,
|
||
'signed in is the only requirement, and the gate names its own prefix');
|
||
});
|
||
|
||
test('nothing here can return another person’s work', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// Every statement that touches the table filters on the owner. A missing
|
||
// WHERE clause here is the whole risk, so it is asserted rather than assumed.
|
||
const statements = route.match(/'(SELECT|UPDATE|DELETE|INSERT)[^']*(?:' \+\s*\n\s*'[^']*)*'/g) || [];
|
||
const touching = statements.filter(s => /user_resources/.test(s));
|
||
assert.ok(touching.length >= 5, 'expected the table statements to be found');
|
||
for (const s of touching) {
|
||
if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead
|
||
assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60));
|
||
}
|
||
assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one');
|
||
});
|
||
|
||
test('markdown is the artifact; every format is rendered from it', () => {
|
||
const exporter = read('src/utils/documentExport.js');
|
||
// Refining means editing text, never patching a binary — which is what makes
|
||
// "change slide 4" possible at all.
|
||
assert.match(exporter, /async function render\(markdown, kind, format\)/);
|
||
assert.match(exporter, /var office = kind === 'presentation' \? 'pptx' : 'docx';/);
|
||
assert.match(exporter, /--reference-doc=' \+ REFERENCE_DECK/, 'decks keep the house template');
|
||
|
||
// PDF goes through Gotenberg because pandoc ships no PDF engine in this
|
||
// image, and converting the office file preserves the deck's layout.
|
||
assert.match(exporter, /forms\/libreoffice\/convert/);
|
||
assert.match(exporter, /AbortSignal\.timeout\(90000\)/, 'and cannot hang a request');
|
||
|
||
// Temporary directories are always cleaned, including on failure.
|
||
assert.match(exporter, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
|
||
});
|
||
|
||
test('a failed PDF says so, because the other two formats still work', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
assert.match(route, /PDF conversion is unavailable right now\. PowerPoint and Word still work\./);
|
||
// Gotenberg is a different stack; PDF is the one export allowed to fail.
|
||
assert.match(read('src/utils/documentExport.js'), /GOTENBERG_URL \|\| 'http:\/\/gotenberg:3000'/);
|
||
assert.match(read('docker-compose.yml'), /danvics_convert/, 'and ped-ai is on its network');
|
||
});
|
||
|
||
test('the generated markdown is told the rules pandoc enforces', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// The same rules the Learning prompt carries, found by rendering decks and
|
||
// looking at them: a table alone on its slide, blank lines around it, no
|
||
// "Slide 3:" prefixes, no deep nesting.
|
||
assert.match(route, /A slide containing a table contains ONLY that table/);
|
||
assert.match(route, /a table needs a blank line/);
|
||
assert.match(route, /the heading is the slide\\'s subject, not "Slide 3:"/);
|
||
// And grounded resources cite only at the end.
|
||
assert.match(route, /Do NOT cite in the body/);
|
||
assert.match(route, /In a presentation that is the final slide, titled References/);
|
||
});
|
||
|
||
test('a library has a ceiling, and generation says when it is reached', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
assert.match(route, /var MAX_PER_USER = 100;/);
|
||
assert.match(route, /You have reached ' \+ MAX_PER_USER \+ ' saved resources/);
|
||
// The count is per owner, so one person filling their library cannot stop
|
||
// anyone else generating.
|
||
assert.match(route, /SELECT COUNT\(\*\)::int AS n FROM user_resources WHERE user_id = \?/);
|
||
});
|
||
|
||
test('the screen is reachable by anyone signed in, and states that it is private', () => {
|
||
const index = read('public/index.html');
|
||
const component = read('public/components/my-resources.html');
|
||
|
||
// A menu item of its own, next to the Learning Hub: related, not the same
|
||
// thing, and sitting together is how someone discovers the difference.
|
||
assert.match(index, /<button class="tab-btn" data-tab="myresources">/);
|
||
assert.match(index, /<section id="myresources-tab" class="tab-content" data-component="my-resources">/);
|
||
// No role gate in the markup: the tab button carries no hidden class, unlike
|
||
// the admin and CMS ones which JavaScript reveals per role.
|
||
const button = index.slice(index.indexOf('data-tab="myresources"') - 40, index.indexOf('data-tab="myresources"') + 40);
|
||
assert.doesNotMatch(button, /hidden/, 'visible to every signed-in user');
|
||
|
||
assert.match(component, /Private to you/);
|
||
assert.match(component, /Nobody else sees these/);
|
||
});
|
||
|
||
test('a row offers the right formats, and the download carries its auth', () => {
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /formats\.forEach\(function \(format\)/);
|
||
// An <a href> cannot carry the Authorization header, so the file is fetched
|
||
// and saved from a blob instead of linked.
|
||
assert.match(js, /headers: getAuthHeaders\(\)/);
|
||
assert.match(js, /filename="\(\[\^"\]\+\)"/, 'and keeps the name the server chose');
|
||
assert.match(js, /URL\.revokeObjectURL\(url\)/, 'without leaking the object URL');
|
||
|
||
// Titles come from a model; this is where they reach the page.
|
||
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
|
||
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
|
||
});
|
||
|
||
test('users pick from the models an admin already approved, and nothing else', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// One allow-list, the one chat already uses. A second would be another thing
|
||
// to keep in step, and would let this reach a model nobody approved.
|
||
assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
|
||
assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
|
||
// A stale option in an open browser tab must not cost someone their
|
||
// generation, so an unknown model falls back rather than being refused.
|
||
assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
|
||
// Refining goes through the same resolution, not req.body.model directly.
|
||
assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
|
||
|
||
// And the screen only asks when there is a real choice to make.
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
|
||
});
|
||
|
||
test('illustration is opt-in, and reuses the assistant’s image tool', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// A model handed a drawing tool will find a reason to use it, so the tool is
|
||
// only offered when the author asked for one.
|
||
assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
|
||
// Tools are assembled per generation: only what the author asked for.
|
||
assert.match(route, /if \(wantsImages && imageModel\) tools = tools\.concat\(imageTool\.tools\);/);
|
||
assert.match(route, /if \(wantsWeb\) tools = tools\.concat\(webSearch\.tools\);/);
|
||
// The same dispatcher the assistant uses, so an image made here is owned,
|
||
// queued and rendered identically to one made there.
|
||
assert.match(route, /imageTool\.dispatch\(ai, \{/);
|
||
assert.match(route, /workflow: 'my_resources'/, 'but attributed to this feature');
|
||
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
|
||
|
||
// The row is hidden entirely when no image model is configured.
|
||
assert.match(read('public/js/myResources.js'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
|
||
assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/);
|
||
});
|
||
|
||
test('a slide shrinks its text rather than spilling off the bottom', () => {
|
||
// pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with
|
||
// no autofit even though the slide master has one. Rendered and counted: a
|
||
// slide with eight bullets showed three and cut the third mid-sentence, and
|
||
// the remaining five were not on the slide at all.
|
||
const exporter = read('src/utils/documentExport.js');
|
||
assert.match(exporter, /async function fitSlideText\(bytes\)/);
|
||
assert.match(exporter, /<a:bodyPr><a:normAutofit\/><\/a:bodyPr>/);
|
||
// No fontScale: the renderer works out the reduction, so a slide that already
|
||
// fits is left alone. A fixed scale would shrink every slide regardless.
|
||
assert.doesNotMatch(exporter, /normAutofit fontScale/);
|
||
// Running it on a deck that already has autofit must not double-inject.
|
||
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
|
||
// And it applies to the PDF path too, which renders from the pptx.
|
||
assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
|
||
// A deck that renders imperfectly beats no deck at all.
|
||
assert.match(exporter, /could not apply slide autofit/);
|
||
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
|
||
});
|
||
|
||
test('an article is never offered as slides', () => {
|
||
const js = read('public/js/myResources.js');
|
||
const route = read('src/routes/myResources.js');
|
||
// A deck of paragraphs is not a presentation. Word and PDF are fine for
|
||
// either; PowerPoint only makes sense for something written as slides.
|
||
assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
|
||
// The route is the boundary that matters, not the button.
|
||
assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
|
||
assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
|
||
});
|