pediatric-ai-scribe-v3/test/notes-sanitize.test.js
Daniel 9961688bfa feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.

Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).

Server (src/routes/notes.js):
  GET    /api/notes              now filters deleted_at IS NULL
  GET    /api/notes/trash        new — list trashed items, newest-
                                  deleted first
  DELETE /api/notes/:id          now soft-deletes (sets deleted_at)
  DELETE /api/notes/:id?hard=1   hard-delete, only allowed on items
                                  already in trash (UI bug can't
                                  erase an active note)
  POST   /api/notes/:id/restore  pull a note out of trash
  POST   /api/notes/trash/empty  hard-delete every trashed note for
                                  the user

Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
  • Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
  • Trash tab shows deleted-at timestamps, Restore + delete-forever
    per row, Empty-trash button at the bottom
  • Active list and trash count refresh in parallel after every
    save / delete / restore
  • Delete button in the editor now says "Move to trash" and uses
    the showConfirm helper (no native dialogs)

Sanitizer swap (public/js/notes.js):
  Replaced the homegrown allowlist walker with DOMPurify (already
  loaded from cdnjs in index.html, used by learningHub.js too).
  Custom HTML sanitizers historically have bypasses; DOMPurify is
  the right primitive.

Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
       as new dev deps):
  9 contract tests covering script-tag stripping, inline event
  handlers, img onerror, iframe/object, style attributes, every
  preserved tag in the allowlist, javascript: URI rejection,
  null/undefined input, and nested-script-inside-paragraph. Total
  test count: 37 → 46 passing.

SW cache bumped to pedscribe-v12-notes5.
2026-04-25 01:02:49 +02:00

110 lines
4.2 KiB
JavaScript

// ============================================================
// Contract test for the Notes reader sanitizer. Confirms that
// DOMPurify (loaded via jsdom here) strips the payloads we care
// about — script tags, inline event handlers, iframes, object
// tags — while preserving the formatting tags we DO allow.
//
// Why this test exists:
// The original Notes module used a homegrown walker-based
// sanitizer. When the body round-trips through localStorage or
// a compromised row, a bypass in the sanitizer means session
// theft. We swapped to DOMPurify; this test holds the contract
// so a regression is loud.
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
// Match the allowlist used in public/js/notes.js `sanitizeHtml`.
const ALLOWED_TAGS = ['p','br','strong','em','b','i','u','s','h2','h3','h4',
'ul','ol','li','a','blockquote','code','pre','hr'];
const ALLOWED_ATTR = ['href','target','rel'];
function makeSanitizer() {
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
return function sanitize(html) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS, ALLOWED_ATTR,
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false,
});
};
}
test('strips <script> tags completely', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p>ok</p><script>alert(1)</script>');
assert.ok(!out.includes('<script'));
assert.ok(!out.includes('alert'));
assert.match(out, /<p>ok<\/p>/);
});
test('strips inline event handlers on otherwise-allowed tags', () => {
const sanitize = makeSanitizer();
const out = sanitize('<a href="https://x" onclick="steal()">link</a>');
assert.ok(!out.includes('onclick'));
assert.ok(!out.includes('steal'));
assert.match(out, /href="https:\/\/x"/);
});
test('drops <img onerror="..."> — img is not in the allowlist', () => {
const sanitize = makeSanitizer();
const out = sanitize('<img src=x onerror="alert(1)">');
assert.ok(!out.includes('<img'));
assert.ok(!out.includes('onerror'));
});
test('drops <iframe> and <object> (framing/plugin injection)', () => {
const sanitize = makeSanitizer();
assert.ok(!sanitize('<iframe src="evil"></iframe>').includes('iframe'));
assert.ok(!sanitize('<object data="evil"></object>').includes('object'));
});
test('drops style attributes — CSS injection / expression() on older browsers', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p style="background:url(evil)">text</p>');
assert.ok(!out.includes('style'));
assert.match(out, /<p>text<\/p>/);
});
test('preserves every tag in the allowlist', () => {
const sanitize = makeSanitizer();
for (const tag of ALLOWED_TAGS) {
const html = `<${tag}>x</${tag}>`;
const out = sanitize(html);
// <br> and <hr> are self-closing; DOMPurify may emit them either way.
if (tag === 'br' || tag === 'hr') {
assert.match(out, new RegExp(`<${tag}`), `expected <${tag}> preserved`);
} else {
assert.match(out, new RegExp(`<${tag}>x</${tag}>`), `expected <${tag}>x</${tag}> preserved`);
}
}
});
test('preserves mailto + https links; drops javascript: URIs', () => {
const sanitize = makeSanitizer();
assert.match(sanitize('<a href="https://ok">x</a>'), /href="https:\/\/ok"/);
assert.match(sanitize('<a href="mailto:a@b.c">x</a>'), /href="mailto:a@b\.c"/);
const jsAttempt = sanitize('<a href="javascript:alert(1)">x</a>');
assert.ok(!jsAttempt.includes('javascript:'), 'javascript: URI should be stripped');
});
test('empty + null + non-string input does not throw', () => {
const sanitize = makeSanitizer();
assert.equal(sanitize(''), '');
assert.equal(sanitize(null), '');
assert.equal(sanitize(undefined), '');
});
test('nested tags: <script> inside <p> still stripped', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p>safe<script>evil()</script>still safe</p>');
assert.ok(!out.includes('<script'));
assert.ok(!out.includes('evil()'));
// Text content before + after the stripped tag should survive
assert.match(out, /safe/);
assert.match(out, /still safe/);
});