From a4a0167515138735b4e6a5365a88feffc3e06cb8 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Sat, 4 Jul 2026 08:44:23 -0700 Subject: [PATCH] enforce minimum 8 character slug on create/update. Closes #4376 --- src/app/api/links/route.test.ts | 71 ++++++++++++++++++++++++++++++++ src/app/api/links/route.ts | 2 +- src/app/api/pixels/route.test.ts | 65 +++++++++++++++++++++++++++++ src/app/api/pixels/route.ts | 2 +- 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/app/api/links/route.test.ts create mode 100644 src/app/api/pixels/route.test.ts diff --git a/src/app/api/links/route.test.ts b/src/app/api/links/route.test.ts new file mode 100644 index 000000000..ecbd963e8 --- /dev/null +++ b/src/app/api/links/route.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createLink } from '@/queries/prisma'; +import { POST } from './route'; + +vi.mock('@/lib/request', () => ({ + getQueryFilters: vi.fn(), + parseRequest: vi.fn(), +})); + +vi.mock('@/permissions', () => ({ + canCreateTeamWebsite: vi.fn(), + canCreateWebsite: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + createLink: vi.fn(), + getUserLinks: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const canCreateTeamWebsiteMock = vi.mocked(canCreateTeamWebsite); +const canCreateWebsiteMock = vi.mocked(canCreateWebsite); +const createLinkMock = vi.mocked(createLink); + +beforeEach(() => { + parseRequestMock.mockReset(); + canCreateTeamWebsiteMock.mockReset(); + canCreateWebsiteMock.mockReset(); + createLinkMock.mockReset(); +}); + +test('POST requires link slugs to be at least 8 characters so create matches edit validation', async () => { + parseRequestMock.mockResolvedValue({ + auth: { + user: { + id: 'user-1', + }, + }, + body: { + name: 'Docs', + url: 'https://example.com', + slug: 'abcdefgh', + }, + error: undefined, + }); + canCreateWebsiteMock.mockResolvedValue(true); + createLinkMock.mockResolvedValue({ id: 'link-1' } as any); + + const response = await POST(new Request('http://localhost/api/links', { method: 'POST' })); + const schema = parseRequestMock.mock.calls[0][1] as { + safeParse: (value: unknown) => { success: boolean }; + }; + + expect(schema.safeParse({ name: 'Docs', url: 'https://example.com', slug: '1234567' }).success).toBe( + false, + ); + expect(schema.safeParse({ name: 'Docs', url: 'https://example.com', slug: '12345678' }).success).toBe( + true, + ); + expect(createLinkMock).toHaveBeenCalledWith({ + id: expect.any(String), + name: 'Docs', + url: 'https://example.com', + slug: 'abcdefgh', + teamId: undefined, + userId: 'user-1', + }); + expect(response.status).toBe(200); +}); diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts index 7c9172546..8c52c90fd 100644 --- a/src/app/api/links/route.ts +++ b/src/app/api/links/route.ts @@ -30,7 +30,7 @@ export async function POST(request: Request) { const schema = z.object({ name: z.string().max(100), url: z.string().max(500), - slug: z.string().max(100), + slug: z.string().min(8).max(100), teamId: z.string().nullable().optional(), id: z.uuid().nullable().optional(), }); diff --git a/src/app/api/pixels/route.test.ts b/src/app/api/pixels/route.test.ts new file mode 100644 index 000000000..267b4837c --- /dev/null +++ b/src/app/api/pixels/route.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createPixel } from '@/queries/prisma'; +import { POST } from './route'; + +vi.mock('@/lib/request', () => ({ + getQueryFilters: vi.fn(), + parseRequest: vi.fn(), +})); + +vi.mock('@/permissions', () => ({ + canCreateTeamWebsite: vi.fn(), + canCreateWebsite: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + createPixel: vi.fn(), + getUserPixels: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const canCreateTeamWebsiteMock = vi.mocked(canCreateTeamWebsite); +const canCreateWebsiteMock = vi.mocked(canCreateWebsite); +const createPixelMock = vi.mocked(createPixel); + +beforeEach(() => { + parseRequestMock.mockReset(); + canCreateTeamWebsiteMock.mockReset(); + canCreateWebsiteMock.mockReset(); + createPixelMock.mockReset(); +}); + +test('POST requires pixel slugs to be at least 8 characters so create matches edit validation', async () => { + parseRequestMock.mockResolvedValue({ + auth: { + user: { + id: 'user-1', + }, + }, + body: { + name: 'Pixel', + slug: 'abcdefgh', + }, + error: undefined, + }); + canCreateWebsiteMock.mockResolvedValue(true); + createPixelMock.mockResolvedValue({ id: 'pixel-1' } as any); + + const response = await POST(new Request('http://localhost/api/pixels', { method: 'POST' })); + const schema = parseRequestMock.mock.calls[0][1] as { + safeParse: (value: unknown) => { success: boolean }; + }; + + expect(schema.safeParse({ name: 'Pixel', slug: '1234567' }).success).toBe(false); + expect(schema.safeParse({ name: 'Pixel', slug: '12345678' }).success).toBe(true); + expect(createPixelMock).toHaveBeenCalledWith({ + id: expect.any(String), + name: 'Pixel', + slug: 'abcdefgh', + teamId: undefined, + userId: 'user-1', + }); + expect(response.status).toBe(200); +}); diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts index 453ede247..ca5b39fbe 100644 --- a/src/app/api/pixels/route.ts +++ b/src/app/api/pixels/route.ts @@ -29,7 +29,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const schema = z.object({ name: z.string().max(100), - slug: z.string().max(100), + slug: z.string().min(8).max(100), teamId: z.string().nullable().optional(), id: z.uuid().nullable().optional(), });