PDF.js's signature dialog saved every signature in localStorage under pdfjs.signature, "save" ticked on every open, for this origin rather than for a bau-pm user. Measured on v0.1.0: a typed and an uploaded signature were offered to a fresh page on the same browser, the way the next person on a shared tablet would get it, a Redmine logout included. Another browser profile saw nothing; the server never had anything. The glue's fifth duty, in every window before PDF.js's modules: delete the store (a device that saved signatures loses them on its first visit), refuse writes to that key, hold the save box unticked through a property on the element and hide it, and hide the dialog's Bild tab and its file picker, so a scanned signature never enters the dialog. test/signatures.mjs is the second gate, in CI and the release workflow: a browser seeded with a real saved entry (captured from v0.1.0's own store) is offered none, the dialog shows neither the box nor the tab, a typed signature is placed and not stored, a fresh page is offered nothing. Fed v0.1.0: 9 findings, exit 1. Against this image: 0, and the zero-host gate drove 117 tool pages with nothing leaving the origin. A signed PDF still exports with the signature on the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
6.2 KiB
JavaScript
122 lines
6.2 KiB
JavaScript
// The signing tool keeps no signature on the device (bau-pm PLAN.md §2.77,
|
|
// Gitea #1): PDF.js's signature editor saves signatures in localStorage for
|
|
// this origin, not for a bau-pm user, so on a shared tablet the next person
|
|
// would find the last one's signature one click away.
|
|
//
|
|
// node test/signatures.mjs <base-url> [chromium-path]
|
|
//
|
|
// 1. A device that already holds a saved signature (written the way an older
|
|
// image left it, from a page the glue does not run on) is offered none,
|
|
// and the store is empty afterwards.
|
|
// 2. In the signing dialog the "save" box and the image tab are hidden, and a
|
|
// signature typed and added with the dialog as it opens is not stored.
|
|
// 3. A fresh page on the same browser, the next person, is offered none.
|
|
//
|
|
// Exits 0 when nothing was kept, 1 otherwise, 2 when it could not run. Fed an
|
|
// image without the glue's fifth duty (v0.1.1 or older) it must fail.
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const base = (process.argv[2] || '').replace(/\/$/, '');
|
|
const executablePath = process.argv[3] || process.env.CHROMIUM || '/usr/bin/chromium';
|
|
if (!base) {
|
|
console.error('usage: signatures.mjs <base-url> [chromium]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const { chromium } = await import(process.env.PLAYWRIGHT_CORE || 'playwright-core');
|
|
const KEY = 'pdfjs.signature';
|
|
const failures = [];
|
|
const browser = await chromium.launch({
|
|
executablePath,
|
|
args: ['--no-sandbox', `--unsafely-treat-insecure-origin-as-secure=${new URL(base).origin}`],
|
|
});
|
|
// Wide enough for the viewer's toolbar to show the signature button.
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
|
|
async function viewer(page) {
|
|
await page.goto(`${base}/de/sign-pdf`, { waitUntil: 'networkidle' });
|
|
await page.setInputFiles('#file-input', join(here, 'fixtures/probe.pdf'));
|
|
const frame = await (await page.waitForSelector('iframe', { timeout: 60000 })).contentFrame();
|
|
await frame.waitForSelector('#editorSignatureButton:not([disabled])', { timeout: 60000 });
|
|
return frame;
|
|
}
|
|
|
|
const stored = (frame) => frame.evaluate((key) => window.localStorage.getItem(key), KEY);
|
|
|
|
// The saved signatures the toolbar's menu offers, read from the menu itself.
|
|
async function offered(frame) {
|
|
await frame.click('#editorSignatureButton');
|
|
await frame.waitForSelector('#editorSignatureAddSignature', { state: 'visible', timeout: 10000 });
|
|
const titles = await frame.$$eval('#addSignatureDoorHanger .toolbarAddSignatureButtonContainer', (rows) =>
|
|
rows.map((row) => row.querySelector('button')?.getAttribute('title') || row.textContent.trim()));
|
|
await frame.click('#editorSignatureButton');
|
|
return titles;
|
|
}
|
|
|
|
const shown = (frame, selector) => frame.$eval(selector, (e) => e.getClientRects().length > 0 && getComputedStyle(e).display !== 'none');
|
|
|
|
try {
|
|
// --- 1. a device that saved one before --------------------------------------
|
|
// config.json is served without the glue, so this write is the browser's own.
|
|
// The entry is a real one, typed in v0.1.0's dialog and read back from its
|
|
// store: a made-up entry might simply fail to render in the menu, and the
|
|
// check would pass on an image that still offers saved signatures.
|
|
const seed = await context.newPage();
|
|
await seed.goto(`${base}/config.json`);
|
|
await seed.evaluate(([key, entry]) => window.localStorage.setItem(key, JSON.stringify({
|
|
'00000000-0000-4000-8000-000000000000': JSON.parse(entry),
|
|
})), [KEY, readFileSync(join(here, 'fixtures/saved-signature.json'), 'utf8')]);
|
|
await seed.close();
|
|
|
|
const a = await context.newPage();
|
|
let frame = await viewer(a);
|
|
const before = await offered(frame);
|
|
if (before.length) failures.push(`a signature saved before is still offered: ${before.join(', ')}`);
|
|
if (await stored(frame)) failures.push('a signature saved before is still stored');
|
|
|
|
// --- 2. signing --------------------------------------------------------------
|
|
// The toolbar button switches the signature mode on; the dialog's controls
|
|
// are clicked through the DOM, because the dialog is taller than the frame.
|
|
await frame.click('#editorSignatureButton');
|
|
await frame.$eval('#editorSignatureAddSignature', (e) => e.click());
|
|
await frame.waitForSelector('#addSignatureDialog[open]', { timeout: 10000 });
|
|
if (await shown(frame, '#addSignatureSaveContainer')) failures.push('the dialog offers "save signature"');
|
|
if (await shown(frame, '#addSignatureImageButton')) failures.push('the dialog offers the image tab');
|
|
if (!(await frame.$eval('#addSignatureFilePicker', (e) => e.disabled))) failures.push('the image file picker is enabled');
|
|
if (await frame.$eval('#addSignatureSaveCheckbox', (e) => e.checked)) failures.push('the "save" box reads ticked');
|
|
await frame.$eval('#addSignatureTypeInput', (e) => {
|
|
e.value = 'Max Muster';
|
|
e.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
await frame.waitForSelector('#addSignatureAddButton:not([disabled])', { state: 'attached', timeout: 10000 });
|
|
await frame.$eval('#addSignatureAddButton', (e) => e.click());
|
|
await frame.waitForSelector('#addSignatureDialog[open]', { state: 'detached', timeout: 10000 }).catch(() => {});
|
|
const placed = await frame.$$eval('.signatureEditor', (e) => e.length);
|
|
if (placed === 0) failures.push('the typed signature was not placed (the check below would prove nothing)');
|
|
if (await stored(frame)) failures.push('the signature just added was stored');
|
|
await a.close();
|
|
|
|
// --- 3. the next person ------------------------------------------------------
|
|
const b = await context.newPage();
|
|
frame = await viewer(b);
|
|
const after = await offered(frame);
|
|
if (after.length) failures.push(`the next page is offered: ${after.join(', ')}`);
|
|
if (await stored(frame)) failures.push('the next page finds a stored signature');
|
|
await b.close();
|
|
} catch (e) {
|
|
console.error(`signatures: could not run: ${e.message}`);
|
|
await browser.close();
|
|
process.exit(2);
|
|
}
|
|
|
|
await browser.close();
|
|
if (failures.length) {
|
|
console.error(`signatures: ${failures.length} finding(s)`);
|
|
for (const f of failures) console.error(` - ${f}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('signatures: nothing kept, nothing offered, no image tab');
|