Files
pn-new-crm/tests/integration/public-residential-inquiry.test.ts
Matt Ciaccio 4c5334d471 sec: gate super-admin invite minting, OCR settings, and alert mutations
Three findings from the branch security review:

1. HIGH — Privilege escalation via super-admin invite. POST
   /api/v1/admin/invitations was gated only by manage_users (held by the
   port-scoped director role). The body schema accepted isSuperAdmin
   from the request, createCrmInvite persisted it verbatim, and
   consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
   the new account cross-tenant access. Now the route rejects
   isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
   requires invitedBy.isSuperAdmin as defense-in-depth.

2. HIGH — Receipt-image exfiltration via OCR settings. The route
   /api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
   in withAuth — any port role including viewer could PUT a swapped
   provider apiKey + flip aiEnabled, redirecting every subsequent
   receipt scan to attacker infrastructure. Both are now wrapped in
   withPermission('admin','manage_settings',…) matching the sibling
   admin routes (ai-budget, settings).

3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
   issued UPDATE … WHERE id=? with no portId predicate. Any
   authenticated user with a foreign alert UUID could mutate it. Both
   service functions now require portId and add it to the WHERE; the
   route handlers pass ctx.portId.

The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.

The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).

Two new regression test files cover the fixes (6 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:27:01 +02:00

142 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* i18n PR910 — public residential inquiry endpoint.
*
* Validates the server-side phone normalization that the public inquiry
* route runs when the website posts a raw international format (older
* Nuxt builds), and that pre-normalized payloads pass through unchanged.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients } from '@/lib/db/schema/residential';
import { makePort } from '../helpers/factories';
import { makeMockRequest } from '../helpers/route-tester';
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
// Randomize per-run prefix so leftover redis sliding-window entries from a
// previous run don't 429 the new run (publicForm limiter pexpires after 1h).
const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`;
let ipCounter = 1;
function uniqueIp(): string {
ipCounter += 1;
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
}
describe('POST /api/public/residential-inquiries', () => {
let POST: typeof import('@/app/api/public/residential-inquiries/route').POST;
beforeAll(async () => {
const mod = await import('@/app/api/public/residential-inquiries/route');
POST = mod.POST;
});
it('parses a raw international phone string into E.164 + country', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Anna',
lastName: 'Nowak',
email,
// Raw international format — server should normalize.
phone: '+44 20 7946 0958',
placeOfResidence: 'Warsaw',
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row).toBeDefined();
expect(row?.phoneE164).toBe('+442079460958');
expect(row?.phoneCountry).toBe('GB');
// Free-text legacy column preserved verbatim for backfill.
expect(row?.phone).toBe('+44 20 7946 0958');
});
it('passes pre-normalized E.164 + country through unchanged', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Jan',
lastName: 'Kowalski',
email,
phone: '+48 22 555 0100',
phoneE164: '+48225550100',
phoneCountry: 'PL',
nationalityIso: 'PL',
timezone: 'Europe/Warsaw',
placeOfResidence: 'Warsaw',
placeOfResidenceCountryIso: 'PL',
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row?.phoneE164).toBe('+48225550100');
expect(row?.phoneCountry).toBe('PL');
expect(row?.nationalityIso).toBe('PL');
expect(row?.timezone).toBe('Europe/Warsaw');
expect(row?.placeOfResidenceCountryIso).toBe('PL');
});
it('persists a national-format phone when the website only sends a country hint', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Marta',
lastName: 'Lewandowska',
email,
phone: '22 555 0200', // National-format
phoneCountry: 'PL', // Hint only — no E.164 yet.
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row?.phoneE164).toBe('+48225550200');
expect(row?.phoneCountry).toBe('PL');
});
});