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",
},
},
);
},
);
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
guard: vi.fn(),
create: vi.fn(),
list: vi.fn(),
cancel: vi.fn(),
after: vi.fn(),
ping: vi.fn(),
source: vi.fn(),
@@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
vi.mock("@/lib/services/furni-job-worker", () => ({
drainFurnitureImports: vi.fn(),
}));
vi.mock("@/lib/services/furni-job-store", () => ({
vi.mock("@/lib/services/furni-job-store", async (original) => ({
...(await original<typeof import("@/lib/services/furni-job-store")>()),
ImportJobStore: class {
create = mocks.create;
list = mocks.list;
requestCancellation = mocks.cancel;
},
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
import { GET, POST } from "./route";
import { GET, PATCH, POST } from "./route";
const item = {
id: 1,
@@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => {
).status,
).toBe(404);
});
it("only returns the current operators history", async () => {
mocks.list.mockResolvedValue([
{ id: "a", userId: 7 },
{ id: "b", userId: 9 },
]);
it("requests bounded owner-scoped history from the store", async () => {
mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
const response = await GET(request({}), ctx);
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
});
it("requests cancellation with the authenticated owner", async () => {
const id = randomUUID();
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
const response = await PATCH(request({ id }), ctx);
expect(response.status).toBe(200);
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
});
it("does not reveal other owners' jobs", async () => {
mocks.cancel.mockResolvedValue(null);
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
});
it("rejects unsafe cancellation IDs", async () => {
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
});
+20 -5
View File
@@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis";
import { getSource } from "@/lib/services/clone-sources";
import { ImportJobStore } from "@/lib/services/furni-job-store";
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
const schema = z.object({
@@ -38,10 +38,10 @@ export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => {
after(drainFurnitureImports);
const jobs = (await new ImportJobStore().list())
.filter((job) => job.userId === ctx.session.user.id)
.reverse()
.slice(0, 30);
const jobs = await new ImportJobStore().list({
userId: ctx.session.user.id,
limit: 30,
});
return apiOk({ jobs });
},
);
@@ -82,3 +82,18 @@ export const POST = withAdmin(
return apiOk({ job });
},
);
export const PATCH = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const body = await request.json().catch(() => null);
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
const job = await new ImportJobStore().requestCancellation(
body.id,
ctx.session.user.id,
);
if (!job) return apiError("Import job not found", 404);
after(drainFurnitureImports);
return apiOk({ job });
},
);