Files
picpeak/backend/__tests__/routes/publicContracts.test.js
T
Paul NothaftandPaul Nothaft f6b81fabf0 fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes (#1374)
* fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes

Resolves 12 open code-scanning alerts (#589-600): sharp libheif RCE,
nodemailer address-parser ReDoS + domain-validation bypasses, multer
upload DoS/race conditions, js-yaml parsing DoS, and joi prototype
pollution. All patch/minor bumps within the currently used major
version.

* fix(backend): set multer's fieldArrayIndexLimit to actually close CVE-2026-82333

The advisory is explicit that the 2.3.0 version bump alone doesn't
remediate the array-index DoS — an app must also set
limits.fieldArrayIndexLimit. Set it on every multer instance, sized to
what each route's form actually needs.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-09-11 10:40:17 +02:00

203 lines
8.3 KiB
JavaScript

/**
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
*
* Four endpoints on the customer-facing surface:
* GET /:token — load contract for signing
* POST /:token/sign — in-browser canvas signature submission
* POST /:token/upload-signed-pdf — wet-signed PDF upload
* GET /:token/pdf — download the contract PDF
*
* Tests pin the publicTokenGuards.loadActionToken contract per
* endpoint and a few endpoint-specific shape assertions. Deeper
* service-layer behaviour (PDF generation, signature attachment,
* integrity-hash compute) is covered by the contractService unit
* tests; here we only assert the HTTP contract.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
const { errorHandler } = require('../../src/middleware/errorHandler');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let appWithErrorHandler;
let customerId;
let contractId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('contracts').insert({
contract_number: 'K-TEST-0001',
customer_account_id: customerId,
title: 'Test Booking Confirmation',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
}).returning('id');
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
// A second app instance wired to the REAL production error handler
// (buildRouteApp's is a simplified stand-in that only reads
// err.statusCode/err.status, which a bare MulterError doesn't set).
// Used below to verify the actual 4xx contract end-to-end, not just
// that multer aborted the request.
appWithErrorHandler = express();
appWithErrorHandler.use(express.json());
appWithErrorHandler.use(cookieParser());
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
appWithErrorHandler.use(errorHandler);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
});
describe('GET /:token', () => {
it('returns 404 for an unknown well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
expect(res.status).toBe(404);
});
it('rejects malformed tokens with 400 before reaching the guard', async () => {
const res = await request(app).get('/api/public/contracts/short');
expect(res.status).toBe(400);
});
it('returns 410 for an expired token', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
it('returns 200 with the contract payload for a valid token', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(200);
expect(res.body.contract).toBeDefined();
});
});
describe('POST /:token/sign', () => {
it('rejects missing required fields (name, accepted) with 400', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/sign`)
.send({}); // missing name + accepted
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on sign', async () => {
const fakeToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/sign`)
.send({ name: 'Jane Doe', accepted: true });
// Either 404 (token not found) or service-level error mapped to
// 4xx — what matters is the request didn't slip past validation.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
describe('POST /:token/upload-signed-pdf', () => {
it('rejects malformed tokens with 400 before multer runs', async () => {
const res = await request(app)
.post('/api/public/contracts/bad-token/upload-signed-pdf')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
});
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
// field-parser DoS — the version bump alone does nothing. This route is
// unauthenticated (token-in-URL only), so it's the sharpest place to
// prove a crafted request with an oversized array-index field name
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
it('rejects a multipart request with an oversized array-index field name', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
// multer aborts the request before the handler runs; buildRouteApp's
// generic error handler falls back to 500 for a bare MulterError
// (see appWithErrorHandler test below for the real 4xx contract), so
// here we only assert the upload was NOT accepted/processed.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).not.toBe(undefined);
});
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(appWithErrorHandler)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
describe('GET /:token/pdf', () => {
it('returns 404 for an unknown token on PDF download', async () => {
const fakeToken = 'd'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
expect(res.status).toBe(404);
});
it('returns 410 for an expired token on PDF download', async () => {
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
});
});