feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
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",
},
},
);
},
);