From 1b345be1c2b8d44a12df1cfefd112e7827be038b Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 14:33:17 +0530 Subject: [PATCH] Validate web API input with zod so bad enums and oversized payloads return 400 --- apps/web/package.json | 3 +- apps/web/src/__tests__/api-validation.test.ts | 400 ++++++++++++++++++ apps/web/src/__tests__/broadcasts-api.test.ts | 38 ++ apps/web/src/__tests__/docs-api.test.ts | 38 ++ apps/web/src/__tests__/tickets-api.test.ts | 9 + apps/web/src/app/api/broadcasts/route.ts | 30 +- apps/web/src/app/api/docs/articles/route.ts | 32 +- apps/web/src/app/api/tickets/route.ts | 101 +++-- apps/web/src/lib/validate.ts | 123 ++++++ pnpm-lock.yaml | 3 + 10 files changed, 728 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/__tests__/api-validation.test.ts create mode 100644 apps/web/src/lib/validate.ts diff --git a/apps/web/package.json b/apps/web/package.json index 230220a2..11fa42f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,8 @@ "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/web/src/__tests__/api-validation.test.ts b/apps/web/src/__tests__/api-validation.test.ts new file mode 100644 index 00000000..36110b2b --- /dev/null +++ b/apps/web/src/__tests__/api-validation.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// ─── Mocks ──────────────────────────────────────────────────────────────── + +const mockTicketFindMany = vi.fn(); +const mockTicketCount = vi.fn(); +const mockTicketCreate = vi.fn(); +const mockBroadcastFindMany = vi.fn(); +const mockBroadcastCreate = vi.fn(); +const mockDocArticleFindMany = vi.fn(); +const mockDocArticleCreate = vi.fn(); +const mockDocCategoryFindUnique = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + ticket: { + findMany: (...args: unknown[]) => mockTicketFindMany(...args), + count: (...args: unknown[]) => mockTicketCount(...args), + create: (...args: unknown[]) => mockTicketCreate(...args), + }, + broadcast: { + findMany: (...args: unknown[]) => mockBroadcastFindMany(...args), + create: (...args: unknown[]) => mockBroadcastCreate(...args), + }, + docArticle: { + findMany: (...args: unknown[]) => mockDocArticleFindMany(...args), + create: (...args: unknown[]) => mockDocArticleCreate(...args), + }, + docCategory: { + findUnique: (...args: unknown[]) => mockDocCategoryFindUnique(...args), + }, + }, + TicketStatus: { + OPEN: 'OPEN', + IN_PROGRESS: 'IN_PROGRESS', + WAITING_ON_CUSTOMER: 'WAITING_ON_CUSTOMER', + WAITING_ON_TEAM: 'WAITING_ON_TEAM', + RESOLVED: 'RESOLVED', + CLOSED: 'CLOSED', + }, + TicketPriority: { CRITICAL: 'CRITICAL', HIGH: 'HIGH', MEDIUM: 'MEDIUM', LOW: 'LOW' }, + TicketType: { + BUG: 'BUG', + FEATURE_REQUEST: 'FEATURE_REQUEST', + QUESTION: 'QUESTION', + INTEGRATION_HELP: 'INTEGRATION_HELP', + ACCOUNT_ISSUE: 'ACCOUNT_ISSUE', + OTHER: 'OTHER', + }, + TicketSource: { + DISCORD: 'DISCORD', + SLACK: 'SLACK', + GITHUB_ISSUE: 'GITHUB_ISSUE', + GITHUB_DISCUSSION: 'GITHUB_DISCUSSION', + WEB: 'WEB', + EMAIL: 'EMAIL', + LINEAR: 'LINEAR', + MANUAL: 'MANUAL', + ORCA: 'ORCA', + TEAMS: 'TEAMS', + }, + BroadcastAudience: { + ALL_ACCOUNTS: 'ALL_ACCOUNTS', + SELECTED_ACCOUNTS: 'SELECTED_ACCOUNTS', + BY_SENTIMENT: 'BY_SENTIMENT', + }, + BroadcastStatus: { DRAFT: 'DRAFT', SENT: 'SENT' }, + Prisma: {}, +})); + +vi.mock('@copilotkit/outpost/shared', () => ({ + DEFAULT_PAGE_SIZE: 25, + MAX_PAGE_SIZE: 100, + generateTicketId: () => 'TKT-VALIDATION1', +})); + +const mockGetServerSession = vi.fn(); + +vi.mock('next-auth/next', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +vi.mock('next-auth', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +vi.mock('@/lib/auth', () => ({ + authOptions: {}, +})); + +vi.mock('@/lib/require-admin', () => ({ + requireSession: vi.fn().mockResolvedValue({}), + requireAdmin: vi.fn().mockResolvedValue({}), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────── + +import { GET as ticketsGet, POST as ticketsPost } from '@/app/api/tickets/route'; +import { POST as broadcastsPost } from '@/app/api/broadcasts/route'; +import { POST as articlesPost } from '@/app/api/docs/articles/route'; +import { + parsePagination, + sanitizeSearch, + ticketCreateSchema, + broadcastCreateSchema, + articleCreateSchema, + MAX_TITLE_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_BROADCAST_LENGTH, +} from '@/lib/validate'; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function getRequest(url: string): NextRequest { + return new NextRequest(new URL(url, 'http://localhost:3000')); +} + +function jsonRequest(url: string, body: unknown): NextRequest { + return new NextRequest(new URL(url, 'http://localhost:3000'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const authedSession = { + user: { id: 'tm-1', name: 'Test', email: 'test@test.com', role: 'ADMIN', memberId: 'tm-1' }, +}; + +// ─── Unit: pagination ───────────────────────────────────────────────────── + +describe('parsePagination', () => { + it('defaults when params are absent', () => { + const p = parsePagination(new URLSearchParams()); + expect(p).toEqual({ page: 1, pageSize: 25, skip: 0 }); + }); + + it('parses valid values', () => { + const p = parsePagination(new URLSearchParams('page=3&pageSize=10')); + expect(p).toEqual({ page: 3, pageSize: 10, skip: 20 }); + }); + + it('falls back to defaults for non-numeric input (previously NaN -> Prisma throw)', () => { + const p = parsePagination(new URLSearchParams('page=abc&pageSize=xyz')); + expect(p.page).toBe(1); + expect(p.pageSize).toBe(25); + }); + + it('clamps pageSize to MAX_PAGE_SIZE and page to >= 1', () => { + const p = parsePagination(new URLSearchParams('page=0&pageSize=9999')); + expect(p.page).toBe(1); + expect(p.pageSize).toBe(100); + }); +}); + +describe('sanitizeSearch', () => { + it('returns undefined for missing/blank search', () => { + expect(sanitizeSearch(null)).toBeUndefined(); + expect(sanitizeSearch(' ')).toBeUndefined(); + }); + + it('truncates overlong search input', () => { + expect(sanitizeSearch('a'.repeat(500))?.length).toBe(200); + }); +}); + +// ─── Unit: schemas ──────────────────────────────────────────────────────── + +describe('ticketCreateSchema', () => { + const valid = { title: 'Login fails', description: 'Steps to reproduce...' }; + + it('accepts a minimal valid payload', () => { + expect(ticketCreateSchema.safeParse(valid).success).toBe(true); + }); + + it('rejects non-string title/description (previously passed through to Prisma)', () => { + expect(ticketCreateSchema.safeParse({ ...valid, title: 123 }).success).toBe(false); + expect(ticketCreateSchema.safeParse({ ...valid, description: ['x'] }).success).toBe(false); + }); + + it('rejects overlong title/description', () => { + expect( + ticketCreateSchema.safeParse({ ...valid, title: 't'.repeat(MAX_TITLE_LENGTH + 1) }) + .success, + ).toBe(false); + expect( + ticketCreateSchema.safeParse({ + ...valid, + description: 'd'.repeat(MAX_DESCRIPTION_LENGTH + 1), + }).success, + ).toBe(false); + }); + + it('rejects unknown enum values', () => { + expect( + ticketCreateSchema.safeParse({ ...valid, priority: 'URGENT' }).success, + ).toBe(false); + expect(ticketCreateSchema.safeParse({ ...valid, source: 'SMS' }).success).toBe(false); + }); +}); + +describe('broadcastCreateSchema', () => { + it('rejects an arbitrary audience string (previously a Prisma throw)', () => { + expect( + broadcastCreateSchema.safeParse({ message: 'hi', audience: 'EVERYONE' }).success, + ).toBe(false); + expect( + broadcastCreateSchema.safeParse({ message: 'hi', audience: 'ALL_ACCOUNTS' }).success, + ).toBe(true); + }); + + it('enforces the message length cap', () => { + expect( + broadcastCreateSchema.safeParse({ message: 'm'.repeat(MAX_BROADCAST_LENGTH + 1) }) + .success, + ).toBe(false); + }); +}); + +describe('articleCreateSchema', () => { + const valid = { title: 'Guide', content: 'Body', categoryId: 'cat-1' }; + + it('accepts a minimal valid payload', () => { + expect(articleCreateSchema.safeParse(valid).success).toBe(true); + }); + + it('rejects overlong content and blank categoryId', () => { + expect( + articleCreateSchema.safeParse({ ...valid, content: 'c'.repeat(100001) }).success, + ).toBe(false); + expect(articleCreateSchema.safeParse({ ...valid, categoryId: ' ' }).success).toBe(false); + }); +}); + +// ─── Route: GET /api/tickets enum validation ────────────────────────────── + +describe('GET /api/tickets validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue(authedSession); + mockTicketFindMany.mockResolvedValue([]); + mockTicketCount.mockResolvedValue(0); + }); + + it('returns 400 for an unknown status value instead of a 500', async () => { + const res = await ticketsGet(getRequest('/api/tickets?status=BOGUS') as never); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('BOGUS'); + expect(mockTicketFindMany).not.toHaveBeenCalled(); + }); + + it('returns 400 for an unknown priority value', async () => { + const res = await ticketsGet(getRequest('/api/tickets?priority=URGENT') as never); + expect(res.status).toBe(400); + expect(mockTicketFindMany).not.toHaveBeenCalled(); + }); + + it('accepts valid enum filters and passes them through', async () => { + const res = await ticketsGet( + getRequest('/api/tickets?status=OPEN&priority=HIGH') as never, + ); + expect(res.status).toBe(200); + const where = mockTicketFindMany.mock.calls[0][0].where; + expect(where.status).toEqual({ in: ['OPEN'] }); + expect(where.priority).toEqual({ in: ['HIGH'] }); + }); + + it('survives non-numeric pagination without a Prisma throw', async () => { + const res = await ticketsGet(getRequest('/api/tickets?page=abc&pageSize=xyz') as never); + expect(res.status).toBe(200); + const call = mockTicketFindMany.mock.calls[0][0]; + expect(call.take).toBe(25); + expect(call.skip).toBe(0); + }); +}); + +// ─── Route: POST /api/tickets ───────────────────────────────────────────── + +describe('POST /api/tickets validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue(authedSession); + mockTicketCreate.mockResolvedValue({ id: 'tkt-1' }); + }); + + it('keeps the legacy message when title/description are missing', async () => { + const res = await ticketsPost(jsonRequest('/api/tickets', {}) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe('title and description are required'); + }); + + it('returns 400 for a non-string title', async () => { + const res = await ticketsPost( + jsonRequest('/api/tickets', { title: 123, description: 'desc' }) as never, + ); + expect(res.status).toBe(400); + expect(mockTicketCreate).not.toHaveBeenCalled(); + }); + + it('returns 400 for an overlong title', async () => { + const res = await ticketsPost( + jsonRequest('/api/tickets', { + title: 't'.repeat(MAX_TITLE_LENGTH + 1), + description: 'desc', + }) as never, + ); + expect(res.status).toBe(400); + expect(mockTicketCreate).not.toHaveBeenCalled(); + }); + + it('returns 400 for an unknown priority', async () => { + const res = await ticketsPost( + jsonRequest('/api/tickets', { + title: 't', + description: 'd', + priority: 'URGENT', + }) as never, + ); + expect(res.status).toBe(400); + expect(mockTicketCreate).not.toHaveBeenCalled(); + }); + + it('creates the ticket for a valid payload with defaults', async () => { + const res = await ticketsPost( + jsonRequest('/api/tickets', { title: 't', description: 'd' }) as never, + ); + expect(res.status).toBe(201); + const data = mockTicketCreate.mock.calls[0][0].data; + expect(data.priority).toBe('MEDIUM'); + expect(data.type).toBe('QUESTION'); + expect(data.source).toBe('MANUAL'); + }); +}); + +// ─── Route: POST /api/broadcasts ────────────────────────────────────────── + +describe('POST /api/broadcasts validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBroadcastCreate.mockResolvedValue({ id: 'bc-1' }); + }); + + it('returns 400 for an arbitrary audience string', async () => { + const res = await broadcastsPost( + jsonRequest('/api/broadcasts', { message: 'hello', audience: 'EVERYONE' }) as never, + ); + expect(res.status).toBe(400); + expect(mockBroadcastCreate).not.toHaveBeenCalled(); + }); + + it('accepts a valid audience and persists it', async () => { + const res = await broadcastsPost( + jsonRequest('/api/broadcasts', { + message: 'hello', + audience: 'SELECTED_ACCOUNTS', + targetAccounts: ['acc-1'], + }) as never, + ); + expect(res.status).toBe(201); + const data = mockBroadcastCreate.mock.calls[0][0].data; + expect(data.audience).toBe('SELECTED_ACCOUNTS'); + expect(data.targetAccounts).toEqual(['acc-1']); + }); +}); + +// ─── Route: POST /api/docs/articles ─────────────────────────────────────── + +describe('POST /api/docs/articles validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDocCategoryFindUnique.mockResolvedValue({ id: 'cat-1' }); + mockDocArticleCreate.mockResolvedValue({ id: 'art-1' }); + }); + + it('returns 400 for an overlong title', async () => { + const res = await articlesPost( + jsonRequest('/api/docs/articles', { + title: 't'.repeat(MAX_TITLE_LENGTH + 1), + content: 'body', + categoryId: 'cat-1', + }) as never, + ); + expect(res.status).toBe(400); + expect(mockDocArticleCreate).not.toHaveBeenCalled(); + }); + + it('returns 400 for a non-string content body', async () => { + const res = await articlesPost( + jsonRequest('/api/docs/articles', { + title: 't', + content: { block: 1 }, + categoryId: 'cat-1', + }) as never, + ); + expect(res.status).toBe(400); + expect(mockDocArticleCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/__tests__/broadcasts-api.test.ts b/apps/web/src/__tests__/broadcasts-api.test.ts index 7adecede..030a7296 100644 --- a/apps/web/src/__tests__/broadcasts-api.test.ts +++ b/apps/web/src/__tests__/broadcasts-api.test.ts @@ -16,6 +16,44 @@ vi.mock('@copilotkit/outpost/db', () => ({ update: (...args: unknown[]) => mockBroadcastUpdate(...args), }, }, + BroadcastAudience: { + ALL_ACCOUNTS: 'ALL_ACCOUNTS', + SELECTED_ACCOUNTS: 'SELECTED_ACCOUNTS', + BY_SENTIMENT: 'BY_SENTIMENT', + }, + BroadcastStatus: { + DRAFT: 'DRAFT', + SENT: 'SENT', + }, + TicketStatus: { + OPEN: 'OPEN', + IN_PROGRESS: 'IN_PROGRESS', + WAITING_ON_CUSTOMER: 'WAITING_ON_CUSTOMER', + WAITING_ON_TEAM: 'WAITING_ON_TEAM', + RESOLVED: 'RESOLVED', + CLOSED: 'CLOSED', + }, + TicketPriority: { CRITICAL: 'CRITICAL', HIGH: 'HIGH', MEDIUM: 'MEDIUM', LOW: 'LOW' }, + TicketType: { + BUG: 'BUG', + FEATURE_REQUEST: 'FEATURE_REQUEST', + QUESTION: 'QUESTION', + INTEGRATION_HELP: 'INTEGRATION_HELP', + ACCOUNT_ISSUE: 'ACCOUNT_ISSUE', + OTHER: 'OTHER', + }, + TicketSource: { + DISCORD: 'DISCORD', + SLACK: 'SLACK', + GITHUB_ISSUE: 'GITHUB_ISSUE', + GITHUB_DISCUSSION: 'GITHUB_DISCUSSION', + WEB: 'WEB', + EMAIL: 'EMAIL', + LINEAR: 'LINEAR', + MANUAL: 'MANUAL', + ORCA: 'ORCA', + TEAMS: 'TEAMS', + }, })); // ─── Mock next-auth ───────────────────────────────────────────────────────── diff --git a/apps/web/src/__tests__/docs-api.test.ts b/apps/web/src/__tests__/docs-api.test.ts index d3c4c954..7ece0be9 100644 --- a/apps/web/src/__tests__/docs-api.test.ts +++ b/apps/web/src/__tests__/docs-api.test.ts @@ -24,6 +24,44 @@ vi.mock('@copilotkit/outpost/db', () => ({ findFirst: (...args: unknown[]) => mockDocCategoryFindFirst(...args), }, }, + TicketStatus: { + OPEN: 'OPEN', + IN_PROGRESS: 'IN_PROGRESS', + WAITING_ON_CUSTOMER: 'WAITING_ON_CUSTOMER', + WAITING_ON_TEAM: 'WAITING_ON_TEAM', + RESOLVED: 'RESOLVED', + CLOSED: 'CLOSED', + }, + TicketPriority: { CRITICAL: 'CRITICAL', HIGH: 'HIGH', MEDIUM: 'MEDIUM', LOW: 'LOW' }, + TicketType: { + BUG: 'BUG', + FEATURE_REQUEST: 'FEATURE_REQUEST', + QUESTION: 'QUESTION', + INTEGRATION_HELP: 'INTEGRATION_HELP', + ACCOUNT_ISSUE: 'ACCOUNT_ISSUE', + OTHER: 'OTHER', + }, + TicketSource: { + DISCORD: 'DISCORD', + SLACK: 'SLACK', + GITHUB_ISSUE: 'GITHUB_ISSUE', + GITHUB_DISCUSSION: 'GITHUB_DISCUSSION', + WEB: 'WEB', + EMAIL: 'EMAIL', + LINEAR: 'LINEAR', + MANUAL: 'MANUAL', + ORCA: 'ORCA', + TEAMS: 'TEAMS', + }, + BroadcastAudience: { + ALL_ACCOUNTS: 'ALL_ACCOUNTS', + SELECTED_ACCOUNTS: 'SELECTED_ACCOUNTS', + BY_SENTIMENT: 'BY_SENTIMENT', + }, + BroadcastStatus: { + DRAFT: 'DRAFT', + SENT: 'SENT', + }, })); // ─── Mock next-auth ───────────────────────────────────────────────────────── diff --git a/apps/web/src/__tests__/tickets-api.test.ts b/apps/web/src/__tests__/tickets-api.test.ts index f3b65713..48bfcf56 100644 --- a/apps/web/src/__tests__/tickets-api.test.ts +++ b/apps/web/src/__tests__/tickets-api.test.ts @@ -66,6 +66,15 @@ vi.mock('@copilotkit/outpost/db', () => ({ BOT: 'BOT', SYSTEM: 'SYSTEM', }, + BroadcastAudience: { + ALL_ACCOUNTS: 'ALL_ACCOUNTS', + SELECTED_ACCOUNTS: 'SELECTED_ACCOUNTS', + BY_SENTIMENT: 'BY_SENTIMENT', + }, + BroadcastStatus: { + DRAFT: 'DRAFT', + SENT: 'SENT', + }, Prisma: {}, })); diff --git a/apps/web/src/app/api/broadcasts/route.ts b/apps/web/src/app/api/broadcasts/route.ts index 9965b85f..2bbcaf09 100644 --- a/apps/web/src/app/api/broadcasts/route.ts +++ b/apps/web/src/app/api/broadcasts/route.ts @@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireSession, requireAdmin } from '@/lib/require-admin'; import { prisma } from '@copilotkit/outpost/db'; import type { Prisma, BroadcastStatus } from '@copilotkit/outpost/db'; - -const MAX_BROADCAST_LENGTH = 500; +import { + broadcastCreateSchema, + formatZodError, +} from '@/lib/validate'; /** * GET /api/broadcasts @@ -44,29 +46,37 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - if (!body.message || typeof body.message !== 'string') { + if (body.message === undefined || (typeof body.message === 'string' && body.message.trim() === '')) { return NextResponse.json( { error: 'message is required' }, { status: 400 }, ); } - if (body.message.length > MAX_BROADCAST_LENGTH) { + const parsed = broadcastCreateSchema.safeParse(body); + if (!parsed.success) { return NextResponse.json( - { error: `message exceeds ${MAX_BROADCAST_LENGTH} character limit` }, + { error: formatZodError(parsed.error) }, { status: 400 }, ); } + const input = parsed.data; - const status: BroadcastStatus = body.status === 'SENT' ? 'SENT' : 'DRAFT'; - const audience = body.audience || 'ALL_ACCOUNTS'; + // `audience` is a Prisma enum (ALL_ACCOUNTS | SELECTED_ACCOUNTS | + // BY_SENTIMENT): an arbitrary string here used to become a Prisma + // throw. The schema above rejects it with a 400 instead. + const status: BroadcastStatus = input.status ?? 'DRAFT'; + const audience = input.audience ?? 'ALL_ACCOUNTS'; const newBroadcast = await prisma.broadcast.create({ data: { - message: body.message, - sendAs: body.sendAs || null, + message: input.message, + sendAs: input.sendAs || null, audience, - targetAccounts: body.targetAccounts || null, + // Omit when absent so the column keeps its DB default (null); + // a previous `|| null` passed a bare null that Prisma's + // Json-input type rejects. + targetAccounts: input.targetAccounts ?? undefined, status, sentAt: status === 'SENT' ? new Date() : null, }, diff --git a/apps/web/src/app/api/docs/articles/route.ts b/apps/web/src/app/api/docs/articles/route.ts index d051f9a6..129085ae 100644 --- a/apps/web/src/app/api/docs/articles/route.ts +++ b/apps/web/src/app/api/docs/articles/route.ts @@ -2,6 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireSession, requireAdmin } from '@/lib/require-admin'; import { prisma } from '@copilotkit/outpost/db'; import type { Prisma } from '@copilotkit/outpost/db'; +import { + articleCreateSchema, + formatZodError, + sanitizeSearch, +} from '@/lib/validate'; /** * GET /api/docs/articles @@ -16,7 +21,7 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const categoryId = searchParams.get('category'); const status = searchParams.get('status')?.toUpperCase() as 'DRAFT' | 'PUBLISHED' | null; - const search = searchParams.get('search'); + const search = sanitizeSearch(searchParams.get('search')); const where: Prisma.DocArticleWhereInput = {}; @@ -56,16 +61,29 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - if (!body.title || !body.categoryId || !body.content) { + if ( + body.title === undefined || + body.categoryId === undefined || + body.content === undefined + ) { return NextResponse.json( { error: 'title, categoryId, and content are required' }, { status: 400 }, ); } + const parsed = articleCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: formatZodError(parsed.error) }, + { status: 400 }, + ); + } + const input = parsed.data; + // Verify the category exists const category = await prisma.docCategory.findUnique({ - where: { id: body.categoryId }, + where: { id: input.categoryId }, }); if (!category) { @@ -77,11 +95,11 @@ export async function POST(request: NextRequest) { const newArticle = await prisma.docArticle.create({ data: { - title: body.title, - content: body.content, + title: input.title, + content: input.content, status: 'DRAFT', - sourceUrl: body.sourceUrl || null, - categoryId: body.categoryId, + sourceUrl: input.sourceUrl || null, + categoryId: input.categoryId, }, include: { category: true }, }); diff --git a/apps/web/src/app/api/tickets/route.ts b/apps/web/src/app/api/tickets/route.ts index 6837d30a..f1d6051f 100644 --- a/apps/web/src/app/api/tickets/route.ts +++ b/apps/web/src/app/api/tickets/route.ts @@ -2,8 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/lib/auth'; import { prisma } from '@copilotkit/outpost/db'; -import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, generateTicketId } from '@copilotkit/outpost/shared'; +import { generateTicketId } from '@copilotkit/outpost/shared'; import { TicketStatus, TicketPriority, TicketType, TicketSource, Prisma } from '@copilotkit/outpost/db'; +import { + ticketCreateSchema, + formatZodError, + parsePagination, + sanitizeSearch, + ticketStatusFilter, + ticketSourceFilter, + ticketPriorityFilter, + ticketTypeFilter, +} from '@/lib/validate'; /** * GET /api/tickets @@ -20,32 +30,47 @@ export async function GET(request: NextRequest) { try { const { searchParams } = request.nextUrl; - const status = searchParams.getAll('status'); - const source = searchParams.getAll('source'); - const priority = searchParams.getAll('priority'); - const type = searchParams.getAll('type'); + const rawStatus = searchParams.getAll('status'); + const rawSource = searchParams.getAll('source'); + const rawPriority = searchParams.getAll('priority'); + const rawType = searchParams.getAll('type'); const accountId = searchParams.get('accountId') || undefined; const assigneeId = searchParams.get('assigneeId') || undefined; - const search = searchParams.get('search') || undefined; - const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); - const pageSize = Math.min( - MAX_PAGE_SIZE, - Math.max(1, parseInt(searchParams.get('pageSize') || String(DEFAULT_PAGE_SIZE), 10)), - ); + const search = sanitizeSearch(searchParams.get('search')); + const { page, pageSize, skip } = parsePagination(searchParams); + + // Unknown enum values are a client bug: report them as 400 instead of + // letting Prisma throw and returning a 500. + const status = ticketStatusFilter().parseAll(rawStatus); + if (!status.ok) { + return NextResponse.json({ error: status.error }, { status: 400 }); + } + const source = ticketSourceFilter().parseAll(rawSource); + if (!source.ok) { + return NextResponse.json({ error: source.error }, { status: 400 }); + } + const priority = ticketPriorityFilter().parseAll(rawPriority); + if (!priority.ok) { + return NextResponse.json({ error: priority.error }, { status: 400 }); + } + const type = ticketTypeFilter().parseAll(rawType); + if (!type.ok) { + return NextResponse.json({ error: type.error }, { status: 400 }); + } const where: Prisma.TicketWhereInput = {}; - if (status.length) { - where.status = { in: status as TicketStatus[] }; + if (status.values.length) { + where.status = { in: status.values }; } - if (source.length) { - where.source = { in: source as TicketSource[] }; + if (source.values.length) { + where.source = { in: source.values }; } - if (priority.length) { - where.priority = { in: priority as TicketPriority[] }; + if (priority.values.length) { + where.priority = { in: priority.values }; } - if (type.length) { - where.type = { in: type as TicketType[] }; + if (type.values.length) { + where.type = { in: type.values }; } if (accountId) { where.accountId = accountId; @@ -75,7 +100,7 @@ export async function GET(request: NextRequest) { }, orderBy: { createdAt: 'desc' }, take: pageSize, - skip: (page - 1) * pageSize, + skip, }), prisma.ticket.count({ where }), ]); @@ -110,27 +135,41 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - if (!body.title || !body.description) { + if ( + body.title === undefined || + body.description === undefined || + (typeof body.title === 'string' && body.title.trim() === '') || + (typeof body.description === 'string' && body.description.trim() === '') + ) { return NextResponse.json( { error: 'title and description are required' }, { status: 400 }, ); } + const parsed = ticketCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: formatZodError(parsed.error) }, + { status: 400 }, + ); + } + const input = parsed.data; + const ticket = await prisma.ticket.create({ data: { displayId: generateTicketId(), - title: body.title, - description: body.description, + title: input.title, + description: input.description, status: TicketStatus.OPEN, - priority: (body.priority as TicketPriority) || TicketPriority.MEDIUM, - type: (body.type as TicketType) || TicketType.QUESTION, - source: (body.source as TicketSource) || TicketSource.MANUAL, - sourceUrl: body.sourceUrl || null, - additionalInfo: body.additionalInfo || undefined, - assigneeId: body.assigneeId || null, - accountId: body.accountId || null, - userId: body.userId || null, + priority: input.priority ?? TicketPriority.MEDIUM, + type: input.type ?? TicketType.QUESTION, + source: input.source ?? TicketSource.MANUAL, + sourceUrl: input.sourceUrl || null, + additionalInfo: input.additionalInfo ?? undefined, + assigneeId: input.assigneeId || null, + accountId: input.accountId || null, + userId: input.userId || null, }, include: { account: true, diff --git a/apps/web/src/lib/validate.ts b/apps/web/src/lib/validate.ts new file mode 100644 index 00000000..c553eb22 --- /dev/null +++ b/apps/web/src/lib/validate.ts @@ -0,0 +1,123 @@ +import { z, ZodError } from 'zod'; +import { + TicketStatus, + TicketPriority, + TicketType, + TicketSource, + BroadcastAudience, + BroadcastStatus, +} from '@copilotkit/outpost/db'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@copilotkit/outpost/shared'; + +/** + * Shared input validation for the web API routes. + * + * Before this module, routes cast query/body values straight to Prisma enums + * (`status as TicketStatus[]`) and accepted unbounded strings. A single bad + * enum value became a Prisma throw (HTTP 500) and an oversized payload was + * written to the database unchecked. Every schema here maps failures to a + * 400 with a message that names the offending field. + */ + +export const MAX_TITLE_LENGTH = 200; +export const MAX_DESCRIPTION_LENGTH = 20000; +export const MAX_CONTENT_LENGTH = 100000; +export const MAX_SEARCH_LENGTH = 200; +export const MAX_URL_LENGTH = 2048; +export const MAX_ID_LENGTH = 100; +export const MAX_BROADCAST_LENGTH = 500; + +const nonEmptyTrimmed = (max: number) => + z + .string() + .trim() + .min(1, 'must not be empty') + .max(max, `must be at most ${max} characters`); + +/** Parse a repeated enum query param; unknown values are reported, not cast. */ +function enumFilter>(enumObj: E, field: string) { + const values = Object.values(enumObj) as string[]; + return { + parseAll(raw: string[]): { ok: true; values: E[keyof E][] } | { ok: false; error: string } { + const invalid = raw.filter((v) => !values.includes(v)); + if (invalid.length > 0) { + return { + ok: false, + error: `Invalid ${field} value(s): ${invalid.join(', ')}. Expected one of: ${values.join(', ')}`, + }; + } + return { ok: true, values: raw as E[keyof E][] }; + }, + }; +} + +export const ticketStatusFilter = () => enumFilter(TicketStatus, 'status'); +export const ticketSourceFilter = () => enumFilter(TicketSource, 'source'); +export const ticketPriorityFilter = () => enumFilter(TicketPriority, 'priority'); +export const ticketTypeFilter = () => enumFilter(TicketType, 'type'); + +export interface Pagination { + page: number; + pageSize: number; + skip: number; +} + +/** Parse page/pageSize defensively: NaN and out-of-range values fall back to defaults. */ +export function parsePagination(searchParams: URLSearchParams): Pagination { + const rawPage = Number.parseInt(searchParams.get('page') ?? '', 10); + const rawPageSize = Number.parseInt(searchParams.get('pageSize') ?? '', 10); + const page = Number.isFinite(rawPage) ? Math.max(1, rawPage) : 1; + const pageSize = Number.isFinite(rawPageSize) + ? Math.min(MAX_PAGE_SIZE, Math.max(1, rawPageSize)) + : DEFAULT_PAGE_SIZE; + return { page, pageSize, skip: (page - 1) * pageSize }; +} + +/** Clamp free-text search input so a huge query string can't become a huge ILIKE. */ +export function sanitizeSearch(raw: string | null): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim().slice(0, MAX_SEARCH_LENGTH); + return trimmed ? trimmed : undefined; +} + +export const ticketCreateSchema = z.object({ + title: nonEmptyTrimmed(MAX_TITLE_LENGTH), + description: nonEmptyTrimmed(MAX_DESCRIPTION_LENGTH), + priority: z.nativeEnum(TicketPriority).optional(), + type: z.nativeEnum(TicketType).optional(), + source: z.nativeEnum(TicketSource).optional(), + sourceUrl: z.string().trim().max(MAX_URL_LENGTH).nullish(), + additionalInfo: z.unknown().optional(), + assigneeId: z.string().trim().max(MAX_ID_LENGTH).nullish(), + accountId: z.string().trim().max(MAX_ID_LENGTH).nullish(), + userId: z.string().trim().max(MAX_ID_LENGTH).nullish(), +}); + +export type TicketCreateInput = z.infer; + +export const broadcastCreateSchema = z.object({ + message: nonEmptyTrimmed(MAX_BROADCAST_LENGTH), + status: z.nativeEnum(BroadcastStatus).optional(), + audience: z.nativeEnum(BroadcastAudience).optional(), + targetAccounts: z.array(z.string().trim().max(MAX_ID_LENGTH)).max(500).nullish(), + sendAs: z.string().trim().max(MAX_ID_LENGTH).nullish(), +}); + +export type BroadcastCreateInput = z.infer; + +export const articleCreateSchema = z.object({ + title: nonEmptyTrimmed(MAX_TITLE_LENGTH), + content: nonEmptyTrimmed(MAX_CONTENT_LENGTH), + categoryId: z.string().trim().min(1, 'must not be empty').max(MAX_ID_LENGTH), + sourceUrl: z.string().trim().max(MAX_URL_LENGTH).nullish(), +}); + +export type ArticleCreateInput = z.infer; + +/** Flatten a ZodError into a single human-readable message. */ +export function formatZodError(error: ZodError): string { + const first = error.issues[0]; + if (!first) return 'Invalid request body'; + const path = first.path.join('.'); + return path ? `${path}: ${first.message}` : first.message; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1be55189..dc288c25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)) + zod: + specifier: ^3.23.0 + version: 3.25.76 devDependencies: '@testing-library/jest-dom': specifier: ^6.9.1