Files
pn-new-crm/tests/integration/public-interest-trio.test.ts

311 lines
11 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeAll } from 'vitest';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { interests } from '@/lib/db/schema/interests';
import { makePort } from '../helpers/factories';
import { makeMockRequest } from '../helpers/route-tester';
// Mock fire-and-forget side-effects so the test doesn't hit Redis / external services.
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
vi.mock('@/lib/queue', () => ({
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
}));
vi.mock('@/lib/services/inquiry-notifications.service', () => ({
sendInquiryNotifications: vi.fn().mockResolvedValue(undefined),
}));
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
// The rate-limiter is keyed by IP header and is now redis-backed; entries
// pexpire after the publicForm window (1h). Randomize the high octets so a
// fresh test run doesn't collide with leftover redis state from a previous
// run sharing the same redis instance.
const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`;
let ipCounter = 1;
function uniqueIp(): string {
ipCounter += 1;
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
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
}
describe('POST /api/public/interests — trio creation', () => {
let POST: typeof import('@/app/api/public/interests/route').POST;
beforeAll(async () => {
// Import after mocks are registered.
const mod = await import('@/app/api/public/interests/route');
POST = mod.POST;
});
it('creates client + yacht + interest atomically', async () => {
const port = await makePort();
const email = `trio-client-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Alice',
lastName: 'Mariner',
email,
phone: '+10000000001',
yacht: {
name: 'Sea Star',
lengthFt: 52,
widthFt: 14,
draftFt: 6,
},
},
});
const res = await POST(req);
expect(res.status).toBe(201);
const body = await res.json();
const interestId: string = body.data.id;
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
expect(interest).toBeDefined();
expect(interest!.portId).toBe(port.id);
expect(interest!.pipelineStage).toBe('open');
expect(interest!.yachtId).not.toBeNull();
expect(interest!.clientId).not.toBeNull();
// Yacht exists, owned by the client
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
expect(yacht).toBeDefined();
expect(yacht!.name).toBe('Sea Star');
expect(yacht!.currentOwnerType).toBe('client');
expect(yacht!.currentOwnerId).toBe(interest!.clientId);
// Ownership history row created
const historyRows = await db
.select()
.from(yachtOwnershipHistory)
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
expect(historyRows.length).toBe(1);
expect(historyRows[0]!.endDate).toBeNull();
expect(historyRows[0]!.ownerType).toBe('client');
expect(historyRows[0]!.ownerId).toBe(interest!.clientId);
// Client has email + phone contacts
const contacts = await db
.select()
.from(clientContacts)
.where(eq(clientContacts.clientId, interest!.clientId));
expect(contacts.some((c) => c.channel === 'email' && c.value === email)).toBe(true);
expect(contacts.some((c) => c.channel === 'phone' && c.value === '+10000000001')).toBe(true);
});
it('creates client + company + membership + company-owned yacht + interest when company provided', async () => {
const port = await makePort();
const email = `trio-co-${Math.random().toString(36).slice(2, 8)}@test.local`;
const companyName = `Nautical Holdings ${Math.random().toString(36).slice(2, 8)}`;
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Bob',
lastName: 'Director',
email,
phone: '+10000000002',
yacht: { name: 'Corporate Cruiser', lengthFt: 80 },
company: {
name: companyName,
role: 'director',
},
},
});
const res = await POST(req);
expect(res.status).toBe(201);
const body = await res.json();
const interestId: string = body.data.id;
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
expect(interest).toBeDefined();
expect(interest!.yachtId).not.toBeNull();
// Yacht owned by the company
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
expect(yacht!.currentOwnerType).toBe('company');
// Company exists and matches
const [company] = await db
.select()
.from(companies)
.where(eq(companies.id, yacht!.currentOwnerId));
expect(company!.name).toBe(companyName);
expect(company!.portId).toBe(port.id);
// Ownership-history points at the company
const historyRows = await db
.select()
.from(yachtOwnershipHistory)
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
expect(historyRows.length).toBe(1);
expect(historyRows[0]!.ownerType).toBe('company');
expect(historyRows[0]!.ownerId).toBe(company!.id);
// Active membership linking client -> company
const memberships = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, company!.id),
eq(companyMemberships.clientId, interest!.clientId),
isNull(companyMemberships.endDate),
),
);
expect(memberships.length).toBe(1);
expect(memberships[0]!.role).toBe('director');
});
it('reuses existing client when email matches (same port)', async () => {
const port = await makePort();
const email = `trio-reuse-${Math.random().toString(36).slice(2, 8)}@test.local`;
const firstReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Carol',
lastName: 'Returning',
email,
phone: '+10000000003',
yacht: { name: 'First Boat' },
},
},
);
const firstRes = await POST(firstReq);
expect(firstRes.status).toBe(201);
const firstBody = await firstRes.json();
const [firstInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, firstBody.data.id));
const originalClientId = firstInterest!.clientId;
// Second submission with the same email
const secondReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Carol',
lastName: 'Returning',
email,
phone: '+10000000003',
yacht: { name: 'Second Boat' },
},
},
);
const secondRes = await POST(secondReq);
expect(secondRes.status).toBe(201);
const secondBody = await secondRes.json();
const [secondInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, secondBody.data.id));
expect(secondInterest!.clientId).toBe(originalClientId);
// A second yacht row was created (not deduped) — each submission is its
// own inquiry about a possibly-different yacht.
const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId));
expect(clientsMatching.length).toBe(1);
const [secondYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, secondInterest!.yachtId!));
expect(secondYacht!.name).toBe('Second Boat');
expect(secondYacht!.id).not.toBe(firstInterest!.yachtId);
});
it('reuses existing company when name matches case-insensitively (same port)', async () => {
const port = await makePort();
const email1 = `trio-coreuse1-${Math.random().toString(36).slice(2, 8)}@test.local`;
const email2 = `trio-coreuse2-${Math.random().toString(36).slice(2, 8)}@test.local`;
const companyName = `Harbor Partners ${Math.random().toString(36).slice(2, 8)}`;
const firstReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Dana',
lastName: 'Founder',
email: email1,
phone: '+10000000004',
yacht: { name: 'Flagship' },
company: { name: companyName, role: 'director' },
},
},
);
const firstRes = await POST(firstReq);
expect(firstRes.status).toBe(201);
const firstBody = await firstRes.json();
const [firstInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, firstBody.data.id));
const [firstYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, firstInterest!.yachtId!));
const originalCompanyId = firstYacht!.currentOwnerId;
// Second submission — same company name, different casing, different client
const secondReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Evan',
lastName: 'Employee',
email: email2,
phone: '+10000000005',
yacht: { name: 'Second Flagship' },
company: { name: companyName.toUpperCase(), role: 'employee' },
},
},
);
const secondRes = await POST(secondReq);
expect(secondRes.status).toBe(201);
const secondBody = await secondRes.json();
const [secondInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, secondBody.data.id));
const [secondYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, secondInterest!.yachtId!));
expect(secondYacht!.currentOwnerId).toBe(originalCompanyId);
// Only one company row exists for that (portId, lowered name)
const allCompanies = await db.select().from(companies).where(eq(companies.portId, port.id));
const matching = allCompanies.filter((c) => c.name.toLowerCase() === companyName.toLowerCase());
expect(matching.length).toBe(1);
// Second client has its own membership in the same company
const memberships = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, originalCompanyId),
eq(companyMemberships.clientId, secondInterest!.clientId),
isNull(companyMemberships.endDate),
),
);
expect(memberships.length).toBe(1);
expect(memberships[0]!.role).toBe('employee');
});
});