250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
import "dotenv/config";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
|
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test("anonymous visitors are redirected away from admin routes", async ({ page }) => {
|
|
await page.goto("/admin");
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
|
|
await page.goto("/admin/posts/new");
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
});
|
|
|
|
test("login rejects bad credentials", async ({ page }) => {
|
|
await page.goto("/admin/login");
|
|
await page.getByLabel("Username").fill(ADMIN_USERNAME);
|
|
await page.getByLabel("Password").fill("definitely-the-wrong-password");
|
|
await page.getByRole("button", { name: "Sign in" }).click();
|
|
|
|
// Next.js's route announcer is also role=alert, so match on the text.
|
|
await expect(page.getByText("Invalid username or password.")).toBeVisible();
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
});
|
|
|
|
test("full editorial flow: login → create → publish → public view → logout", async ({
|
|
page,
|
|
}) => {
|
|
const title = "Publishing pipeline smoke test";
|
|
const slug = "publishing-pipeline-smoke-test";
|
|
|
|
// --- Login ---------------------------------------------------------------
|
|
await page.goto("/admin/login");
|
|
await page.getByLabel("Username").fill(ADMIN_USERNAME);
|
|
await page.getByLabel("Password").fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign in" }).click();
|
|
await expect(page).toHaveURL(/\/admin$/);
|
|
|
|
// --- Create a post in the rich text editor -------------------------------
|
|
// The editor opens on the Content tab; metadata lives on Post settings.
|
|
await page.goto("/admin/posts/new");
|
|
|
|
const editor = page.locator(".tiptap");
|
|
await editor.click();
|
|
await page.getByRole("button", { name: "Heading level 2" }).click();
|
|
await page.keyboard.type("Why this works");
|
|
await page.keyboard.press("Enter");
|
|
await page.getByRole("button", { name: "Bold", exact: true }).click();
|
|
await page.keyboard.type("Bold statement");
|
|
await page.getByRole("button", { name: "Bold", exact: true }).click();
|
|
await page.keyboard.type(" and plain text follow.");
|
|
|
|
// WYSIWYG: the editor itself shows the structure it will publish.
|
|
await expect(editor.locator("h2")).toContainText("Why this works");
|
|
await expect(editor.locator("strong")).toContainText("Bold statement");
|
|
|
|
await page.getByRole("tab", { name: "Post settings" }).click();
|
|
await page.getByLabel("Title").fill(title);
|
|
await expect(page.getByLabel("Slug")).toHaveValue(slug); // auto-generated
|
|
await page.getByLabel("Design").check(); // existing seeded tag
|
|
await page.getByLabel("New tags (optional)").fill("E2E Coverage");
|
|
|
|
// Switching back to Content must not lose the composed body.
|
|
await page.getByRole("tab", { name: "Content" }).click();
|
|
await expect(editor.locator("h2")).toContainText("Why this works");
|
|
|
|
// Status + save live in the always-visible action bar.
|
|
await page.getByLabel("Status").selectOption("published");
|
|
await page.getByRole("button", { name: "Save post" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/admin\/posts\/\d+\/edit\?saved=1/);
|
|
await expect(page.getByText("Saved.", { exact: true })).toBeVisible();
|
|
|
|
// --- Public listing shows it; seeded drafts stay hidden -------------------
|
|
await page.goto("/posts");
|
|
await expect(page.getByRole("link", { name: title })).toBeVisible();
|
|
await expect(page.getByText("Secret draft (should never be public)")).toHaveCount(0);
|
|
|
|
// --- Full post page: rich content rendered as authored -------------------
|
|
await page.getByRole("link", { name: title }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/posts/${slug}$`));
|
|
await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible();
|
|
await expect(
|
|
page.locator(".markdown-body").getByRole("heading", { level: 2, name: "Why this works" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".markdown-body strong")).toContainText("Bold statement");
|
|
|
|
// --- The new tag is in the sidebar and filters correctly ------------------
|
|
const tagNav = page.getByRole("navigation", { name: "Posts by tag" });
|
|
await expect(tagNav.getByRole("link", { name: /E2E Coverage/ })).toBeVisible();
|
|
// The seeded draft-only tag must not be listed publicly.
|
|
await expect(tagNav.getByRole("link", { name: /Secrets/ })).toHaveCount(0);
|
|
|
|
await tagNav.getByRole("link", { name: /E2E Coverage/ }).click();
|
|
await expect(page).toHaveURL(/\/tags\/e2e-coverage/);
|
|
await expect(page.getByRole("link", { name: title })).toBeVisible();
|
|
|
|
// --- Draft posts 404 on the public site ----------------------------------
|
|
const draftResponse = await page.goto("/posts/secret-draft");
|
|
expect(draftResponse?.status()).toBe(404);
|
|
|
|
// --- Logout locks the admin again -----------------------------------------
|
|
await page.goto("/admin");
|
|
await page.getByRole("button", { name: "Log out" }).click();
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
await page.goto("/admin/posts");
|
|
await expect(page).toHaveURL(/\/admin\/login/);
|
|
});
|
|
|
|
test("rich editor: markdown paste, XSS stripping, and inline image upload", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const PNG_FIXTURE = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
await page.goto("/admin/login");
|
|
await page.getByLabel("Username").fill(ADMIN_USERNAME);
|
|
await page.getByLabel("Password").fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign in" }).click();
|
|
await expect(page).toHaveURL(/\/admin$/);
|
|
|
|
await page.goto("/admin/posts/new");
|
|
await page.getByRole("tab", { name: "Post settings" }).click();
|
|
await page.getByLabel("Title").fill("Editor capabilities check");
|
|
await page.getByRole("tab", { name: "Content" }).click();
|
|
const editor = page.locator(".tiptap");
|
|
await editor.click();
|
|
|
|
// Pasting plain-text Markdown converts it — including stripping an
|
|
// embedded <script> via the shared sanitizer.
|
|
await page.evaluate(() => {
|
|
const dt = new DataTransfer();
|
|
dt.setData(
|
|
"text/plain",
|
|
"## Pasted heading\n\nSome **pasted bold** text.\n\n<script>alert(1)</script>",
|
|
);
|
|
document
|
|
.querySelector(".tiptap")!
|
|
.dispatchEvent(
|
|
new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await expect(editor.getByRole("heading", { level: 2 })).toContainText("Pasted heading");
|
|
await expect(editor.locator("strong")).toContainText("pasted bold");
|
|
expect(await editor.innerHTML()).not.toContain("alert(1)");
|
|
|
|
// Upload an image through the toolbar's file input; it lands inline.
|
|
await page
|
|
.getByTestId("editor-image-input")
|
|
.setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: PNG_FIXTURE });
|
|
await expect(editor.locator('img[src^="/uploads/"]')).toBeVisible();
|
|
const uploadedSrc = await editor.locator('img[src^="/uploads/"]').getAttribute("src");
|
|
|
|
// Publish and verify the public page renders everything.
|
|
await page.getByLabel("Status").selectOption("published");
|
|
await page.getByRole("button", { name: "Save post" }).click();
|
|
await expect(page).toHaveURL(/\/admin\/posts\/\d+\/edit\?saved=1/);
|
|
|
|
await page.goto("/posts/editor-capabilities-check");
|
|
await expect(
|
|
page.locator(".markdown-body").getByRole("heading", { level: 2, name: "Pasted heading" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".markdown-body strong")).toContainText("pasted bold");
|
|
const publicBody = await page.locator(".markdown-body").innerHTML();
|
|
expect(publicBody).not.toContain("<script");
|
|
expect(publicBody).not.toContain("alert(1)");
|
|
|
|
// The uploaded file is actually served by the server.
|
|
const image = page.locator(`.markdown-body img[src="${uploadedSrc}"]`);
|
|
await expect(image).toBeVisible();
|
|
const response = await page.request.get(uploadedSrc!);
|
|
expect(response.status()).toBe(200);
|
|
expect(response.headers()["content-type"]).toContain("image/png");
|
|
|
|
// The upload endpoint refuses anonymous requests (the `request`
|
|
// fixture shares no cookies with the logged-in page).
|
|
const anonymous = await request.fetch("/api/admin/uploads", {
|
|
method: "POST",
|
|
multipart: { file: { name: "x.png", mimeType: "image/png", buffer: PNG_FIXTURE } },
|
|
});
|
|
expect(anonymous.status()).toBe(401);
|
|
});
|
|
|
|
test("theme and font can be changed in appearance settings", async ({ page }) => {
|
|
// Seeded defaults.
|
|
await page.goto("/");
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark");
|
|
await expect(page.locator("html")).toHaveAttribute("data-font", "geist");
|
|
|
|
await page.goto("/admin/login");
|
|
await page.getByLabel("Username").fill(ADMIN_USERNAME);
|
|
await page.getByLabel("Password").fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign in" }).click();
|
|
await expect(page).toHaveURL(/\/admin$/);
|
|
|
|
// Switch to Dracula + Lora.
|
|
await page.goto("/admin/settings");
|
|
await page.getByRole("radio", { name: "Dracula" }).check();
|
|
await page.getByRole("radio", { name: "Lora" }).check();
|
|
await page.getByRole("button", { name: "Save settings" }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible();
|
|
|
|
// Public site picks up both; body background is Dracula's #282a36 and
|
|
// the computed font stack leads with Lora.
|
|
await page.goto("/");
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula");
|
|
await expect(page.locator("html")).toHaveAttribute("data-font", "lora");
|
|
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(40, 42, 54)");
|
|
await expect(page.locator("body")).toHaveCSS("font-family", /Lora/);
|
|
|
|
// The admin area follows the same appearance settings.
|
|
await page.goto("/admin/settings");
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula");
|
|
|
|
// Black & White renders plain white.
|
|
await page.getByRole("radio", { name: "Black & White" }).check();
|
|
await page.getByRole("button", { name: "Save settings" }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible();
|
|
await page.goto("/");
|
|
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
|
|
|
// One of the newer palettes + fonts: Tokyo Night with Space Grotesk.
|
|
await page.goto("/admin/settings");
|
|
await page.getByRole("radio", { name: "Tokyo Night" }).check();
|
|
await page.getByRole("radio", { name: "Space Grotesk" }).check();
|
|
await page.getByRole("button", { name: "Save settings" }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible();
|
|
await page.goto("/");
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "tokyo-night");
|
|
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(26, 27, 38)");
|
|
await expect(page.locator("body")).toHaveCSS("font-family", /Space Grotesk/);
|
|
|
|
// And back to the seeded defaults.
|
|
await page.goto("/admin/settings");
|
|
await page.getByRole("radio", { name: "Solarized Dark" }).check();
|
|
await page.getByRole("radio", { name: /^Geist/ }).check();
|
|
await page.getByRole("button", { name: "Save settings" }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible();
|
|
|
|
await page.goto("/");
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark");
|
|
await expect(page.locator("html")).toHaveAttribute("data-font", "geist");
|
|
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(0, 43, 54)");
|
|
});
|