refactor(api): roll out returnAllZodIssues to auth/settings/admin routes

Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
the remaining 12 sites: auth password + register, admin settings +
assistant-flags + feedback PATCH + users PUT, user thresholds, public
feedback + bugreport submissions, and the three consent endpoints.
Status codes preserved everywhere — consent routes keep 400, the rest
stay 422. No audit-ledger breadcrumb on this batch (low-traffic admin
and onboarding flows).

Also tops up the legacy `measurements/__tests__/group-by-day` mock with
the `prisma.auditLog.create` shim now that the route's validation-
failed path writes a breadcrumb.

Tests: 26 new unit cases (2-issue + 3-issue per site; cadence-style
narrow schemas pinned on ≥ 1).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:37:10 +02:00
1 parent 1592fcc52b
commit d556d7c339
23 files changed
+836 -14

No files matched your search

@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+3 -1
View File
@@ -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 } });
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -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<AssistantFlagsRow> = {};
+3 -1
View File
@@ -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;
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
+3 -1
View File
@@ -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 } });
@@ -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<unknown> };
};
// 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);
});
});
+3 -1
View File
@@ -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;
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
async function postPathThrough(body: unknown): Promise<Response> {
return POST(postReq(body));
}
+3 -1
View File
@@ -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;
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+3 -1
View File
@@ -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;
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
});
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
});
});
});
+8 -3
View File
@@ -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;
+7 -2
View File
@@ -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;
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -1
View File
@@ -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({
@@ -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)", () => {
@@ -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<typeof import("@/lib/analytics/effective-range")>();
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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -1
View File
@@ -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({