feat(editorial): validate publications and preserve partial event updates
This commit is contained in:
1 parent
76f0420d64
commit
db4acbb46e
14 files changed
+613
-16
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existing: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: mocks.existing }) }),
|
||||
}),
|
||||
insert: () => ({ values: mocks.insert }),
|
||||
update: () => ({ set: () => ({ where: mocks.update }) }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction:
|
||||
(
|
||||
options: { schema: { parse: (data: unknown) => unknown } },
|
||||
handler: (ctx: unknown) => unknown,
|
||||
) =>
|
||||
(data: unknown) =>
|
||||
handler({
|
||||
data: options.schema.parse(data),
|
||||
session: { user: { id: 7, username: "Staff" } },
|
||||
}),
|
||||
authAction: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/foundation/action", () => ({ handleActionError: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs"));
|
||||
|
||||
import { createEvent, updateEvent } from "./events";
|
||||
|
||||
const base = {
|
||||
title: "Event",
|
||||
description: "Details",
|
||||
typeId: 1,
|
||||
startsAt: new Date("2030-01-01"),
|
||||
status: "published" as const,
|
||||
isRecurring: 0,
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.existing.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
status: "published",
|
||||
title: "Event",
|
||||
image: "/cover.png",
|
||||
startsAt: new Date("2030-01-01"),
|
||||
endsAt: new Date("2030-01-02"),
|
||||
},
|
||||
]);
|
||||
mocks.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||
mocks.update.mockResolvedValue(undefined);
|
||||
});
|
||||
it("rejects invalid published event images before writing", async () => {
|
||||
await expect(
|
||||
createEvent({ ...base, image: "javascript:alert(1)" }),
|
||||
).rejects.toThrow("Check the event image");
|
||||
expect(mocks.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("validates an update against existing dates before writing", async () => {
|
||||
await expect(
|
||||
updateEvent({ id: 1, endsAt: new Date("2029-12-01") }),
|
||||
).rejects.toThrow("Check the event image");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows draft saves and partial updates with unchanged valid dates", async () => {
|
||||
await createEvent({ ...base, status: "draft", image: "javascript:alert(1)" });
|
||||
expect(mocks.insert).toHaveBeenCalledOnce();
|
||||
await updateEvent({ id: 1, title: "Changed", startsAt: undefined });
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not bypass published preflight when a partial update omits status", async () => {
|
||||
await expect(
|
||||
updateEvent({ id: 1, image: "javascript:alert(1)" }),
|
||||
).rejects.toThrow("Check the event image");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicitly removing an invalid image while publishing the draft", async () => {
|
||||
mocks.existing.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
status: "draft",
|
||||
title: "Draft",
|
||||
image: "javascript:alert(1)",
|
||||
startsAt: new Date("2030-01-01"),
|
||||
endsAt: null,
|
||||
},
|
||||
]);
|
||||
await updateEvent({ id: 1, status: "published", image: "" });
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WebsiteEventWinner,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
@@ -108,6 +109,15 @@ export const createEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||
async (ctx) => {
|
||||
const now = new Date();
|
||||
if (
|
||||
ctx.data.status === "published" &&
|
||||
publicationIssues({ kind: "event", ...ctx.data }).some(
|
||||
(issue) => issue.severity === "error",
|
||||
)
|
||||
)
|
||||
throw new ActionError(
|
||||
"Check the event image and schedule before publishing",
|
||||
);
|
||||
const [result] = await db.insert(WebsiteEvent).values({
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
@@ -143,11 +153,27 @@ export const updateEvent = adminAction(
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
image: WebsiteEvent.image,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
if (
|
||||
(data.status ?? existing.status) === "published" &&
|
||||
publicationIssues({
|
||||
kind: "event",
|
||||
image: data.image === undefined ? existing.image : data.image,
|
||||
startsAt:
|
||||
data.startsAt === undefined ? existing.startsAt : data.startsAt,
|
||||
endsAt: data.endsAt === undefined ? existing.endsAt : data.endsAt,
|
||||
}).some((issue) => issue.severity === "error")
|
||||
)
|
||||
throw new ActionError(
|
||||
"Check the event image and schedule before publishing",
|
||||
);
|
||||
|
||||
await db
|
||||
.update(WebsiteEvent)
|
||||
|
||||
Reference in new issue
Block a user