pediatric-ai-scribe-v3/test/resource-to-nextcloud.test.js
Daniel a7132c218e feat: a ciphertext now only decrypts in the row it was written for
enc1 said what a value was but not where it belonged. The blob in one
user's nextcloud_token would decrypt perfectly if copied onto another
user's row, and that account's exports would then go into someone else's
storage. Nothing about the ciphertext objected.

Adds enc2, which binds a context string — 'users:nextcloud_token:41' —
as AES-GCM additional authenticated data. The auth tag covers it, so a
moved ciphertext fails to open rather than opening somewhere wrong. The
context is authenticated, not encrypted: it is not a secret, it is a
claim about location.

enc1 is still read, unchanged and forever — every existing row is enc1.
Bound rows are distinguishable (isBound), so the Nextcloud token is
rebound in place the next time it is successfully used, the same way a
legacy plaintext one was already upgraded. That gate moved from
isEncrypted to isBound; isEncrypted is true for enc1, so it would have
skipped exactly the rows that need rebinding.

Bound so far: Nextcloud app passwords, and invite codes — which bind to
their own code_hash, since unlike the row id that exists at INSERT time.
Decrypting an invite moved into list(), so the cipher and the hash that
opens it no longer leave the module together.

Mutation-tested: dropping the AAD, dropping the guard, gating on
isEncrypted, or dropping a context at a call site each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:25:24 +02:00

70 lines
3.8 KiB
JavaScript

// Sending a resource to Nextcloud pushes the rendered file, not the markdown.
// A .pptx landing in someone's own storage is the thing worth having; a text
// blob is not, and it is not what they would have downloaded.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
const route = read('src/routes/myResources.js');
const util = read('src/utils/nextcloudFiles.js');
const ui = read('public/js/myResources.js');
test('the file is rendered by the same path a download uses', () => {
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2200), /documentExport\.render\(row\.markdown, row\.kind, format/);
assert.match(handler.slice(0, 2200), /deck: deck/, 'the stored deck, so it renders as designed');
assert.match(handler.slice(0, 2200), /collectFigures\(row\.image_ids/, 'with its figures');
// Never the markdown on its own.
assert.doesNotMatch(handler.slice(0, 2200), /send\([^)]*row\.markdown/);
});
test('it is scoped to the owner and refuses a format the resource cannot be', () => {
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2200), /WHERE id = \? AND user_id = \?/);
assert.match(handler.slice(0, 2200), /An article has no slides/);
assert.match(handler.slice(0, 2200), /documentExport\.isSupported\(format\)/);
});
test('a disconnected Nextcloud says so, rather than failing as a server error', () => {
assert.match(util, /Nextcloud is not connected\. Connect it in Settings\./);
assert.match(util, /Nextcloud credentials are invalid\. Reconnect in Settings\./);
// Those carry a status the route passes through instead of flattening to 502.
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
assert.match(handler.slice(0, 2400), /if \(err\.statusCode\) return res\.status\(err\.statusCode\)/);
});
test('one module knows how to put a file in Nextcloud', () => {
// A route importing another route is what this replaced.
assert.match(route, /require\('\.\.\/utils\/nextcloudFiles'\)/);
assert.doesNotMatch(route, /require\('\.\/nextcloud'\)/);
assert.match(util, /async function send\(userId, name, bytes, contentType\)/);
});
test('the filename cannot traverse or confuse a filesystem', () => {
assert.match(util, /replace\(\/\[\^a-zA-Z0-9\._-\]\/g, '_'\)/);
assert.match(util, /\.slice\(0, 120\) \|\| 'resource'/, 'and cannot end up empty');
});
test('the folder tree is made a segment at a time, and an existing one is not an error', () => {
// WebDAV will not create a tree in one call; MKCOL on an existing folder is 405.
assert.match(util, /for \(var part of folder\.split\('\/'\)\.filter\(Boolean\)\)/);
assert.match(util, /already there, or the PUT below will report the real fault/);
});
test('the button appears only when there is a Nextcloud to send to', () => {
assert.match(route, /nextcloudConnected: Boolean/);
assert.match(ui, /if \(nextcloudConnected\) \{/);
assert.match(ui, /data-nextcloud|dataset\.nextcloud/);
// An article offers Word, a deck offers PowerPoint.
assert.match(ui, /row\.kind === 'article' \? 'docx' : 'pptx'/);
});
test('an unbound token is upgraded on first successful use', () => {
// Covers both older forms — plaintext, and encrypted before binding existed.
// isEncrypted() is true for the latter, so gating on it would leave exactly
// the rows that need rebinding untouched.
assert.match(util, /if \(!cryptoUtil\.isBound\(place\.user\.nextcloud_token\)\)/);
assert.match(util, /cryptoUtil\.encryptString\(place\.password, tokenContext\(userId\)\)/);
});