allow sending xftp client hello after handshake - for web clients that dont know if established connection exists

This commit is contained in:
Evgeny @ SimpleX Chat
2026-02-08 21:54:18 +00:00
parent 78dc2cddec
commit dfda816d60
18 changed files with 1916 additions and 1276 deletions

View 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)

View File

@@ -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

View File

@@ -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_

View File

@@ -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

View File

@@ -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'
})

View File

@@ -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
View 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}`
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
import {teardown as teardownFn} from './globalSetup'
export default teardownFn

View File

@@ -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)
}
})
})

View 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()
}
}

View File

@@ -0,0 +1,2 @@
export {UploadPage} from './upload-page'
export {DownloadPage} from './download-page'

View 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
}
}

View 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')

View File

@@ -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,
}
})

View File

@@ -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>

View File

@@ -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)