diff --git a/__tests__/api/push-send.test.ts b/__tests__/api/push-send.test.ts new file mode 100644 index 0000000..44bc616 --- /dev/null +++ b/__tests__/api/push-send.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +const { mockSendPushToUser } = vi.hoisted(() => ({ + mockSendPushToUser: vi.fn(), +})) + +vi.mock('@/lib/push-server', () => ({ + sendPushToUser: mockSendPushToUser, + enabled: true, +})) + +vi.hoisted(() => { + process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars' +}) + +import { POST } from '@/app/api/internal/push/send/route' + +const VALID_BODY = { + userId: 'user-1', + payload: { title: 'Hello', body: 'World', url: '/dashboard' }, +} +const SECRET = 'a-valid-secret-that-is-at-least-32-chars' + +function makeRequest(body: unknown, bearer?: string) { + return new Request('http://localhost/api/internal/push/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(bearer !== undefined ? { Authorization: bearer } : {}), + }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockSendPushToUser.mockResolvedValue(undefined) +}) + +describe('POST /api/internal/push/send', () => { + it('returns 401 without authorization header', async () => { + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(401) + expect(mockSendPushToUser).not.toHaveBeenCalled() + }) + + it('returns 401 with wrong bearer secret', async () => { + const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret')) + expect(res.status).toBe(401) + }) + + it('returns 422 with invalid body', async () => { + const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`)) + expect(res.status).toBe(422) + expect(mockSendPushToUser).not.toHaveBeenCalled() + }) + + it('returns 204 and calls sendPushToUser on success', async () => { + const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`)) + expect(res.status).toBe(204) + expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload) + }) + + it('returns 400 for invalid JSON', async () => { + const req = new Request('http://localhost/api/internal/push/send', { + method: 'POST', + headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' }, + body: 'not-json', + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) diff --git a/__tests__/lib/push-server.test.ts b/__tests__/lib/push-server.test.ts index 7a9b75a..87af039 100644 --- a/__tests__/lib/push-server.test.ts +++ b/__tests__/lib/push-server.test.ts @@ -13,13 +13,11 @@ vi.mock('web-push', () => ({ }, })) -vi.mock('@/lib/env', () => ({ - env: { - NEXT_PUBLIC_VAPID_PUBLIC_KEY: 'pk', - VAPID_PRIVATE_KEY: 'sk', - VAPID_SUBJECT: 'mailto:test@example.com', - }, -})) +vi.hoisted(() => { + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'pk' + process.env.VAPID_PRIVATE_KEY = 'sk' + process.env.VAPID_SUBJECT = 'mailto:test@example.com' +}) const { mockPushSubscription } = vi.hoisted(() => ({ mockPushSubscription: { diff --git a/app/api/internal/push/send/route.ts b/app/api/internal/push/send/route.ts new file mode 100644 index 0000000..4891e59 --- /dev/null +++ b/app/api/internal/push/send/route.ts @@ -0,0 +1,48 @@ +import { timingSafeEqual } from 'crypto' +import { z } from 'zod' +import { sendPushToUser } from '@/lib/push-server' + +const schema = z.object({ + userId: z.string().min(1), + payload: z.object({ + title: z.string().max(80), + body: z.string().max(300), + url: z.string().startsWith('/').or(z.string().url()), + tag: z.string().optional(), + }), +}) + +export async function POST(req: Request) { + if (!process.env.INTERNAL_PUSH_SECRET) { + return new Response(null, { status: 503 }) + } + + const authHeader = req.headers.get('authorization') ?? '' + const expected = `Bearer ${process.env.INTERNAL_PUSH_SECRET}` + let authorized = false + try { + authorized = + authHeader.length === expected.length && + timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) + } catch { + authorized = false + } + if (!authorized) { + return new Response(null, { status: 401 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return new Response(null, { status: 400 }) + } + + const parsed = schema.safeParse(body) + if (!parsed.success) { + return Response.json({ errors: parsed.error.flatten().fieldErrors }, { status: 422 }) + } + + await sendPushToUser(parsed.data.userId, parsed.data.payload) + return new Response(null, { status: 204 }) +} diff --git a/lib/push-server.ts b/lib/push-server.ts index 5e1ef59..5774253 100644 --- a/lib/push-server.ts +++ b/lib/push-server.ts @@ -2,7 +2,6 @@ import 'server-only' import webpush from 'web-push' import { prisma } from '@/lib/prisma' -import { env } from '@/lib/env' export type PushPayload = { title: string @@ -12,15 +11,15 @@ export type PushPayload = { } const vapidReady = - !!env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && - !!env.VAPID_PRIVATE_KEY && - !!env.VAPID_SUBJECT + !!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && + !!process.env.VAPID_PRIVATE_KEY && + !!process.env.VAPID_SUBJECT if (vapidReady) { webpush.setVapidDetails( - env.VAPID_SUBJECT!, - env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - env.VAPID_PRIVATE_KEY!, + process.env.VAPID_SUBJECT!, + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, ) }