fix: support resumable Cloud uploads in the public demo
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// Exercise the actual browser upload protocol against an isolated demo server.
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, test } from 'node:test'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { File } from 'node:buffer'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import ts from 'typescript'
|
||||
import http from 'node:http'
|
||||
|
||||
const root = new URL('../', import.meta.url)
|
||||
const source = await readFile(new URL('src/api/resumable-upload.ts', root), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText
|
||||
const { resumableUpload } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||
globalThis.window = new EventTarget()
|
||||
globalThis.document = new EventTarget()
|
||||
document.visibilityState = 'visible'
|
||||
let child, origin, output = ''
|
||||
before(async () => {
|
||||
const server = createServer()
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
origin = `http://127.0.0.1:${port}`
|
||||
child = spawn(process.execPath, ['mock-backend.js'], { cwd: root, env: {
|
||||
...process.env, DEMO: '1', MOCK_BACKEND_HOST: '127.0.0.1',
|
||||
MOCK_BACKEND_PORT: String(port), DEMO_FILE_QUOTA_BYTES: String(8 * 1024 * 1024),
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
child.stdout.on('data', data => { output = (output + data).slice(-10000) })
|
||||
child.stderr.on('data', data => { output = (output + data).slice(-10000) })
|
||||
for (let n = 0; n < 600; n++) {
|
||||
try { if ((await fetch(`${origin}/health`)).ok) return } catch {}
|
||||
if (child.exitCode !== null) break
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error(`Demo failed to start: ${output}`)
|
||||
})
|
||||
after(async () => {
|
||||
if (child && child.exitCode === null) {
|
||||
const exited = new Promise(resolve => child.once('exit', resolve))
|
||||
child.kill('SIGTERM')
|
||||
await exited
|
||||
}
|
||||
})
|
||||
async function visitor() {
|
||||
const response = await fetch(`${origin}/health`)
|
||||
const cookie = response.headers.getSetCookie().find(v => v.startsWith('demo_sid='))?.split(';')[0]
|
||||
assert.ok(cookie, 'a new visitor must receive an isolated session')
|
||||
return (url, init = {}) => fetch(url.startsWith('http') ? url : origin + url, {
|
||||
...init, headers: { ...init.headers, Cookie: cookie },
|
||||
})
|
||||
}
|
||||
const prefix = '/app/filebrowser/api'
|
||||
const path = p => p.split('/').map(encodeURIComponent).join('/')
|
||||
const begin = (client, name, size) => client(`${prefix}/tus${path(name)}`, {
|
||||
method: 'POST', headers: { 'Upload-Length': String(size), 'Tus-Resumable': '1.0.0' },
|
||||
})
|
||||
const patch = (client, name, offset, body) => client(`${prefix}/tus${path(name)}`, {
|
||||
method: 'PATCH', headers: { 'Upload-Offset': String(offset), 'Tus-Resumable': '1.0.0',
|
||||
'Content-Type': 'application/offset+octet-stream' }, body,
|
||||
})
|
||||
test('real client resumes lost chunk/rename replies; exact bytes, encoded name and visitor isolation', async () => {
|
||||
const alice = await visitor(), bob = await visitor()
|
||||
const data = randomBytes(5 * 1024 * 1024 + 123)
|
||||
const name = 'literal %2F + ? # é.bin'
|
||||
let droppedChunk = false, droppedRename = false, last = 0
|
||||
const transport = async (url, init) => {
|
||||
const response = await alice(url, init)
|
||||
if (init?.method === 'PATCH' && url.includes('/tus/') && !droppedChunk) {
|
||||
assert.equal(response.status, 204)
|
||||
droppedChunk = true
|
||||
throw new TypeError('simulated lost chunk reply')
|
||||
}
|
||||
if (init?.method === 'PATCH' && url.includes('action=rename') && !droppedRename) {
|
||||
assert.equal(response.status, 200)
|
||||
droppedRename = true
|
||||
throw new TypeError('simulated lost rename reply')
|
||||
}
|
||||
return response
|
||||
}
|
||||
await resumableUpload(`${origin}/app/filebrowser`, '/Documents', new File([data], name), transport,
|
||||
{ signal: new AbortController().signal, onProgress: value => { last = value } })
|
||||
assert.ok(droppedChunk && droppedRename)
|
||||
assert.equal(last, data.length)
|
||||
const url = `${prefix}/resources${path(`/Documents/${name}`)}?checksum=sha256`
|
||||
const saved = await (await alice(url)).json()
|
||||
assert.equal(saved.size, data.length)
|
||||
assert.equal(saved.checksums.sha256, createHash('sha256').update(data).digest('hex'))
|
||||
assert.deepEqual(Buffer.from(await (await alice(`${prefix}/raw${path(`/Documents/${name}`)}`)).arrayBuffer()), data)
|
||||
assert.equal((await bob(url)).status, 404)
|
||||
assert.equal((await bob(`${prefix}/raw${path(`/Documents/${name}`)}`)).status, 404)
|
||||
const listed = await (await alice(`${prefix}/resources/Documents`)).json()
|
||||
assert.ok(listed.items.some(e => e.name === name))
|
||||
assert.ok(!listed.items.some(e => e.name.startsWith('.archy-upload-')))
|
||||
})
|
||||
test('zero bytes, replacement and cancellation leave only completed destinations', async () => {
|
||||
const client = await visitor()
|
||||
for (const bytes of [Buffer.from('old'), Buffer.alloc(0), Buffer.from('new')]) {
|
||||
await resumableUpload(`${origin}/app/filebrowser`, '/Documents', new File([bytes], 'replace.bin'), client,
|
||||
{ signal: new AbortController().signal, onProgress() {} })
|
||||
assert.deepEqual(Buffer.from(await (await client(`${prefix}/raw/Documents/replace.bin`)).arrayBuffer()), bytes)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
await assert.rejects(resumableUpload(`${origin}/app/filebrowser`, '/Documents',
|
||||
new File([randomBytes(3 * 1024 * 1024)], 'cancel.bin'), client, {
|
||||
signal: controller.signal, onProgress(n) { if (n > 0) controller.abort() },
|
||||
}), { name: 'AbortError' })
|
||||
const listed = await (await client(`${prefix}/resources/Documents`)).json()
|
||||
assert.ok(!listed.items.some(e => e.name.startsWith('.archy-upload-') || e.name === 'cancel.bin'))
|
||||
assert.equal((await begin(client, '/Documents/quota.part', 7 * 1024 * 1024)).status, 201)
|
||||
})
|
||||
test('quota includes simultaneous reservations; stale offsets and oversized chunks preserve committed bytes', async () => {
|
||||
const client = await visitor()
|
||||
// Deleting a seeded file must not make the byte counter negative.
|
||||
assert.equal((await client(`${prefix}/resources/Documents/bitcoin-whitepaper-notes.md`, { method: 'DELETE' })).status, 200)
|
||||
assert.equal((await begin(client, '/Documents/a.part', 6 * 1024 * 1024)).status, 201)
|
||||
assert.equal((await begin(client, '/Documents/b.part', 3 * 1024 * 1024)).status, 507)
|
||||
assert.equal((await patch(client, '/Documents/a.part', 1, Buffer.from('bad'))).status, 409)
|
||||
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.alloc(2 * 1024 * 1024 + 1))).status, 413)
|
||||
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '0')
|
||||
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.from('abc'))).status, 204)
|
||||
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.from('abc'))).status, 409)
|
||||
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '3')
|
||||
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'DELETE' })).status, 204)
|
||||
assert.equal((await begin(client, '/Documents/b.part', 8 * 1024 * 1024)).status, 201)
|
||||
})
|
||||
test('interrupted chunk resumes from committed offset; deleting a folder releases pending reservations', async () => {
|
||||
const response = await fetch(`${origin}/health`)
|
||||
const cookie = response.headers.getSetCookie().find(v => v.startsWith('demo_sid='))?.split(';')[0]
|
||||
const client = (url, init = {}) => fetch(origin + url, { ...init, headers: { ...init.headers, Cookie: cookie } })
|
||||
await client(`${prefix}/resources/Temporary/`, { method: 'POST' })
|
||||
assert.equal((await begin(client, '/Temporary/a.part', 6)).status, 201)
|
||||
const request = http.request(`${origin}${prefix}/tus/Temporary/a.part`, { method: 'PATCH', headers: {
|
||||
Cookie: cookie, 'Upload-Offset': '0', 'Tus-Resumable': '1.0.0',
|
||||
'Content-Type': 'application/offset+octet-stream', 'Content-Length': '6',
|
||||
} })
|
||||
request.on('error', () => {})
|
||||
request.write('abc')
|
||||
// HEAD can run while the chunk is incomplete, but must not report those bytes
|
||||
// committed. Abort the real TCP request, then resume the exact same offset.
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
assert.equal((await client(`${prefix}/tus/Temporary/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '0')
|
||||
const closed = new Promise(resolve => request.once('close', resolve))
|
||||
request.destroy()
|
||||
await closed
|
||||
let result
|
||||
for (let n = 0; n < 20; n++) {
|
||||
result = await patch(client, '/Temporary/a.part', 0, Buffer.from('abcdef'))
|
||||
if (result.status !== 409) break
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
assert.equal(result.status, 204)
|
||||
assert.equal(await (await client(`${prefix}/raw/Temporary/a.part`)).text(), 'abcdef')
|
||||
assert.equal((await begin(client, '/Temporary/b.part', 7 * 1024 * 1024)).status, 201)
|
||||
await client(`${prefix}/resources/Temporary`, { method: 'DELETE' })
|
||||
assert.equal((await client(`${prefix}/tus/Temporary/b.part`, { method: 'HEAD' })).status, 404)
|
||||
assert.equal((await begin(client, '/Documents/c.part', 8 * 1024 * 1024)).status, 201)
|
||||
})
|
||||
@@ -96,7 +96,8 @@ console.log(`LIVE: booting mock-backend.js on :${port}`)
|
||||
|
||||
const child = spawn(process.execPath, [MOCK], {
|
||||
cwd: UI_ROOT,
|
||||
env: { ...process.env, MOCK_BACKEND_PORT: String(port) },
|
||||
// Never permit this regression harness to contact the live container runtime.
|
||||
env: { ...process.env, DEMO: '1', MOCK_BACKEND_HOST: '127.0.0.1', MOCK_BACKEND_PORT: String(port) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.resume()
|
||||
@@ -108,12 +109,15 @@ child.on('exit', () => {
|
||||
})
|
||||
|
||||
const base = `http://127.0.0.1:${port}`
|
||||
let cookie = ''
|
||||
const rpc = async (method, params) => {
|
||||
const resp = await fetch(`${base}/rpc/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: { 'content-type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
})
|
||||
const session = resp.headers.getSetCookie().find(value => value.startsWith('demo_sid='))
|
||||
if (session) cookie = session.split(';')[0]
|
||||
if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`)
|
||||
const body = await resp.json()
|
||||
if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`)
|
||||
@@ -122,19 +126,26 @@ const rpc = async (method, params) => {
|
||||
|
||||
try {
|
||||
// Poll until the server answers (bounded).
|
||||
const deadline = Date.now() + 10000
|
||||
const deadline = Date.now() + 60000
|
||||
for (;;) {
|
||||
if (childExited) throw new Error('mock-backend exited before becoming ready')
|
||||
try {
|
||||
await rpc('server.echo', { message: 'parity-probe' })
|
||||
break
|
||||
} catch {
|
||||
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 10s')
|
||||
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 60s')
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
pass('mock-backend answered on the ephemeral port')
|
||||
|
||||
const radio = await rpc('mesh.rnode-config')
|
||||
if (radio.live === null && radio.live_error.includes('demo')) pass('radio settings report unavailable demo hardware')
|
||||
else fail('demo radio status implies real hardware')
|
||||
const applied = await rpc('mesh.rnode-config-apply', { settings: radio.settings })
|
||||
if (applied.applied === false && applied.confirmed === false && applied.message.includes('demo')) pass('demo never claims a radio configuration was applied')
|
||||
else fail('demo radio apply claims hardware success')
|
||||
|
||||
// 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks).
|
||||
const peers = await rpc('mesh.contacts-list')
|
||||
const target = peers.contacts.find((c) => c.pubkey)
|
||||
|
||||
Reference in New Issue
Block a user