From 13a233e0405a10c57fcc3b567adc5cfa81552381 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Fri, 17 Jul 2026 14:46:08 +0530 Subject: [PATCH] fix: validate daily focus input --- src/app/api/daily-focus/route.ts | 46 +++++++++++++- test/daily-focus-validation.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 test/daily-focus-validation.test.ts diff --git a/src/app/api/daily-focus/route.ts b/src/app/api/daily-focus/route.ts index 3fbf92fb0..15af20ccb 100644 --- a/src/app/api/daily-focus/route.ts +++ b/src/app/api/daily-focus/route.ts @@ -3,6 +3,29 @@ import { supabaseAdmin } from "@/lib/supabase"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { resolveAppUser } from "@/lib/resolve-user"; +import { stripHtml } from "@/lib/sanitize"; + +const MAX_GOAL_LEN = 280; +const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +function isValidDate(value: unknown): value is string { + if (typeof value !== "string") return false; + + const match = value.match(DATE_PATTERN); + if (!match) return false; + + const [, year, month, day] = match; + const date = new Date(`${value}T00:00:00Z`); + return ( + date.getUTCFullYear() === Number(year) && + date.getUTCMonth() + 1 === Number(month) && + date.getUTCDate() === Number(day) + ); +} + +function validateDate(value: unknown): string | null { + return isValidDate(value) ? null : "Date must be a valid YYYY-MM-DD value"; +} export async function GET(req: NextRequest) { try { @@ -21,6 +44,9 @@ export async function GET(req: NextRequest) { date = new Date().toISOString().split("T")[0]; } + const dateError = validateDate(date); + if (dateError) return NextResponse.json({ error: dateError }, { status: 400 }); + const { data } = await supabaseAdmin .from("daily_focus") .select("*") @@ -49,16 +75,29 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { goal_text, date } = body; - if (!goal_text || !goal_text.trim()) { + if (typeof goal_text !== "string" || !goal_text.trim()) { + return NextResponse.json({ error: "Goal cannot be empty" }, { status: 400 }); + } + + const sanitizedGoal = stripHtml(goal_text); + if (!sanitizedGoal) { return NextResponse.json({ error: "Goal cannot be empty" }, { status: 400 }); } + if (sanitizedGoal.length > MAX_GOAL_LEN) { + return NextResponse.json( + { error: `Goal must be ${MAX_GOAL_LEN} characters or fewer` }, + { status: 400 } + ); + } const targetDate = date || new Date().toISOString().split("T")[0]; + const dateError = validateDate(targetDate); + if (dateError) return NextResponse.json({ error: dateError }, { status: 400 }); const { data, error } = await supabaseAdmin .from("daily_focus") .upsert( - { user_id: user.id, date: targetDate, goal_text: goal_text.trim() }, + { user_id: user.id, date: targetDate, goal_text: sanitizedGoal }, { onConflict: "user_id,date" } ) .select() @@ -91,6 +130,9 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ error: "Date is required" }, { status: 400 }); } + const dateError = validateDate(date); + if (dateError) return NextResponse.json({ error: dateError }, { status: 400 }); + const { error } = await supabaseAdmin .from("daily_focus") .delete() diff --git a/test/daily-focus-validation.test.ts b/test/daily-focus-validation.test.ts new file mode 100644 index 000000000..7ba174352 --- /dev/null +++ b/test/daily-focus-validation.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { DELETE, GET, POST } from "@/app/api/daily-focus/route"; + +const mocks = vi.hoisted(() => ({ + getServerSession: vi.fn(), + resolveAppUser: vi.fn(), + supabaseFrom: vi.fn(), + upsert: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession })); +vi.mock("@/lib/auth", () => ({ authOptions: {} })); +vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser })); +vi.mock("@/lib/supabase", () => ({ + supabaseAdmin: { from: mocks.supabaseFrom }, +})); + +function makeRequest( + url: string, + body?: unknown, + method = body === undefined ? "GET" : "POST" +): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method, + headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +describe("daily focus input validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" }); + mocks.resolveAppUser.mockResolvedValue({ id: "user-1" }); + + const single = vi.fn().mockResolvedValue({ + data: { id: "focus-1", goal_text: "Plan", date: "2026-07-17" }, + error: null, + }); + mocks.upsert.mockReturnValue({ select: vi.fn().mockReturnValue({ single }) }); + mocks.supabaseFrom.mockReturnValue({ + upsert: mocks.upsert, + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ single }), + }), + }), + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ error: null }), + }), + }), + }); + }); + + it("sanitizes a valid goal before saving", async () => { + const response = await POST( + makeRequest("/api/daily-focus", { goal_text: "Plan", date: "2026-07-17" }) + ); + + expect(response.status).toBe(200); + expect(mocks.upsert).toHaveBeenCalledWith( + { user_id: "user-1", date: "2026-07-17", goal_text: "Plan" }, + { onConflict: "user_id,date" } + ); + }); + + it("rejects oversized or non-string goals", async () => { + const oversized = await POST( + makeRequest("/api/daily-focus", { goal_text: "a".repeat(281), date: "2026-07-17" }) + ); + const nonString = await POST( + makeRequest("/api/daily-focus", { goal_text: 123, date: "2026-07-17" }) + ); + + expect(oversized.status).toBe(400); + expect(nonString.status).toBe(400); + expect(mocks.upsert).not.toHaveBeenCalled(); + }); + + it("rejects invalid calendar dates for POST, GET, and DELETE", async () => { + const post = await POST( + makeRequest("/api/daily-focus", { goal_text: "Plan", date: "2026-02-30" }) + ); + const get = await GET(makeRequest("/api/daily-focus?date=not-a-date")); + const remove = await DELETE(makeRequest("/api/daily-focus?date=2026-13-01", undefined, "DELETE")); + + expect(post.status).toBe(400); + expect(get.status).toBe(400); + expect(remove.status).toBe(400); + expect(mocks.supabaseFrom).not.toHaveBeenCalled(); + }); +});