diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index ea90306..e71df21 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -218,6 +218,22 @@ describe('dashboard', () => { expect(database).not.toHaveBeenCalled() }) + it.each([ + ['/api/v1/playlists/generate', { seedTrackId: '9ad3649a-a78f-4aea-99dc-473c7c1c5501', name: 'bad\u0000name' }], + ['/api/v1/playlists/proposals', { title: 'bad\u0000title' }], + ])('rejects a NUL character in the %s name or title before any query runs', async (url, payload) => { + // Postgres text cannot hold NUL, so a database error would otherwise surface as a 500. + const database = vi.fn(async () => { throw new Error('invalid byte sequence for encoding "UTF8": 0x00') }) + const app = createServer({ database: database as unknown as Database, jobQueue: { send: vi.fn() } }) + await app.ready() + const headers = { cookie: `musearr_session=${app.jwt.sign({ sub: 'owner-id', role: 'owner' })}` } + const response = await app.inject({ method: 'POST', url, headers, payload }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchObject({ code: 'INVALID_REQUEST' }) + expect(database).not.toHaveBeenCalled() + }) + it('requires a local session before managing playlist proposals', async () => { const getRes = await createServer().inject({ method: 'GET', diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 889af7c..c09508b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { noNul } from './text.js' export * from './lidarr.js' export * from './local-ai.js' @@ -188,9 +189,6 @@ export const DailyBriefResponseSchema = z.object({ brief: DailyBriefSchema.nullable(), }) -// Postgres text cannot hold NUL, so reject it here instead of failing in a query. -const noNul = (value: string) => !value.includes('\u0000') - export const ScrobbleItemSchema = z.object({ artistName: z.string().trim().min(1).refine(noNul), trackTitle: z.string().trim().min(1).refine(noNul), @@ -235,7 +233,7 @@ export const PlaylistProposalSchema = z.object({ export const CreatePlaylistProposalRequestSchema = z.object({ kind: RecommendationKindSchema.default('daily_mix'), - title: z.string().trim().min(1).max(280).optional(), + title: z.string().trim().min(1).max(280).refine(noNul).optional(), limit: z.number().int().min(5).max(100).default(20), }) diff --git a/packages/contracts/src/playlists.ts b/packages/contracts/src/playlists.ts index 776e636..b0c732d 100644 --- a/packages/contracts/src/playlists.ts +++ b/packages/contracts/src/playlists.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { noNul } from './text.js' /** * A generated playlist is an eventually-consistent object. Items that already @@ -33,7 +34,7 @@ export const PlaylistGenerationStatusSchema = z.enum([ export const GeneratePlaylistRequestSchema = z.object({ seedTrackId: z.string().uuid(), - name: z.string().trim().min(1).max(120).optional(), + name: z.string().trim().min(1).max(120).refine(noNul).optional(), targetSize: z.number().int().min(5).max(100).default(25), /** Ask Lidarr to acquire suggestions that are not already in the library. */ acquireMissing: z.boolean().default(false), diff --git a/packages/contracts/src/text.ts b/packages/contracts/src/text.ts new file mode 100644 index 0000000..7b73fcb --- /dev/null +++ b/packages/contracts/src/text.ts @@ -0,0 +1,2 @@ +/** Postgres text cannot hold NUL, so reject it at validation instead of failing in a query. */ +export const noNul = (value: string): boolean => !value.includes('\u0000')