import { BlobReader, TextWriter, ZipReader } from '@zip.js/zip.js' import { expect, test, type APIRequestContext, type BrowserContext, type Page, } from '@playwright/test' import { execFileSync } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:8080' const API_BASE_URL = process.env.E2E_API_URL ?? `${BASE_URL}/api/v1` const INVALID_LINK_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' test.describe.configure({ mode: 'serial' }) test.beforeEach(async ({ page }) => { resetRateLimitState() await resetLocalBrowserState(page) }) test('encrypts a single file end to end and rejects missing or invalid keys', async ({ context, page, request, }, testInfo) => { const suffix = uniqueSuffix() const transferName = `Single Capsule ${suffix}` const fileName = `single-e2e-note-${suffix}.txt` const contentMarker = `XDROP_E2E_SINGLE_${suffix}_ALPHA` const { shareLink, transferId } = await createSingleFileTransfer(page, { transferName, fileName, content: contentMarker, }) const senderRecord = await readLocalTransfer(page, transferId) expect(senderRecord?.files?.length).toBe(1) const receiver = await context.newPage() const seenRequests: string[] = [] receiver.on('request', (request) => { seenRequests.push(request.url()) }) await receiver.goto(shareLink, { waitUntil: 'networkidle' }) await expect(receiver.getByText(fileName).first()).toBeVisible() const [download] = await Promise.all([ receiver.waitForEvent('download'), receiver.getByRole('button', { name: 'Download', exact: true }).click(), ]) const downloadedFilePath = testInfo.outputPath(fileName) await download.saveAs(downloadedFilePath) expect(await readFile(downloadedFilePath, 'utf8')).toBe(contentMarker) expect(seenRequests.some((url) => url.includes('#k='))).toBeFalsy() await assertCiphertextOnlyStorage(request, page, transferId, senderRecord.files[0].fileId, [ transferName, fileName, contentMarker, ]) const missingKeyPage = await context.newPage() await missingKeyPage.goto(stripFragment(shareLink), { waitUntil: 'networkidle' }) await expect( missingKeyPage.getByRole('heading', { name: "Can't open this transfer" }), ).toBeVisible() await expect(missingKeyPage.getByText('This link is missing the decryption key.')).toBeVisible() const invalidKeyPage = await context.newPage() await invalidKeyPage.goto(withLinkKey(shareLink, INVALID_LINK_KEY), { waitUntil: 'networkidle' }) await expect( invalidKeyPage.getByRole('heading', { name: "Can't open this transfer" }), ).toBeVisible() await expect(invalidKeyPage.getByText('This decryption key is invalid.')).toBeVisible() }) test('preserves folder paths, downloads a zip, and manages transfers', async ({ context, page, request, }, testInfo) => { const suffix = uniqueSuffix() const transferName = `Folder Capsule ${suffix}` const folderName = `folder-capsule-${suffix}` const readmePath = `${folderName}/docs/readme-e2e.txt` const notesPath = `${folderName}/nested/notes-e2e.txt` const readmeMarker = `XDROP_E2E_FOLDER_README_${suffix}_BETA` const notesMarker = `XDROP_E2E_FOLDER_NOTES_${suffix}_GAMMA` const { shareLink, transferId } = await createFolderTransfer(page, { transferName, files: [ { name: 'readme-e2e.txt', relativePath: readmePath, content: readmeMarker }, { name: 'notes-e2e.txt', relativePath: notesPath, content: notesMarker }, ], }) const receiver = await context.newPage() await receiver.goto(shareLink, { waitUntil: 'networkidle' }) await expect(receiver.getByText(readmePath, { exact: true })).toBeVisible() await expect(receiver.getByText(notesPath, { exact: true })).toBeVisible() const [download] = await Promise.all([ receiver.waitForEvent('download'), receiver.getByRole('button', { name: /Download all as(?: a)? ZIP/i }).click(), ]) const zipPath = testInfo.outputPath(`folder-${suffix}.zip`) await download.saveAs(zipPath) const zipEntries = await readZipEntries(zipPath) expect(zipEntries[readmePath]).toBe(readmeMarker) expect(zipEntries[notesPath]).toBe(notesMarker) await page.goto(`${BASE_URL}/transfers`, { waitUntil: 'networkidle' }) const card = page.locator('section').filter({ hasText: transferName }).first() await expect(card).toContainText(transferName) const descriptorBefore = await getPublicDescriptor(request, transferId) const expiryBefore = Date.parse(descriptorBefore.expiresAt) await card.getByRole('link', { name: 'Open share page' }).click() await page.getByRole('button', { name: 'Set expiry to 1 week from now' }).click() await expect .poll(async () => Date.parse((await getPublicDescriptor(request, transferId)).expiresAt)) .toBeGreaterThan(expiryBefore) await receiver.reload({ waitUntil: 'networkidle' }) await expect(receiver.getByText(transferName).first()).toBeVisible() await page.goto(`${BASE_URL}/transfers`, { waitUntil: 'networkidle' }) await card.getByRole('button', { name: 'Delete' }).click() await card.getByRole('button', { name: 'Confirm delete' }).click() await expect(page.locator('section').filter({ hasText: transferName })).toHaveCount(0) await receiver.reload({ waitUntil: 'networkidle' }) await expect(receiver.getByRole('heading', { name: 'Transfer deleted' })).toBeVisible() await expect(receiver.getByText('The sender removed it from storage.')).toBeVisible() }) test('continues an in-flight upload after refresh without manual recovery', async ({ context, page, request, }, testInfo) => { const suffix = uniqueSuffix() const transferName = `Resume Capsule ${suffix}` const fileName = `refresh-recovery-${suffix}.bin` await page.route('**/xdrop/transfers/**/chunks/**', async (route) => { await new Promise((resolve) => setTimeout(resolve, 10_000)) try { await route.continue() } catch (error) { if (!(error instanceof Error) || !error.message.includes('Route is already handled')) { throw error } } }) await page.goto(BASE_URL, { waitUntil: 'networkidle' }) await page.getByLabel('Transfer name').fill(transferName) const uploadPath = testInfo.outputPath(fileName) await writeFile(uploadPath, Buffer.alloc(64 * 1024 * 1024, 0x61)) await page.locator('input[type=file]').first().setInputFiles(uploadPath) await page.getByRole('button', { name: 'Start transfer' }).click() await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 }) const shareLink = await readShareLink(page) const transferId = transferIdFromSharePage(page.url()) await page.getByRole('link', { name: 'Transfers', exact: true }).click() const card = page.locator('section').filter({ hasText: transferName }).first() await expect(card).toContainText(transferName) const resumeRequest = page.waitForResponse( (response) => response.request().method() === 'GET' && response.url().includes(`/transfers/${transferId}/resume`), ) await page.reload({ waitUntil: 'networkidle' }) await resumeRequest await expect(card.getByRole('button', { name: 'Resume upload' })).toHaveCount(0) await page.unroute('**/xdrop/transfers/**/chunks/**') await expect .poll(async () => (await getPublicDescriptor(request, transferId)).status, { timeout: 180_000 }) .toBe('ready') await page.reload({ waitUntil: 'networkidle' }) await expect(card).toContainText('Ready') const receiver = await context.newPage() await receiver.goto(shareLink, { waitUntil: 'networkidle' }) await expect(receiver.getByText(fileName).first()).toBeVisible() }) async function createSingleFileTransfer( page: Page, options: { transferName: string fileName: string content: string }, ) { await page.goto(BASE_URL, { waitUntil: 'networkidle' }) await page.getByLabel('Transfer name').fill(options.transferName) await page .locator('input[type=file]') .first() .setInputFiles({ name: options.fileName, mimeType: 'text/plain', buffer: Buffer.from(options.content, 'utf8'), }) await page.getByRole('button', { name: 'Start transfer' }).click() await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 }) await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({ timeout: 60_000, }) await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({ timeout: 60_000, }) return { transferId: transferIdFromSharePage(page.url()), shareLink: await readShareLink(page), } } async function createFolderTransfer( page: Page, options: { transferName: string files: Array<{ name: string relativePath: string content: string }> }, ) { await page.goto(BASE_URL, { waitUntil: 'networkidle' }) await page.getByLabel('Transfer name').fill(options.transferName) await page.evaluate((files) => { const input = document.querySelectorAll('input[type=file]')[1] const dataTransfer = new DataTransfer() for (const file of files) { const nextFile = new File([file.content], file.name, { type: 'text/plain', lastModified: Date.now(), }) Object.defineProperty(nextFile, 'webkitRelativePath', { value: file.relativePath }) dataTransfer.items.add(nextFile) } input.files = dataTransfer.files input.dispatchEvent(new Event('input', { bubbles: true })) input.dispatchEvent(new Event('change', { bubbles: true })) }, options.files) await page.getByRole('button', { name: 'Start transfer' }).click() await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 }) await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({ timeout: 60_000, }) await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({ timeout: 60_000, }) return { transferId: transferIdFromSharePage(page.url()), shareLink: await readShareLink(page), } } async function readShareLink(page: Page) { const transferId = transferIdFromSharePage(page.url()) const transfer = await readLocalTransfer(page, transferId) if (!transfer?.shareUrl) { throw new Error(`Could not read share link for ${transferId}`) } return transfer.shareUrl } function transferIdFromSharePage(url: string) { const pathname = new URL(url).pathname const transferId = pathname.split('/').pop() if (!transferId) { throw new Error(`Could not parse transfer id from ${url}`) } return transferId } async function resetLocalBrowserState(page: Page) { await page.goto(BASE_URL, { waitUntil: 'networkidle' }) await page.evaluate(async () => { localStorage.clear() sessionStorage.clear() const registrations = await navigator.serviceWorker.getRegistrations() await Promise.all(registrations.map((registration) => registration.unregister())) const cacheNames = await caches.keys() await Promise.all(cacheNames.map((name) => caches.delete(name))) await new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase('xdrop-local') request.onsuccess = () => resolve() request.onerror = () => reject(request.error) request.onblocked = () => resolve() }) }) } async function readLocalTransfer(page: Page, transferId: string) { return page.evaluate(async (id) => { return new Promise((resolve, reject) => { const request = indexedDB.open('xdrop-local') request.onerror = () => reject(request.error) request.onsuccess = () => { const database = request.result const transaction = database.transaction('transfers', 'readonly') const store = transaction.objectStore('transfers') const getRequest = store.get(id) getRequest.onerror = () => reject(getRequest.error) getRequest.onsuccess = () => resolve(getRequest.result ?? null) } }) }, transferId) } async function assertCiphertextOnlyStorage( request: APIRequestContext, page: Page, transferId: string, fileId: string, patterns: string[], ) { const descriptor = await getPublicDescriptor(request, transferId) const descriptorText = JSON.stringify(descriptor) for (const pattern of patterns) { expect(descriptorText).not.toContain(pattern) } const manifestResponse = await request.get(descriptor.manifestUrl!) expect(manifestResponse.ok()).toBeTruthy() const manifestBytes = Buffer.from(await manifestResponse.body()) for (const pattern of patterns) { expect(manifestBytes.includes(Buffer.from(pattern, 'utf8'))).toBeFalsy() } const downloadUrlsResponse = await request.post( `${API_BASE_URL}/public/transfers/${transferId}/download-urls`, { data: { chunks: [{ fileId, chunkIndex: 0 }], }, }, ) expect(downloadUrlsResponse.ok()).toBeTruthy() const downloadUrlsPayload = (await downloadUrlsResponse.json()) as { items: Array<{ url: string }> } expect(downloadUrlsPayload.items).toHaveLength(1) const chunkUrl = downloadUrlsPayload.items[0].url for (const pattern of patterns) { expect(chunkUrl).not.toContain(pattern) } const chunkResponse = await request.get(chunkUrl) expect(chunkResponse.ok()).toBeTruthy() const chunkBytes = Buffer.from(await chunkResponse.body()) for (const pattern of patterns) { expect(chunkBytes.includes(Buffer.from(pattern, 'utf8'))).toBeFalsy() } const dump = execFileSync( 'docker', [ 'compose', 'exec', '-T', 'postgres', 'pg_dump', '-U', 'xdrop', '-d', 'xdrop', '--data-only', '--inserts', ], { cwd: process.cwd(), encoding: 'utf8', }, ) for (const pattern of patterns) { expect(dump).not.toContain(pattern) } const localTransfer = await readLocalTransfer(page, transferId) expect(localTransfer?.shareUrl).toContain(`#k=`) } async function getPublicDescriptor(request: APIRequestContext, transferId: string) { const response = await request.get(`${API_BASE_URL}/public/transfers/${transferId}`) expect(response.ok()).toBeTruthy() return (await response.json()) as { status: string expiresAt: string manifestUrl?: string } } async function readZipEntries(zipPath: string) { const zipBytes = await readFile(zipPath) const reader = new ZipReader(new BlobReader(new Blob([zipBytes]))) const entries = await reader.getEntries() const contents: Record = {} for (const entry of entries) { if (entry.directory) { continue } contents[entry.filename] = await entry.getData!(new TextWriter()) } await reader.close() return contents } function stripFragment(url: string) { return url.replace(/#.*$/, '') } function withLinkKey(url: string, linkKey: string) { return `${stripFragment(url)}#k=${linkKey}` } function uniqueSuffix() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } function resetRateLimitState() { execFileSync('docker', ['compose', 'exec', '-T', 'redis', 'redis-cli', 'FLUSHALL'], { cwd: process.cwd(), encoding: 'utf8', }) }