diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/generate-pdfs.mjs b/src/frontend/apps/e2e/__tests__/app-drive/assets/generate-pdfs.mjs new file mode 100644 index 00000000..fa68eb21 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/assets/generate-pdfs.mjs @@ -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); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_corrupted.pdf b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_corrupted.pdf new file mode 100644 index 00000000..d2767645 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_corrupted.pdf @@ -0,0 +1 @@ +This is not a valid PDF file. Corrupted content. \ No newline at end of file diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js.pdf b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js.pdf new file mode 100644 index 00000000..7788dec1 Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js_link.pdf b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js_link.pdf new file mode 100644 index 00000000..0dd4a7fe Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js_link.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_links.pdf b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_links.pdf new file mode 100644 index 00000000..12a31d8c Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_links.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-drive/pdf-preview.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/pdf-preview.spec.ts new file mode 100644 index 00000000..70e256eb --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/pdf-preview.spec.ts @@ -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.", + ); + }); +}); diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index 0e621aaf..83ecf75f 100644 --- a/src/frontend/apps/e2e/package.json +++ b/src/frontend/apps/e2e/package.json @@ -14,5 +14,8 @@ "devDependencies": { "@playwright/test": "1.58.2", "@types/node": "24.10.1" + }, + "dependencies": { + "pdf-lib": "1.17.1" } }