test(audit-fixes): cover the new permission and webhook surfaces
Adds integration coverage for the routes / handlers shipped in the preceding audit-fix commits, plus refactors two route files to expose inner handlers from a sibling `handlers.ts` (the pattern used elsewhere in `src/app/api/v1`) so tests can call them without the `withAuth(withPermission(…))` wrapper. New tests (18 cases across 4 files): - `tests/integration/portal-auth.test.ts` (6) — verifyPortalToken rejects tokens missing `aud: 'portal'` or `iss: 'pn-crm'`, with the wrong audience (CRM-session-replay shape) or wrong issuer, plus a round-trip happy path. Locks in the portal-vs-CRM token isolation. - `tests/integration/api/saved-views-ownership.test.ts` (6) — patch and delete handlers return 403 for a different user, 404 for an unknown id or cross-port id, and 200 for the owner. Ownership is enforced at the route layer regardless of the service's internal filtering. - `tests/integration/api/berth-reservations-list.test.ts` (3) — the new global list returns rows for the current port only and honors pagination params. A reservation in a different port never leaks. - `tests/integration/documents-expired-webhook.test.ts` (3) — handleDocumentExpired flips the document to `expired`, also flips the linked interest's `eoiStatus`, writes a `documentEvents` row, and is a no-op (not a throw) when the documensoId is unknown. Refactors: - `src/app/api/v1/saved-views/[id]/route.ts` extracts `patchHandler` / `deleteHandler` (and the shared `assertViewOwner`) into `handlers.ts`. The route file is now a 4-line `withAuth(handler)` wrapper. - `src/app/api/v1/berth-reservations/route.ts` extracts `listHandler` similarly. Tests import directly from `handlers.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||
|
||||
/**
|
||||
* Port-scoped global list of reservations across all berths. Inner handler
|
||||
* lives here so it can be invoked directly from integration tests without
|
||||
* the `withAuth(withPermission(...))` wrappers (matches the convention
|
||||
* used throughout `src/app/api/v1/*`).
|
||||
*/
|
||||
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||
try {
|
||||
const query = parseQuery(req as never, listReservationsSchema);
|
||||
const result = await listReservations(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,4 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||
import { listHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reservations', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listReservationsSchema);
|
||||
const result = await listReservations(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||
|
||||
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { savedViews } from '@/lib/db/schema';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||
|
||||
/**
|
||||
* Resolves the view and enforces ownership before mutating.
|
||||
*
|
||||
* Returns a 404 when the view does not exist (or lives in a different port)
|
||||
* and a 403 when it belongs to a different user. The 404-before-403 split
|
||||
* matches the rest of the API and avoids leaking the existence of another
|
||||
* user's saved view via timing or status code.
|
||||
*/
|
||||
async function assertViewOwner(
|
||||
id: string,
|
||||
portId: string,
|
||||
userId: string,
|
||||
): Promise<NextResponse | null> {
|
||||
const view = await db.query.savedViews.findFirst({
|
||||
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||
});
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
if (view.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function patchHandler(
|
||||
req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||
return NextResponse.json({ data: view });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHandler(
|
||||
_req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||
return NextResponse.json({ data: null }, { status: 200 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,5 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { savedViews } from '@/lib/db/schema';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||
import { patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
/** Resolves the view and enforces ownership before mutating. */
|
||||
async function assertViewOwner(
|
||||
id: string,
|
||||
portId: string,
|
||||
userId: string,
|
||||
): Promise<NextResponse | null> {
|
||||
const view = await db.query.savedViews.findFirst({
|
||||
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||
});
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
if (view.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const body = await parseBody(req, updateSavedViewSchema);
|
||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||
return NextResponse.json({ data: view });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||
return NextResponse.json({ data: null }, { status: 200 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
export const DELETE = withAuth(deleteHandler);
|
||||
|
||||
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Port-scoped global reservations list — locks in feat(marina): the new
|
||||
* `GET /api/v1/berth-reservations` endpoint that powers the
|
||||
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery →
|
||||
* listReservations); the test guarantees port scoping at the handler
|
||||
* boundary so a future refactor of the service can't accidentally leak
|
||||
* cross-port rows.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { listHandler } from '@/app/api/v1/berth-reservations/handlers';
|
||||
import { createHandler as createReservationHandler } from '@/app/api/v1/berths/[id]/reservations/handlers';
|
||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||
import {
|
||||
makeBerth,
|
||||
makeClient,
|
||||
makeFullPermissions,
|
||||
makePort,
|
||||
makeYacht,
|
||||
} from '../../helpers/factories';
|
||||
|
||||
async function seedReservation(portId: string) {
|
||||
const berth = await makeBerth({ portId });
|
||||
const client = await makeClient({ portId });
|
||||
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
|
||||
const ctx = makeMockCtx({ portId, permissions: makeFullPermissions() });
|
||||
const res = await createReservationHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
yachtId: yacht.id,
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: berth.id },
|
||||
);
|
||||
return (await res.json()).data as { id: string; berthId: string };
|
||||
}
|
||||
|
||||
describe('GET /api/v1/berth-reservations', () => {
|
||||
it('returns all reservations for the requesting port', async () => {
|
||||
const port = await makePort();
|
||||
const r1 = await seedReservation(port.id);
|
||||
const r2 = await seedReservation(port.id);
|
||||
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
const ids = (body.data as Array<{ id: string }>).map((r) => r.id).sort();
|
||||
expect(ids).toEqual([r1.id, r2.id].sort());
|
||||
expect(body.pagination).toMatchObject({ page: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('does not leak reservations from a different port', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const reservationInB = await seedReservation(portB.id);
|
||||
|
||||
// Caller is operating in portA; portB's reservation must not appear.
|
||||
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
const ids = (body.data as Array<{ id: string }>).map((r) => r.id);
|
||||
expect(ids).not.toContain(reservationInB.id);
|
||||
});
|
||||
|
||||
it('honors pagination via query params', async () => {
|
||||
const port = await makePort();
|
||||
await seedReservation(port.id);
|
||||
await seedReservation(port.id);
|
||||
await seedReservation(port.id);
|
||||
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations?page=1&limit=2'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.pagination).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
total: 3,
|
||||
totalPages: 2,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Saved-views ownership enforcement — locks in the 403/404 split shipped
|
||||
* in fix(auth). The route handlers preflight `assertViewOwner` BEFORE the
|
||||
* service call, so even if the service's internal userId filter is later
|
||||
* refactored, the route still rejects cross-user mutations.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { patchHandler, deleteHandler } from '@/app/api/v1/saved-views/[id]/handlers';
|
||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||
import { makePort } from '../../helpers/factories';
|
||||
|
||||
describe('saved-views ownership enforcement', () => {
|
||||
let portId: string;
|
||||
let viewId: string;
|
||||
const ownerUserId = 'user-owner';
|
||||
const otherUserId = 'user-other';
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
const view = await savedViewsService.create(portId, ownerUserId, {
|
||||
entityType: 'clients',
|
||||
name: 'Hot leads',
|
||||
filters: { stage: 'hot_lead' } as Record<string, unknown>,
|
||||
isShared: false,
|
||||
isDefault: false,
|
||||
});
|
||||
if (!view) throw new Error('seed view failed');
|
||||
viewId = view.id;
|
||||
});
|
||||
|
||||
it('PATCH from owner: 200', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'Renamed by owner' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.name).toBe('Renamed by owner');
|
||||
});
|
||||
|
||||
it('PATCH from a different user: 403 (not 404)', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'Hostile rename' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Verify the row was not mutated.
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row?.name).toBe('Renamed by owner');
|
||||
});
|
||||
|
||||
it('DELETE from a different user: 403 and view still exists', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||
const res = await deleteHandler(
|
||||
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PATCH on a non-existent id: 404', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest(
|
||||
'PATCH',
|
||||
'http://localhost/api/v1/saved-views/00000000-0000-0000-0000-000000000000',
|
||||
{ body: { name: 'no-op' } },
|
||||
),
|
||||
ctx,
|
||||
{ id: '00000000-0000-0000-0000-000000000000' },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PATCH on a view in a different port: 404 (cross-port enumeration is blocked)', async () => {
|
||||
// The view exists in `portId` but the auth context says we're operating
|
||||
// in a different port. The lookup is scoped to `(id, portId)` so the row
|
||||
// is invisible — should 404, not 403.
|
||||
const otherPort = await makePort();
|
||||
const ctx = makeMockCtx({ portId: otherPort.id, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'cross-port attempt' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE from owner: 200 and view is gone', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await deleteHandler(
|
||||
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
92
tests/integration/documents-expired-webhook.test.ts
Normal file
92
tests/integration/documents-expired-webhook.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* DOCUMENT_EXPIRED webhook handling — locks in fix(documenso). The handler
|
||||
* was previously defined but never wired to the route's event switch, so
|
||||
* expired EOIs stayed in `sent` / `partially_signed` forever.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { handleDocumentExpired } from '@/lib/services/documents.service';
|
||||
import { makeBerth, makeClient, makePort } from '../helpers/factories';
|
||||
|
||||
describe('handleDocumentExpired', () => {
|
||||
it('flips a sent EOI to expired and writes a documentEvents row', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
const documensoId = `documenso-test-${Date.now()}`;
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
documentType: 'eoi',
|
||||
title: 'Expiring EOI',
|
||||
status: 'sent',
|
||||
documensoId,
|
||||
createdBy: 'seed',
|
||||
})
|
||||
.returning();
|
||||
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
|
||||
const after = await db.query.documents.findFirst({
|
||||
where: eq(documents.id, doc!.id),
|
||||
});
|
||||
expect(after?.status).toBe('expired');
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(documentEvents)
|
||||
.where(eq(documentEvents.documentId, doc!.id));
|
||||
expect(events.map((e) => e.eventType)).toContain('expired');
|
||||
});
|
||||
|
||||
it('also flips the linked interest eoiStatus to expired', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
|
||||
const [interest] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
berthId: berth.id,
|
||||
pipelineStage: 'eoi_sent',
|
||||
leadCategory: 'hot_lead',
|
||||
eoiStatus: 'sent',
|
||||
})
|
||||
.returning();
|
||||
|
||||
const documensoId = `documenso-test-${Date.now()}-i`;
|
||||
await db.insert(documents).values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
interestId: interest!.id,
|
||||
documentType: 'eoi',
|
||||
title: 'Expiring EOI for interest',
|
||||
status: 'sent',
|
||||
documensoId,
|
||||
createdBy: 'seed',
|
||||
});
|
||||
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
|
||||
const updatedInterest = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, interest!.id),
|
||||
});
|
||||
expect(updatedInterest?.eoiStatus).toBe('expired');
|
||||
});
|
||||
|
||||
it('is a no-op when the documensoId does not match any document', async () => {
|
||||
// Should NOT throw — the handler logs a warning and returns. Verify no
|
||||
// exception propagates up to the webhook route.
|
||||
await expect(
|
||||
handleDocumentExpired({ documentId: 'definitely-not-a-real-doc' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
84
tests/integration/portal-auth.test.ts
Normal file
84
tests/integration/portal-auth.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Portal JWT verification — locks in the audience/issuer hardening shipped
|
||||
* in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims
|
||||
* must NOT verify, even if it's signed with the correct shared secret.
|
||||
*
|
||||
* Without these claims the CRM (better-auth) and portal sessions are
|
||||
* structurally identical, so a portal token could be replayed against any
|
||||
* `verifyPortalToken` consumer (and vice versa).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
import { createPortalToken, verifyPortalToken } from '@/lib/portal/auth';
|
||||
|
||||
const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
||||
|
||||
const SESSION = {
|
||||
clientId: '11111111-1111-1111-1111-111111111111',
|
||||
portId: '22222222-2222-2222-2222-222222222222',
|
||||
email: 'client@example.com',
|
||||
};
|
||||
|
||||
describe('portal JWT', () => {
|
||||
it('round-trips a token signed with createPortalToken', async () => {
|
||||
const token = await createPortalToken(SESSION);
|
||||
const verified = await verifyPortalToken(token);
|
||||
expect(verified).toMatchObject(SESSION);
|
||||
});
|
||||
|
||||
it('rejects a token missing the `aud: portal` claim', async () => {
|
||||
// Issuer present, audience absent — exactly the shape an old (pre-fix)
|
||||
// portal session would have.
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuer('pn-crm')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token missing the `iss: pn-crm` claim', async () => {
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('portal')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong audience (CRM session replay shape)', async () => {
|
||||
// What a better-auth session token might roughly look like — same secret,
|
||||
// different audience. Must not verify against the portal path.
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('crm')
|
||||
.setIssuer('pn-crm')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong issuer', async () => {
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('portal')
|
||||
.setIssuer('attacker')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage', async () => {
|
||||
expect(await verifyPortalToken('not.a.jwt')).toBeNull();
|
||||
expect(await verifyPortalToken('')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user