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
@@ -1,3 +1,4 @@
|
||||
import { publicationIssues } from "./publication-preflight";
|
||||
import type { ActionResult } from "./safe-action-shared";
|
||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
|
||||
| "summaryTooLong"
|
||||
| "imageTooLong"
|
||||
| "invalidStatus"
|
||||
| "publishDateRequired",
|
||||
| "publishDateRequired"
|
||||
| "imageInvalid"
|
||||
| "linksInvalid",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
|
||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||
throw new ArticleInputError("publishDateRequired");
|
||||
if (status !== "draft") {
|
||||
const issue = publicationIssues({
|
||||
kind: "article",
|
||||
image,
|
||||
body: fullStory,
|
||||
status,
|
||||
publishAt,
|
||||
}).find((issue) => issue.severity === "error");
|
||||
if (issue?.code === "imageInvalid" || issue?.code === "linksInvalid")
|
||||
throw new ArticleInputError(issue.code);
|
||||
}
|
||||
return {
|
||||
title,
|
||||
shortStory,
|
||||
@@ -57,6 +71,8 @@ export function articleInputField(
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
imageInvalid: "image",
|
||||
linksInvalid: "fullStory",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readArticleInput } from "./article-input";
|
||||
import { publicationIssues, safePublicationUrl } from "./publication-preflight";
|
||||
|
||||
describe("publication preflight", () => {
|
||||
it("allows local images and web links but rejects unsafe schemes and credentials", () => {
|
||||
expect(safePublicationUrl("/images/news.png", true)).toBe(true);
|
||||
expect(safePublicationUrl("https://example.com/image.png", true)).toBe(
|
||||
true,
|
||||
);
|
||||
for (const url of [
|
||||
"javascript:alert(1)",
|
||||
"//external.test/image",
|
||||
"https://user:[email protected]/a",
|
||||
"data:text/html,test",
|
||||
"https:\\example.com",
|
||||
]) {
|
||||
expect(safePublicationUrl(url, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
it("checks encoded link schemes without fetching destinations", () => {
|
||||
const issues = publicationIssues({
|
||||
kind: "article",
|
||||
image: "/cover.png",
|
||||
body: '<a href="javascript:alert(1)">bad</a>',
|
||||
});
|
||||
expect(issues).toContainEqual({
|
||||
code: "linksInvalid",
|
||||
severity: "error",
|
||||
field: "fullStory",
|
||||
});
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "article",
|
||||
image: "/cover.png",
|
||||
body: '<a href="/help">Help</a><a href="mailto:[email protected]">Mail</a>',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("rejects reversed event dates and distinguishes a past-date warning", () => {
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "event",
|
||||
startsAt: "2030-01-02",
|
||||
endsAt: "2030-01-01",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "scheduleInvalid", severity: "error" }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
publicationIssues(
|
||||
{ kind: "event", startsAt: "2020-01-01" },
|
||||
Date.parse("2021-01-01"),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "schedulePast", severity: "warning" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("warns for missing image and normalized slug without blocking drafts", () => {
|
||||
const issues = publicationIssues({
|
||||
kind: "article",
|
||||
slug: "Hello World",
|
||||
normalizedSlug: "hello-world",
|
||||
});
|
||||
expect(issues.map((i) => i.code)).toEqual([
|
||||
"imageMissing",
|
||||
"slugNormalized",
|
||||
]);
|
||||
expect(issues.every((i) => i.severity === "warning")).toBe(true);
|
||||
});
|
||||
it("requires a valid scheduled publication date", () => {
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "article",
|
||||
status: "scheduled",
|
||||
publishAt: "",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "scheduleInvalid" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("enforces image and link checks in server article input but preserves draft saving", () => {
|
||||
const form = new FormData();
|
||||
form.set("title", "News");
|
||||
form.set("image", "javascript:alert(1)");
|
||||
expect(() => readArticleInput(form)).toThrow("imageInvalid");
|
||||
form.set("status", "draft");
|
||||
expect(readArticleInput(form).image).toBe("javascript:alert(1)");
|
||||
form.set("status", "published");
|
||||
form.set("image", "/cover.png");
|
||||
form.set("fullStory", '<a href="javascript:alert(1)">bad</a>');
|
||||
expect(() => readArticleInput(form)).toThrow("linksInvalid");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEventSchema, eventTypeSchema } from "./event";
|
||||
import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
|
||||
|
||||
describe("eventTypeSchema", () => {
|
||||
it("accepts valid type", () => {
|
||||
@@ -98,3 +98,27 @@ describe("createEventSchema", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves omitted status and recurrence on partial event updates", () => {
|
||||
expect(updateEventSchema.parse({ title: "Changed" })).toEqual({
|
||||
title: "Changed",
|
||||
});
|
||||
});
|
||||
it("rejects a null event start instead of converting it to the Unix epoch", () => {
|
||||
expect(
|
||||
createEventSchema.safeParse({
|
||||
title: "Event",
|
||||
description: "Details",
|
||||
typeId: 1,
|
||||
startsAt: null,
|
||||
status: "published",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes an omitted image from explicit image removal", () => {
|
||||
expect(updateEventSchema.parse({ title: "Changed" })).not.toHaveProperty(
|
||||
"image",
|
||||
);
|
||||
expect(updateEventSchema.parse({ image: "" })).toEqual({ image: null });
|
||||
});
|
||||
@@ -25,7 +25,11 @@ export const createEventSchema = z.object({
|
||||
emptyToUndefined,
|
||||
z.coerce.number().int().positive().nullable().optional(),
|
||||
),
|
||||
startsAt: z.coerce.date(),
|
||||
startsAt: z.preprocess(
|
||||
(value) =>
|
||||
value == null || typeof value === "boolean" ? undefined : value,
|
||||
z.coerce.date(),
|
||||
),
|
||||
endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()),
|
||||
maxPlayers: z.preprocess(
|
||||
emptyToUndefined,
|
||||
@@ -45,7 +49,15 @@ export const createEventSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const updateEventSchema = createEventSchema.partial();
|
||||
// Zod applies defaults inside optional schemas; partial edits must not reset state.
|
||||
export const updateEventSchema = createEventSchema.partial().extend({
|
||||
status: createEventSchema.shape.status.removeDefault().optional(),
|
||||
isRecurring: createEventSchema.shape.isRecurring.removeDefault().optional(),
|
||||
image: z.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z.string().max(500).nullable().optional(),
|
||||
),
|
||||
});
|
||||
|
||||
export const eventPrizeSchema = z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
|
||||
Reference in new issue
Block a user