Files

386 lines
15 KiB
TypeScript

import {test, expect, createTestContent, createTextContent} from './fixtures'
import {UploadPage} from './pages/upload-page'
import {DownloadPage} from './pages/download-page'
import {readFileSync, statSync} from 'fs'
import {createHash} from 'crypto'
// ─────────────────────────────────────────────────────────────────────────────
// Upload Flow Tests
// ─────────────────────────────────────────────────────────────────────────────
test.describe('Upload Flow', () => {
test('upload via file picker button', async ({uploadPage}) => {
await uploadPage.expectDropZoneVisible()
await uploadPage.selectTextFile('picker-test.txt', 'test content ' + Date.now())
// Small files (< 100KB) skip "Encrypting" and go directly to "Uploading"
// await uploadPage.waitForEncrypting()
await uploadPage.waitForUploading()
const link = await uploadPage.waitForShareLink()
expect(link).toMatch(/^http:\/\/localhost:\d+\/?#/)
})
test('upload via file input (drag-drop code path)', async ({uploadPage}) => {
// Tests file handling logic - the drop handler uses the same input processing
await uploadPage.dragDropFile('dragdrop-test.txt', createTextContent('drag drop test'))
await uploadPage.expectProgressVisible()
const link = await uploadPage.waitForShareLink()
expect(link).toContain('#')
})
test('upload rejects file over 100MB', async ({uploadPage}) => {
await uploadPage.selectLargeFile('large.bin', 100 * 1024 * 1024 + 1)
await uploadPage.expectError('too large')
await uploadPage.expectError('100 MB')
})
test('upload rejects empty file', async ({uploadPage}) => {
await uploadPage.selectFile('empty.txt', Buffer.alloc(0))
await uploadPage.expectError('empty')
})
test('upload shows progress during encryption and upload', async ({uploadPage}) => {
await uploadPage.selectFile('progress-test.bin', createTestContent(500 * 1024))
await uploadPage.expectProgressVisible()
await uploadPage.waitForEncrypting()
await uploadPage.waitForUploading()
await uploadPage.waitForShareLink()
})
test('cancel button aborts upload and returns to landing', async ({uploadPage}) => {
await uploadPage.selectFile('cancel-test.bin', createTestContent(1024 * 1024))
await uploadPage.expectProgressVisible()
await uploadPage.clickCancel()
await uploadPage.expectDropZoneVisible()
await uploadPage.expectShareLinkNotVisible()
})
test('share link copy button works', async ({uploadPage, context}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await uploadPage.selectTextFile('copy-test.txt', 'copy test content')
const link = await uploadPage.waitForShareLink()
await uploadPage.clickCopy()
// Verify clipboard (may fail in headless)
try {
const clipboardText = await uploadPage.page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toBe(link)
} catch {
// Clipboard API may not be available in headless mode
}
})
test('error state shows retry button', async ({uploadPage}) => {
await uploadPage.selectFile('error-test.txt', Buffer.alloc(0))
await uploadPage.expectError('empty')
await uploadPage.expectRetryButtonVisible()
})
test('upload complete shows expiry and security note', async ({uploadPage}) => {
await uploadPage.selectTextFile('ui-test.txt', 'ui test')
await uploadPage.waitForShareLink()
await uploadPage.expectCompleteWithExpiry()
await uploadPage.expectSecurityNote()
})
})
// ─────────────────────────────────────────────────────────────────────────────
// Download Flow Tests
// ─────────────────────────────────────────────────────────────────────────────
test.describe('Download Flow', () => {
test('non-XFTP anchor hash shows upload page', async ({page}) => {
await page.goto('http://localhost:4173#hello-world')
await expect(page.locator('#drop-zone')).toBeVisible()
})
test('short non-XFTP hash shows upload page', async ({page}) => {
await page.goto('http://localhost:4173#AAAA')
await expect(page.locator('#drop-zone')).toBeVisible()
})
test('corrupted XFTP hash shows error', async ({downloadPage}) => {
// Long base64url string that looks like XFTP but is not valid DEFLATE data
const corrupted = 'A'.repeat(100)
await downloadPage.goto('#' + corrupted)
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
})
test('download button initiates download', async ({uploadPage, downloadPage}) => {
await uploadPage.selectTextFile('dl-btn-test.txt', 'download test content')
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
await downloadPage.expectFileReady()
const download = await downloadPage.clickDownload()
expect(download.suggestedFilename()).toBe('dl-btn-test.txt')
})
test('download shows progress', async ({uploadPage, downloadPage}) => {
// Use larger file to ensure progress is observable
await uploadPage.selectFile('dl-progress.bin', createTestContent(1024 * 1024))
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
// Set up download listener before clicking
const downloadPromise = downloadPage.page.waitForEvent('download')
// Click starts download - progress should be visible while downloading
await downloadPage.startDownload()
await downloadPage.expectProgressVisible()
await downloadPromise
})
test('downloaded file content matches upload', async ({uploadPage, downloadPage}) => {
const content = 'verification content ' + Date.now()
const fileName = 'verify.txt'
await uploadPage.selectTextFile(fileName, content)
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
expect(download.suggestedFilename()).toBe(fileName)
const path = await download.path()
if (path) {
const downloadedContent = readFileSync(path, 'utf-8')
expect(downloadedContent).toBe(content)
}
})
test('download page shows file size and security note', async ({uploadPage, downloadPage}) => {
await uploadPage.selectFile('size-test.bin', createTestContent(1024))
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
await downloadPage.expectFileSizeDisplayed()
await downloadPage.expectSecurityNote()
})
})
// ─────────────────────────────────────────────────────────────────────────────
// Edge Cases
// ─────────────────────────────────────────────────────────────────────────────
test.describe('Edge Cases', () => {
test('upload and download 1-byte file', async ({uploadPage, downloadPage}) => {
await uploadPage.selectFile('tiny.bin', Buffer.from([0x42]))
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
expect(download.suggestedFilename()).toBe('tiny.bin')
const path = await download.path()
if (path) {
const content = readFileSync(path)
expect(content.length).toBe(1)
expect(content[0]).toBe(0x42)
}
})
test('upload and download file with unicode filename', async ({uploadPage, downloadPage}) => {
const fileName = 'test-\u4e2d\u6587-\u0420\u0443\u0441\u0441\u043a\u0438\u0439.txt'
await uploadPage.selectTextFile(fileName, 'unicode filename test')
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
// Browser download attribute uses encodeURIComponent for non-ASCII filenames
expect(download.suggestedFilename()).toBe(encodeURIComponent(fileName))
})
test('upload and download file with spaces', async ({uploadPage, downloadPage}) => {
const fileName = 'my document (final) v2.txt'
await uploadPage.selectTextFile(fileName, 'spaces test')
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
// Browser download attribute uses encodeURIComponent for the filename
expect(download.suggestedFilename()).toBe(encodeURIComponent(fileName))
})
test('filename with path separators is sanitized', async ({uploadPage, downloadPage}) => {
await uploadPage.selectTextFile('../../../etc/passwd', 'path traversal test')
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
expect(download.suggestedFilename()).not.toContain('/')
expect(download.suggestedFilename()).not.toContain('\\')
})
test('binary file with all byte values', async ({uploadPage, downloadPage}) => {
// Create buffer with all 256 byte values
const buffer = Buffer.alloc(256)
for (let i = 0; i < 256; i++) buffer[i] = i
await uploadPage.selectFile('all-bytes.bin', buffer)
const link = await uploadPage.waitForShareLink()
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
const path = await download.path()
if (path) {
const content = readFileSync(path)
expect(content.length).toBe(256)
for (let i = 0; i < 256; i++) {
expect(content[i]).toBe(i)
}
}
})
test('upload handles network error gracefully', async ({uploadPage}) => {
// Intercept and abort server requests after encryption starts
await uploadPage.page.route('**/*', route => {
const url = route.request().url()
// Only abort XFTP server requests (HTTPS), not the web page (HTTP)
if (url.startsWith('https://') && route.request().method() !== 'GET') {
route.abort('failed')
} else {
route.continue()
}
})
await uploadPage.selectTextFile('network-error.txt', 'network error test')
await uploadPage.expectError(/.+/) // Any error message
})
test('concurrent downloads from same link', async ({browser}) => {
const context = await browser.newContext({ignoreHTTPSErrors: true})
const page1 = await context.newPage()
const upload = new UploadPage(page1)
await upload.goto()
await upload.selectTextFile('concurrent.txt', 'concurrent download test')
const link = await upload.waitForShareLink()
const hash = upload.getHashFromLink(link)
// Open two tabs and download concurrently (shared HTTP/2 connection)
const page2 = await context.newPage()
const page3 = await context.newPage()
const dl2 = new DownloadPage(page2)
const dl3 = new DownloadPage(page3)
await dl2.goto(hash)
await dl3.goto(hash)
const [download2, download3] = await Promise.all([
dl2.clickDownload(),
dl3.clickDownload()
])
expect(download2.suggestedFilename()).toBe('concurrent.txt')
expect(download3.suggestedFilename()).toBe('concurrent.txt')
await context.close()
})
})
// ─────────────────────────────────────────────────────────────────────────────
// Slow Tests (Large Files)
// ─────────────────────────────────────────────────────────────────────────────
test.describe('Slow Tests', () => {
test('upload file at exactly 100MB', async ({uploadPage}) => {
test.slow()
await uploadPage.selectLargeFile('exactly-100mb.bin', 100 * 1024 * 1024)
// Should succeed (not show error)
await uploadPage.expectNoError()
await uploadPage.expectProgressVisible()
// Wait for completion (may take a while)
await uploadPage.waitForShareLink(300_000)
})
test('upload and download multi-chunk file with redirect', async ({uploadPage, downloadPage}) => {
test.slow()
// Use ~5MB file to get multiple chunks
await uploadPage.selectLargeFile('multi-chunk.bin', 5 * 1024 * 1024)
const link = await uploadPage.waitForShareLink(120_000)
await downloadPage.gotoWithLink(link)
const download = await downloadPage.clickDownload()
expect(download.suggestedFilename()).toBe('multi-chunk.bin')
const path = await download.path()
if (path) {
const stat = statSync(path)
expect(stat.size).toBe(5 * 1024 * 1024)
}
})
// Disabled by default — run with: npx playwright test -g "100MB"
test.skip('upload and download 100MB random file', async ({browser}) => {
test.setTimeout(600_000) // 10 minutes
const SIZE = 100 * 1024 * 1024
const context = await browser.newContext({ignoreHTTPSErrors: true})
const page = await context.newPage()
const upload = new UploadPage(page)
await upload.goto()
// Generate random content in browser and compute SHA-256
const uploadHash = await page.evaluate(async (size: number) => {
const buffer = new ArrayBuffer(size)
const view = new Uint8Array(buffer)
for (let off = 0; off < size; off += 65536) {
crypto.getRandomValues(view.subarray(off, Math.min(off + 65536, size)))
}
const hashBuf = await crypto.subtle.digest('SHA-256', buffer)
const hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('')
const input = document.getElementById('file-input') as HTMLInputElement
const file = new File([buffer], 'large-100mb.bin', {type: 'application/octet-stream'})
const dt = new DataTransfer()
dt.items.add(file)
input.files = dt.files
input.dispatchEvent(new Event('change', {bubbles: true}))
return hash
}, SIZE)
const link = await upload.waitForShareLink(300_000)
const dlPage = await context.newPage()
const dl = new DownloadPage(dlPage)
await dl.gotoWithLink(link)
const download = await dl.clickDownload()
expect(download.suggestedFilename()).toBe('large-100mb.bin')
const path = await download.path()
expect(path).toBeTruthy()
const stat = statSync(path!)
expect(stat.size).toBe(SIZE)
const downloadHash = createHash('sha256').update(readFileSync(path!)).digest('hex')
expect(downloadHash).toBe(uploadHash)
await context.close()
})
})