feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
options: {} as Record<string, unknown>,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 2,
|
||||
username: '=HYPERLINK("bad")',
|
||||
action: "update",
|
||||
target: "user",
|
||||
targetId: 3,
|
||||
createdAt: "2026-09-09",
|
||||
diff: '{"password":{"from":"oldsecret","to":"newsecret"}}',
|
||||
before: null,
|
||||
after: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: Record<string, unknown>, handler: unknown) => {
|
||||
state.options = options;
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { LOGS_VIEW: "logs.view" } }));
|
||||
vi.mock("@/lib/services/audit", () => ({
|
||||
getAuditLogs: vi.fn(async () => ({ rows: state.rows })),
|
||||
}));
|
||||
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("audit CSV", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuditLogs).mockClear();
|
||||
});
|
||||
it("requires the audit viewing permission and passes the same filters and page", async () => {
|
||||
expect(state.options).toMatchObject({ permission: "logs.view" });
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"https://example.test/api/admin/logs/audit/export?actor=Alice&action=update&from=2026-09-09&to=2026-09-10&search=user&page=2&perPage=10",
|
||||
) as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(getAuditLogs).toHaveBeenCalledWith({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-10",
|
||||
search: "user",
|
||||
page: 2,
|
||||
perPage: 10,
|
||||
});
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
||||
const response = await GET(
|
||||
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
||||
{} as never,
|
||||
);
|
||||
const csv = await response.text();
|
||||
expect(csv).toContain("'=HYPERLINK");
|
||||
expect(csv).not.toContain("oldsecret");
|
||||
expect(csv).not.toContain("newsecret");
|
||||
expect(csv).toContain("[Redacted]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
|
||||
function cell(value: unknown) {
|
||||
const raw = value == null ? "" : String(value);
|
||||
const safe = /^[\s]*[=+@-]/.test(raw) ? `'${raw}` : raw;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
}
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.LOGS_VIEW },
|
||||
async (request) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const { search, page, perPage } = parseListParams(params);
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: params.get("actor") ?? undefined,
|
||||
action: params.get("action") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
});
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
"id",
|
||||
"user_id",
|
||||
"username",
|
||||
"action",
|
||||
"target",
|
||||
"target_id",
|
||||
"created_at",
|
||||
"changes",
|
||||
],
|
||||
];
|
||||
for (const row of result.rows) {
|
||||
const details = readAuditChanges(row.diff, row.before, row.after);
|
||||
rows.push([
|
||||
row.id,
|
||||
row.userId,
|
||||
row.username,
|
||||
row.action,
|
||||
row.target,
|
||||
row.targetId,
|
||||
row.createdAt,
|
||||
details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes
|
||||
.map(
|
||||
(change) =>
|
||||
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
||||
)
|
||||
.join("\n"),
|
||||
]);
|
||||
}
|
||||
return new Response(
|
||||
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="audit-page.csv"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user