f22999aba6
* fix(storage): write business documents under STORAGE_PATH, not the cwd persistDocPdf, the invoice sending and reminder writers and both contract signature writers built their target from `path.join(process.cwd(), 'storage', 'business-docs', ...)` and never consulted STORAGE_PATH. docker-compose.yml and docker-compose.production.yml both pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so on a stock deployment the two expressions name the same directory and nothing looked wrong. Point STORAGE_PATH anywhere else and quotes, invoices, Mahnungen and contract PDFs land outside the configured storage root: missed by the backup walker, invisible to the storage accounting, and gone when the container is replaced. It also fails outright where the working directory is not writable by the runtime user. Routed all six writers through getStoragePath(), the resolver the rest of the app already uses. Two read-side sites of the same class came along: the custom PDF font lookup now checks the storage root before the legacy cwd path (a font under STORAGE_PATH/fonts was simply never found, and the document silently fell back to the built-in face), and the dev-test scratch directory follows the same root. Left alone deliberately: resolveLogoFile and adminBusinessProfile already try both roots, so their cwd reference is a legacy fallback rather than a miss. No migration needed — the persisted path is stored absolute, so rows written before this keep resolving to where those files actually are. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(storage): allow the configured contract root, and move signature images too Two holes in the previous commit, both found by review. Contract downloads would have broken. assertContractPdfPath() guards the admin unsigned/signed PDF routes and GET /api/public/contracts/:token/pdf, and it listed only <cwd>/storage/business-docs/contract. Moving the writers to STORAGE_PATH without moving that root meant every newly generated contract was refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being fixed, and only on the installs the fix was for. The configured root is now allowed alongside the cwd one, which stays for contracts written before the move; their absolute paths are in the database and still resolve. Note the sibling root on the next line already honoured STORAGE_PATH, so the helper was half-migrated already. persistSignatureImage() still wrote customer and admin signature PNGs under process.cwd(). It was missed because its path.join is spread over seven lines while the others are single-line — and the regression test compared against the single-line literal, so it reported green over a live bug. The test now collapses whitespace before matching, which is the only reason a formatting difference ever hid this. A sweep of the whole of src/ with the same normalisation confirms the remaining process.cwd()/storage references are all deliberate `STORAGE_PATH || cwd` fallbacks, not misses. Added a case that drives assertContractPdfPath against real files on disk — the guard realpaths both the file and its roots, so a test using imaginary paths proves nothing. It fails without the fix. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(storage): resolve the contract guard's root through the shared resolver The guard still built its own `STORAGE_PATH || <cwd>/storage`. That matches getStoragePath() only while STORAGE_PATH is set — with it unset the shared resolver falls back module-relative to <repo>/storage while this fell back to <cwd>/storage, and the backend is normally started from backend/, so the two name different directories. Writers and guard then disagreed about where contracts live and the download routes refused them, which is the same failure the previous commit fixed for the configured case, reappearing in the fallback case. One resolver on both sides now, which is the point of the whole change. Docblock updated to describe the three roots as they actually are. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(storage): make the fallback test safe, and align the backup diagnostics The test added in the previous commit was dangerous. To exercise the STORAGE_PATH-unset case it deleted process.env.STORAGE_PATH and then, in cleanup, recursively removed `<resolved root>/business-docs` — which with the variable unset resolves to the developer's real, gitignored <repo>/storage. Running `npm test` in a working checkout would have destroyed local business documents. This checkout has 65 MB there, including a populated business-docs tree. Rewritten to mock the shared resolver instead. That is both safe (every path stays in the tmpdir) and a sharper assertion: if the guard consumes getStoragePath() the mock moves its root, and if it went back to rolling its own expression the mock would have no effect and the test fails — which is exactly the regression being pinned. backupCoverageService and backupIntegrityService kept their own `STORAGE_PATH || cwd` roots. The backup walker itself already falls back module-relative, so with the variable unset the two diagnostics inspected a directory neither the walker nor the writers use and would report the business-docs tree as missing while it was in fact being backed up. Both now use the shared resolver. No regression: the same jest invocation over contract/quote/invoice/pdf/ backup suites gives an identical 11 failed, 24 passed before and after — those failures are a locally missing cron-parser dependency and reproduce on an unmodified tree. 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);
|
|
});
|
|
});
|