Files
picpeak/backend/__tests__/routes/publicQuotes.test.js
T
Paul Nothaft 5c4da1eacd test(crm): HTTP route tests for CRM public + admin surface (#570)
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)
2026-05-29 22:36:59 +02:00

172 lines
6.8 KiB
JavaScript

/**
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
*
* Public token guards (publicTokenGuards.loadActionToken) are the most
* security-sensitive surface in the CRM module — these are the routes
* a customer hits via the link in the quote email, reachable from any
* IP with the raw token. A regression here means leaked tokens become
* permanently usable, or worse, an expired token starts working again.
*
* Tests pin the contract documented in publicTokenGuards.js:
* - 404 on unknown token (and IP bad-attempt counter ticks)
* - 410 on expired token
* - 410 on NULL expiry (defensive — historical bug)
* - 429 after 20 invalid attempts from one IP
* - 200 + sanitised payload on valid token
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// MUST set the test DB env BEFORE the first require of anything that
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
// time. The helper's bootCrmDb also has to be called once per file
// because the db module is cached; calling it from a second describe
// would silently reuse (or kill) the first instance's connection pool.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-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('publicQuotes routes', () => {
let db;
let cleanup;
let app;
let customerId;
let quoteId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('quotes').insert({
quote_number: 'Q-TEST-0001',
customer_account_id: customerId,
currency: 'CHF',
issue_date: new Date().toISOString().slice(0, 10),
net_amount_minor: 10000,
vat_amount_minor: 0,
total_amount_minor: 10000,
status: 'sent',
language: 'de',
created_at: new Date(),
}).returning('id');
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Clear the in-memory IP bad-attempts map between scenarios so the
// lockout test starts from a known state — and so it doesn't bleed
// 429s into the unrelated tests that follow.
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) {
tokenGuards._internal.badAttempts.clear();
}
});
describe('GET /:token', () => {
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
expect(res.status).toBe(404);
expect(res.body.error).toBeTruthy();
});
it('rejects malformed (non-64-hex) tokens with 400', async () => {
const res = await request(app).get('/api/public/quotes/not-a-real-token');
expect(res.status).toBe(400);
});
it('returns 410 for a token whose expires_at is in the past', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
// The NULL-expiry guard in loadActionToken is intentionally
// defensive but the current schema declares
// quote_action_tokens.expires_at NOT NULL — so the defensive
// branch is unreachable at the route level. Test it directly
// against loadActionToken in a unit suite if you want coverage.
it('returns 200 with a sanitised quote payload for a valid token', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(200);
expect(res.body.quote).toBeDefined();
// API uses camelCase on the public view (see publicQuoteView in
// the route handler).
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
// Internal IDs / admin metadata must NOT appear on the public payload
expect(res.body.quote.customer_account_id).toBeUndefined();
expect(res.body.quote.customerAccountId).toBeUndefined();
expect(res.body.quote.createdByAdminId).toBeUndefined();
});
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
const fakeToken = 'b'.repeat(64);
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const r = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(r.status).toBe(404);
}
const locked = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(locked.status).toBe(429);
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
}, 30000);
});
describe('POST /:token/respond', () => {
it('rejects an invalid action (must be accept|decline) with 400', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on respond', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/quotes/${fakeToken}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(404);
});
it('returns 410 when the token has expired (service-side check)', async () => {
// The POST path goes through quoteService.recordResponse rather
// than loadActionToken, so the error shape can differ from the
// GET expiry response — what matters is the HTTP status.
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(410);
});
});
});