yap-blog/tests/e2e/blog.spec.ts
2026-07-02 21:32:33 -04:00

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)");
});