fix: support resumable Cloud uploads in the public demo

This commit is contained in:
archipelago
2026-10-05 14:41:28 -04:00
parent 5aa74d0513
commit b8266c2872
6 changed files with 367 additions and 27 deletions
+26
View File
@@ -559,3 +559,29 @@ existing-signer verification. Dev serving is verified byte-for-byte with SHA256
The operator reports “works, we can proceed” after the phone save/open/cancel The operator reports “works, we can proceed” after the phone save/open/cancel
check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads; check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads;
only the dev-box test download is updated now. No fleet/public release is claimed. only the dev-box test download is updated now. No fleet/public release is claimed.
### Demo upload compatibility — 2026-10-05
The prior demo backend lacked TUS, returned folder metadata for completed files,
and only accepted JSON-body renames. Added the actual Cloud upload protocol with
per-visitor sessions, reserved-byte quota, committed offsets, checksum metadata,
query-based rename and partial cancellation. Literal encoded filenames survive
unchanged. Deleting seeded files cannot subtract uncharged bytes from the quota;
deleting a folder releases its pending upload reservations.
Four real HTTP scenarios run the production `resumableUpload` client against an
isolated demo process and pass: lost chunk/save responses with exact5MiB+123bytes,
visitor isolation, zero-byte/replacement/cancellation, simultaneous quota, stale
offsets/oversized chunks and a real interrupted TCP request. The first run's10s
cold-start limit failed before startup; the bounded60s run passes. Evidence:
`/tmp/archy-190-demo-upload-final-2.log`.
The existing mesh/federation parity harness initially discarded demo cookies,
creating a new visitor on every request. It now retains its session and always
runs demo-only on loopback, never against the live container runtime. It also
found two newly missing radio configuration handlers. Demo now explicitly reports
hardware unavailable and refuses to claim an applied radio configuration. The
full parity run passes, including those assertions:
`/tmp/archy-190-demo-rpc-parity-final-2.log`. Both checks are release harness stages.
These changes are locally qualified; the public demo remains unchanged pending
final release, fresh AIUI packaging and deployed browser acceptance.
+86
View File
@@ -0,0 +1,86 @@
// In-memory File Browser TUS subset used by the public demo. Each visitor's
// reservations and bytes belong to their existing isolated, expiring session.
export function installDemoUploads(app, { currentStore, quota, normalize, parent, base, type }) {
const route = '/app/filebrowser/api/tus/*'
const number = value => typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null
const locate = req => normalize(req.params[0] || '')
const headers = (res, upload) => res.set({
'Tus-Resumable': '1.0.0', 'Upload-Offset': String(upload.offset),
'Upload-Length': String(upload.length), 'Cache-Control': 'no-store',
})
const complete = (files, name, upload) => {
const data = Buffer.concat(upload.chunks, upload.length)
files.contents[name] = data
files.tree[parent(name)].push({ name: base(name), path: name, size: data.length,
modified: new Date().toISOString(), isDir: false, type: type(base(name)) })
files.bytes += data.length
files.reserved -= upload.length
files.uploaded.add(name)
files.uploads.delete(name)
}
app.head(route, (req, res) => {
const upload = currentStore().files.uploads.get(locate(req))
if (!upload) return res.sendStatus(404)
headers(res, upload).status(200).end()
})
app.post(route, (req, res) => {
const name = locate(req)
const files = currentStore().files
const length = number(req.get('Upload-Length'))
if (req.get('Tus-Resumable') !== '1.0.0' || length === null || name === '/' ||
name.split('/').some(p => p === '.' || p === '..' || p.includes('\0'))) return res.sendStatus(400)
if (!files.tree[parent(name)]) return res.sendStatus(404)
if (files.uploads.has(name) || files.tree[parent(name)].some(e => e.path === name)) return res.sendStatus(409)
// Reserve the whole declared size, so concurrent uploads cannot evade quota.
// Bound empty/abandoned session entries independently of their byte length.
if (files.bytes + files.reserved + length > quota || files.uploads.size >= 64) return res.sendStatus(507)
const upload = { length, offset: 0, chunks: [], writing: false }
files.uploads.set(name, upload)
files.reserved += length
if (!length) complete(files, name, upload)
headers(res, upload).location(req.path).status(201).end()
})
app.patch(route, async (req, res) => {
const name = locate(req)
const files = currentStore().files
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
if (req.get('Tus-Resumable') !== '1.0.0') return res.sendStatus(412)
if (req.get('Content-Type') !== 'application/offset+octet-stream') return res.sendStatus(415)
if (upload.writing || number(req.get('Upload-Offset')) !== upload.offset) return headers(res, upload).status(409).end()
upload.writing = true
const chunks = []
let size = 0
try {
for await (const chunk of req) {
size += chunk.length
if (size > 2 * 1024 * 1024 || size > upload.length - upload.offset) {
res.status(413).end()
return
}
chunks.push(chunk)
}
// A simultaneous cancellation must never resurrect its staging file.
if (files.uploads.get(name) !== upload) return res.sendStatus(404)
upload.chunks.push(...chunks)
upload.offset += size
if (upload.offset === upload.length) complete(files, name, upload)
headers(res, upload).status(204).end()
} catch {
// An interrupted chunk does not advance the committed offset. HEAD tells
// the reconnecting client exactly where to resume.
if (!res.headersSent && !req.destroyed) res.sendStatus(400)
} finally {
upload.writing = false
}
})
app.delete(route, (req, res) => {
const files = currentStore().files
const name = locate(req)
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
files.reserved -= upload.length
files.uploads.delete(name)
res.status(204).end()
})
}
+78 -23
View File
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'url'
import Docker from 'dockerode' import Docker from 'dockerode'
import { AsyncLocalStorage } from 'node:async_hooks' import { AsyncLocalStorage } from 'node:async_hooks'
import crypto from 'crypto' import crypto from 'crypto'
import { installDemoUploads } from './demo-uploads.js'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
@@ -209,7 +210,8 @@ const corsOptions = {
app.use(cors(corsOptions)) app.use(cors(corsOptions))
// Skip JSON body parsing for filebrowser upload routes (binary file bodies) // Skip JSON body parsing for filebrowser upload routes (binary file bodies)
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') { if ((req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') ||
(req.path.startsWith('/app/filebrowser/api/tus') && req.method === 'PATCH')) {
return next() return next()
} }
express.json({ limit: '50mb' })(req, res, next) express.json({ limit: '50mb' })(req, res, next)
@@ -3246,6 +3248,19 @@ app.post('/rpc/v1', (req, res) => {
// ===================================================================== // =====================================================================
// Mesh Networking (LoRa radio via Meshcore) // Mesh Networking (LoRa radio via Meshcore)
// ===================================================================== // =====================================================================
case 'mesh.rnode-config': {
return res.json({ result: {
settings: { enabled: true, port: null, frequency: 869525000,
bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17,
airtime_limit_short: null, airtime_limit_long: null },
live: null, live_error: 'Radio hardware is unavailable in this demo.',
} })
}
case 'mesh.rnode-config-apply': {
// Never make a hardware claim or execute a radio command in the demo.
return res.json({ result: { applied: false, confirmed: false, live: null,
message: 'Radio settings cannot be applied in this demo. Connect to your own node to configure its radio.' } })
}
case 'mesh.status': { case 'mesh.status': {
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
// Stateful enable/disable so the dev UI can demo the global // Stateful enable/disable so the dev UI can demo the global
@@ -5242,7 +5257,8 @@ const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 *
function fbNormalize(raw) { function fbNormalize(raw) {
// → leading slash, no trailing slash (root stays '/') // → leading slash, no trailing slash (root stays '/')
const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/') // Express already decodes path parameters. Preserve literal %, + and ? names.
const p = '/' + (raw || '').split('/').filter(Boolean).join('/')
return p === '/' ? '/' : p.replace(/\/+$/, '') return p === '/' ? '/' : p.replace(/\/+$/, '')
} }
function fbParent(p) { function fbParent(p) {
@@ -5275,11 +5291,18 @@ function fbListResponse(res, items) {
}) })
} }
installDemoUploads(app, { currentStore, quota: FB_QUOTA_BYTES, normalize: fbNormalize, parent: fbParent, base: fbBase, type: fbType })
// FileBrowser list resources (root: /api/resources or /api/resources/) // FileBrowser list resources (root: /api/resources or /api/resources/)
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => { app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
const dir = fbNormalize(req.params[0] || '') const dir = fbNormalize(req.params[0] || '')
const items = currentStore().files.tree[dir] || [] const { tree, contents } = currentStore().files
fbListResponse(res, items) if (tree[dir]) return fbListResponse(res, tree[dir])
const entry = (tree[fbParent(dir)] || []).find(e => e.path === dir)
if (!entry) return res.sendStatus(404)
const checksums = req.query.checksum === 'sha256' && contents[dir] !== undefined
? { sha256: crypto.createHash('sha256').update(contents[dir]).digest('hex') } : undefined
res.json({ ...entry, checksums })
}) })
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder) // FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
@@ -5301,33 +5324,38 @@ app.post('/app/filebrowser/api/resources/*', (req, res) => {
return res.sendStatus(200) return res.sendStatus(200)
} }
// File upload — collect body with a quota guard. // Reserve incoming bytes immediately, including concurrent legacy/TUS writes.
const chunks = [] const chunks = []
let size = 0 let size = 0
let aborted = false let aborted = false
const release = () => { store.files.reserved -= size; size = 0 }
req.on('data', (c) => { req.on('data', (c) => {
size += c.length if (aborted) return
if (store.files.bytes + size > FB_QUOTA_BYTES) { if (store.files.bytes + store.files.reserved + c.length > FB_QUOTA_BYTES) {
aborted = true aborted = true
req.destroy() release()
res.status(507).send('Demo storage quota exceeded')
return return
} }
size += c.length
store.files.reserved += c.length
chunks.push(c) chunks.push(c)
}) })
req.on('end', () => { req.on('end', () => {
if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)') if (aborted) return
const buf = Buffer.concat(chunks) const buf = Buffer.concat(chunks)
// Replace existing entry of the same name (override=true). release()
const existing = tree[parent].find(e => e.name === name && !e.isDir) if (!tree[parent] || store.files.uploads.has(full) || tree[parent].some(e => e.name === name && e.isDir)) return res.sendStatus(409)
if (existing) store.files.bytes -= existing.size if (store.files.uploaded.has(full)) store.files.bytes -= Buffer.byteLength(contents[full])
tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir)) tree[parent] = tree[parent].filter(e => e.name !== name)
const type = fbType(name) tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type: fbType(name) })
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type }) contents[full] = buf
contents[full] = type === 'text' ? buf.toString('utf-8') : buf
store.files.bytes += buf.length store.files.bytes += buf.length
store.files.uploaded.add(full)
res.sendStatus(200) res.sendStatus(200)
}) })
req.on('error', () => { if (!res.headersSent) res.sendStatus(400) }) req.on('aborted', () => { aborted = true; release() })
req.on('error', () => { aborted = true; release(); if (!res.headersSent) res.sendStatus(400) })
}) })
// FileBrowser delete (file or folder + its subtree) // FileBrowser delete (file or folder + its subtree)
@@ -5337,10 +5365,16 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
const full = fbNormalize(req.params[0] || '') const full = fbNormalize(req.params[0] || '')
const parent = fbParent(full) const parent = fbParent(full)
if (tree[parent]) { if (tree[parent]) {
const entry = tree[parent].find(e => e.path === full) if (store.files.uploaded.delete(full)) store.files.bytes -= Buffer.byteLength(contents[full])
if (entry && !entry.isDir) store.files.bytes -= entry.size || 0
tree[parent] = tree[parent].filter(e => e.path !== full) tree[parent] = tree[parent].filter(e => e.path !== full)
} }
// Cancel unfinished sessions in the deleted subtree too.
for (const [name, upload] of store.files.uploads) {
if (name === full || name.startsWith(full + '/')) {
store.files.reserved -= upload.length
store.files.uploads.delete(name)
}
}
// Recursively drop a directory's children. // Recursively drop a directory's children.
if (tree[full]) { if (tree[full]) {
const stack = [full] const stack = [full]
@@ -5348,7 +5382,10 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
const d = stack.pop() const d = stack.pop()
for (const e of tree[d] || []) { for (const e of tree[d] || []) {
if (e.isDir) stack.push(e.path) if (e.isDir) stack.push(e.path)
else { store.files.bytes -= e.size || 0; delete contents[e.path] } else {
if (store.files.uploaded.delete(e.path)) store.files.bytes -= Buffer.byteLength(contents[e.path])
delete contents[e.path]
}
} }
delete tree[d] delete tree[d]
} }
@@ -5362,11 +5399,29 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => {
const store = currentStore() const store = currentStore()
const { tree, contents } = store.files const { tree, contents } = store.files
const full = fbNormalize(req.params[0] || '') const full = fbNormalize(req.params[0] || '')
const dest = fbNormalize((req.body && req.body.destination) || '') let destination = req.body?.destination || ''
if (req.query.action === 'rename') {
try { destination = decodeURIComponent(String(req.query.destination || '')) }
catch { return res.sendStatus(400) }
}
const dest = fbNormalize(destination)
if (!dest || dest === '/') return res.sendStatus(400) if (!dest || dest === '/') return res.sendStatus(400)
const parent = fbParent(full) const parent = fbParent(full)
const entry = (tree[parent] || []).find(e => e.path === full) const entry = (tree[parent] || []).find(e => e.path === full)
if (!entry) return res.sendStatus(404) if (!entry) return res.sendStatus(404)
const newParent = fbParent(dest)
if (!tree[newParent]) return res.sendStatus(404)
if (dest === full) return res.sendStatus(200)
const replaced = tree[newParent].find(e => e.path === dest)
if (replaced && (replaced.isDir || req.query.override !== 'true')) return res.sendStatus(409)
if (store.files.uploaded.has(dest)) {
store.files.bytes -= Buffer.byteLength(contents[dest])
store.files.uploaded.delete(dest)
}
tree[parent] = tree[parent].filter(e => e !== entry)
tree[newParent] = tree[newParent].filter(e => e.path !== dest)
tree[newParent].push(entry)
if (store.files.uploaded.delete(full)) store.files.uploaded.add(dest)
const newName = fbBase(dest) const newName = fbBase(dest)
entry.name = newName entry.name = newName
entry.path = dest entry.path = dest
@@ -6071,7 +6126,7 @@ function makeSessionStore() {
userState: seedUserState(), userState: seedUserState(),
mockState: seedMockState(), mockState: seedMockState(),
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY), bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 }, files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0, reserved: 0, uploads: new Map(), uploaded: new Set() },
// Mesh chat mutable state: messages sent this session + attachment bytes // Mesh chat mutable state: messages sent this session + attachment bytes
// (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo // (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo
// visitor's uploads are never visible to another. // visitor's uploads are never visible to another.
@@ -6348,7 +6403,7 @@ async function hydrateRealTestnetTxids() {
} catch { /* offline — keep mock hashes */ } } catch { /* offline — keep mock hashes */ }
} }
server.listen(PORT, '0.0.0.0', async () => { server.listen(PORT, process.env.MOCK_BACKEND_HOST || '0.0.0.0', async () => {
const runtime = await isContainerRuntimeAvailable() const runtime = await isContainerRuntimeAvailable()
// Initialize package data from Docker // Initialize package data from Docker
+159
View File
@@ -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)
})
+15 -4
View File
@@ -96,7 +96,8 @@ console.log(`LIVE: booting mock-backend.js on :${port}`)
const child = spawn(process.execPath, [MOCK], { const child = spawn(process.execPath, [MOCK], {
cwd: UI_ROOT, 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'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
child.stdout.resume() child.stdout.resume()
@@ -108,12 +109,15 @@ child.on('exit', () => {
}) })
const base = `http://127.0.0.1:${port}` const base = `http://127.0.0.1:${port}`
let cookie = ''
const rpc = async (method, params) => { const rpc = async (method, params) => {
const resp = await fetch(`${base}/rpc/v1`, { const resp = await fetch(`${base}/rpc/v1`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json', Cookie: cookie },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), 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}`) if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`)
const body = await resp.json() const body = await resp.json()
if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`) 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 { try {
// Poll until the server answers (bounded). // Poll until the server answers (bounded).
const deadline = Date.now() + 10000 const deadline = Date.now() + 60000
for (;;) { for (;;) {
if (childExited) throw new Error('mock-backend exited before becoming ready') if (childExited) throw new Error('mock-backend exited before becoming ready')
try { try {
await rpc('server.echo', { message: 'parity-probe' }) await rpc('server.echo', { message: 'parity-probe' })
break break
} catch { } 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)) await new Promise((r) => setTimeout(r, 200))
} }
} }
pass('mock-backend answered on the ephemeral port') 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). // 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks).
const peers = await rpc('mesh.contacts-list') const peers = await rpc('mesh.contacts-list')
const target = peers.contacts.find((c) => c.pubkey) const target = peers.contacts.find((c) => c.pubkey)
+3
View File
@@ -72,6 +72,9 @@ summary() {
stage "git-diff-check" git diff --check stage "git-diff-check" git diff --check
stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py
stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py
stage "demo-rpc-parity" timeout 120 node neode-ui/scripts/mock-rpc-parity.mjs
stage "demo-resumable-uploads" timeout 120 node --test neode-ui/scripts/demo-upload-test.mjs
stage "companion-signature-regression" python3 scripts/tests/test_companion_apk_verification.py
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py stage "manifest-shell" python3 scripts/check-manifest-shell.py