Merge branch 'fix/v1438-api' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:39:39 +02:00
commit 4a0db28713
99 files changed
+5219 -1018

No files matched your search

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
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 { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+29 -21
View File
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage, parseListQuery } from "@/lib/http/list-query";
import { returnAllZodIssues } from "@/lib/api-response";
export const dynamic = "force-dynamic";
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const found = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: overfetch(limit),
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
// The shared list query: an out-of-range limit is a 422 naming it, as on
// every other list, rather than a silent clamp to something not asked for.
const query = parseListQuery(request.nextUrl.searchParams, {
defaultLimit: DEFAULT_LIMIT,
maxLimit: MAX_LIMIT,
});
if (!query.success) {
return returnAllZodIssues(query.error, 422, {
errorCode: "security_activity.invalid",
});
}
const { limit } = query.data;
const { rows, cap } = await listPage(limit, (take) =>
prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
}),
);
const { rows, cap } = capRows(found, limit);
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),