/** * 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(); }); });