diff --git a/src/app/api/v1/berth-reservations/handlers.ts b/src/app/api/v1/berth-reservations/handlers.ts new file mode 100644 index 0000000..947cbe2 --- /dev/null +++ b/src/app/api/v1/berth-reservations/handlers.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/v1/berth-reservations/route.ts b/src/app/api/v1/berth-reservations/route.ts index 626ea7d..2497266 100644 --- a/src/app/api/v1/berth-reservations/route.ts +++ b/src/app/api/v1/berth-reservations/route.ts @@ -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)); diff --git a/src/app/api/v1/saved-views/[id]/handlers.ts b/src/app/api/v1/saved-views/[id]/handlers.ts new file mode 100644 index 0000000..9a974b4 --- /dev/null +++ b/src/app/api/v1/saved-views/[id]/handlers.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/app/api/v1/saved-views/[id]/route.ts b/src/app/api/v1/saved-views/[id]/route.ts index 6c76af4..bea776a 100644 --- a/src/app/api/v1/saved-views/[id]/route.ts +++ b/src/app/api/v1/saved-views/[id]/route.ts @@ -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 { - 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); diff --git a/tests/integration/api/berth-reservations-list.test.ts b/tests/integration/api/berth-reservations-list.test.ts new file mode 100644 index 0000000..7956ccd --- /dev/null +++ b/tests/integration/api/berth-reservations-list.test.ts @@ -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, + }); + }); +}); diff --git a/tests/integration/api/saved-views-ownership.test.ts b/tests/integration/api/saved-views-ownership.test.ts new file mode 100644 index 0000000..ee3c8aa --- /dev/null +++ b/tests/integration/api/saved-views-ownership.test.ts @@ -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, + 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(); + }); +}); diff --git a/tests/integration/documents-expired-webhook.test.ts b/tests/integration/documents-expired-webhook.test.ts new file mode 100644 index 0000000..6f02c22 --- /dev/null +++ b/tests/integration/documents-expired-webhook.test.ts @@ -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(); + }); +}); diff --git a/tests/integration/portal-auth.test.ts b/tests/integration/portal-auth.test.ts new file mode 100644 index 0000000..aa1a83f --- /dev/null +++ b/tests/integration/portal-auth.test.ts @@ -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) + .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) + .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) + .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) + .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(); + }); +});