fix: support resumable Cloud uploads in the public demo
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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], {
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user