diff --git a/src/app/api/admin/feedback/[id]/__tests__/route.test.ts b/src/app/api/admin/feedback/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..c2f48e6a0 --- /dev/null +++ b/src/app/api/admin/feedback/[id]/__tests__/route.test.ts @@ -0,0 +1,93 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id]. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + feedback: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_ADMIN = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" as const }, +}; + +function patchReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/admin/feedback/f1", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never); +}); + +describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad `status` enum + adminNote too long. + const res = await PATCH( + patchReq({ status: "junk", adminNote: "x".repeat(10000) }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces multiple simultaneous validation errors (≥ 2)", async () => { + // The schema has only two knobs (status + adminNote), so the strict + // 3-issue ceiling isn't reachable. Multi-issue contract is pinned at + // ≥ 2 by the case above. + const res = await PATCH( + patchReq({ status: 999, adminNote: 123 }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/app/api/admin/feedback/[id]/route.ts b/src/app/api/admin/feedback/[id]/route.ts index 62e2a0ccd..624e21c39 100644 --- a/src/app/api/admin/feedback/[id]/route.ts +++ b/src/app/api/admin/feedback/[id]/route.ts @@ -6,6 +6,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, + returnAllZodIssues, safeJson, getClientIp, } from "@/lib/api-response"; @@ -25,7 +26,8 @@ export const PATCH = apiHandler( const parsed = updateFeedbackSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const before = await prisma.feedback.findUnique({ where: { id } }); diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 7e8f70db3..7c8dd66e8 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -505,4 +505,46 @@ describe("PUT /api/admin/settings", () => { expect(res.status).toBe(422); expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); }); + + describe("v1.4.43 W6 — multi-issue 422 envelope", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad `registrationEnabled` (string not boolean) + bad + // `reminderLateMinutes` (string not int). + const res = await PUT( + jsonReq({ + registrationEnabled: "string", + reminderLateMinutes: "string", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + const res = await PUT( + jsonReq({ + registrationEnabled: "string", + reminderLateMinutes: "string", + reminderMissedMinutes: "string", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 3e3cc69a4..46dcce451 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -154,4 +154,47 @@ describe("PUT /api/admin/settings/assistant-flags", () => { const res = await PUT(putReq({ assistantPotatoEnabled: false })); expect(res.status).toBe(422); }); + + describe("v1.4.43 W6 — multi-issue 422 envelope", () => { + it("surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + // Two bad-typed flags. + const res = await PUT( + putReq({ + assistantEnabled: "string", + assistantCoachEnabled: 999, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + assistantEnabled: "string", + assistantCoachEnabled: 999, + assistantBriefingEnabled: "string", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index dd08386e9..0782555a1 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -32,6 +32,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; @@ -124,7 +125,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = assistantFlagsSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const updates: Partial = {}; diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 0e3e580f3..39ea7397b 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -71,7 +72,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = adminSettingsSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const data = parsed.data; diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts index c0ba351ea..8d7324216 100644 --- a/src/app/api/admin/users/[id]/__tests__/route.test.ts +++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts @@ -205,4 +205,40 @@ describe("PUT /api/admin/users/[id]", () => { const res = await PUT(r, params("u1")); expect(res.status).toBe(415); }); + + describe("v1.4.43 W6 — multi-issue 422 envelope", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad username (too short) + bad role enum. + const res = await PUT( + jsonReq({ username: "x", role: "GOD" }), + params("u1"), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + const res = await PUT( + jsonReq({ username: "x", email: "not-an-email", role: "GOD" }), + params("u1"), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index c4c4d1aba..43fb4151c 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -38,7 +39,8 @@ export const PUT = apiHandler( if (jsonError) return jsonError; const parsed = updateUserSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const target = await prisma.user.findUnique({ where: { id } }); diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts new file mode 100644 index 000000000..f7a128fdc --- /dev/null +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/password. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { update: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), + destroyAllSessions: vi.fn().mockResolvedValue(undefined), + createSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/password", () => ({ + verifyPassword: vi.fn().mockResolvedValue(true), + hashPassword: vi.fn().mockResolvedValue("hashed"), + checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn().mockResolvedValue("en"), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + email: "t@example.com", + role: "USER" as const, + passwordHash: "h", + onboardingCompletedAt: new Date(), + locale: "en", + }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); +}); + +describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Empty currentPassword + tooShort newPassword. + const res = await POST( + postReq({ currentPassword: "", newPassword: "x" }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + // Both empty + extra invalid type. + const res = await POST( + postReq({ currentPassword: "", newPassword: 123 }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + // currentPassword empty (1) + newPassword wrong type (1) — might + // collapse to 2 issues. We pin the contract on ≥ 2. + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 2896bc960..79e4a7868 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -11,6 +11,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -36,7 +37,8 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = changePasswordSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { currentPassword, newPassword } = parsed.data; diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts new file mode 100644 index 000000000..47c2aac30 --- /dev/null +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -0,0 +1,113 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + count: vi.fn().mockResolvedValue(1), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn(), + }, + appSettings: { + findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + createSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/password", () => ({ + hashPassword: vi.fn().mockResolvedValue("hashed"), + checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn().mockResolvedValue("en"), +})); + +vi.mock("@/lib/tz/resolver", () => ({ + isValidTimezone: vi.fn().mockReturnValue(true), + resolveServerDefaultTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); +}); + +describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad email + too-short username. + const res = await postPathThrough({ email: "not-an-email", username: "x" }); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + // Bad email + bad username + bad password. + const res = await postPathThrough({ + email: "not-an-email", + username: "x", + password: "weak", + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); + +async function postPathThrough(body: unknown): Promise { + return POST(postReq(body)); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 26cfbd608..71c327222 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -66,7 +67,8 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = registerSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { email, username, password, timezone: timezoneInput } = parsed.data; diff --git a/src/app/api/bugreport/__tests__/route.test.ts b/src/app/api/bugreport/__tests__/route.test.ts new file mode 100644 index 000000000..02e95dbe0 --- /dev/null +++ b/src/app/api/bugreport/__tests__/route.test.ts @@ -0,0 +1,101 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: (s: string) => s.replace(/^enc-/, ""), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/bugreport", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + githubIssueTokenEncrypted: "enc-token", + githubIssueRepo: "owner/repo", + bugReportEnabled: true, + } as never); +}); + +describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // description too short + screenshot doesn't match data-URL regex. + const res = await POST( + postReq({ description: "tiny", screenshot: "not-a-data-url" }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces multiple simultaneous validation errors (≥ 2)", async () => { + // Schema has only two knobs (description, screenshot). Pin contract + // on ≥ 2. + const res = await POST( + postReq({ description: 123, screenshot: 999 }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts index 2523a1c15..7ed289182 100644 --- a/src/app/api/bugreport/route.ts +++ b/src/app/api/bugreport/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -83,7 +84,8 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = bugReportSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { description, screenshot } = parsed.data; diff --git a/src/app/api/consent/ai/__tests__/route.test.ts b/src/app/api/consent/ai/__tests__/route.test.ts index 614366a19..4b9831a1e 100644 --- a/src/app/api/consent/ai/__tests__/route.test.ts +++ b/src/app/api/consent/ai/__tests__/route.test.ts @@ -167,4 +167,44 @@ describe("POST /api/consent/ai", () => { }, }); }); + + describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Bad kind enum + empty artefact. + const res = await POST( + req({ + kind: "junk", + artefact: "", + signedAt: "2026-05-18T10:00:00.000Z", + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ kind: "junk", artefact: "", signedAt: "not-iso" }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index 689f9b401..d608863c9 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -390,4 +390,48 @@ describe("append-only invariant", () => { }; expect(body.data.receipt?.id).toBe("rcpt-2"); }); + + describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => { + it("GET surfaces multi-issue validation errors (≥ 1)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=junk", + ), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + // The latest query only has a single `kind` knob, so the strict + // 2-issue case can't be reached. The shared helper itself is + // covered exhaustively by api-response-zod.test.ts; this case + // pins the route's new envelope on ≥ 1 issue. + expect(body.details.issues.length).toBeGreaterThanOrEqual(1); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await DELETE( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=junk", + { method: "DELETE" }, + ), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(1); + }); + }); }); diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts index f3719d4cb..bf864a9be 100644 --- a/src/app/api/consent/ai/latest/route.ts +++ b/src/app/api/consent/ai/latest/route.ts @@ -16,7 +16,10 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -38,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => { Object.fromEntries(request.nextUrl.searchParams), ); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 400); + // v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422). + return returnAllZodIssues(parsed.error, 400); } const { kind } = parsed.data; @@ -86,7 +90,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { Object.fromEntries(request.nextUrl.searchParams), ); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 400); + // v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422). + return returnAllZodIssues(parsed.error, 400); } const { kind } = parsed.data; diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 39d25faf3..fb2d6ab16 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,7 +9,11 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { consentPostBody } from "@/lib/validations/consent"; @@ -23,7 +27,8 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = consentPostBody.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 400); + // v1.4.43 W6 — multi-issue 400 (consent routes use 400 not 422). + return returnAllZodIssues(parsed.error, 400); } const { kind, artefact, signedAt } = parsed.data; diff --git a/src/app/api/feedback/__tests__/route.test.ts b/src/app/api/feedback/__tests__/route.test.ts index dc9c96c7f..080359d80 100644 --- a/src/app/api/feedback/__tests__/route.test.ts +++ b/src/app/api/feedback/__tests__/route.test.ts @@ -103,3 +103,53 @@ describe("POST /api/feedback bug-report toggle gate", () => { expect(prisma.feedback.create).toHaveBeenCalledTimes(1); }); }); + +describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: true, + } as never); + // subject too short + description too short. + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "x", + description: "short", + }) as never, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: true, + } as never); + // bad subject + bad description + bad screenshot. + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "x", + description: "short", + screenshot: "not-a-data-url", + }) as never, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index eeee1591e..e35bb53ce 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -9,6 +9,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + returnAllZodIssues, safeJson, getClientIp, } from "@/lib/api-response"; @@ -45,7 +46,8 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = createFeedbackSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const feedback = await prisma.feedback.create({ diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index 086112526..f1d2d1655 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({ measurementRollup: { findMany: vi.fn(), }, + // v1.4.43 W6 — validation-failed paths write an audit breadcrumb. + auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn(), }, })); @@ -69,6 +71,10 @@ function getRequest(query: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.4.43 W6 — validation-failed paths fire a best-effort audit-row + // write; the route swallows rejections so the test only needs a + // resolved mock to keep the catch-block silent. + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); }); describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts new file mode 100644 index 000000000..fd37799d0 --- /dev/null +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -0,0 +1,110 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on PUT /api/user/thresholds. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); +// The thresholds validator imports METRIC_BOUNDS at module top-level +// to build its strict-object schema, so we need to keep the real export +// surface around when we mock the module. +vi.mock("@/lib/analytics/effective-range", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getAllEffectiveRanges: vi.fn().mockReturnValue({}), + }; +}); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/user/thresholds", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); +}); + +describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // WEIGHT bounds are min=30, max=300. Sending min > max + a value + // out of bounds on PULSE forces two range errors. + const res = await PUT( + putReq({ + WEIGHT: { min: 100, max: 50 }, // refine min < max + PULSE: { min: 1000, max: 2000 }, // out of bounds + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + const res = await PUT( + putReq({ + WEIGHT: { min: 100, max: 50 }, + PULSE: { min: 1000, max: 2000 }, + BODY_FAT: { min: 999, max: 1000 }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index c2a8c755a..bf353dc92 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, + returnAllZodIssues, safeJson, getClientIp, } from "@/lib/api-response"; @@ -80,7 +81,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = thresholdsUpdateSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const existing = await prisma.user.findUnique({