From b8266c2872f6b48c0a9eb0b8a516fc44ca678541 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 14:41:28 -0400 Subject: [PATCH] fix: support resumable Cloud uploads in the public demo --- docs/release-1.9.0-acceptance.md | 26 +++++ neode-ui/demo-uploads.js | 86 ++++++++++++++ neode-ui/mock-backend.js | 101 ++++++++++++---- neode-ui/scripts/demo-upload-test.mjs | 159 ++++++++++++++++++++++++++ neode-ui/scripts/mock-rpc-parity.mjs | 19 ++- tests/release/run.sh | 3 + 6 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 neode-ui/demo-uploads.js create mode 100644 neode-ui/scripts/demo-upload-test.mjs diff --git a/docs/release-1.9.0-acceptance.md b/docs/release-1.9.0-acceptance.md index aa0d7267..210f3fee 100644 --- a/docs/release-1.9.0-acceptance.md +++ b/docs/release-1.9.0-acceptance.md @@ -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 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. + +### 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. diff --git a/neode-ui/demo-uploads.js b/neode-ui/demo-uploads.js new file mode 100644 index 00000000..680414e5 --- /dev/null +++ b/neode-ui/demo-uploads.js @@ -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() + }) +} diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 0efb1afd..7628123e 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -19,6 +19,7 @@ import { fileURLToPath } from 'url' import Docker from 'dockerode' import { AsyncLocalStorage } from 'node:async_hooks' import crypto from 'crypto' +import { installDemoUploads } from './demo-uploads.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -209,7 +210,8 @@ const corsOptions = { app.use(cors(corsOptions)) // Skip JSON body parsing for filebrowser upload routes (binary file bodies) 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() } express.json({ limit: '50mb' })(req, res, next) @@ -3246,6 +3248,19 @@ app.post('/rpc/v1', (req, res) => { // ===================================================================== // 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': { globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } // 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) { // → 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(/\/+$/, '') } 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/) app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => { const dir = fbNormalize(req.params[0] || '') - const items = currentStore().files.tree[dir] || [] - fbListResponse(res, items) + const { tree, contents } = currentStore().files + 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) @@ -5301,33 +5324,38 @@ app.post('/app/filebrowser/api/resources/*', (req, res) => { return res.sendStatus(200) } - // File upload — collect body with a quota guard. + // Reserve incoming bytes immediately, including concurrent legacy/TUS writes. const chunks = [] let size = 0 let aborted = false + const release = () => { store.files.reserved -= size; size = 0 } req.on('data', (c) => { - size += c.length - if (store.files.bytes + size > FB_QUOTA_BYTES) { + if (aborted) return + if (store.files.bytes + store.files.reserved + c.length > FB_QUOTA_BYTES) { aborted = true - req.destroy() + release() + res.status(507).send('Demo storage quota exceeded') return } + size += c.length + store.files.reserved += c.length chunks.push(c) }) req.on('end', () => { - if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)') + if (aborted) return const buf = Buffer.concat(chunks) - // Replace existing entry of the same name (override=true). - const existing = tree[parent].find(e => e.name === name && !e.isDir) - if (existing) store.files.bytes -= existing.size - tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir)) - const type = fbType(name) - tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type }) - contents[full] = type === 'text' ? buf.toString('utf-8') : buf + release() + if (!tree[parent] || store.files.uploads.has(full) || tree[parent].some(e => e.name === name && e.isDir)) return res.sendStatus(409) + if (store.files.uploaded.has(full)) store.files.bytes -= Buffer.byteLength(contents[full]) + tree[parent] = tree[parent].filter(e => e.name !== name) + tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type: fbType(name) }) + contents[full] = buf store.files.bytes += buf.length + store.files.uploaded.add(full) 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) @@ -5337,10 +5365,16 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => { const full = fbNormalize(req.params[0] || '') const parent = fbParent(full) if (tree[parent]) { - const entry = tree[parent].find(e => e.path === full) - if (entry && !entry.isDir) store.files.bytes -= entry.size || 0 + if (store.files.uploaded.delete(full)) store.files.bytes -= Buffer.byteLength(contents[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. if (tree[full]) { const stack = [full] @@ -5348,7 +5382,10 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => { const d = stack.pop() for (const e of tree[d] || []) { 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] } @@ -5362,11 +5399,29 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => { const store = currentStore() const { tree, contents } = store.files 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) const parent = fbParent(full) const entry = (tree[parent] || []).find(e => e.path === full) 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) entry.name = newName entry.path = dest @@ -6071,7 +6126,7 @@ function makeSessionStore() { userState: seedUserState(), mockState: seedMockState(), 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 // (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo // visitor's uploads are never visible to another. @@ -6348,7 +6403,7 @@ async function hydrateRealTestnetTxids() { } 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() // Initialize package data from Docker diff --git a/neode-ui/scripts/demo-upload-test.mjs b/neode-ui/scripts/demo-upload-test.mjs new file mode 100644 index 00000000..1daeb73b --- /dev/null +++ b/neode-ui/scripts/demo-upload-test.mjs @@ -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) +}) diff --git a/neode-ui/scripts/mock-rpc-parity.mjs b/neode-ui/scripts/mock-rpc-parity.mjs index d09b47bc..67ba62db 100644 --- a/neode-ui/scripts/mock-rpc-parity.mjs +++ b/neode-ui/scripts/mock-rpc-parity.mjs @@ -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) diff --git a/tests/release/run.sh b/tests/release/run.sh index 19424a1e..49211ab0 100755 --- a/tests/release/run.sh +++ b/tests/release/run.sh @@ -72,6 +72,9 @@ summary() { stage "git-diff-check" git diff --check 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 "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 "app-build-contexts" python3 tests/regression/app-build-contexts.py stage "manifest-shell" python3 scripts/check-manifest-shell.py