From 86ff491e8baf687d33ce543394f861758767e607 Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 18:20:52 -0600 Subject: [PATCH 1/3] fix(notifications): cap pagination offsets --- src/app/api/notifications/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 553e4331..a7d5bfac 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; +const MAX_NOTIFICATION_OFFSET = 100_000; + // GET /api/notifications - List user's notifications export async function GET(request: NextRequest) { try { @@ -24,7 +26,9 @@ export async function GET(request: NextRequest) { const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 100) : 50; - const offset = Number.isFinite(parsedOffset) ? Math.max(parsedOffset, 0) : 0; + const offset = Number.isFinite(parsedOffset) + ? Math.min(Math.max(parsedOffset, 0), MAX_NOTIFICATION_OFFSET) + : 0; let query = supabase .from("notifications") From d35a3c49e2f4318ad9dc6ee7de2b3a9e7938235d Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 18:20:54 -0600 Subject: [PATCH 2/3] fix(notifications): cap pagination offsets --- src/app/api/notifications/route.test.ts | 84 +++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/app/api/notifications/route.test.ts diff --git a/src/app/api/notifications/route.test.ts b/src/app/api/notifications/route.test.ts new file mode 100644 index 00000000..fe332668 --- /dev/null +++ b/src/app/api/notifications/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; +import { getAuthContext } from "@/lib/auth/get-user"; + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), +})); + +const mockGetAuthContext = vi.mocked(getAuthContext); + +function makeRequest(params: Record = {}) { + const url = new URL("http://localhost/api/notifications"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new NextRequest(url); +} + +async function expectNotificationRange( + input: Record, + expectedFrom: number, + expectedTo: number +) { + const range = vi.fn().mockResolvedValue({ + data: [], + error: null, + count: 0, + }); + const order = vi.fn().mockReturnValue({ range }); + const notificationEq = vi.fn().mockReturnValue({ order }); + const notificationSelect = vi.fn().mockReturnValue({ eq: notificationEq }); + + const unreadIs = vi.fn().mockResolvedValue({ count: 0 }); + const unreadEq = vi.fn().mockReturnValue({ is: unreadIs }); + const unreadSelect = vi.fn().mockReturnValue({ eq: unreadEq }); + + const from = vi + .fn() + .mockReturnValueOnce({ select: notificationSelect }) + .mockReturnValueOnce({ select: unreadSelect }); + + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-1", authMethod: "session" }, + supabase: { from }, + } as any); + + const response = await GET(makeRequest(input)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(expectedFrom, expectedTo); + expect(body.pagination.offset).toBe(expectedFrom); +} + +describe("GET /api/notifications", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetAuthContext.mockResolvedValue(null); + + const response = await GET(makeRequest()); + + expect(response.status).toBe(401); + }); + + it("defaults malformed offsets to zero", async () => { + await expectNotificationRange({ offset: "abc" }, 0, 49); + }); + + it("clamps negative offsets to zero", async () => { + await expectNotificationRange({ offset: "-50", limit: "10" }, 0, 9); + }); + + it("caps huge offsets before building the Supabase range", async () => { + await expectNotificationRange( + { offset: "999999999", limit: "25" }, + 100000, + 100024 + ); + }); +}); From 4bcc210a912df74501ffeb1ff8d1b352661e497d Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 18:25:18 -0600 Subject: [PATCH 3/3] test(notifications): mock last-active update --- src/app/api/notifications/route.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/api/notifications/route.test.ts b/src/app/api/notifications/route.test.ts index fe332668..203d9b42 100644 --- a/src/app/api/notifications/route.test.ts +++ b/src/app/api/notifications/route.test.ts @@ -22,6 +22,10 @@ async function expectNotificationRange( expectedFrom: number, expectedTo: number ) { + const activeAtThen = vi.fn((resolve: () => void) => resolve()); + const activeAtEq = vi.fn().mockReturnValue({ then: activeAtThen }); + const profileUpdate = vi.fn().mockReturnValue({ eq: activeAtEq }); + const range = vi.fn().mockResolvedValue({ data: [], error: null, @@ -37,6 +41,7 @@ async function expectNotificationRange( const from = vi .fn() + .mockReturnValueOnce({ update: profileUpdate }) .mockReturnValueOnce({ select: notificationSelect }) .mockReturnValueOnce({ select: unreadSelect });