Merge pull request #586 from the-luap/feat/crm-route-tests-570
test(crm): HTTP route tests for CRM public + admin surface
This commit is contained in:
@@ -118,4 +118,113 @@ async function seedMinimal(db) {
|
|||||||
return { adminId, customerId };
|
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 <token>`.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user