From 1ef1dd4a80a75b1f7c2e2cdcc4723432ebe32a46 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 10 Mar 2026 11:30:57 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=85(frontend)=20add=20PDF=20viewer=20e2e?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover PDF rendering, corrupted file handling, JavaScript injection prevention and external link interception with redirect disclaimer. --- .../app-drive/assets/generate-pdfs.mjs | 165 +++++ .../app-drive/assets/pdf_corrupted.pdf | 1 + .../app-drive/assets/pdf_with_js.pdf | Bin 0 -> 1006 bytes .../app-drive/assets/pdf_with_js_link.pdf | Bin 0 -> 1011 bytes .../app-drive/assets/pdf_with_links.pdf | Bin 0 -> 1473 bytes .../__tests__/app-drive/pdf-preview.spec.ts | 591 ++++++++++++++++++ src/frontend/apps/e2e/package.json | 3 + 7 files changed, 760 insertions(+) create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/generate-pdfs.mjs create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_corrupted.pdf create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js.pdf create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_js_link.pdf create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/pdf_with_links.pdf create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/pdf-preview.spec.ts 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 0000000000000000000000000000000000000000..7788dec1562a0963c89bee332629191cb818173d GIT binary patch literal 1006 zcmY!laBvvxttt1T))>HUDNDQ+jq`^#r1Q>MYS379V;}l zJ}UkCH)n-wK!^Cc6JIS3EMjU4+pg>r_WN{lz5Uy5$%p5tELpPgly7(6l?8EuhMaO?A7Wtpm&Ma)ZClx7J#CCB_?d+oe-EY4-8yapq^R)X5r=PErHdkt1 z3e@dD7RbX~=7fA6Qdy9ypzoiQ6^lZL zb>G(u)b6Ujar5KRzRj}iFI6KmHFeJ>Bq#IdCH3_j=IdNv^1rTV&eqq5Z+zxBG|BGe zWKnNUg9j6qZD@5_z%E;Lzz^I*$8-eY2G-_QNqY-Yug+Yt9!{qB}z`wFM^(h)BcZ@ic+`S;bM zRTg(bo*w;m*h(ZS>u&!#>+NYRv9VW8lO>aVmT62fjC)k+a5B*1R!3p0mDe=ArhPf5 zZ)|f|)2Fm~hVTc=FrA;G^CYyLeI%2EMc+=}wJ=Q7^2h#nlizdhEsb5dw=wkRJY&6^ z(>h%?xTM5i|B>vzc<=A}*|XpG`~SYqci+e7t(5H4Lea-=Ph*AaHhs~i>Lulx%Hu(t@~}heZQP%ZK^ZVI7+Y4;tDfQJ#S&pF$IY; zq3aFAQ_@sTE+?&-86e%eY~7NRCt^+fdVhQC-pbs~@s+2PDYljOOMcT2*9WOjau-=1 zv1j?A^uWYSJ-1eQ^^au&LH~|AYA79PJ;i=h*zdY!DpzV=3e>eg7RZZS=7jtjQdy9y zpzoiQ6tPw+#gLe%5}LAupw;w3VfeQNJj9 zizBnTYd4#%fl#7`=?RBL&0pRdD_a#7X%y+T2wL8rt@zWeiGelBna!0`%4gXD2R+j> zX+81(_m&^fe|wI%r~c(F#=HyK3EcZ7<6E2i6>ip?^V_H`{nqYHgcp)%-`?fv@@zJ-O;<)%kAWkOMHDc2kdwLZSIaumRw&eeCyo%hW{`A9OA#b z`Jt|oU|Ysz>!hnEr1zCNTYTXa>U`vua%)dRy@pHZqm$j6oZj_ms>g?BOr3t}$GSOqL{Nv-RJ=Fc!HpTAPw)ymjju`PDGf&M~DdeSlegoI1x`(wLCY;Of zxnExW`p2!G_E&7hb}{yT4v{?3)8HiK6w$WtsG-M?q$4%F*De?4z&sD;NR|F;a*H8IqD(0TeJ$ump0E zbC>9mgS-p|9LyWJ6~5lqy-~m4Ys!zr1T~8XPYx~BE_B?ZD{Ytfn>ing!;4E2i%Ker PfYw?V05iF&tG^onRNkYP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..12a31d8c0327fd5402f69c9931f75d456c52a144 GIT binary patch literal 1473 zcmY!laB2s{rZOQs5|LG$PGFx}*rCvJyU-if7 znMX>}TI|%EwtF-M)h+$OqS+8SlPfhZ1?nRp3lt1o7DR@Jr6EdiXg?|9dT12DaBxwH zUTy!CIh(FbI`hOO*1I^ES=iWHnYox%S-5qAiJrjA!>Jr$St}B<7BGY?5SVm2H90FG zEh8;2Eh{AngA7>2voL=3Pw(U6c6Ln;eW74-d+ zvVu!;x%B-M%t6VosJKMI!~mY=OpVP@Ja9GSe*R?xfx7SUh5fggzxE#~vk9GgYndd6 z+Oj;3BS)O4MW|jl8v1C_yZz5)%3o`>TwtkoxOns9-SX$>_gNPwC^%?&95C>3T&A(0 zg^70#H#1wspod0z8&lIS7_-fA=F~3j72V0Uj zC&w7+eSIFhr|;afdw=)0OaCxm_xCMl|EHK8*MzIK?)`e$NW*gB=1toVGJV+^`+vi} zm0nKbxldm$i)H+KW6R}j`Enx11)UGJY-esP-nVz>h9@er-Vw0;W5ZUzbmqwq zPpph1t~Q@Om{y_{qPFBz-PTr~6rWi>Z*x6b>#FL1pA9=KmbmlA&(GTOdCFQ%?+#t_ zwJf%N!`U|9q)5f+M39{m+YRB%+e+<9&OD51eswZ*Z}zSC4^wtrJHJ$;l zDoxJa-KuQjo>C)`aj~N1_}ljIMPFxJEKtw-U-YN;7H^r;s-A>jt(VGuY<{O7*?PrE zt+Q(5E^D=OHyeNY8!Twb#g_bxkdj|;W>u 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" } }