Closes #570. PR #555 shipped the CRM module with strong service-layer coverage but no HTTP-layer tests. This adds Supertest-based route coverage across the externally-reachable public routes (P0) and an auth-gate sweep of every CRM admin route (P1+P2). ## What's covered ### P0 — Public routes (49% of new tests) The three public routes are the security-sensitive surface — any IP with the raw token from a leaked email can hit them. Tests pin the publicTokenGuards.loadActionToken contract end-to-end: - **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown, 400 malformed, 410 expired, 200 valid w/ sanitised payload (no customer_account_id / created_by_admin_id leakage), 429 after 20 bad attempts (IP lockout), 400 invalid action. - **publicContracts** (10 tests) — GET load + POST sign + POST upload-signed-pdf + GET pdf: same guard outcomes per endpoint, plus the pre-multer token check (malformed token rejected before multer reads the body — prevents the disk-spam attack the preMulterTokenGuard was added for). - **publicPaymentCheck** (6 tests) — different shape (no loadActionToken; service does its own validation): validator gate on token shape, all 4 canonical actions pass through the validator, negative amountMinor rejected. The NULL-expires_at defensive branch in loadActionToken is documented but not tested here — current schema declares quote/contract_action_tokens.expires_at NOT NULL, so the branch is unreachable at the route level. Worth a direct unit test on loadActionToken if anyone wants to cover it. ### P1 + P2 — Admin routes (51% of new tests, 25 cases) One consolidated `adminCrmAuth.test.js` file rather than nine per-route files — the auth-gate contract is identical for every CRM admin route, so a parametrised `describe.each` is more efficient and lands the same coverage: Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar, adminDeals, adminTaxReport, adminBusinessProfile): - 401 without Authorization header (adminAuth gate) - 401 with invalid JWT signature (adminAuth signature check) - 2xx with super-admin token + CRM feature flags on (permission + feature-flag gates both pass) Plus 4 tests for the CRM additions in adminCustomers (hour-entries / bill / trigger-monthly-bill) — those endpoints are mixed in with pre-existing customer routes, so they get explicit coverage rather than bulk via the parametrised sweep. ## Harness extensions to integration/helpers/crmDb.js Three new helpers (one place for any future route test to find): - `mintAdminToken(adminId, opts)` — JWT signed with the test JWT_SECRET, shape matches what adminAuth expects. - `createPublicToken(db, tableName, opts)` — insert a row into quote/contract_action_tokens with controllable expires_at / used_at / token. Note: Date values are explicitly ISO-stringified before insert — bare Date objects round-tripped inconsistently through knex+SQLite, sometimes via .toString() → literal `"[object Object]"` which parsed back to NaN and silently defeated the expiry guard. Caught it in test bring-up. - `buildRouteApp(mount, router)` — minimal Express app (json + cookies) with a catch-all error handler that mirrors middleware/errorHandler (uses err.statusCode, not err.status — getting that wrong silently maps every 4xx to 500 in tests). - `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal admin into super_admin (or any seeded role) for happy-path tests. ## Out of scope (follow-up) Deeper integration tests for the document mint/send paths (adminQuotes.send → PDF persisted + token minted + email queued; adminInvoices.Storno → new row with shared deal_uuid + original cancelled; adminContracts.countersign → integrity_hash computed) are deferred. The service-layer behind those is already covered by the existing __tests__/services/ suites — this PR pins the HTTP-layer contract, which is what #570 actually asked for. ## Counts - 4 new test files, 49 tests total - ~860 LOC of test code + ~85 LOC of new harness in crmDb.js - All tests pass in <2.5s (no real network, no real disk except the per-test tmpdir, no email sending)
154 lines
5.8 KiB
JavaScript
154 lines
5.8 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 request = require('supertest');
|
|
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
|
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
|
|
|
describe('publicContracts routes', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
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'));
|
|
}, 60000);
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|