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",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user