Export downloads a versioned JSON snapshot of all posts, pages, tags, navigation, and settings from GET /api/admin/export. Cross-references are keyed by slug rather than database id, so a backup restores cleanly into any database. Import (a new settings-page section) validates the file and atomically replaces all content in one transaction, sanitizing bodies at the trust boundary; users, sessions, and uploaded files are untouched. Server-action body limit raised so backups fit in the import upload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { db } from "@/db";
|
|
import { navItems, posts, tags } from "@/db/schema";
|
|
import {
|
|
buildSiteExport,
|
|
importSiteExport,
|
|
parseSiteExportJson,
|
|
} from "@/lib/services/import-export";
|
|
import { createPage } from "@/lib/services/pages";
|
|
import { createPost, getPublishedPostBySlug, type PostInput } from "@/lib/services/posts";
|
|
import { getSettings, listNavItems, saveSettings } from "@/lib/services/settings";
|
|
import { resetDb } from "../helpers/db";
|
|
|
|
const input = (overrides: Partial<PostInput> = {}): PostInput => ({
|
|
title: "A post",
|
|
slug: "",
|
|
body: "<p>body</p>",
|
|
authorName: "Tester",
|
|
featuredImageUrl: null,
|
|
featuredImageAlt: null,
|
|
status: "published",
|
|
tagIds: [],
|
|
newTagNames: [],
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(resetDb);
|
|
|
|
/** Simulates writing the export to disk and reading it back. */
|
|
async function exportViaJson() {
|
|
const parsed = parseSiteExportJson(JSON.stringify(await buildSiteExport()));
|
|
if ("error" in parsed) throw new Error(parsed.error);
|
|
return parsed.data;
|
|
}
|
|
|
|
describe("export/import round trip", () => {
|
|
it("restores posts, tags, pages, nav, and settings from a JSON export", async () => {
|
|
const published = await createPost(
|
|
input({ title: "Keep me", newTagNames: ["Alpha", "Beta"] }),
|
|
);
|
|
await createPost(input({ title: "Draft one", status: "draft" }));
|
|
const about = await createPage({
|
|
title: "About",
|
|
slug: "",
|
|
body: "<p>hi</p>",
|
|
status: "published",
|
|
});
|
|
await saveSettings(
|
|
{
|
|
siteTitle: "Round Trip",
|
|
headerText: "hdr",
|
|
footerText: "ftr",
|
|
postsPerPage: 7,
|
|
excerptWords: 25,
|
|
homeMode: "page",
|
|
homeTagId: null,
|
|
homePageId: about.id,
|
|
theme: "nord",
|
|
font: "lora",
|
|
},
|
|
[
|
|
{ label: "About", url: null, pageId: about.id },
|
|
{ label: "Search", url: "https://example.com", pageId: null },
|
|
],
|
|
);
|
|
|
|
const snapshot = await exportViaJson();
|
|
|
|
// Mutate everything, then restore.
|
|
await db.delete(navItems);
|
|
await db.delete(posts);
|
|
await db.delete(tags);
|
|
await createPost(input({ title: "Impostor" }));
|
|
await importSiteExport(snapshot);
|
|
|
|
const restored = await getPublishedPostBySlug(published.slug);
|
|
expect(restored).not.toBeNull();
|
|
expect(restored?.title).toBe("Keep me");
|
|
expect(restored?.tags.map((t) => t.name).sort()).toEqual(["Alpha", "Beta"]);
|
|
// Timestamps survive (public ordering depends on publishedAt).
|
|
expect(restored?.publishedAt?.getTime()).toBe(published.publishedAt?.getTime());
|
|
expect(await getPublishedPostBySlug("impostor")).toBeNull();
|
|
|
|
const settings = await getSettings();
|
|
expect(settings.siteTitle).toBe("Round Trip");
|
|
expect(settings.postsPerPage).toBe(7);
|
|
expect(settings.homeMode).toBe("page");
|
|
expect(settings.theme).toBe("nord");
|
|
expect(settings.font).toBe("lora");
|
|
|
|
// home page and nav point at the re-created page row, not the old id.
|
|
const nav = await listNavItems();
|
|
expect(nav).toHaveLength(2);
|
|
expect(nav[0].label).toBe("About");
|
|
expect(nav[0].pageId).toBe(settings.homePageId);
|
|
expect(nav[1].url).toBe("https://example.com");
|
|
});
|
|
|
|
it("keeps draft posts and unpublished state intact", async () => {
|
|
await createPost(input({ title: "Draft", status: "draft" }));
|
|
const snapshot = await exportViaJson();
|
|
await importSiteExport(snapshot);
|
|
const [row] = await db.select().from(posts);
|
|
expect(row.status).toBe("draft");
|
|
expect(row.publishedAt).toBeNull();
|
|
});
|
|
|
|
it("sanitizes imported bodies", async () => {
|
|
const snapshot = await exportViaJson();
|
|
snapshot.posts.push({
|
|
title: "Sneaky",
|
|
slug: "sneaky",
|
|
body: '<p>ok</p><script>alert("x")</script>',
|
|
authorName: "Mallory",
|
|
featuredImageUrl: null,
|
|
featuredImageAlt: null,
|
|
status: "published",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
publishedAt: new Date(),
|
|
tagSlugs: [],
|
|
});
|
|
await importSiteExport(snapshot);
|
|
const post = await getPublishedPostBySlug("sneaky");
|
|
expect(post?.body).toContain("<p>ok</p>");
|
|
expect(post?.body).not.toContain("<script>");
|
|
});
|
|
});
|
|
|
|
describe("parseSiteExportJson", () => {
|
|
it("rejects non-JSON and foreign JSON", () => {
|
|
expect(parseSiteExportJson("not json {")).toHaveProperty("error");
|
|
expect(parseSiteExportJson('{"hello":"world"}')).toHaveProperty("error");
|
|
});
|
|
|
|
it("rejects posts referencing tags missing from the file", async () => {
|
|
const snapshot = await exportViaJson();
|
|
snapshot.posts.push({
|
|
title: "Orphan",
|
|
slug: "orphan",
|
|
body: "",
|
|
authorName: "A",
|
|
featuredImageUrl: null,
|
|
featuredImageAlt: null,
|
|
status: "draft",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
publishedAt: null,
|
|
tagSlugs: ["missing-tag"],
|
|
});
|
|
const result = parseSiteExportJson(JSON.stringify(snapshot));
|
|
expect(result).toHaveProperty("error");
|
|
if ("error" in result) expect(result.error).toContain("missing-tag");
|
|
});
|
|
|
|
it("rejects duplicate slugs", async () => {
|
|
const snapshot = await exportViaJson();
|
|
snapshot.pages.push(
|
|
{ title: "P", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
|
|
{ title: "Q", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
|
|
);
|
|
const result = parseSiteExportJson(JSON.stringify(snapshot));
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
|
|
it("rejects a home tag that is not part of the export", async () => {
|
|
const snapshot = await exportViaJson();
|
|
snapshot.settings.homeMode = "tag";
|
|
snapshot.settings.homeTagSlug = "ghost";
|
|
const result = parseSiteExportJson(JSON.stringify(snapshot));
|
|
expect(result).toHaveProperty("error");
|
|
});
|
|
});
|