Merge Retry-After on every refused request, stable list pagination and the corrected error contract

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:10:05 +02:00
commit 6a2282fef8
80 files changed
+2261 -220

No files matched your search

@@ -32,6 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -192,6 +193,7 @@ describe("PATCH /api/auth/me/diabetes", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -247,6 +248,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -36,6 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -197,6 +198,7 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -397,6 +398,7 @@ describe("PATCH — envelope and transport", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -147,6 +147,10 @@ describe("POST /api/auth/me/mfa/disable", () => {
} as never);
const res = await POST(req());
expect(res.status).toBe(401);
// The code is what separates "re-prompt for a digit" from "this session is
// gone"; a client that branches on prose here signs the user out on a typo.
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("auth.mfa.code_invalid");
expect(destroyOtherSessions).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -13,6 +13,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -81,7 +82,7 @@ export const POST = apiHandler(async (req: Request) => {
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
// Every cheap check has passed and the teardown is next, so spend the
@@ -18,6 +18,7 @@
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
@@ -99,7 +100,7 @@ export const POST = apiHandler(async (req: Request) => {
action: { name: "auth.mfa.totp.confirm.invalid" },
meta: { replay: result.replay },
});
throw new HttpError(401, "Invalid code");
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
const recoveryCodes = generateRecoveryCodes();
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -300,6 +301,7 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -26,6 +26,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -587,6 +588,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
+7 -2
View File
@@ -13,7 +13,12 @@
*/
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
AUTH_ERROR_CODES,
HttpError,
requireAuth,
} from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
async function presentedBearerToken(): Promise<string> {
const authHeader = (await headers()).get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new HttpError(401, "Not authenticated");
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
return authHeader.slice(7);
}