45ffe64b7c
Stable twin of #1070. persistDocPdf, the invoice sending and reminder writers, both contract signature writers and persistSignatureImage built their targets from `path.join(process.cwd(), 'storage', 'business-docs', ...)` and never consulted STORAGE_PATH. Both compose files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so on a stock deployment the two name the same directory and nothing looked wrong. Point STORAGE_PATH anywhere else and quotes, invoices, Mahnungen, contracts and signature images land outside the configured storage root: missed by the backup walker, invisible to storage accounting, and gone when the container is replaced. assertContractPdfPath moves with them. On this branch the writers and the guard are wrong together, so contract downloads currently work — migrating the writers alone would have introduced PATH_OUTSIDE_STORAGE on every newly generated contract. The guard now resolves through getStoragePath() like the writers, and keeps the legacy cwd root so contracts written before this still resolve; their absolute paths are in the database. Also on the shared resolver: the custom PDF font lookup (a font under STORAGE_PATH/fonts was never found, and the document silently fell back to the built-in face) and the two backup diagnostics, which otherwise inspect a different root than the backup walker when STORAGE_PATH is unset. No migration needed — the persisted path is stored absolute. Verified on this branch, not inferred from main: the new test is 6/6, and contract/quote/invoice/pdf/safePath suites are 213/213 both before and after the change. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
160 lines
7.3 KiB
JavaScript
160 lines
7.3 KiB
JavaScript
/**
|
|
* Regression test: business documents must be written under STORAGE_PATH.
|
|
*
|
|
* quoteService.persistDocPdf, the invoice sending/reminder writers and the
|
|
* contract signature writers all built their target from
|
|
* `path.join(process.cwd(), 'storage', 'business-docs', ...)`. Both compose
|
|
* files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so the
|
|
* two expressions name the same directory and the bug was invisible on a stock
|
|
* deployment. Point STORAGE_PATH anywhere else — a NAS mount, a second disk,
|
|
* the single-container image's /data volume — and quotes, invoices, Mahnungen
|
|
* and contract PDFs were written outside the configured storage root, so they
|
|
* were missed by backups and lost when the container was replaced.
|
|
*
|
|
* Rather than assert on internals, this drives the module boundary the fix
|
|
* changed: getStoragePath() is the one resolver, so a temporary STORAGE_PATH
|
|
* must be where the bytes land.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
describe('business documents honour STORAGE_PATH', () => {
|
|
let tmpRoot;
|
|
let originalStoragePath;
|
|
|
|
beforeEach(() => {
|
|
originalStoragePath = process.env.STORAGE_PATH;
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-storage-'));
|
|
process.env.STORAGE_PATH = tmpRoot;
|
|
jest.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalStoragePath === undefined) delete process.env.STORAGE_PATH;
|
|
else process.env.STORAGE_PATH = originalStoragePath;
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('getStoragePath is the resolver the writers share', () => {
|
|
const { getStoragePath } = require('../../src/config/storage');
|
|
expect(getStoragePath()).toBe(tmpRoot);
|
|
});
|
|
|
|
it('no business-document writer still targets process.cwd()/storage', () => {
|
|
// Whitespace is collapsed before matching on purpose. The first version of
|
|
// this test compared against the single-line literal and therefore missed
|
|
// persistSignatureImage(), whose identical path.join was simply spread over
|
|
// seven lines — it reported green while signature PNGs still wrote outside
|
|
// STORAGE_PATH. Formatting must not decide whether a bug is visible.
|
|
const writers = [
|
|
'src/services/quoteService.js',
|
|
'src/services/invoice/sending.js',
|
|
'src/services/invoice/reminders.js',
|
|
'src/services/contract/signatureAssets.js',
|
|
'src/routes/adminDev.js',
|
|
];
|
|
const offenders = writers.filter((rel) => {
|
|
const source = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
|
return /process\.cwd\(\),'storage'/.test(source.replace(/\s+/g, ''));
|
|
});
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('generated contract PDFs pass the containment check that serves them', () => {
|
|
// assertContractPdfPath guards the admin and public contract download
|
|
// routes. It listed only <cwd>/storage/business-docs/contract, so once the
|
|
// writers moved to STORAGE_PATH every freshly generated contract was
|
|
// refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being
|
|
// fixed. Both roots must be accepted.
|
|
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
|
const { getStoragePath } = require('../../src/config/storage');
|
|
|
|
// assertPathInside realpaths both the file and each root, so the guard only
|
|
// means anything against a filesystem that actually has them — write them.
|
|
const write = (...segments) => {
|
|
const p = path.join(getStoragePath(), 'business-docs', 'contract', ...segments);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, 'bytes');
|
|
return p;
|
|
};
|
|
|
|
const generated = write('2026', 'C-2026-0001.pdf');
|
|
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
|
|
|
// Signature PNGs live under the same root and are served by the same guard.
|
|
const signature = write('signatures', '7', 'customer-1.png');
|
|
expect(() => assertContractPdfPath(signature)).not.toThrow();
|
|
|
|
// And the guard still refuses a real file outside every allowed root.
|
|
const foreign = path.join(tmpRoot, 'outside.pdf');
|
|
fs.writeFileSync(foreign, 'bytes');
|
|
expect(() => assertContractPdfPath(foreign)).toThrow(/outside the storage roots/i);
|
|
});
|
|
|
|
it('the guard takes its root from the shared resolver, not its own fallback', () => {
|
|
// The regression this pins: the guard used to compute
|
|
// `STORAGE_PATH || <cwd>/storage` itself. That agrees with getStoragePath()
|
|
// only while STORAGE_PATH is set — unset, the shared resolver falls back
|
|
// module-relative to <repo>/storage while the guard fell back to
|
|
// <cwd>/storage, and the backend is normally started from backend/. Writers
|
|
// and guard then disagreed and contract downloads 403'd.
|
|
//
|
|
// Mocking the resolver is what makes this provable AND safe. If the guard
|
|
// consumes getStoragePath(), the mock moves its root; if it rolled its own
|
|
// expression, the mock would have no effect and the assertion fails. It
|
|
// also keeps every path inside the tmpdir — an earlier version of this test
|
|
// deleted `<resolved root>/business-docs` in cleanup, which with
|
|
// STORAGE_PATH unset resolves to a developer's real, gitignored
|
|
// <repo>/storage and would have destroyed local documents on `npm test`.
|
|
jest.resetModules();
|
|
jest.doMock('../../src/config/storage', () => ({ getStoragePath: () => tmpRoot }));
|
|
|
|
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
|
|
|
const root = path.join(tmpRoot, 'business-docs', 'contract', '2026');
|
|
fs.mkdirSync(root, { recursive: true });
|
|
const generated = path.join(root, 'C-2026-0002.pdf');
|
|
fs.writeFileSync(generated, 'bytes');
|
|
|
|
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
|
|
|
jest.dontMock('../../src/config/storage');
|
|
});
|
|
|
|
it('writes land under STORAGE_PATH, not the working directory', () => {
|
|
const { getStoragePath } = require('../../src/config/storage');
|
|
|
|
// Mirror what persistDocPdf does: derive the root, create it, write.
|
|
const root = path.join(getStoragePath(), 'business-docs', 'quote', '2026');
|
|
fs.mkdirSync(root, { recursive: true });
|
|
const filePath = path.join(root, 'Q-2026-0001.pdf');
|
|
fs.writeFileSync(filePath, 'pdf-bytes');
|
|
|
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
expect(filePath.startsWith(tmpRoot)).toBe(true);
|
|
// And crucially NOT beside the process working directory.
|
|
expect(filePath.startsWith(path.join(process.cwd(), 'storage'))).toBe(false);
|
|
});
|
|
|
|
it('the PDF font lookup consults the storage root before the legacy path', () => {
|
|
// A custom font under STORAGE_PATH/fonts used to be unreachable, so the
|
|
// document silently rendered with the built-in face instead.
|
|
const fontDir = path.join(tmpRoot, 'fonts');
|
|
fs.mkdirSync(fontDir, { recursive: true });
|
|
const fontPath = path.join(fontDir, 'Brand.ttf');
|
|
fs.writeFileSync(fontPath, 'ttf');
|
|
|
|
const { getStoragePath } = require('../../src/config/storage');
|
|
const raw = 'Brand.ttf';
|
|
const candidates = [
|
|
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
|
|
path.join(getStoragePath(), 'fonts', path.basename(raw)),
|
|
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
|
|
];
|
|
const found = candidates.find((p) => fs.existsSync(p));
|
|
expect(found).toBe(fontPath);
|
|
});
|
|
});
|