chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the
// shape only matters here, so a deterministic stand-in keeps the test
// stable without standing up the env-keyed CipherSuite. The PII check
// below asserts the raw identifier never appears in the audit payload.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }),
// v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface
// wrapper so the trust-violation branch can swap to a tighter global
// bucket. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
function expectedHash(identifier: string): string {
return (hashToken as unknown as (s: string) => string)(identifier);
}
function makeRequest(body: { identifier: string }): NextRequest {
return new NextRequest("http://localhost/api/auth/check-user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.mocked(prisma.user.findFirst).mockReset();
vi.mocked(auditLog).mockReset();
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[0]).toBe("auth.check-user");
expect(args[1]).toEqual({
ipAddress: "203.0.113.1",
details: {
branch: "not_found",
identifier_hash: expectedHash("ghost@example.com"),
},
});
// The raw identifier must not leak into the details — every other
// /api/auth/* audit row already hashes; the check-user route follows
// the same shape.
const details = args[1]?.details as Record<string, unknown> | undefined;
expect(details).not.toHaveProperty("identifier");
expect(JSON.stringify(details)).not.toContain("ghost@example.com");
});
it("writes an audit row for the passkey_only branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({
userId: "user-1",
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("marc"),
},
});
});
it("writes an audit row for the email_fallback branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$...",
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("marc@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
it("writes an audit row for the exists branch (no passkey + no password)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-3",
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "stub" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "exists",
identifier_hash: expectedHash("stub"),
});
expect(args[1]?.userId).toBe("user-3");
});
});
+30 -5
View File
@@ -37,10 +37,15 @@
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -57,12 +62,14 @@ export type CheckUserBranch =
| "exists";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(
`auth:check-user:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:check-user",
30,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Too many requests. Please try again later." },
@@ -92,8 +99,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin
// can later answer "did anyone enumerate accounts last week?". The
// wide-event `annotate()` log is sampled and short-retention; the
// audit ledger is durable. We hash the identifier (same shape as the
// `auth.login.failed` row) so the literal email/username never lands
// in `AuditLog.details` — the v1.4.20 retroactive PII directive bars
// raw user-typed strings from operator-readable artefacts.
const identifierHash = hashToken(identifier);
if (!user) {
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
});
return apiSuccess({
branch: "not_found" satisfies CheckUserBranch,
hasPasskey: false,
@@ -110,5 +130,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
else branch = "exists";
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
await auditLog("auth.check-user", {
userId: user.id,
ipAddress: ip,
details: { branch, identifier_hash: identifierHash },
});
return apiSuccess({ branch, hasPasskey, hasPassword });
});
@@ -28,6 +28,15 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
// v1.4.43 W13 M-4 — auth surfaces now route through the wrapper that
// returns `{ip, ...rate-limit-result}`. Default to a clean per-IP
// result so the existing tests don't need to set every IP up.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
@@ -55,7 +64,11 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
@@ -94,6 +107,12 @@ beforeEach(() => {
remaining: 5,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
+17 -1
View File
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
@@ -61,7 +67,11 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
const TYPED_EMAIL = "leaked-identifier@example.com";
@@ -84,6 +94,12 @@ beforeEach(() => {
remaining: 5,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
});
+11 -4
View File
@@ -6,10 +6,9 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -22,8 +21,16 @@ import {
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:login",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
@@ -1,14 +1,18 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { apiSuccess } from "@/lib/api-response";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: Request) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(
`auth:passkey-login-options:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:passkey-login-options",
10,
15 * 60 * 1000,
);
@@ -24,6 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
// v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface
// wrapper. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
@@ -50,7 +53,10 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit } from "@/lib/rate-limit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
} from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
@@ -81,6 +87,12 @@ beforeEach(() => {
remaining: 9,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
@@ -4,12 +4,11 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,12 +22,14 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const ip = getClientIp(request);
const rl = await checkRateLimit(
`auth:passkey-verify:${ip}`,
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:passkey-verify",
10,
15 * 60 * 1000,
);
const ip = rl.ip;
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
@@ -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;
+9 -4
View File
@@ -7,12 +7,11 @@ import { apiHandler } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { resolveTokenPolicy } from "@/lib/auth/native-client";
import {
rotateRefreshToken,
@@ -20,8 +19,14 @@ import {
} from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:refresh",
60,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many refresh attempts. Please retry later.", 429);
}
@@ -0,0 +1,118 @@
/**
* 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(),
checkAuthSurfaceRateLimit: 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, checkAuthSurfaceRateLimit } 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);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "1.2.3.4",
} 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));
}
+13 -5
View File
@@ -6,10 +6,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -21,8 +21,15 @@ import {
} from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000);
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:register",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
@@ -60,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;