mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-03-30 22:55:50 +00:00
allow sending xftp client hello after handshake - for web clients that dont know if established connection exists
This commit is contained in:
859
rfcs/2026-01-30-send-file-page/2026-02-05-xftp-web-e2e-tests.md
Normal file
859
rfcs/2026-01-30-send-file-page/2026-02-05-xftp-web-e2e-tests.md
Normal file
@@ -0,0 +1,859 @@
|
||||
# XFTP Web Page E2E Tests Plan
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Test Infrastructure](#2-test-infrastructure)
|
||||
3. [Test Infrastructure - Page Objects](#3-test-infrastructure---page-objects)
|
||||
4. [Upload Flow Tests](#4-upload-flow-tests)
|
||||
5. [Download Flow Tests](#5-download-flow-tests)
|
||||
6. [Edge Cases](#6-edge-cases)
|
||||
7. [Implementation Order](#7-implementation-order)
|
||||
8. [Test Utilities](#8-test-utilities)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document specifies comprehensive Playwright E2E tests for the XFTP web page. The existing test (`page.spec.ts`) performs a basic upload/download round-trip. This plan extends coverage to:
|
||||
|
||||
- **Upload flow**: File selection (picker + drag-drop), validation, progress, cancellation, link sharing, error handling
|
||||
- **Download flow**: Invalid link handling, download button, progress, file save, error states
|
||||
- **Edge cases**: Boundary file sizes, special characters, network failures, multi-chunk files with redirect, UI information display
|
||||
|
||||
**Key constraints**:
|
||||
- Tests run against a local XFTP server (started via `globalSetup.ts`)
|
||||
- Server port is dynamic (read from `/tmp/xftp-test-server.port`)
|
||||
- Browser uses `--ignore-certificate-errors` for self-signed certs
|
||||
- OPFS and Web Workers are required (Chromium supports both)
|
||||
|
||||
**Test file location**: `/code/simplexmq/xftp-web/test/page.spec.ts`
|
||||
|
||||
**Architecture**: Tests use the Page Object Model pattern to encapsulate UI interactions, making tests read as domain-specific scenarios rather than raw Playwright API calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Infrastructure
|
||||
|
||||
### 2.1 Current Setup
|
||||
|
||||
```
|
||||
xftp-web/
|
||||
├── playwright.config.ts # Playwright config (webServer, globalSetup)
|
||||
├── test/
|
||||
│ ├── globalSetup.ts # Starts xftp-server, writes port to PORT_FILE
|
||||
│ ├── page.spec.ts # E2E tests (to be extended)
|
||||
│ └── pages/ # Page Objects (new)
|
||||
│ ├── UploadPage.ts
|
||||
│ └── DownloadPage.ts
|
||||
```
|
||||
|
||||
### 2.2 Prerequisites
|
||||
|
||||
- `globalSetup.ts` starts the XFTP server and writes port to `PORT_FILE`
|
||||
- Tests must read the port dynamically: `readFileSync(PORT_FILE, 'utf-8').trim()`
|
||||
- Vite builds and serves the page at `http://localhost:4173`
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Infrastructure - Page Objects
|
||||
|
||||
Page Objects encapsulate page-specific selectors and actions, providing a clean API for tests. This follows the standard Page Object Model pattern used in simplex-chat and most professional test suites.
|
||||
|
||||
### 3.1 UploadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/UploadPage.ts
|
||||
import {Page, Locator, expect} from '@playwright/test'
|
||||
|
||||
export class UploadPage {
|
||||
readonly page: Page
|
||||
readonly dropZone: Locator
|
||||
readonly fileInput: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly cancelButton: Locator
|
||||
readonly completeStage: Locator
|
||||
readonly shareLink: Locator
|
||||
readonly copyButton: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly expiryNote: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.dropZone = page.locator('#drop-zone')
|
||||
this.fileInput = page.locator('#file-input')
|
||||
this.progressStage = page.locator('#upload-progress')
|
||||
this.progressCanvas = page.locator('#progress-container canvas')
|
||||
this.statusText = page.locator('#upload-status')
|
||||
this.cancelButton = page.locator('#cancel-btn')
|
||||
this.completeStage = page.locator('#upload-complete')
|
||||
this.shareLink = page.locator('[data-testid="share-link"]')
|
||||
this.copyButton = page.locator('#copy-btn')
|
||||
this.errorStage = page.locator('#upload-error')
|
||||
this.errorMessage = page.locator('#error-msg')
|
||||
this.retryButton = page.locator('#retry-btn')
|
||||
this.expiryNote = page.locator('.expiry')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('http://localhost:4173')
|
||||
}
|
||||
|
||||
async selectFile(name: string, content: Buffer, mimeType = 'application/octet-stream') {
|
||||
await this.fileInput.setInputFiles({name, mimeType, buffer: content})
|
||||
}
|
||||
|
||||
async selectTextFile(name: string, content: string) {
|
||||
await this.selectFile(name, Buffer.from(content, 'utf-8'), 'text/plain')
|
||||
}
|
||||
|
||||
async selectLargeFile(name: string, sizeBytes: number) {
|
||||
// Create large file in browser to avoid memory issues in test process
|
||||
await this.page.evaluate(({name, size}) => {
|
||||
const input = document.getElementById('file-input') as HTMLInputElement
|
||||
const buffer = new ArrayBuffer(size)
|
||||
new Uint8Array(buffer).fill(0x55)
|
||||
const file = new File([buffer], name, {type: 'application/octet-stream'})
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}))
|
||||
}, {name, size: sizeBytes})
|
||||
}
|
||||
|
||||
async dragDropFile(name: string, content: Buffer) {
|
||||
// Drag-drop uses same file input handler internally
|
||||
await this.selectFile(name, content)
|
||||
}
|
||||
|
||||
async waitForEncrypting(timeout = 10_000) {
|
||||
await expect(this.statusText).toContainText('Encrypting', {timeout})
|
||||
}
|
||||
|
||||
async waitForUploading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Uploading', {timeout})
|
||||
}
|
||||
|
||||
async waitForShareLink(timeout = 60_000): Promise<string> {
|
||||
await expect(this.shareLink).toBeVisible({timeout})
|
||||
return await this.shareLink.inputValue()
|
||||
}
|
||||
|
||||
async clickCopy() {
|
||||
await this.copyButton.click()
|
||||
await expect(this.copyButton).toContainText('Copied!')
|
||||
}
|
||||
|
||||
async clickCancel() {
|
||||
await this.cancelButton.click()
|
||||
}
|
||||
|
||||
async clickRetry() {
|
||||
await this.retryButton.click()
|
||||
}
|
||||
|
||||
async expectError(messagePattern: string | RegExp) {
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectDropZoneVisible() {
|
||||
await expect(this.dropZone).toBeVisible()
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectCompleteWithExpiry() {
|
||||
await expect(this.completeStage).toBeVisible()
|
||||
await expect(this.expiryNote).toContainText('48 hours')
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
|
||||
getHashFromLink(url: string): string {
|
||||
return new URL(url).hash
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 DownloadPage
|
||||
|
||||
```typescript
|
||||
// test/pages/DownloadPage.ts
|
||||
import {Page, Locator, expect, Download} from '@playwright/test'
|
||||
|
||||
export class DownloadPage {
|
||||
readonly page: Page
|
||||
readonly readyStage: Locator
|
||||
readonly downloadButton: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.readyStage = page.locator('#dl-ready')
|
||||
this.downloadButton = page.locator('#dl-btn')
|
||||
this.progressStage = page.locator('#dl-progress')
|
||||
this.progressCanvas = page.locator('#dl-progress-container canvas')
|
||||
this.statusText = page.locator('#dl-status')
|
||||
this.errorStage = page.locator('#dl-error')
|
||||
this.errorMessage = page.locator('#dl-error-msg')
|
||||
this.retryButton = page.locator('#dl-retry-btn')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto(hash: string) {
|
||||
await this.page.goto(`http://localhost:4173${hash}`)
|
||||
}
|
||||
|
||||
async gotoWithLink(fullUrl: string) {
|
||||
const hash = new URL(fullUrl).hash
|
||||
await this.goto(hash)
|
||||
}
|
||||
|
||||
async expectFileReady() {
|
||||
await expect(this.readyStage).toBeVisible()
|
||||
await expect(this.downloadButton).toBeVisible()
|
||||
}
|
||||
|
||||
async expectFileSizeDisplayed() {
|
||||
await expect(this.readyStage).toContainText(/\d+(?:\.\d+)?\s*(?:KB|MB|B)/)
|
||||
}
|
||||
|
||||
async clickDownload(): Promise<Download> {
|
||||
const downloadPromise = this.page.waitForEvent('download')
|
||||
await this.downloadButton.click()
|
||||
return downloadPromise
|
||||
}
|
||||
|
||||
async waitForDownloading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Downloading', {timeout})
|
||||
}
|
||||
|
||||
async waitForDecrypting(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Decrypting', {timeout})
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectInitialError(messagePattern: string | RegExp) {
|
||||
// For malformed links - error shown in card without #dl-error stage
|
||||
await expect(this.page.locator('.card .error')).toBeVisible()
|
||||
await expect(this.page.locator('.card .error')).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectRuntimeError(messagePattern: string | RegExp) {
|
||||
// For runtime download errors - uses #dl-error stage
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Test Fixtures
|
||||
|
||||
```typescript
|
||||
// test/fixtures.ts
|
||||
import {test as base} from '@playwright/test'
|
||||
import {UploadPage} from './pages/UploadPage'
|
||||
import {DownloadPage} from './pages/DownloadPage'
|
||||
import {readFileSync} from 'fs'
|
||||
|
||||
// Extend Playwright test with page objects
|
||||
export const test = base.extend<{
|
||||
uploadPage: UploadPage
|
||||
downloadPage: DownloadPage
|
||||
}>({
|
||||
uploadPage: async ({page}, use) => {
|
||||
const uploadPage = new UploadPage(page)
|
||||
await uploadPage.goto()
|
||||
await use(uploadPage)
|
||||
},
|
||||
downloadPage: async ({page}, use) => {
|
||||
await use(new DownloadPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export {expect} from '@playwright/test'
|
||||
|
||||
// Test data helpers
|
||||
export function createTestContent(size: number, fill = 0x41): Buffer {
|
||||
return Buffer.alloc(size, fill)
|
||||
}
|
||||
|
||||
export function createTextContent(text: string): Buffer {
|
||||
return Buffer.from(text, 'utf-8')
|
||||
}
|
||||
|
||||
export function uniqueFileName(base: string, ext = 'txt'): string {
|
||||
return `${base}-${Date.now()}.${ext}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Upload Flow Tests
|
||||
|
||||
### 4.1 File Selection - File Picker Button
|
||||
|
||||
**Test ID**: `upload-file-picker`
|
||||
|
||||
```typescript
|
||||
test('upload via file picker button', async ({uploadPage}) => {
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
|
||||
await uploadPage.selectTextFile('picker-test.txt', 'test content ' + Date.now())
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toMatch(/^http:\/\/localhost:\d+\/#/)
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 File Selection - Drag and Drop
|
||||
|
||||
**Test ID**: `upload-drag-drop`
|
||||
|
||||
```typescript
|
||||
test('upload via drag and drop', async ({uploadPage}) => {
|
||||
await uploadPage.dragDropFile('dragdrop-test.txt', createTextContent('drag drop test'))
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toContain('#')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 File Size Validation - Too Large
|
||||
|
||||
**Test ID**: `upload-file-too-large`
|
||||
|
||||
```typescript
|
||||
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')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.4 File Size Validation - Empty File
|
||||
|
||||
**Test ID**: `upload-file-empty`
|
||||
|
||||
```typescript
|
||||
test('upload rejects empty file', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('empty.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
})
|
||||
```
|
||||
|
||||
### 4.5 Progress Display
|
||||
|
||||
**Test ID**: `upload-progress-display`
|
||||
|
||||
```typescript
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.6 Cancel Button
|
||||
|
||||
**Test ID**: `upload-cancel`
|
||||
|
||||
```typescript
|
||||
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 expect(uploadPage.shareLink).toBeHidden()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.7 Share Link Display and Copy
|
||||
|
||||
**Test ID**: `upload-share-link-copy`
|
||||
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.8 Error Handling and Retry
|
||||
|
||||
**Test ID**: `upload-error-retry`
|
||||
|
||||
```typescript
|
||||
test('error state shows retry button', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('error-test.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
await expect(uploadPage.retryButton).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Download Flow Tests
|
||||
|
||||
### 5.1 Invalid Link Handling - Malformed Hash
|
||||
|
||||
**Test ID**: `download-invalid-hash-malformed`
|
||||
|
||||
```typescript
|
||||
test('download shows error for malformed hash', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#not-valid-base64!!!')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
await expect(downloadPage.downloadButton).not.toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### 5.2 Invalid Link Handling - Valid Base64 but Invalid Structure
|
||||
|
||||
**Test ID**: `download-invalid-hash-structure`
|
||||
|
||||
```typescript
|
||||
test('download shows error for invalid structure', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#AAAA')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 Download Button Click
|
||||
|
||||
**Test ID**: `download-button-click`
|
||||
|
||||
```typescript
|
||||
test('download button initiates download', async ({uploadPage, downloadPage}) => {
|
||||
// Upload first
|
||||
await uploadPage.selectTextFile('dl-btn-test.txt', 'download test content')
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
// Navigate to download
|
||||
await downloadPage.gotoWithLink(link)
|
||||
await downloadPage.expectFileReady()
|
||||
|
||||
// Click download
|
||||
const download = await downloadPage.clickDownload()
|
||||
expect(download.suggestedFilename()).toBe('dl-btn-test.txt')
|
||||
})
|
||||
```
|
||||
|
||||
### 5.4 Progress Display
|
||||
|
||||
**Test ID**: `download-progress-display`
|
||||
|
||||
```typescript
|
||||
test('download shows progress', async ({uploadPage, downloadPage}) => {
|
||||
await uploadPage.selectFile('dl-progress.bin', createTestContent(500 * 1024))
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
|
||||
await downloadPage.gotoWithLink(link)
|
||||
const downloadPromise = downloadPage.clickDownload()
|
||||
|
||||
await downloadPage.expectProgressVisible()
|
||||
await downloadPage.waitForDownloading()
|
||||
|
||||
await downloadPromise
|
||||
})
|
||||
```
|
||||
|
||||
### 5.5 File Save Verification
|
||||
|
||||
**Test ID**: `download-file-save`
|
||||
|
||||
```typescript
|
||||
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 = (await import('fs')).readFileSync(path, 'utf-8')
|
||||
expect(downloadedContent).toBe(content)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases
|
||||
|
||||
### 6.1 Very Small Files
|
||||
|
||||
**Test ID**: `edge-small-file`
|
||||
|
||||
```typescript
|
||||
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 = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(1)
|
||||
expect(content[0]).toBe(0x42)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.2 Files Near 100MB Limit
|
||||
|
||||
**Test ID**: `edge-near-limit`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload file at exactly 100MB', async ({uploadPage}) => {
|
||||
await uploadPage.selectLargeFile('exactly-100mb.bin', 100 * 1024 * 1024)
|
||||
|
||||
// Should succeed (not show error)
|
||||
await expect(uploadPage.errorStage).toBeHidden({timeout: 5000})
|
||||
await uploadPage.expectProgressVisible()
|
||||
|
||||
// Wait for completion (may take a while)
|
||||
await uploadPage.waitForShareLink(300_000)
|
||||
})
|
||||
```
|
||||
|
||||
### 6.3 Special Characters in Filename
|
||||
|
||||
**Test ID**: `edge-special-chars-filename`
|
||||
|
||||
```typescript
|
||||
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()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(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()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(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('\\')
|
||||
})
|
||||
```
|
||||
|
||||
### 6.4 Network Errors (Mocked)
|
||||
|
||||
**Test ID**: `edge-network-error`
|
||||
|
||||
```typescript
|
||||
test('upload handles network error gracefully', async ({uploadPage}) => {
|
||||
// Intercept and abort POST requests
|
||||
await uploadPage.page.route('**/localhost:*', route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.abort('failed')
|
||||
} else {
|
||||
route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await uploadPage.selectTextFile('network-error.txt', 'network error test')
|
||||
await uploadPage.expectError(/.+/) // Any error message
|
||||
})
|
||||
```
|
||||
|
||||
### 6.5 Binary File Content Integrity
|
||||
|
||||
**Test ID**: `edge-binary-content`
|
||||
|
||||
```typescript
|
||||
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 = (await import('fs')).readFileSync(path)
|
||||
expect(content.length).toBe(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(content[i]).toBe(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.6 Multiple Concurrent Downloads
|
||||
|
||||
**Test ID**: `edge-concurrent-downloads`
|
||||
|
||||
```typescript
|
||||
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
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
### 6.7 Redirect File Handling (Multi-chunk)
|
||||
|
||||
**Test ID**: `edge-redirect-file`
|
||||
|
||||
```typescript
|
||||
test.slow()
|
||||
test('upload and download multi-chunk file with redirect', async ({uploadPage, downloadPage}) => {
|
||||
// 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 = (await import('fs')).statSync(path)
|
||||
expect(stat.size).toBe(5 * 1024 * 1024)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 6.8 UI Information Display
|
||||
|
||||
**Test ID**: `edge-ui-info`
|
||||
|
||||
```typescript
|
||||
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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
### Phase 1: Core Infrastructure (Priority: High)
|
||||
1. Create `test/pages/UploadPage.ts` with Page Object
|
||||
2. Create `test/pages/DownloadPage.ts` with Page Object
|
||||
3. Create `test/fixtures.ts` with extended test function
|
||||
4. Refactor existing test to use Page Objects
|
||||
|
||||
### Phase 2: Core Happy Path (Priority: High)
|
||||
5. `upload-file-picker` - Basic upload via file picker
|
||||
6. `download-button-click` - Basic download
|
||||
7. `download-file-save` - Content verification
|
||||
|
||||
### Phase 3: Validation (Priority: High)
|
||||
8. `upload-file-too-large` - Size validation
|
||||
9. `upload-file-empty` - Empty file validation
|
||||
10. `download-invalid-hash-malformed` - Invalid link handling
|
||||
11. `download-invalid-hash-structure` - Invalid structure handling
|
||||
|
||||
### Phase 4: Progress and Cancel (Priority: Medium)
|
||||
12. `upload-progress-display` - Progress visibility
|
||||
13. `upload-cancel` - Cancel functionality
|
||||
14. `download-progress-display` - Download progress
|
||||
|
||||
### Phase 5: Link Sharing (Priority: Medium)
|
||||
15. `upload-share-link-copy` - Copy button functionality
|
||||
16. `upload-drag-drop` - Drag-drop upload
|
||||
|
||||
### Phase 6: Edge Cases (Priority: Low)
|
||||
17. `edge-small-file` - 1-byte file
|
||||
18. `edge-special-chars-filename` - Unicode/special characters
|
||||
19. `edge-binary-content` - Binary content integrity
|
||||
20. `edge-near-limit` - 100MB file (slow test)
|
||||
21. `edge-network-error` - Network error handling
|
||||
|
||||
### Phase 7: Error Recovery and Advanced (Priority: Low)
|
||||
22. `upload-error-retry` - Retry after error
|
||||
23. `edge-concurrent-downloads` - Concurrent access
|
||||
24. `edge-redirect-file` - Multi-chunk file with redirect (slow)
|
||||
25. `edge-ui-info` - Expiry message, security notes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Utilities
|
||||
|
||||
### 8.1 Shared Test Setup
|
||||
|
||||
```typescript
|
||||
// test/page.spec.ts
|
||||
import {test, expect, createTestContent, createTextContent, uniqueFileName} from './fixtures'
|
||||
|
||||
test.describe('Upload Flow', () => {
|
||||
test('upload via file picker', async ({uploadPage}) => {
|
||||
// Tests use uploadPage fixture which navigates automatically
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Download Flow', () => {
|
||||
test('download works', async ({uploadPage, downloadPage}) => {
|
||||
// Both pages available via fixtures
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Cases', () => {
|
||||
// Edge case tests
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 File Structure
|
||||
|
||||
```
|
||||
xftp-web/test/
|
||||
├── fixtures.ts # Playwright fixtures with page objects
|
||||
├── pages/
|
||||
│ ├── UploadPage.ts # Upload page object
|
||||
│ └── DownloadPage.ts # Download page object
|
||||
├── page.spec.ts # All E2E tests
|
||||
└── globalSetup.ts # Server startup (existing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Test Matrix
|
||||
|
||||
| Test ID | Category | Priority | Estimated Time | Dependencies |
|
||||
|---------|----------|----------|----------------|--------------|
|
||||
| upload-file-picker | Upload | High | 30s | - |
|
||||
| upload-drag-drop | Upload | Medium | 30s | - |
|
||||
| upload-file-too-large | Upload | High | 5s | - |
|
||||
| upload-file-empty | Upload | High | 5s | - |
|
||||
| upload-progress-display | Upload | Medium | 45s | - |
|
||||
| upload-cancel | Upload | Medium | 30s | - |
|
||||
| upload-share-link-copy | Upload | Medium | 30s | - |
|
||||
| upload-error-retry | Upload | Low | 30s | - |
|
||||
| download-invalid-hash-malformed | Download | High | 5s | - |
|
||||
| download-invalid-hash-structure | Download | High | 5s | - |
|
||||
| download-button-click | Download | High | 45s | upload |
|
||||
| download-progress-display | Download | Medium | 60s | upload |
|
||||
| download-file-save | Download | High | 45s | upload |
|
||||
| edge-small-file | Edge | Low | 30s | - |
|
||||
| edge-near-limit | Edge | Low | 300s | - |
|
||||
| edge-special-chars-filename | Edge | Low | 30s | - |
|
||||
| edge-network-error | Edge | Low | 45s | - |
|
||||
| edge-binary-content | Edge | Low | 30s | - |
|
||||
| edge-concurrent-downloads | Edge | Low | 60s | upload |
|
||||
| edge-redirect-file | Edge | Low | 120s | - |
|
||||
| edge-ui-info | Edge | Low | 60s | upload |
|
||||
|
||||
**Total estimated time**: ~18 minutes (excluding 100MB and 5MB tests)
|
||||
@@ -0,0 +1,221 @@
|
||||
# XFTP Web Hello Header — Session Re-handshake for Browser Connection Reuse
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Browser HTTP/2 connection pooling reuses TLS connections across page navigations (same origin = same connection pool). The XFTP server maintains per-TLS-connection session state in `TMap SessionId Handshake` keyed by `tlsUniq tls`. When a browser navigates from the upload page to the download page (or reloads), the new page sends a fresh ClientHello on the reused HTTP/2 connection. The server is already in `HandshakeAccepted` state for that connection, so it routes the request to `processRequest`, which expects a 16384-byte command block but receives a 34-byte ClientHello → `ERR BLOCK`.
|
||||
|
||||
**Root cause**: The server cannot distinguish a ClientHello from a command on an already-handshaked connection because both arrive on the same HTTP/2 connection (same `tlsUniq`), and there is no content-level discriminator (ClientHello is unpadded, but the server never gets to parse it — the size check in `processRequest` rejects it first).
|
||||
|
||||
**Browser limitation**: `fetch()` provides zero control over HTTP/2 connection pooling. There is no browser API to force a new connection or detect connection reuse before a request is sent.
|
||||
|
||||
## 2. Solution Summary
|
||||
|
||||
Add an HTTP header `xftp-web-hello` to web ClientHello requests. When the server sees this header on an already-handshaked connection (`HandshakeAccepted` state), it re-runs `processHello` **reusing the existing session keys** (same X25519 key pair from the original handshake). The client then completes the normal handshake flow (sends ClientHandshake, receives ack) and proceeds with commands.
|
||||
|
||||
Key properties:
|
||||
- Server reuses existing `serverPrivKey` — no new key material generated on re-handshake, so `thAuth` remains consistent with any in-flight commands on concurrent HTTP/2 streams.
|
||||
- Header is only checked when `sniUsed` is true (web/browser connections). Native XFTP clients are unaffected.
|
||||
- CORS preflight already allows all headers (`Access-Control-Allow-Headers: *`).
|
||||
- Web clients always send this header on ClientHello — it's harmless on first connection (`Nothing` state) and enables re-handshake on reused connections (`HandshakeAccepted` state).
|
||||
|
||||
## 3. Detailed Technical Design
|
||||
|
||||
### 3.1 Server change: parameterize `processHello` (`src/Simplex/FileTransfer/Server.hs`)
|
||||
|
||||
The entire server change is parameterizing the existing `processHello` with `Maybe C.PrivateKeyX25519`. Zero new functions.
|
||||
|
||||
#### Current code (lines 165-191):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams) -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
processHello = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- atomically . C.generateKeyPair =<< asks random
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...build and send ServerHandshake...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### After (diff is ~10 lines):
|
||||
|
||||
```haskell
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions
|
||||
XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, request, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
-- ^^^^^^^ bind request
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello Nothing
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams)
|
||||
| webHello -> processHello (serverPrivKey <$> thAuth thParams)
|
||||
| otherwise -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
webHello = sniUsed && any (\(t, _) -> tokenKey t == "xftp-web-hello") (fst $ H.requestHeaders request)
|
||||
processHello pk_ = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
| sniUsed -> do
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- maybe
|
||||
(atomically . C.generateKeyPair =<< asks random)
|
||||
(\pk -> pure (C.publicKey pk, pk))
|
||||
pk_
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
-- ...rest unchanged...
|
||||
pure Nothing
|
||||
```
|
||||
|
||||
#### What changes:
|
||||
|
||||
1. **Bind `request`** in the `XFTPTransportRequest` pattern (+1 field)
|
||||
2. **Add `webHello`** binding in `where` clause (1 line) — checks header only when `sniUsed`
|
||||
3. **Add `pk_` parameter** to `processHello` (change signature)
|
||||
4. **Replace key generation** with `maybe` that generates fresh keys when `pk_ = Nothing`, or derives public from existing private when `pk_ = Just pk` (3 lines replace 1 line)
|
||||
5. **Add guard** in `HandshakeAccepted` branch (2 lines replace 1 line)
|
||||
6. **Call site** `Nothing -> processHello Nothing` (+1 word)
|
||||
7. **One import** added: `Network.HPACK.Token (tokenKey)`
|
||||
|
||||
#### Imports to add:
|
||||
|
||||
```haskell
|
||||
import Network.HPACK.Token (tokenKey)
|
||||
```
|
||||
|
||||
`OverloadedStrings` (already enabled in Server.hs) provides the `IsString` instance for `CI ByteString`, so `tokenKey t == "xftp-web-hello"` works without importing `Data.CaseInsensitive`. Verified on Hackage: `requestHeaders :: Request -> HeaderTable`, `tokenKey :: Token -> CI ByteString`.
|
||||
|
||||
### 3.2 Re-handshake flow
|
||||
|
||||
When `webHello` is true in `HandshakeAccepted` state:
|
||||
|
||||
1. `processHello (serverPrivKey <$> thAuth thParams)` is called with `Just pk` (existing private key)
|
||||
2. `(k, pk) <- pure (C.publicKey pk, pk)` — reuses same key pair, no generation
|
||||
3. `TM.insert sessionId (HandshakeSent pk) sessions` — transitions state back to `HandshakeSent` with same `pk`
|
||||
4. Server sends `ServerHandshake` response (same format as initial handshake)
|
||||
5. Client sends `ClientHandshake` on next stream → enters `Just (HandshakeSent pk) -> processClientHandshake pk` → normal flow
|
||||
6. `processClientHandshake` stores `HandshakeAccepted thParams` with same `serverPrivKey = pk`
|
||||
|
||||
### 3.3 Web client change (`xftp-web/src/client.ts`)
|
||||
|
||||
Add optional `headers?` parameter to `Transport.post()`, thread it through `fetch()` and `session.request()`, and pass `{"xftp-web-hello": "1"}` in the ClientHello call in `connectXFTP`.
|
||||
|
||||
### 3.4 What does NOT change
|
||||
|
||||
- **CORS**: Already has `Access-Control-Allow-Headers: *` (Server.hs:106).
|
||||
- **Native Haskell client**: Uses `[]` headers. No header = existing behavior.
|
||||
- **Protocol wire format**: ClientHello, ServerHandshake, ClientHandshake, commands — all unchanged.
|
||||
- **`processRequest`**, **`processClientHandshake`**, **`sendError`**, **`encodeXftp`** — unchanged.
|
||||
|
||||
### 3.5 Haskell test (`tests/XFTPServerTests.hs`)
|
||||
|
||||
Add `testWebReHandshake` next to the existing `testWebHandshake` (line 504). It reuses the same SNI + HTTP/2 setup pattern, performs a full handshake, then sends a second ClientHello with the `xftp-web-hello` header on the same connection and verifies the server responds with a valid ServerHandshake (same `sessionId`), then completes the second handshake.
|
||||
|
||||
```haskell
|
||||
-- Register in xftpServerTests (after line 86):
|
||||
it "should re-handshake on same connection with xftp-web-hello header" testWebReHandshake
|
||||
|
||||
-- Test (after testWebHandshake):
|
||||
testWebReHandshake :: Expectation
|
||||
testWebReHandshake =
|
||||
withXFTPServerSNI $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint "tests/fixtures/ca.crt"
|
||||
let keyHash = C.KeyHash fp
|
||||
cfg = defaultTransportClientConfig {clientALPN = Just ["h2"], useSNI = True}
|
||||
runTLSTransportClient defaultSupportedParamsHTTPS Nothing cfg Nothing "localhost" xftpTestPort (Just keyHash) $ \(tls :: TLS 'TClient) -> do
|
||||
let h2cfg = HC.defaultHTTP2ClientConfig {HC.bodyHeadSize = 65536}
|
||||
h2 <- either (error . show) pure =<< HC.attachHTTP2Client h2cfg (THDomainName "localhost") xftpTestPort mempty 65536 tls
|
||||
g <- C.newRandom
|
||||
-- First handshake (same as testWebHandshake)
|
||||
challenge1 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq1 = H2.requestBuilder "POST" "/" [] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge1}))
|
||||
resp1 <- either (error . show) pure =<< HC.sendRequest h2 helloReq1 (Just 5000000)
|
||||
shs1 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp1))
|
||||
let XFTPServerHandshake {sessionId = sid1} = shs1
|
||||
clientHsPadded <- either (error . show) pure $ C.pad (smpEncode (XFTPClientHandshake {xftpVersion = VersionXFTP 1, keyHash})) xftpBlockSize
|
||||
resp1b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp1b)) `shouldBe` 0
|
||||
-- Second handshake on same connection with xftp-web-hello header
|
||||
challenge2 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq2 = H2.requestBuilder "POST" "/" [("xftp-web-hello", "1")] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge2}))
|
||||
resp2 <- either (error . show) pure =<< HC.sendRequest h2 helloReq2 (Just 5000000)
|
||||
shs2 <- either error pure $ smpDecode =<< C.unPad (bodyHead (HC.respBody resp2))
|
||||
let XFTPServerHandshake {sessionId = sid2} = shs2
|
||||
sid2 `shouldBe` sid1 -- same TLS connection → same sessionId
|
||||
-- Complete second handshake
|
||||
resp2b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp2b)) `shouldBe` 0
|
||||
```
|
||||
|
||||
The only difference from `testWebHandshake`: the second `helloReq2` passes `[("xftp-web-hello", "1")]` instead of `[]`. The test verifies:
|
||||
1. Server responds with `ServerHandshake` (not `ERR BLOCK`)
|
||||
2. Same `sessionId` (same TLS connection)
|
||||
3. Second `ClientHandshake` completes with empty ACK
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Step 1: Server — parameterize `processHello`
|
||||
|
||||
Apply the diff from Section 3.1 to `src/Simplex/FileTransfer/Server.hs`.
|
||||
|
||||
### Step 2: Test — add `testWebReHandshake`
|
||||
|
||||
Add the test from Section 3.5 to `tests/XFTPServerTests.hs`.
|
||||
|
||||
### Step 3: Client — add `xftp-web-hello` header
|
||||
|
||||
Add optional `headers?` to `Transport.post()`, pass `{"xftp-web-hello": "1"}` on ClientHello in `connectXFTP`.
|
||||
|
||||
### Step 4: Test
|
||||
|
||||
Run Haskell tests (`cabal test`) and E2E Playwright tests (`npx playwright test` in `xftp-web/`).
|
||||
|
||||
## 5. Race Condition Analysis
|
||||
|
||||
### Single-tab navigation (the common case)
|
||||
|
||||
1. Upload page completes, all fetch() requests finish
|
||||
2. Browser navigates to download page (or reloads)
|
||||
3. All upload-page fetches are aborted on page unload
|
||||
4. Download page sends ClientHello with `xftp-web-hello` header
|
||||
5. Server is in `HandshakeAccepted` → `processHello (Just pk)` → `HandshakeSent pk` (same key)
|
||||
6. No concurrent streams → no race
|
||||
|
||||
**Safe.**
|
||||
|
||||
### Multi-tab (edge case)
|
||||
|
||||
Tab A (upload) and Tab B (download) share the same HTTP/2 connection.
|
||||
|
||||
1. Tab A has active command streams (e.g., FPUT upload in progress)
|
||||
2. Tab B sends ClientHello with header
|
||||
3. Server reads `HandshakeAccepted` atomically for both streams
|
||||
4. Tab A's stream already has its `thParams` snapshot → proceeds with `processRequest` using old `thParams`
|
||||
5. Tab B's stream triggers `processHello (Just pk)` → stores `HandshakeSent pk` (same pk!)
|
||||
6. Tab A's in-progress FPUT continues with snapshot `thParams` → completes normally (same `serverPrivKey`)
|
||||
7. Tab A's NEXT command reads `HandshakeSent` from TMap → enters `processClientHandshake` → fails (command body ≠ ClientHandshake format) → HANDSHAKE error
|
||||
|
||||
**Tab A's in-flight commands succeed. Tab A's subsequent commands fail with HANDSHAKE error.** This is the inherent multi-tab problem — unavoidable with per-connection session state and HTTP/2 connection sharing. The failure is clean (HANDSHAKE error, not silent corruption).
|
||||
|
||||
## 6. Security Considerations
|
||||
|
||||
- **No new key material**: Re-handshake reuses existing `serverPrivKey`. No opportunity for key confusion or downgrade.
|
||||
- **Identity re-verification**: Server re-signs the web challenge with its long-term signing key. Client verifies identity again.
|
||||
- **Header cannot escalate privileges**: The header only triggers re-handshake (which the server was already capable of doing on first connection). It does not bypass any authentication.
|
||||
- **Timing**: Re-handshake takes the same code path as initial handshake, so timing side-channels are unchanged.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import GHC.IO.Handle (hSetNewlineMode)
|
||||
import GHC.IORef (atomicSwapIORef)
|
||||
import GHC.Stats (getRTSStats)
|
||||
import qualified Network.HTTP.Types as N
|
||||
import Network.HPACK.Token (tokenKey)
|
||||
import qualified Network.HTTP2.Server as H
|
||||
import Network.Socket
|
||||
import Simplex.FileTransfer.Protocol
|
||||
@@ -162,15 +163,18 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
|
||||
Just thParams -> processRequest req0 {thParams}
|
||||
| otherwise -> liftIO . sendResponse $ H.responseNoBody N.ok200 (corsHeaders addCORS')
|
||||
xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion 'TServer))
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, request, reqBody = HTTP2Body {bodyHead}, sendResponse, sniUsed, addCORS} = do
|
||||
s <- atomically $ TM.lookup sessionId sessions
|
||||
r <- runExceptT $ case s of
|
||||
Nothing -> processHello
|
||||
Nothing -> processHello Nothing
|
||||
Just (HandshakeSent pk) -> processClientHandshake pk
|
||||
Just (HandshakeAccepted thParams) -> pure $ Just thParams
|
||||
Just (HandshakeAccepted thParams)
|
||||
| webHello -> processHello (serverPrivKey <$> thAuth thParams)
|
||||
| otherwise -> pure $ Just thParams
|
||||
either sendError pure r
|
||||
where
|
||||
processHello = do
|
||||
webHello = sniUsed && any (\(t, _) -> tokenKey t == "xftp-web-hello") (fst $ H.requestHeaders request)
|
||||
processHello pk_ = do
|
||||
challenge_ <-
|
||||
if
|
||||
| B.null bodyHead -> pure Nothing
|
||||
@@ -178,7 +182,10 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira
|
||||
XFTPClientHello {webChallenge} <- liftHS $ smpDecode bodyHead
|
||||
pure webChallenge
|
||||
| otherwise -> throwE HANDSHAKE
|
||||
(k, pk) <- atomically . C.generateKeyPair =<< asks random
|
||||
(k, pk) <- maybe
|
||||
(atomically . C.generateKeyPair =<< asks random)
|
||||
(\pk -> pure (C.publicKey pk, pk))
|
||||
pk_
|
||||
atomically $ TM.insert sessionId (HandshakeSent pk) sessions
|
||||
let authPubKey = CertChainPubKey chain (C.signX509 serverSignKey $ C.publicToX509 k)
|
||||
webIdentityProof = C.sign serverSignKey . (<> sessionId) <$> challenge_
|
||||
|
||||
@@ -84,6 +84,7 @@ xftpServerTests =
|
||||
it "should not add CORS headers without SNI" testNoCORSWithoutSNI
|
||||
it "should upload and receive file chunk through SNI-enabled server" testFileChunkDeliverySNI
|
||||
it "should complete web handshake with challenge-response" testWebHandshake
|
||||
it "should re-handshake on same connection with xftp-web-hello header" testWebReHandshake
|
||||
|
||||
chSize :: Integral a => a
|
||||
chSize = kb 128
|
||||
@@ -540,3 +541,33 @@ testWebHandshake =
|
||||
resp2 <- either (error . show) pure =<< HC.sendRequest h2 clientHsReq (Just 5000000)
|
||||
let ackBody = bodyHead (HC.respBody resp2)
|
||||
B.length ackBody `shouldBe` 0
|
||||
|
||||
testWebReHandshake :: Expectation
|
||||
testWebReHandshake =
|
||||
withXFTPServerSNI $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint "tests/fixtures/ca.crt"
|
||||
let keyHash = C.KeyHash fp
|
||||
cfg = defaultTransportClientConfig {clientALPN = Just ["h2"], useSNI = True}
|
||||
runTLSTransportClient defaultSupportedParamsHTTPS Nothing cfg Nothing "localhost" xftpTestPort (Just keyHash) $ \(tls :: TLS 'TClient) -> do
|
||||
let h2cfg = HC.defaultHTTP2ClientConfig {HC.bodyHeadSize = 65536}
|
||||
h2 <- either (error . show) pure =<< HC.attachHTTP2Client h2cfg (THDomainName "localhost") xftpTestPort mempty 65536 tls
|
||||
g <- C.newRandom
|
||||
-- First handshake
|
||||
challenge1 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq1 = H2.requestBuilder "POST" "/" [] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge1}))
|
||||
resp1 <- either (error . show) pure =<< HC.sendRequest h2 helloReq1 (Just 5000000)
|
||||
serverHs1 <- either (error . show) pure $ C.unPad (bodyHead (HC.respBody resp1))
|
||||
XFTPServerHandshake {sessionId = sid1} <- either error pure $ smpDecode serverHs1
|
||||
clientHsPadded <- either (error . show) pure $ C.pad (smpEncode (XFTPClientHandshake {xftpVersion = VersionXFTP 1, keyHash})) xftpBlockSize
|
||||
resp1b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp1b)) `shouldBe` 0
|
||||
-- Re-handshake on same connection with xftp-web-hello header
|
||||
challenge2 <- atomically $ C.randomBytes 32 g
|
||||
let helloReq2 = H2.requestBuilder "POST" "/" [("xftp-web-hello", "1")] $ byteString (smpEncode (XFTPClientHello {webChallenge = Just challenge2}))
|
||||
resp2 <- either (error . show) pure =<< HC.sendRequest h2 helloReq2 (Just 5000000)
|
||||
serverHs2 <- either (error . show) pure $ C.unPad (bodyHead (HC.respBody resp2))
|
||||
XFTPServerHandshake {sessionId = sid2} <- either error pure $ smpDecode serverHs2
|
||||
sid2 `shouldBe` sid1
|
||||
-- Complete re-handshake
|
||||
resp2b <- either (error . show) pure =<< HC.sendRequest h2 (H2.requestBuilder "POST" "/" [] $ byteString clientHsPadded) (Just 5000000)
|
||||
B.length (bodyHead (HC.respBody resp2b)) `shouldBe` 0
|
||||
|
||||
@@ -7,13 +7,21 @@ export default defineConfig({
|
||||
use: {
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: ['--ignore-certificate-errors']
|
||||
// --ignore-certificate-errors makes fetch() accept self-signed certs
|
||||
args: [
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--allow-insecure-localhost',
|
||||
]
|
||||
}
|
||||
},
|
||||
// Note: globalSetup runs AFTER webServer plugins in playwright 1.58+, so we
|
||||
// run setup from the webServer command instead
|
||||
globalTeardown: './test/globalTeardown.ts',
|
||||
webServer: {
|
||||
command: 'npx vite build --mode development && npx vite preview',
|
||||
// Run setup script first (starts XFTP server + proxy), then build, then preview
|
||||
command: 'npx tsx test/runSetup.ts && npx vite build --mode development && npx vite preview --mode development',
|
||||
url: 'http://localhost:4173',
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
globalSetup: './test/globalSetup.ts'
|
||||
})
|
||||
|
||||
@@ -41,6 +41,10 @@ interface Transport {
|
||||
|
||||
const isNode = typeof globalThis.process !== "undefined" && globalThis.process.versions?.node
|
||||
|
||||
// In development mode, use HTTP proxy to avoid self-signed cert issues in browser
|
||||
// __XFTP_PROXY_PORT__ is injected by vite build (null in production)
|
||||
declare const __XFTP_PROXY_PORT__: string | null
|
||||
|
||||
async function createTransport(baseUrl: string): Promise<Transport> {
|
||||
if (isNode) {
|
||||
return createNodeTransport(baseUrl)
|
||||
@@ -70,13 +74,17 @@ async function createNodeTransport(baseUrl: string): Promise<Transport> {
|
||||
}
|
||||
|
||||
function createBrowserTransport(baseUrl: string): Transport {
|
||||
// In dev mode, route through /xftp-proxy to avoid self-signed cert rejection
|
||||
// __XFTP_PROXY_PORT__ is 'proxy' in dev mode (uses relative path), null in production
|
||||
const effectiveUrl = typeof __XFTP_PROXY_PORT__ !== 'undefined' && __XFTP_PROXY_PORT__
|
||||
? '/xftp-proxy'
|
||||
: baseUrl
|
||||
return {
|
||||
async post(body: Uint8Array): Promise<Uint8Array> {
|
||||
const resp = await fetch(baseUrl, {
|
||||
const resp = await fetch(effectiveUrl, {
|
||||
method: "POST",
|
||||
body,
|
||||
duplex: "half",
|
||||
} as RequestInit)
|
||||
})
|
||||
if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`)
|
||||
return new Uint8Array(await resp.arrayBuffer())
|
||||
},
|
||||
|
||||
33
xftp-web/test/fixtures.ts
Normal file
33
xftp-web/test/fixtures.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {test as base} from '@playwright/test'
|
||||
import {UploadPage} from './pages/upload-page'
|
||||
import {DownloadPage} from './pages/download-page'
|
||||
|
||||
// Extend Playwright test with page objects
|
||||
export const test = base.extend<{
|
||||
uploadPage: UploadPage
|
||||
downloadPage: DownloadPage
|
||||
}>({
|
||||
uploadPage: async ({page}, use) => {
|
||||
const uploadPage = new UploadPage(page)
|
||||
await uploadPage.goto()
|
||||
await use(uploadPage)
|
||||
},
|
||||
downloadPage: async ({page}, use) => {
|
||||
await use(new DownloadPage(page))
|
||||
},
|
||||
})
|
||||
|
||||
export {expect} from '@playwright/test'
|
||||
|
||||
// Test data helpers
|
||||
export function createTestContent(size: number, fill = 0x41): Buffer {
|
||||
return Buffer.alloc(size, fill)
|
||||
}
|
||||
|
||||
export function createTextContent(text: string): Buffer {
|
||||
return Buffer.from(text, 'utf-8')
|
||||
}
|
||||
|
||||
export function uniqueFileName(base: string, ext = 'txt'): string {
|
||||
return `${base}-${Date.now()}.${ext}`
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import {spawn, execSync, ChildProcess} from 'child_process'
|
||||
import {createHash} from 'crypto'
|
||||
import {createConnection, createServer} from 'net'
|
||||
import {resolve, join} from 'path'
|
||||
import {resolve, join, dirname} from 'path'
|
||||
import {fileURLToPath} from 'url'
|
||||
import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
|
||||
const LOCK_FILE = join(tmpdir(), 'xftp-test-server.pid')
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const LOCK_FILE = join(tmpdir(), 'xftp-test-server.lock')
|
||||
const SERVER_PID_FILE = join(tmpdir(), 'xftp-test-server.pid')
|
||||
export const PORT_FILE = join(tmpdir(), 'xftp-test-server.port')
|
||||
|
||||
// Find a free port by binding to port 0
|
||||
@@ -28,19 +33,21 @@ function findFreePort(): Promise<number> {
|
||||
let server: ChildProcess | null = null
|
||||
let isOwner = false
|
||||
|
||||
export async function setup() {
|
||||
// Check if another test process owns the server
|
||||
if (existsSync(LOCK_FILE) && existsSync(PORT_FILE)) {
|
||||
const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10)
|
||||
async function setup() {
|
||||
// Check if an xftp-server is already running from a previous test
|
||||
if (existsSync(SERVER_PID_FILE) && existsSync(PORT_FILE)) {
|
||||
const serverPid = parseInt(readFileSync(SERVER_PID_FILE, 'utf-8').trim(), 10)
|
||||
const port = parseInt(readFileSync(PORT_FILE, 'utf-8').trim(), 10)
|
||||
try {
|
||||
process.kill(pid, 0) // check if process exists
|
||||
// Lock owner is alive — wait for server to be ready
|
||||
process.kill(serverPid, 0) // check if server process exists
|
||||
// Server is alive — wait for it to be ready and reuse
|
||||
await waitForPort(port)
|
||||
console.log('[runSetup] Reusing existing xftp-server on port', port)
|
||||
return
|
||||
} catch (_) {
|
||||
// Lock owner is dead — clean up
|
||||
// Server is dead — clean up stale files
|
||||
try { unlinkSync(LOCK_FILE) } catch (_) {}
|
||||
try { unlinkSync(SERVER_PID_FILE) } catch (_) {}
|
||||
try { unlinkSync(PORT_FILE) } catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -91,14 +98,15 @@ key: ${join(fixtures, 'web.key')}
|
||||
// Resolve binary path once (avoids cabal rebuild check on every run)
|
||||
const serverBin = execSync('cabal -v0 list-bin xftp-server', {encoding: 'utf-8'}).trim()
|
||||
|
||||
// Spawn xftp-server directly
|
||||
// Spawn xftp-server as detached process so runSetup.ts can exit
|
||||
server = spawn(serverBin, ['start'], {
|
||||
env: {
|
||||
...process.env,
|
||||
XFTP_SERVER_CFG_PATH: cfgDir,
|
||||
XFTP_SERVER_LOG_PATH: logDir
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
})
|
||||
|
||||
server.stderr?.on('data', (data: Buffer) => {
|
||||
@@ -107,20 +115,32 @@ key: ${join(fixtures, 'web.key')}
|
||||
|
||||
// Poll-connect until the server is actually listening
|
||||
await waitForServerReady(server, xftpPort)
|
||||
|
||||
// Store server PID for teardown
|
||||
writeFileSync(SERVER_PID_FILE, String(server.pid))
|
||||
|
||||
// Detach stdio so the setup process can exit
|
||||
server.stdout?.destroy()
|
||||
server.stderr?.destroy()
|
||||
server.unref()
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
if (isOwner) {
|
||||
try { unlinkSync(LOCK_FILE) } catch (_) {}
|
||||
try { unlinkSync(PORT_FILE) } catch (_) {}
|
||||
if (server) {
|
||||
server.kill('SIGTERM')
|
||||
await new Promise<void>(resolve => {
|
||||
server!.on('exit', () => resolve())
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
// Kill the xftp-server if it's running
|
||||
if (existsSync(SERVER_PID_FILE)) {
|
||||
try {
|
||||
const serverPid = parseInt(readFileSync(SERVER_PID_FILE, 'utf-8').trim(), 10)
|
||||
process.kill(serverPid, 'SIGTERM')
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
} catch (_) {
|
||||
// Server already dead
|
||||
}
|
||||
}
|
||||
// Clean up files
|
||||
try { unlinkSync(LOCK_FILE) } catch (_) {}
|
||||
try { unlinkSync(SERVER_PID_FILE) } catch (_) {}
|
||||
try { unlinkSync(PORT_FILE) } catch (_) {}
|
||||
}
|
||||
|
||||
function waitForServerReady(proc: ChildProcess, port: number): Promise<void> {
|
||||
@@ -168,3 +188,5 @@ function waitForPort(port: number): Promise<void> {
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
export default setup
|
||||
|
||||
3
xftp-web/test/globalTeardown.ts
Normal file
3
xftp-web/test/globalTeardown.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import {teardown as teardownFn} from './globalSetup'
|
||||
|
||||
export default teardownFn
|
||||
@@ -1,42 +1,325 @@
|
||||
import {test, expect} from '@playwright/test'
|
||||
import {test, expect, createTestContent, createTextContent} from './fixtures'
|
||||
import {UploadPage} from './pages/upload-page'
|
||||
import {DownloadPage} from './pages/download-page'
|
||||
import {readFileSync} from 'fs'
|
||||
|
||||
const PAGE_URL = 'http://localhost:4173'
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Upload Flow Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('page upload + download round-trip', async ({page}) => {
|
||||
// Upload page
|
||||
await page.goto(PAGE_URL)
|
||||
await expect(page.locator('#drop-zone')).toBeVisible()
|
||||
test.describe('Upload Flow', () => {
|
||||
test('upload via file picker button', async ({uploadPage}) => {
|
||||
await uploadPage.expectDropZoneVisible()
|
||||
|
||||
// Create a small test file
|
||||
const content = 'Hello SimpleX ' + Date.now()
|
||||
const fileName = 'test-file.txt'
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
await uploadPage.selectTextFile('picker-test.txt', 'test content ' + Date.now())
|
||||
await uploadPage.waitForEncrypting()
|
||||
await uploadPage.waitForUploading()
|
||||
|
||||
// Set file via hidden input
|
||||
const fileInput = page.locator('#file-input')
|
||||
await fileInput.setInputFiles({name: fileName, mimeType: 'text/plain', buffer})
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toMatch(/^http:\/\/localhost:\d+\/?#/)
|
||||
})
|
||||
|
||||
// Wait for upload to complete
|
||||
const shareLink = page.locator('[data-testid="share-link"]')
|
||||
await expect(shareLink).toBeVisible({timeout: 30_000})
|
||||
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()
|
||||
|
||||
// Extract the hash from the share link
|
||||
const linkValue = await shareLink.inputValue()
|
||||
const hash = new URL(linkValue).hash
|
||||
const link = await uploadPage.waitForShareLink()
|
||||
expect(link).toContain('#')
|
||||
})
|
||||
|
||||
// Navigate to download page
|
||||
await page.goto(PAGE_URL + hash)
|
||||
await expect(page.locator('#dl-btn')).toBeVisible()
|
||||
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')
|
||||
})
|
||||
|
||||
// Start download and wait for completion
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.locator('#dl-btn').click()
|
||||
const download = await downloadPromise
|
||||
test('upload rejects empty file', async ({uploadPage}) => {
|
||||
await uploadPage.selectFile('empty.txt', Buffer.alloc(0))
|
||||
await uploadPage.expectError('empty')
|
||||
})
|
||||
|
||||
// Verify downloaded file
|
||||
expect(download.suggestedFilename()).toBe(fileName)
|
||||
const downloadedContent = (await download.path()) !== null
|
||||
? (await import('fs')).readFileSync(await download.path()!, 'utf-8')
|
||||
: ''
|
||||
expect(downloadedContent).toBe(content)
|
||||
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('download shows error for malformed hash', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#not-valid-base64!!!')
|
||||
await downloadPage.expectInitialError(/[Ii]nvalid|corrupted/)
|
||||
await downloadPage.expectDownloadButtonNotVisible()
|
||||
})
|
||||
|
||||
test('download shows error for invalid structure', async ({downloadPage}) => {
|
||||
await downloadPage.goto('#AAAA')
|
||||
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()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(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()
|
||||
|
||||
expect(download.suggestedFilename()).toBe(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, not the web page
|
||||
if (url.includes(':443') && 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
|
||||
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 = require('fs').statSync(path)
|
||||
expect(stat.size).toBe(5 * 1024 * 1024)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
89
xftp-web/test/pages/download-page.ts
Normal file
89
xftp-web/test/pages/download-page.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {Page, Locator, expect, Download} from '@playwright/test'
|
||||
|
||||
export class DownloadPage {
|
||||
readonly page: Page
|
||||
readonly readyStage: Locator
|
||||
readonly downloadButton: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.readyStage = page.locator('#dl-ready')
|
||||
this.downloadButton = page.locator('#dl-btn')
|
||||
this.progressStage = page.locator('#dl-progress')
|
||||
this.progressCanvas = page.locator('#dl-progress-container canvas')
|
||||
this.statusText = page.locator('#dl-status')
|
||||
this.errorStage = page.locator('#dl-error')
|
||||
this.errorMessage = page.locator('#dl-error-msg')
|
||||
this.retryButton = page.locator('#dl-retry-btn')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto(hash: string) {
|
||||
await this.page.goto(`http://localhost:4173${hash}`)
|
||||
}
|
||||
|
||||
async gotoWithLink(fullUrl: string) {
|
||||
const hash = new URL(fullUrl).hash
|
||||
await this.goto(hash)
|
||||
}
|
||||
|
||||
async expectFileReady() {
|
||||
await expect(this.readyStage).toBeVisible()
|
||||
await expect(this.downloadButton).toBeVisible()
|
||||
}
|
||||
|
||||
async expectFileSizeDisplayed() {
|
||||
await expect(this.readyStage).toContainText(/\d+(?:\.\d+)?\s*(?:KB|MB|B)/)
|
||||
}
|
||||
|
||||
async clickDownload(): Promise<Download> {
|
||||
const downloadPromise = this.page.waitForEvent('download')
|
||||
await this.downloadButton.click()
|
||||
return downloadPromise
|
||||
}
|
||||
|
||||
async startDownload() {
|
||||
await this.downloadButton.click()
|
||||
}
|
||||
|
||||
async waitForDownloading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Downloading', {timeout})
|
||||
}
|
||||
|
||||
async waitForDecrypting(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Decrypting', {timeout})
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectInitialError(messagePattern: string | RegExp) {
|
||||
// For malformed links - error shown in card without #dl-error stage
|
||||
await expect(this.page.locator('.card .error')).toBeVisible()
|
||||
await expect(this.page.locator('.card .error')).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectRuntimeError(messagePattern: string | RegExp) {
|
||||
// For runtime download errors - uses #dl-error stage
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
|
||||
async expectDownloadButtonNotVisible() {
|
||||
await expect(this.downloadButton).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
2
xftp-web/test/pages/index.ts
Normal file
2
xftp-web/test/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {UploadPage} from './upload-page'
|
||||
export {DownloadPage} from './download-page'
|
||||
136
xftp-web/test/pages/upload-page.ts
Normal file
136
xftp-web/test/pages/upload-page.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {Page, Locator, expect} from '@playwright/test'
|
||||
|
||||
export class UploadPage {
|
||||
readonly page: Page
|
||||
readonly dropZone: Locator
|
||||
readonly fileInput: Locator
|
||||
readonly progressStage: Locator
|
||||
readonly progressCanvas: Locator
|
||||
readonly statusText: Locator
|
||||
readonly cancelButton: Locator
|
||||
readonly completeStage: Locator
|
||||
readonly shareLink: Locator
|
||||
readonly copyButton: Locator
|
||||
readonly errorStage: Locator
|
||||
readonly errorMessage: Locator
|
||||
readonly retryButton: Locator
|
||||
readonly expiryNote: Locator
|
||||
readonly securityNote: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.dropZone = page.locator('#drop-zone')
|
||||
this.fileInput = page.locator('#file-input')
|
||||
this.progressStage = page.locator('#upload-progress')
|
||||
this.progressCanvas = page.locator('#progress-container canvas')
|
||||
this.statusText = page.locator('#upload-status')
|
||||
this.cancelButton = page.locator('#cancel-btn')
|
||||
this.completeStage = page.locator('#upload-complete')
|
||||
this.shareLink = page.locator('[data-testid="share-link"]')
|
||||
this.copyButton = page.locator('#copy-btn')
|
||||
this.errorStage = page.locator('#upload-error')
|
||||
this.errorMessage = page.locator('#error-msg')
|
||||
this.retryButton = page.locator('#retry-btn')
|
||||
this.expiryNote = page.locator('.expiry')
|
||||
this.securityNote = page.locator('.security-note')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('http://localhost:4173')
|
||||
}
|
||||
|
||||
async selectFile(name: string, content: Buffer, mimeType = 'application/octet-stream') {
|
||||
await this.fileInput.setInputFiles({name, mimeType, buffer: content})
|
||||
}
|
||||
|
||||
async selectTextFile(name: string, content: string) {
|
||||
await this.selectFile(name, Buffer.from(content, 'utf-8'), 'text/plain')
|
||||
}
|
||||
|
||||
async selectLargeFile(name: string, sizeBytes: number) {
|
||||
// Create large file in browser to avoid memory issues in test process
|
||||
await this.page.evaluate(({name, size}) => {
|
||||
const input = document.getElementById('file-input') as HTMLInputElement
|
||||
const buffer = new ArrayBuffer(size)
|
||||
new Uint8Array(buffer).fill(0x55)
|
||||
const file = new File([buffer], name, {type: 'application/octet-stream'})
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}))
|
||||
}, {name, size: sizeBytes})
|
||||
}
|
||||
|
||||
async dragDropFile(name: string, content: Buffer) {
|
||||
// Note: True drag-drop simulation is complex in Playwright. The app's drop handler
|
||||
// dispatches a 'change' event on the file input, so setting input files triggers
|
||||
// the same code path. This tests the file handling logic, not the DnD UI events.
|
||||
await this.selectFile(name, content)
|
||||
}
|
||||
|
||||
async waitForEncrypting(timeout = 10_000) {
|
||||
await expect(this.statusText).toContainText('Encrypting', {timeout})
|
||||
}
|
||||
|
||||
async waitForUploading(timeout = 30_000) {
|
||||
await expect(this.statusText).toContainText('Uploading', {timeout})
|
||||
}
|
||||
|
||||
async waitForShareLink(timeout = 60_000): Promise<string> {
|
||||
await expect(this.shareLink).toBeVisible({timeout})
|
||||
return await this.shareLink.inputValue()
|
||||
}
|
||||
|
||||
async clickCopy() {
|
||||
await this.copyButton.click()
|
||||
await expect(this.copyButton).toContainText('Copied!')
|
||||
}
|
||||
|
||||
async clickCancel() {
|
||||
await this.cancelButton.click()
|
||||
}
|
||||
|
||||
async clickRetry() {
|
||||
await this.retryButton.click()
|
||||
}
|
||||
|
||||
async expectError(messagePattern: string | RegExp) {
|
||||
await expect(this.errorStage).toBeVisible()
|
||||
await expect(this.errorMessage).toContainText(messagePattern)
|
||||
}
|
||||
|
||||
async expectDropZoneVisible() {
|
||||
await expect(this.dropZone).toBeVisible()
|
||||
}
|
||||
|
||||
async expectProgressVisible() {
|
||||
await expect(this.progressStage).toBeVisible()
|
||||
await expect(this.progressCanvas).toBeVisible()
|
||||
}
|
||||
|
||||
async expectCompleteWithExpiry() {
|
||||
await expect(this.completeStage).toBeVisible()
|
||||
await expect(this.expiryNote).toContainText('48 hours')
|
||||
}
|
||||
|
||||
async expectSecurityNote() {
|
||||
await expect(this.securityNote).toBeVisible()
|
||||
await expect(this.securityNote).toContainText('encrypted')
|
||||
}
|
||||
|
||||
async expectRetryButtonVisible() {
|
||||
await expect(this.retryButton).toBeVisible()
|
||||
}
|
||||
|
||||
async expectShareLinkNotVisible() {
|
||||
await expect(this.shareLink).not.toBeVisible()
|
||||
}
|
||||
|
||||
async expectNoError(timeout = 5000) {
|
||||
await expect(this.errorStage).not.toBeVisible({timeout})
|
||||
}
|
||||
|
||||
getHashFromLink(url: string): string {
|
||||
return new URL(url).hash
|
||||
}
|
||||
}
|
||||
5
xftp-web/test/runSetup.ts
Normal file
5
xftp-web/test/runSetup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Helper script to run globalSetup synchronously before vite build
|
||||
import setup from './globalSetup.js'
|
||||
|
||||
await setup()
|
||||
console.log('[runSetup] Setup complete')
|
||||
@@ -1,9 +1,12 @@
|
||||
import {defineConfig, type Plugin} from 'vite'
|
||||
import {defineConfig, type Plugin, type PreviewServer} from 'vite'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
import {resolve} from 'path'
|
||||
import {resolve, join} from 'path'
|
||||
import {tmpdir} from 'os'
|
||||
import * as http2 from 'http2'
|
||||
import presets from './web/servers.json'
|
||||
import {PORT_FILE} from './test/globalSetup'
|
||||
|
||||
const PORT_FILE = join(tmpdir(), 'xftp-test-server.port')
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
|
||||
@@ -30,29 +33,155 @@ function cspPlugin(servers: string[]): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute fingerprint from ca.crt (SHA-256 of DER)
|
||||
function getFingerprint(): string {
|
||||
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
return createHash('sha256').update(der).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
// Plugin to inject __XFTP_SERVERS__ lazily (reads PORT_FILE written by test/runSetup.ts)
|
||||
function xftpServersPlugin(): Plugin {
|
||||
let serverAddr: string | null = null
|
||||
const fp = getFingerprint()
|
||||
return {
|
||||
name: 'xftp-servers-define',
|
||||
transform(code, _id) {
|
||||
if (!code.includes('__XFTP_SERVERS__')) return null
|
||||
if (!serverAddr) {
|
||||
const port = readFileSync(PORT_FILE, 'utf-8').trim()
|
||||
serverAddr = `xftp://${fp}@localhost:${port}`
|
||||
}
|
||||
return code.replace(/__XFTP_SERVERS__/g, JSON.stringify([serverAddr]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP/2 proxy plugin for dev/test mode
|
||||
// Routes /xftp-proxy to the XFTP server using HTTP/2
|
||||
function xftpH2ProxyPlugin(): Plugin {
|
||||
let h2Session: http2.ClientHttp2Session | null = null
|
||||
let xftpPort: number | null = null
|
||||
|
||||
function getSession(): http2.ClientHttp2Session {
|
||||
if (h2Session && !h2Session.closed && !h2Session.destroyed) {
|
||||
return h2Session
|
||||
}
|
||||
if (!xftpPort) {
|
||||
xftpPort = parseInt(readFileSync(PORT_FILE, 'utf-8').trim(), 10)
|
||||
}
|
||||
h2Session = http2.connect(`https://localhost:${xftpPort}`, {
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
h2Session.on('error', (err) => {
|
||||
console.error('[h2proxy]', err.message)
|
||||
})
|
||||
return h2Session
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'xftp-h2-proxy',
|
||||
configurePreviewServer(server: PreviewServer) {
|
||||
console.log('[xftp-h2-proxy] Plugin registered')
|
||||
server.middlewares.use('/xftp-proxy', (req, res, next) => {
|
||||
console.log('[xftp-h2-proxy] Request:', req.method, req.url)
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
return next()
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks)
|
||||
let session: http2.ClientHttp2Session
|
||||
try {
|
||||
session = getSession()
|
||||
} catch (e) {
|
||||
res.writeHead(502)
|
||||
res.end('Proxy error: failed to connect to upstream')
|
||||
return
|
||||
}
|
||||
const h2req = session.request({
|
||||
':method': 'POST',
|
||||
':path': '/',
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': body.length
|
||||
})
|
||||
const resChunks: Buffer[] = []
|
||||
let statusCode = 200
|
||||
h2req.on('response', (headers) => {
|
||||
statusCode = (headers[':status'] as number) || 200
|
||||
})
|
||||
h2req.on('data', (chunk: Buffer) => resChunks.push(chunk))
|
||||
h2req.on('end', () => {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
})
|
||||
res.end(Buffer.concat(resChunks))
|
||||
})
|
||||
h2req.on('error', (e) => {
|
||||
res.writeHead(502)
|
||||
res.end('Proxy error: ' + e.message)
|
||||
})
|
||||
h2req.write(body)
|
||||
h2req.end()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin to inject proxy path for dev mode (uses /xftp-proxy endpoint)
|
||||
function xftpProxyDefinePlugin(): Plugin {
|
||||
return {
|
||||
name: 'xftp-proxy-define',
|
||||
transform(code, _id) {
|
||||
if (!code.includes('__XFTP_PROXY_PORT__')) return null
|
||||
// Use relative path for proxy - vite preview handles it
|
||||
return code.replace(/__XFTP_PROXY_PORT__/g, JSON.stringify('proxy'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const define: Record<string, string> = {}
|
||||
let servers: string[]
|
||||
const plugins: Plugin[] = []
|
||||
|
||||
if (mode === 'development') {
|
||||
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
const fp = createHash('sha256').update(der).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
// PORT_FILE is written by globalSetup before vite build runs
|
||||
const port = readFileSync(PORT_FILE, 'utf-8').trim()
|
||||
servers = [`xftp://${fp}@localhost:${port}`]
|
||||
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
|
||||
// In development mode, use the test server (port from globalSetup)
|
||||
plugins.push(xftpServersPlugin())
|
||||
plugins.push(xftpProxyDefinePlugin())
|
||||
plugins.push(xftpH2ProxyPlugin())
|
||||
// For CSP plugin, use localhost placeholder (CSP stripped in dev server anyway)
|
||||
servers = ['xftp://fp@localhost:443']
|
||||
} else {
|
||||
// In production mode, use the preset servers
|
||||
servers = [...presets.simplex, ...presets.flux]
|
||||
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
|
||||
define['__XFTP_PROXY_PORT__'] = JSON.stringify(null)
|
||||
}
|
||||
|
||||
plugins.push(cspPlugin(servers))
|
||||
|
||||
return {
|
||||
root: 'web',
|
||||
build: {outDir: resolve(__dirname, 'dist-web'), target: 'esnext'},
|
||||
preview: {host: true},
|
||||
define,
|
||||
worker: {format: 'es' as const},
|
||||
plugins: [cspPlugin(servers)],
|
||||
plugins,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src __CSP_CONNECT_SRC__;">
|
||||
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' __CSP_CONNECT_SRC__;">
|
||||
<title>SimpleX File Transfer</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
|
||||
import presets from './servers.json'
|
||||
|
||||
// __XFTP_SERVERS__ is injected at build time by vite.config.ts
|
||||
// In development mode: test server from globalSetup
|
||||
// In production mode: preset servers from servers.json
|
||||
declare const __XFTP_SERVERS__: string[]
|
||||
|
||||
const serverAddresses: string[] = typeof __XFTP_SERVERS__ !== 'undefined'
|
||||
? __XFTP_SERVERS__
|
||||
: [...presets.simplex, ...presets.flux]
|
||||
const serverAddresses: string[] = __XFTP_SERVERS__
|
||||
|
||||
export function getServers(): XFTPServer[] {
|
||||
return serverAddresses.map(parseXFTPServer)
|
||||
|
||||
Reference in New Issue
Block a user