✅(frontend) add PDF viewer e2e tests

Tests cover PDF rendering, corrupted file handling,
JavaScript injection prevention and external link
interception with redirect disclaimer.
This commit is contained in:
Nathan Vasse
2026-03-30 10:54:34 +02:00
parent 6fa44aac62
commit 1ef1dd4a80
7 changed files with 760 additions and 0 deletions
@@ -0,0 +1,165 @@
/**
* Generates test PDFs for e2e testing:
*
* 1. pdf_with_links.pdf — 3-page PDF with link annotations
* Page 1: title + external URI link (https://example.com) + internal GoTo link (→ page 3)
* Pages 2 & 3: identification text
*
* 2. pdf_with_js.pdf — 1-page PDF with embedded JavaScript via OpenAction
* Used to verify that the viewer does NOT execute embedded scripts.
*
* Usage:
* npx --package=pdf-lib node generate-pdfs.mjs
*/
import { PDFDocument, rgb, PDFName, PDFArray, PDFDict, PDFNumber, PDFString } from "pdf-lib";
import { writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
async function generatePdfWithLinks() {
const doc = await PDFDocument.create();
const font = await doc.embedFont("Helvetica");
const PAGE_WIDTH = 595.28;
const PAGE_HEIGHT = 841.89;
// --- Page 1 ---
const page1 = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page1.drawText("PDF Links Test", { x: 50, y: 750, size: 24, font, color: rgb(0, 0, 0) });
page1.drawText("External link: https://example.com", { x: 50, y: 700, size: 14, font, color: rgb(0, 0, 1) });
page1.drawText("Internal link: Go to page 3", { x: 50, y: 650, size: 14, font, color: rgb(0, 0, 1) });
// --- Page 2 ---
const page2 = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page2.drawText("Page 2", { x: 50, y: 750, size: 24, font, color: rgb(0, 0, 0) });
// --- Page 3 ---
const page3 = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page3.drawText("Page 3", { x: 50, y: 750, size: 24, font, color: rgb(0, 0, 0) });
// --- Annotations (must be added at the low level) ---
const context = doc.context;
// External URI annotation on page 1
const uriAction = context.obj({
Type: "Action",
S: "URI",
URI: PDFString.of("https://example.com"),
});
const extAnnot = context.obj({
Type: "Annot",
Subtype: "Link",
Rect: [45, 695, 350, 715], // around the external link text
Border: [0, 0, 0],
A: uriAction,
});
// Internal GoTo annotation on page 1 → page 3
const page3Ref = doc.getPages()[2].ref;
const destArray = context.obj([page3Ref, PDFName.of("Fit")]);
const gotoAction = context.obj({
Type: "Action",
S: "GoTo",
D: destArray,
});
const intAnnot = context.obj({
Type: "Annot",
Subtype: "Link",
Rect: [45, 645, 280, 665], // around the internal link text
Border: [0, 0, 0],
A: gotoAction,
});
// Attach annotations to page 1
const page1Dict = page1.node;
const annotsArray = context.obj([extAnnot, intAnnot]);
page1Dict.set(PDFName.of("Annots"), annotsArray);
const pdfBytes = await doc.save();
const outPath = join(__dirname, "pdf_with_links.pdf");
writeFileSync(outPath, pdfBytes);
console.log(`Written ${outPath} (${pdfBytes.length} bytes)`);
}
async function generatePdfWithJs() {
const doc = await PDFDocument.create();
const font = await doc.embedFont("Helvetica");
const page = doc.addPage([595.28, 841.89]);
page.drawText("PDF with embedded JavaScript", {
x: 50, y: 750, size: 24, font, color: rgb(0, 0, 0),
});
page.drawText("This PDF has an OpenAction /JavaScript entry.", {
x: 50, y: 700, size: 14, font, color: rgb(0, 0, 0),
});
// Build an OpenAction that tries to execute JS when the PDF is opened.
// A secure viewer must ignore this entirely.
const context = doc.context;
const jsAction = context.obj({
Type: "Action",
S: "JavaScript",
JS: PDFString.of('app.alert("HACKED");'),
});
doc.catalog.set(PDFName.of("OpenAction"), jsAction);
const pdfBytes = await doc.save();
const outPath = join(__dirname, "pdf_with_js.pdf");
writeFileSync(outPath, pdfBytes);
console.log(`Written ${outPath} (${pdfBytes.length} bytes)`);
}
async function generatePdfWithJsLink() {
const doc = await PDFDocument.create();
const font = await doc.embedFont("Helvetica");
const PAGE_WIDTH = 595.28;
const PAGE_HEIGHT = 841.89;
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page.drawText("PDF with javascript: link", {
x: 50, y: 750, size: 24, font, color: rgb(0, 0, 0),
});
page.drawText("Click here (javascript URI)", {
x: 50, y: 700, size: 14, font, color: rgb(0, 0, 1),
});
const context = doc.context;
const uriAction = context.obj({
Type: "Action",
S: "URI",
URI: PDFString.of("javascript:alert(1)"),
});
const annot = context.obj({
Type: "Annot",
Subtype: "Link",
Rect: [45, 695, 350, 715],
Border: [0, 0, 0],
A: uriAction,
});
const pageDict = page.node;
const annotsArray = context.obj([annot]);
pageDict.set(PDFName.of("Annots"), annotsArray);
const pdfBytes = await doc.save();
const outPath = join(__dirname, "pdf_with_js_link.pdf");
writeFileSync(outPath, pdfBytes);
console.log(`Written ${outPath} (${pdfBytes.length} bytes)`);
}
async function main() {
await generatePdfWithLinks();
await generatePdfWithJs();
await generatePdfWithJsLink();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1 @@
This is not a valid PDF file. Corrupted content.
@@ -0,0 +1,591 @@
import path from "path";
import test, { expect } from "@playwright/test";
import { clearDb, login } from "./utils-common";
import { clickToMyFiles } from "./utils-navigate";
import { uploadFile } from "./utils/upload-utils";
const PDF_FILE_PATH = path.join(__dirname, "/assets/pv_cm.pdf");
const PDF_LINKS_FILE_PATH = path.join(__dirname, "/assets/pdf_with_links.pdf");
const PDF_JS_FILE_PATH = path.join(__dirname, "/assets/pdf_with_js.pdf");
const PDF_JS_LINK_FILE_PATH = path.join(
__dirname,
"/assets/pdf_with_js_link.pdf",
);
const PDF_CORRUPTED_FILE_PATH = path.join(
__dirname,
"/assets/pdf_corrupted.pdf",
);
import type { Page, Locator } from "@playwright/test";
/**
* Dismiss the persistent upload toast overlay that blocks pointer events
* on the PDF preview controls.
*/
async function dismissToast(page: Page) {
const closeBtn = page.locator(".Toastify__toast").getByRole("button", {
name: "close",
});
if ((await closeBtn.count()) > 0) {
await closeBtn.first().dispatchEvent("click");
await page
.locator(".Toastify__toast")
.first()
.waitFor({ state: "detached", timeout: 5000 });
}
}
async function waitForPdfReady(page: Page) {
await expect(page.locator(".react-pdf__Page").first()).toBeVisible({
timeout: 10000,
});
}
async function openSidebar(page: Page) {
const toggle = page.locator('button[aria-label="Toggle sidebar"]');
await toggle.dispatchEvent("click");
await expect(page.locator("[data-thumb-page]").first()).toBeVisible({
timeout: 10000,
});
}
async function expectActiveThumbnail(page: Page, pageNum: number) {
await expect(
page.locator(`[data-thumb-page="${pageNum}"]`),
).toHaveClass(/pdf-preview__thumbnail--active/, { timeout: 10000 });
}
async function expectInactiveThumbnail(page: Page, pageNum: number) {
await expect(
page.locator(`[data-thumb-page="${pageNum}"]`),
).not.toHaveClass(/pdf-preview__thumbnail--active/);
}
async function scrollPdfViewer(page: Page, deltaY: number) {
const grid = page.locator(
".pdf-preview__container .ReactVirtualized__Grid",
);
await grid.hover();
await page.mouse.wheel(0, deltaY);
}
function getPageInput(page: Page): Locator {
return page.locator('input[aria-label="Current page"]');
}
function getExternalLink(page: Page): Locator {
return page
.locator(
".annotationLayer section.linkAnnotation:not([data-internal-link]) a",
)
.first();
}
function getInternalLink(page: Page): Locator {
return page.locator("[data-internal-link] a").first();
}
function getDisclaimerModal(page: Page): Locator {
return page.getByRole("dialog", { name: "External link" });
}
async function clickExternalLinkAndWaitForModal(page: Page) {
await getExternalLink(page).click({ force: true });
const modal = getDisclaimerModal(page);
await expect(modal).toBeVisible({ timeout: 5000 });
return modal;
}
async function waitForPdfReadyAndDismissToast(page: Page) {
await waitForPdfReady(page);
await dismissToast(page);
}
test.describe("PDF Preview", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pv_cm", exact: true }),
).toBeVisible({ timeout: 10000 });
await page.getByRole("cell", { name: "pv_cm", exact: true }).dblclick();
await expect(page.locator(".pdf-preview")).toBeVisible({ timeout: 10000 });
});
test("Opens the PDF viewer when double-clicking a PDF file", async ({
page,
}) => {
await expect(page.getByTestId("file-preview")).toBeVisible();
await expect(page.locator(".pdf-preview")).toBeVisible();
await expect(
page
.locator(".textLayer")
.getByText("PROCÈS VERBAL DU CONSEIL MUNICIPAL")
.first(),
).toBeAttached({ timeout: 10000 });
});
test("Displays the correct page count in the controls bar", async ({
page,
}) => {
const pageInput = getPageInput(page);
await expect(pageInput).toBeVisible();
await expect(pageInput).toHaveValue("1");
const pageTotal = page.locator(".pdf-preview__page-total");
await expect(pageTotal).toBeVisible();
await expect(pageTotal).toHaveText("/ 9");
});
test("Shows the controls bar with all expected buttons", async ({ page }) => {
const controls = page.locator(".pdf-preview__controls");
await expect(controls).toBeVisible();
await expect(
controls.locator('button[aria-label="Toggle sidebar"]'),
).toBeVisible();
const zoomButtons = controls.locator(".pdf-preview__zoom-controls button");
await expect(zoomButtons).toHaveCount(3);
});
// Section 2 — Zoom controls
test("Zooms in when clicking the zoom-in button", async ({ page }) => {
const pdfPage = page.locator(".react-pdf__Page").first();
await waitForPdfReady(page);
const initialBox = await pdfPage.boundingBox();
expect(initialBox).toBeTruthy();
const initialWidth = initialBox!.width;
const zoomIn = page.locator(
".pdf-preview__zoom-controls button >> nth=2",
);
await zoomIn.dispatchEvent("click");
await expect(async () => {
const box = await pdfPage.boundingBox();
expect(box!.width).toBeGreaterThan(initialWidth);
}).toPass({ timeout: 10000 });
});
test("Zooms out when clicking the zoom-out button", async ({ page }) => {
const pdfPage = page.locator(".react-pdf__Page").first();
await waitForPdfReady(page);
const initialBox = await pdfPage.boundingBox();
const initialWidth = initialBox!.width;
const zoomIn = page.locator(
".pdf-preview__zoom-controls button >> nth=2",
);
const zoomOut = page.locator(
".pdf-preview__zoom-controls button >> nth=0",
);
await zoomIn.dispatchEvent("click");
await expect(async () => {
const box = await pdfPage.boundingBox();
expect(box!.width).toBeGreaterThan(initialWidth);
}).toPass({ timeout: 10000 });
await zoomOut.dispatchEvent("click");
await expect(async () => {
const box = await pdfPage.boundingBox();
expect(Math.round(box!.width)).toBe(Math.round(initialWidth));
}).toPass({ timeout: 10000 });
});
test("Resets zoom to default when clicking the reset button", async ({
page,
}) => {
const pdfPage = page.locator(".react-pdf__Page").first();
await waitForPdfReady(page);
const initialBox = await pdfPage.boundingBox();
const initialWidth = initialBox!.width;
const zoomIn = page.locator(
".pdf-preview__zoom-controls button >> nth=2",
);
const zoomReset = page.locator(
".pdf-preview__zoom-controls button >> nth=1",
);
await zoomIn.dispatchEvent("click");
await zoomIn.dispatchEvent("click");
await expect(async () => {
const box = await pdfPage.boundingBox();
expect(box!.width).toBeGreaterThan(initialWidth);
}).toPass({ timeout: 10000 });
await zoomReset.dispatchEvent("click");
await expect(async () => {
const box = await pdfPage.boundingBox();
expect(Math.round(box!.width)).toBe(Math.round(initialWidth));
}).toPass({ timeout: 10000 });
});
// Section 3 — Thumbnail sidebar
test("Opens and closes the thumbnail sidebar", async ({ page }) => {
await expect(page.locator(".pdf-preview__sidebar")).not.toBeAttached();
await openSidebar(page);
await expect(page.locator(".pdf-preview__sidebar")).toBeVisible();
const toggle = page.locator('button[aria-label="Toggle sidebar"]');
await toggle.dispatchEvent("click");
await expect(page.locator(".pdf-preview__sidebar")).not.toBeAttached({
timeout: 5000,
});
});
test("Marks the current page thumbnail as active", async ({ page }) => {
await openSidebar(page);
await expectActiveThumbnail(page, 1);
await expectInactiveThumbnail(page, 2);
});
test("Navigates to a page when clicking a thumbnail", async ({ page }) => {
await openSidebar(page);
await page.locator('button[aria-label="Go to page 3"]').dispatchEvent("click");
const pageInput = getPageInput(page);
await expect(pageInput).toHaveValue("3", { timeout: 5000 });
await expectActiveThumbnail(page, 3);
});
test("Updates the active thumbnail when scrolling the document", async ({
page,
}) => {
await openSidebar(page);
await waitForPdfReadyAndDismissToast(page);
await expectActiveThumbnail(page, 1);
await scrollPdfViewer(page, 3000);
await expectActiveThumbnail(page, 3);
await expectInactiveThumbnail(page, 1);
});
// Section 4 — Page navigation
test("Navigates to a specific page via the page input", async ({ page }) => {
const pageInput = getPageInput(page);
await expect(pageInput).toBeVisible();
await waitForPdfReadyAndDismissToast(page);
await pageInput.fill("5");
await pageInput.press("Enter");
await expect(pageInput).toHaveValue("5", { timeout: 5000 });
});
test("Clamps out-of-range page numbers to valid range", async ({ page }) => {
const pageInput = getPageInput(page);
await expect(pageInput).toBeVisible();
await waitForPdfReadyAndDismissToast(page);
await pageInput.fill("99");
await pageInput.press("Enter");
await expect(pageInput).toHaveValue("9", { timeout: 5000 });
});
test("Resets invalid input to current page on blur", async ({ page }) => {
const pageInput = getPageInput(page);
await expect(pageInput).toHaveValue("1");
await dismissToast(page);
await pageInput.fill("abc");
await pageInput.blur();
await expect(pageInput).toHaveValue("1", { timeout: 5000 });
});
test("Updates page number when scrolling the document", async ({ page }) => {
const pageInput = getPageInput(page);
await expect(pageInput).toHaveValue("1");
await waitForPdfReadyAndDismissToast(page);
await scrollPdfViewer(page, 3000);
await expect(async () => {
const value = await pageInput.inputValue();
expect(value).not.toBe("1");
}).toPass({ timeout: 10000 });
});
});
test.describe("PDF Links", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_LINKS_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pdf_with_links", exact: true }),
).toBeVisible({ timeout: 10000 });
await page
.getByRole("cell", { name: "pdf_with_links", exact: true })
.dblclick();
await expect(page.locator(".pdf-preview")).toBeVisible({ timeout: 10000 });
});
test("Shows a disclaimer modal when clicking an external link", async ({
page,
}) => {
await waitForPdfReadyAndDismissToast(page);
const modal = await clickExternalLinkAndWaitForModal(page);
await expect(modal.locator(".c__modal__title")).toHaveText("External link");
await expect(modal.locator(".pdf-preview__external-link")).toContainText(
"example.com",
);
await expect(modal).toContainText("Do you want to continue?");
});
test("Opens external link in a new tab when confirming", async ({
page,
context,
}) => {
await waitForPdfReadyAndDismissToast(page);
const modal = await clickExternalLinkAndWaitForModal(page);
const [newPage] = await Promise.all([
context.waitForEvent("page"),
modal.getByRole("button", { name: "Yes" }).click(),
]);
expect(newPage.url()).toContain("example.com");
await newPage.close();
});
test("Does not open a new tab when declining the disclaimer", async ({
page,
}) => {
await waitForPdfReadyAndDismissToast(page);
const modal = await clickExternalLinkAndWaitForModal(page);
await modal.getByRole("button", { name: "Cancel" }).click();
await expect(modal).not.toBeAttached({ timeout: 5000 });
await expect(page.locator(".pdf-preview")).toBeVisible();
});
test("Navigates to target page via an internal link without disclaimer", async ({
page,
}) => {
await waitForPdfReadyAndDismissToast(page);
await getInternalLink(page).click({ force: true });
await expect(getDisclaimerModal(page)).not.toBeAttached({ timeout: 2000 });
await expect(getPageInput(page)).toHaveValue("3", { timeout: 5000 });
});
});
test.describe("PDF Security", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_JS_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pdf_with_js", exact: true }),
).toBeVisible({ timeout: 10000 });
await page
.getByRole("cell", { name: "pdf_with_js", exact: true })
.dblclick();
await expect(page.locator(".pdf-preview")).toBeVisible({ timeout: 10000 });
});
test("Does not execute JavaScript embedded in a PDF OpenAction", async ({
page,
}) => {
let alertFired = false;
page.on("dialog", () => {
alertFired = true;
});
await waitForPdfReadyAndDismissToast(page);
// Give any rogue script time to fire (it would trigger on document open)
await page.waitForTimeout(1000);
// The embedded JS calls app.alert("HACKED") — must never produce a dialog
expect(alertFired).toBe(false);
// The viewer itself should still be functional
await expect(page.locator(".pdf-preview")).toBeVisible();
});
});
test.describe("PDF Security — javascript: URI link", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_JS_LINK_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pdf_with_js_link", exact: true }),
).toBeVisible({ timeout: 10000 });
await page
.getByRole("cell", { name: "pdf_with_js_link", exact: true })
.dblclick();
await expect(page.locator(".pdf-preview")).toBeVisible({ timeout: 10000 });
});
test("Blocks javascript: URI links and does not show disclaimer modal", async ({
page,
}) => {
let alertFired = false;
page.on("dialog", async (dialog) => {
alertFired = true;
await dialog.dismiss();
});
await waitForPdfReadyAndDismissToast(page);
// pdfjs should not render javascript: URIs as clickable annotations
const annotationLinks = page.locator(
".annotationLayer section.linkAnnotation a",
);
await expect(annotationLinks).toHaveCount(0, { timeout: 5000 });
// No alert/dialog should have fired
expect(alertFired).toBe(false);
// Even if a javascript: link were injected into the DOM, our handler
// would block it. Simulate this by injecting a link and clicking it.
await page.evaluate(() => {
const annotLayer = document.querySelector(".annotationLayer");
if (!annotLayer) return;
const section = document.createElement("section");
section.className = "linkAnnotation";
const a = document.createElement("a");
a.href = "javascript:alert('xss')";
a.textContent = "injected";
a.style.cssText =
"position:absolute;top:0;left:0;width:50px;height:20px;";
section.appendChild(a);
annotLayer.appendChild(section);
});
const injectedLink = page
.locator(".annotationLayer section.linkAnnotation a")
.first();
await injectedLink.click({ force: true });
// The confirmation modal must NOT appear (unsafe protocol blocked)
await expect(getDisclaimerModal(page)).not.toBeAttached({ timeout: 2000 });
// Wait to ensure no alert fires even with rendering lag
await page.waitForTimeout(2000);
expect(alertFired).toBe(false);
// The viewer should still be functional
await expect(page.locator(".pdf-preview")).toBeVisible();
});
});
test.describe("PDF Outdated Browser", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pv_cm", exact: true }),
).toBeVisible({ timeout: 10000 });
});
test("Shows outdated browser error when the PDF worker fails to initialize", async ({
page,
}) => {
// Intercept the PDF worker script and replace it with one that throws,
// simulating a browser where required APIs are missing.
await page.route("**/pdf.worker.mjs", (route) =>
route.fulfill({
status: 200,
contentType: "application/javascript",
body: "throw new Error('Simulated worker init failure');",
}),
);
await page.getByRole("cell", { name: "pv_cm", exact: true }).dblclick();
const errorContainer = page.locator(".file-preview-unsupported");
await expect(errorContainer).toBeVisible({ timeout: 15000 });
await expect(errorContainer).toContainText(
"Your browser is not supported",
);
await expect(errorContainer).toContainText(
"For security reasons, the PDF viewer requires a recent browser",
);
await expect(page.locator(".pdf-preview__controls")).not.toBeAttached();
});
});
test.describe("PDF Error Handling", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
await uploadFile(page, PDF_CORRUPTED_FILE_PATH);
await expect(
page.getByRole("cell", { name: "pdf_corrupted", exact: true }),
).toBeVisible({ timeout: 10000 });
await page
.getByRole("cell", { name: "pdf_corrupted", exact: true })
.dblclick();
});
test("Displays an error message when opening a corrupted PDF", async ({
page,
}) => {
const errorContainer = page.locator(".file-preview-unsupported");
await expect(errorContainer).toBeVisible({ timeout: 15000 });
await expect(errorContainer).toContainText(
"An error occurred while loading the document.",
);
await expect(errorContainer).toContainText(
"You can contact the support for help.",
);
});
});
+3
View File
@@ -14,5 +14,8 @@
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/node": "24.10.1"
},
"dependencies": {
"pdf-lib": "1.17.1"
}
}