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