From 5c4da1eacd8b3683d7aaba466ab0a966d1249511 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 29 May 2026 22:36:59 +0200 Subject: [PATCH] test(crm): HTTP route tests for CRM public + admin surface (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/integration/helpers/crmDb.js | 111 +++++++++++- backend/__tests__/routes/adminCrmAuth.test.js | 157 ++++++++++++++++ .../__tests__/routes/publicContracts.test.js | 153 ++++++++++++++++ .../routes/publicPaymentCheck.test.js | 106 +++++++++++ backend/__tests__/routes/publicQuotes.test.js | 171 ++++++++++++++++++ 5 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/routes/adminCrmAuth.test.js create mode 100644 backend/__tests__/routes/publicContracts.test.js create mode 100644 backend/__tests__/routes/publicPaymentCheck.test.js create mode 100644 backend/__tests__/routes/publicQuotes.test.js diff --git a/backend/__tests__/integration/helpers/crmDb.js b/backend/__tests__/integration/helpers/crmDb.js index ba51f9e6..b695ace7 100644 --- a/backend/__tests__/integration/helpers/crmDb.js +++ b/backend/__tests__/integration/helpers/crmDb.js @@ -118,4 +118,113 @@ async function seedMinimal(db) { return { adminId, customerId }; } -module.exports = { bootCrmDb, seedMinimal }; +// --------------------------------------------------------------------- +// Route-test helpers (#570) — building blocks for the CRM HTTP layer +// tests. Kept here so every supertest suite shares the same minting + +// app-wiring shape and a refactor lands in one place. +// --------------------------------------------------------------------- + +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const express = require('express'); +const cookieParser = require('cookie-parser'); + +/** + * Promote a seeded admin into a role (default `super_admin`) so + * `requirePermission(...)` checks pass. seedMinimal creates an admin + * without a role — that's good for negative tests (expect 403) but + * happy-path tests need the role assignment. + * + * Returns the role id the admin was assigned to. + */ +async function assignAdminRole(db, adminId, roleName = 'super_admin') { + const role = await db('roles').where({ name: roleName }).first(); + if (!role) { + throw new Error(`Role '${roleName}' not seeded — check the test DB`); + } + await db('admin_users').where({ id: adminId }).update({ role_id: role.id }); + return role.id; +} + +/** + * Mint an admin JWT in the same shape adminAuth middleware expects. + * The tests inject this via `Authorization: Bearer `. + */ +function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) { + process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret'; + return jwt.sign( + { id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims }, + process.env.JWT_SECRET, + { expiresIn, issuer: 'picpeak-auth' } + ); +} + +/** + * Insert a row into one of the public-token tables for testing the + * loadActionToken guard outcomes. Returns the generated 64-hex token. + * + * Usage: + * await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id }); + * await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate }); + * await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() }); + * await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null }); + */ +async function createPublicToken(db, tableName, opts = {}) { + const token = opts.token || crypto.randomBytes(32).toString('hex'); + const expiresAt = opts.expires_at === null + ? null + : (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)); + // Serialise Date → ISO string. Bare Date objects round-tripped + // inconsistently through knex+SQLite — sometimes as epoch ms, + // sometimes via .toString() → literal "[object Object]" which then + // parses back to NaN and silently defeats the expiry guard. + const toStorable = (v) => (v instanceof Date ? v.toISOString() : v); + const row = { + ...opts, + token, + expires_at: toStorable(expiresAt), + created_at: toStorable(new Date()), + }; + await db(tableName).insert(row); + return token; +} + +/** + * Build an Express app with the requested route file mounted. Mirrors + * the production app's middleware shape (json + cookies) but skips + * everything else (CORS, helmet, rate limiters) — route tests pin the + * handler's contract, not the surrounding cross-cutting concerns. + * + * Example: + * const app = buildRouteApp('/api/public/quotes', + * require('../../src/routes/publicQuotes')); + */ +function buildRouteApp(mount, router) { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use(mount, router); + // Catch-all error handler. Mirrors the real middleware/errorHandler: + // AppError subclasses (ValidationError, NotFoundError, etc.) use + // `.statusCode` (NOT `.status` — getting that wrong silently maps + // every 400 / 404 / 410 to 500 in tests). + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + const statusCode = err.statusCode || err.status || 500; + res.status(statusCode).json({ + error: err.message || 'Internal error', + code: err.code, + ...(err.details ? { details: err.details } : {}), + }); + }); + return app; +} + +module.exports = { + bootCrmDb, + seedMinimal, + assignAdminRole, + mintAdminToken, + createPublicToken, + buildRouteApp, +}; diff --git a/backend/__tests__/routes/adminCrmAuth.test.js b/backend/__tests__/routes/adminCrmAuth.test.js new file mode 100644 index 00000000..567f0a6f --- /dev/null +++ b/backend/__tests__/routes/adminCrmAuth.test.js @@ -0,0 +1,157 @@ +/** + * HTTP route auth-gate tests for the CRM admin surface (P1 / P2 — #570). + * + * Bundled into one file rather than nine because the contract is the + * same for every CRM admin route: + * - No token → 401 (adminAuth at the router level) + * - Valid token, missing permission → 403 (requirePermission middleware) + * - Valid token + super_admin role → 2xx / 404 (resource-based) + * + * Deeper service-layer behaviour (PDF generation, send, Storno, + * countersign, integrity hash) is covered by the existing service + * unit tests in __tests__/services/. This file pins the contract + * between the HTTP layer and the auth+permission middleware so a + * misconfigured route ("forgot requirePermission") can never ship + * unnoticed. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admincrm-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, assignAdminRole, + mintAdminToken, buildRouteApp, +} = require('../integration/helpers/crmDb'); + +// One row per admin CRM route. `mount` matches server.js's app.use, +// `loader` is the require()'d router, `getPath` is one path on the +// router we'll exercise. The path should be a GET-shaped read where +// possible — listing endpoints (`/`) are safest because they don't +// require pre-seeded resource ids. +const ROUTES = [ + { name: 'adminQuotes', mount: '/api/admin/quotes', loader: () => require('../../src/routes/adminQuotes'), getPath: '/' }, + { name: 'adminContracts', mount: '/api/admin/contracts', loader: () => require('../../src/routes/adminContracts'), getPath: '/' }, + { name: 'adminInvoices', mount: '/api/admin/invoices', loader: () => require('../../src/routes/adminInvoices'), getPath: '/' }, + { name: 'adminCalendar', mount: '/api/admin/calendar', loader: () => require('../../src/routes/adminCalendar'), getPath: '/items?from=2026-01-01&to=2026-12-31' }, + { name: 'adminDeals', mount: '/api/admin/deals', loader: () => require('../../src/routes/adminDeals'), getPath: '/' }, + { name: 'adminTaxReport', mount: '/api/admin/tax-report', loader: () => require('../../src/routes/adminTaxReport'), getPath: '/?period=2026-Q1' }, + { name: 'adminBusinessProfile', mount: '/api/admin/business-profile', loader: () => require('../../src/routes/adminBusinessProfile'), getPath: '/' }, +]; + +describe('admin CRM routes — auth + permission gate', () => { + let db; + let cleanup; + let adminId; + let customerId; + let superAdminToken; + let invalidToken; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + + // Super-admin: assign the seeded super_admin role (created by + // migration 057). requirePermission lookups short-circuit because + // super_admin role inherits every permission via role_permissions + // rows seeded by mig 107 and earlier. + await assignAdminRole(db, adminId, 'super_admin'); + superAdminToken = mintAdminToken(adminId); + + // CRM routes have a feature-flag gate that runs INSIDE the route + // handler — even a super-admin gets 403 (`QUOTES_DISABLED` / + // similar) when the flag is off. The flag check is independent + // of permissions, so for happy-path tests we flip every CRM flag + // on. Negative tests (no-token, bad-signature) hit adminAuth + // first and never reach the flag check, so they're unaffected. + const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients']; + for (const key of crmFlags) { + // eslint-disable-next-line no-await-in-loop + await db('feature_flags').where({ key }).update({ value: 1 }); + } + + // Invalid: signed with a different secret. adminAuth must reject. + const jwt = require('jsonwebtoken'); + invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' }); + }, 60000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + describe.each(ROUTES)('$name', ({ mount, loader, getPath }) => { + let app; + + beforeAll(() => { + app = buildRouteApp(mount, loader()); + }); + + it('returns 401 with no Authorization header', async () => { + const res = await request(app).get(`${mount}${getPath}`); + expect(res.status).toBe(401); + }); + + it('returns 401 with an invalid JWT signature', async () => { + const res = await request(app) + .get(`${mount}${getPath}`) + .set('Authorization', `Bearer ${invalidToken}`); + expect(res.status).toBe(401); + }); + + it('returns 2xx (or resource-shaped 4xx) with a valid super-admin token', async () => { + const res = await request(app) + .get(`${mount}${getPath}`) + .set('Authorization', `Bearer ${superAdminToken}`); + // 200 if listing succeeds (likely empty list), 400 if a + // validator complains about query shape, 404 if the route + // doesn't have a list endpoint at `/`. What MUST NOT happen: + // 401 (auth gate failed) or 403 (permission gate failed). + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + expect(res.status).toBeLessThan(500); + }); + }); + + describe('adminCustomers — CRM additions (hour-entries / bill / trigger-monthly-bill)', () => { + let app; + beforeAll(() => { + app = buildRouteApp('/api/admin/customers', require('../../src/routes/adminCustomers')); + }); + + it('GET /:id/hour-entries — 401 without token', async () => { + const res = await request(app).get(`/api/admin/customers/${customerId}/hour-entries`); + expect(res.status).toBe(401); + }); + + it('GET /:id/hour-entries — 2xx with super-admin token', async () => { + const res = await request(app) + .get(`/api/admin/customers/${customerId}/hour-entries`) + .set('Authorization', `Bearer ${superAdminToken}`); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + expect(res.status).toBeLessThan(500); + }); + + it('POST /:id/hour-entries/bill — 401 without token', async () => { + const res = await request(app) + .post(`/api/admin/customers/${customerId}/hour-entries/bill`) + .send({}); + expect(res.status).toBe(401); + }); + + it('POST /:id/trigger-monthly-bill — 401 without token', async () => { + const res = await request(app) + .post(`/api/admin/customers/${customerId}/trigger-monthly-bill`) + .send({}); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/__tests__/routes/publicContracts.test.js b/backend/__tests__/routes/publicContracts.test.js new file mode 100644 index 00000000..a1ed781c --- /dev/null +++ b/backend/__tests__/routes/publicContracts.test.js @@ -0,0 +1,153 @@ +/** + * 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'); + }); + }); +}); diff --git a/backend/__tests__/routes/publicPaymentCheck.test.js b/backend/__tests__/routes/publicPaymentCheck.test.js new file mode 100644 index 00000000..ea5c0ee8 --- /dev/null +++ b/backend/__tests__/routes/publicPaymentCheck.test.js @@ -0,0 +1,106 @@ +/** + * HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570). + * + * Two endpoints: + * GET /:token — load invoice payment-check view + * POST /:token — record customer's "paid / unpaid / partial" claim + * + * Unlike the quote / contract public routes, payment-check goes + * through invoiceService rather than the shared publicTokenGuards. + * Tests focus on the validator gates and the unknown-token edge. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-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, buildRouteApp } = require('../integration/helpers/crmDb'); + +describe('publicPaymentCheck routes', () => { + let cleanup; + let app; + + beforeAll(async () => { + let db; + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck')); + }, 60000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + describe('GET /:token', () => { + it('rejects malformed tokens with 400', async () => { + const res = await request(app).get('/api/public/payment-check/short'); + expect(res.status).toBe(400); + }); + + it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => { + const fakeToken = 'a'.repeat(64); + const res = await request(app).get(`/api/public/payment-check/${fakeToken}`); + // Service throws NotFound or similar — what matters is the + // request reaches the service AND isn't an unhandled 500. + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(600); + }); + }); + + describe('POST /:token', () => { + it('rejects malformed tokens with 400', async () => { + const res = await request(app) + .post('/api/public/payment-check/short') + .send({ action: 'paid_full' }); + expect(res.status).toBe(400); + }); + + it('rejects an invalid action with 400', async () => { + const validToken = 'b'.repeat(64); + const res = await request(app) + .post(`/api/public/payment-check/${validToken}`) + .send({ action: 'maybe' }); + expect(res.status).toBe(400); + }); + + it('accepts the canonical four actions through the validator', async () => { + // Each action passes validator (token is well-formed); service + // then rejects unknown token with a 4xx — what we're pinning is + // the validator doesn't reject any of the canonical actions. + const validToken = 'c'.repeat(64); + for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) { + // eslint-disable-next-line no-await-in-loop + const res = await request(app) + .post(`/api/public/payment-check/${validToken}`) + .send({ action }); + // Either succeeds (rare — no real invoice) or service-level + // 4xx for unknown token. Must NOT be 400 (which would mean + // the validator rejected the action). + expect(res.status).not.toBe(400); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(600); + } + }); + + it('rejects negative amountMinor with 400', async () => { + // Validator chain: optional({ values: 'falsy' }) means + // amountMinor=0 / null / undefined gets skipped (allowed). For + // any actually-supplied integer, isInt({ min: 1 }) takes over — + // pin the negative-rejection so a future refactor can't loosen + // the lower bound silently. + const validToken = 'd'.repeat(64); + const res = await request(app) + .post(`/api/public/payment-check/${validToken}`) + .send({ action: 'partial', amountMinor: -100 }); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/backend/__tests__/routes/publicQuotes.test.js b/backend/__tests__/routes/publicQuotes.test.js new file mode 100644 index 00000000..f5234f71 --- /dev/null +++ b/backend/__tests__/routes/publicQuotes.test.js @@ -0,0 +1,171 @@ +/** + * 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); + }); + }); +});