Move regression tests to private repository
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 06:55:29 +02:00
parent f88cfd73e6
commit 2810ff8d98
78 changed files with 11 additions and 24101 deletions
+2
View File
@@ -1,4 +1,6 @@
node_modules/
/tests/
/services/backup-api/test/
.dev-runner.lock
release/
.artifacts/
+2 -3
View File
@@ -11,6 +11,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows zum Hochlade
- Version `2.1.50` enthält verbesserte Sicherungsmenüs, getrennte Online-Backup-Bereiche, einheitliche Automatik- und Log-Einstellungen sowie korrigierte Such- und Update-Anzeigen.
- Ordnerüberwachung lässt sich unabhängig von ihrem Aktivierungszustand mit gespeicherten Regeln schreibgeschützt testen. Testscans starten keine Uploads und verändern keine laufende Überwachung.
- Nach dem Release wurden ausschließlich Wartebedingungen zweier Electron-Tests korrigiert. Die Anwendungsversion bleibt unverändert.
- Regressionstests sind in das private Repository `Multi-Hoster-Upload-Tests` ausgelagert. Beide privaten Remotes enthalten Desktop- und Backup-API-Tests. Öffentliche CI führt nur Lint, Abhängigkeitsprüfung und Build aus. Historische Commits und Tags bleiben unverändert.
## Entscheidungen
@@ -43,14 +44,12 @@ Bei Problemen mit Shell-Sonderzeichen im Projektpfad können die Prüfungen dire
```powershell
node node_modules/eslint/bin/eslint.js .
$testFiles = @((Get-ChildItem tests -Filter '*.test.js').FullName) + (Resolve-Path tests/ui-smoke.js).Path
node --test @testFiles
node --test services/backup-api/test/server.test.mjs
npm audit --omit=dev
```
## Bekannte Probleme und nächste Schritte
- Nach jedem App-Push die private Testsuite mit dem vollständigen App-Commit starten: `gh workflow run tests.yml --repo Sucukdeluxe/Multi-Hoster-Upload-Tests -f source_ref=<Commit>`. Ein erfolgreicher öffentlicher Build ersetzt diesen Testlauf nicht. Lokale Ausführung ist in der privaten README dokumentiert.
- Die DoodStream-Web-Upload-Serverermittlung ist weiterhin gesondert zu prüfen. Ein erfolgreicher Accountcheck bestätigt keinen erfolgreichen Dateitransfer.
- Der optionale Langzeit-UI-Smoke hat bekannte Timing-/Fixture-Abweichungen außerhalb der regulären CI. Diese getrennt untersuchen; reguläre Regressionen nicht überspringen.
- Die CI meldet eine Laufzeit-Abkündigung für die verwendeten Checkout-/Node-Setup-Actions. Ein Versionswechsel ist separat zu prüfen.
+4 -3
View File
@@ -287,13 +287,14 @@ npm install
npm start
```
Run the test suite and lint checks:
Run the public lint and dependency checks:
```powershell
npm test
npm run lint
npm run verify
```
The regression suite is maintained and executed in a separate private repository against an exact application commit. Public CI runs lint, dependency checks and Windows builds; it does not run the private tests. Maintainers must also verify the private regression run before releasing. Historical commits and release tags have not been rewritten.
Create both Windows release targets without publishing them:
```powershell
+1 -3
View File
@@ -6,9 +6,7 @@
"scripts": {
"start": "electron .",
"dev": "node scripts/dev-runner.cjs",
"test": "node --test tests/*.test.js tests/ui-smoke.js",
"test:backup-api": "npm --prefix services/backup-api test",
"verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev",
"verify": "npm run lint && npm audit --omit=dev",
"lint": "eslint .",
"dist": "electron-builder --publish never --win",
"release:win": "electron-builder --publish never --win nsis portable"
+1 -75
View File
@@ -96,78 +96,6 @@ const sourceFiles = [
'services/backup-api/package.json',
'services/backup-api/src/cli.mjs',
'services/backup-api/src/server.mjs',
'services/backup-api/test/server.test.mjs',
'tests/online-backup-recovery.test.js',
'tests/account-auth.test.js',
'tests/account-rotation.test.js',
'tests/account-status.test.js',
'tests/automation-control.test.js',
'tests/auto-resume.test.js',
'tests/backup-crypto.test.js',
'tests/byse-reject-recovery.test.js',
'tests/coalesced-set.test.js',
'tests/config-store.test.js',
'tests/diagnostics-agent.test.js',
'tests/diagnostics-collectors.test.js',
'tests/diagnostics-protocol.test.js',
'tests/dev-runner.test.js',
'tests/doodstream-api-upload.test.js',
'tests/doodstream-upload.test.js',
'tests/file-probe.test.js',
'tests/file-discovery.test.js',
'tests/folder-monitor.test.js',
'tests/history-status.test.js',
'tests/history-retention.test.js',
'tests/import-preflight.test.js',
'tests/hosters.test.js',
'tests/i18n.test.js',
'tests/ip-allowlist.test.js',
'tests/log-mode.test.js',
'tests/log-policy.test.js',
'tests/log-rotation.test.js',
'tests/online-backup-service.test.js',
'tests/online-backup.test.js',
'tests/online-backup-keyring.test.js',
'tests/online-backup-manager.test.js',
'tests/orphan-tmp.test.js',
'tests/package-build-files.test.js',
'tests/public-release-verifier.test.js',
'tests/queue-dedup-property.test.js',
'tests/queue-dedup.test.js',
'tests/queue-persistence-scenario.test.js',
'tests/queue-prune.test.js',
'tests/queue-stats.test.js',
'tests/remote-config.test.js',
'tests/remote-server.test.js',
'tests/semaphore.test.js',
'tests/secret-store.test.js',
'tests/serialized-runner.test.js',
'tests/settings-backup.test.js',
'tests/settings-import-gate.test.js',
'tests/speed-history.test.js',
'tests/source-cleanup-policy.test.js',
'tests/source-delete-journal.test.js',
'tests/source-file-cleanup.test.js',
'tests/startup-renderer.test.js',
'tests/stats.test.js',
'tests/support-bundle.test.js',
'tests/support/ui-network-safety.js',
'tests/suspect-reject-alternates.test.js',
'tests/throttle-timer.test.js',
'tests/throttle.test.js',
'tests/throttled-cache.test.js',
'tests/ui-network-safety.test.js',
'tests/ui-smoke.js',
'tests/updater-version.test.js',
'tests/upload-audit.test.js',
'tests/upload-log.test.js',
'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js',
'tests/upload-manager.test.js',
'tests/vidmoly-upload.test.js',
'tests/session-report.test.js',
'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js'
];
const screenshotFiles = [
'assets/product-overview.png',
@@ -182,9 +110,7 @@ const binaryExtensions = new Set(['.ico', '.png']);
const expectedScripts = {
start: 'electron .',
dev: 'node scripts/dev-runner.cjs',
test: 'node --test tests/*.test.js tests/ui-smoke.js',
'test:backup-api': 'npm --prefix services/backup-api test',
verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev',
verify: 'npm run lint && npm audit --omit=dev',
lint: 'eslint .',
dist: 'electron-builder --publish never --win',
'release:win': 'electron-builder --publish never --win nsis portable'
+1 -2
View File
@@ -4,8 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "node src/cli.mjs",
"test": "node --test"
"start": "node src/cli.mjs"
},
"engines": {
"node": ">=20"
-499
View File
@@ -1,499 +0,0 @@
import assert from 'node:assert/strict'
import { createHash, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createConnection } from 'node:net'
import test from 'node:test'
import lockfile from 'proper-lockfile'
import { createBackupServer } from '../src/server.mjs'
const allowedOrigin = 'https://uploader.24-music.de'
function fixture() {
const deleteSecret = randomBytes(32).toString('base64url')
return {
deleteSecret,
payload: {
id: randomBytes(16).toString('base64url'),
blob: randomBytes(96).toString('base64url'),
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
}
}
}
async function startApi(options = {}) {
const rootDir = await mkdtemp(join(tmpdir(), 'mhu-backup-api-'))
const server = createBackupServer({
rootDir,
allowedOrigins: [allowedOrigin],
rateLimit: { max: 100, windowMs: 60_000 },
...options
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
return {
rootDir,
server,
baseUrl: `http://127.0.0.1:${server.address().port}`,
async close() {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await rm(rootDir, { recursive: true, force: true })
}
}
}
function request(api, path, options = {}) {
return fetch(`${api.baseUrl}${path}`, options)
}
test('health reports readiness without storage details and sends security headers', async (t) => {
const api = await startApi()
t.after(() => api.close())
const response = await request(api, '/health')
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), { status: 'ok' })
assert.equal(response.headers.get('cache-control'), 'no-store')
assert.equal(response.headers.get('x-content-type-options'), 'nosniff')
assert.equal(response.headers.get('content-security-policy'), "default-src 'none'")
})
test('creates immutable ciphertext records and restores them after a restart', async (t) => {
const api = await startApi()
const backup = fixture()
t.after(async () => {
if (api.server.listening) await new Promise((resolve) => api.server.close(resolve))
await rm(api.rootDir, { recursive: true, force: true })
})
const created = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
assert.equal(created.status, 201)
assert.equal((await created.json()).sourceIp, '127.0.0.1')
await new Promise((resolve) => api.server.close(resolve))
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
await new Promise((resolve, reject) => {
api.server.once('error', reject)
api.server.listen(0, '127.0.0.1', resolve)
})
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id })
})
assert.equal(restored.status, 200)
assert.deepEqual(await restored.json(), { blob: backup.payload.blob })
const duplicate = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...backup.payload, blob: randomBytes(96).toString('base64url') })
})
assert.equal(duplicate.status, 409)
})
test('validates payload shape, content type and decoded blob size', async (t) => {
const api = await startApi()
t.after(() => api.close())
const valid = fixture()
const invalid = [
{ ...valid.payload, id: 'short' },
{ ...valid.payload, blob: 'not+base64url' },
{ ...valid.payload, deleteVerifier: 'short' },
{ id: valid.payload.id, blob: valid.payload.blob },
{ ...valid.payload, extra: true },
{ ...valid.payload, expiresInSeconds: 0 },
{ ...valid.payload, expiresInSeconds: 30 * 24 * 60 * 60 },
{ ...valid.payload, expiresInSeconds: '604800' }
]
for (const body of invalid) {
const response = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
})
assert.equal(response.status, 400)
}
const wrongType = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: JSON.stringify(valid.payload)
})
assert.equal(wrongType.status, 415)
const oversized = fixture()
oversized.payload.blob = randomBytes(262_145).toString('base64url')
const tooLarge = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(oversized.payload)
})
assert.equal(tooLarge.status, 413)
})
test('expires finite backups at the exact deadline and removes their ciphertext', async (t) => {
let nowMs = Date.parse('2026-09-01T10:00:00.000Z')
const api = await startApi({ now: () => nowMs })
t.after(() => api.close())
const backup = fixture()
backup.payload.expiresInSeconds = 86_400
const created = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
assert.equal(created.status, 201)
const storedPath = join(api.rootDir, `${backup.payload.id}.json`)
const stored = JSON.parse(await readFile(storedPath, 'utf8'))
assert.equal(stored.version, 4)
assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z')
const beforeDeadline = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id })
})
assert.equal(beforeDeadline.status, 200)
nowMs += 86_400_000
const expired = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id })
})
assert.equal(expired.status, 404)
await assert.rejects(stat(storedPath), error => error.code === 'ENOENT')
})
test('keeps legacy records unlimited and reclaims expired records on the next upload', async (t) => {
let nowMs = Date.parse('2026-09-01T10:00:00.000Z')
const api = await startApi({ now: () => nowMs })
t.after(() => api.close())
const legacy = fixture()
await writeFile(join(api.rootDir, `${legacy.payload.id}.json`), JSON.stringify({
version: 1,
blob: legacy.payload.blob,
deleteVerifier: legacy.payload.deleteVerifier,
createdAt: '2026-01-01T00:00:00.000Z'
}))
const finite = fixture()
finite.payload.expiresInSeconds = 86_400
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(finite.payload)
})
nowMs += 2 * 86_400_000
const replacement = fixture()
replacement.payload.expiresInSeconds = null
assert.equal((await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(replacement.payload)
})).status, 201)
await assert.rejects(stat(join(api.rootDir, `${finite.payload.id}.json`)), error => error.code === 'ENOENT')
assert.equal((await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: legacy.payload.id })
})).status, 200)
assert.equal((await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: replacement.payload.id })
})).status, 200)
})
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
const api = await startApi()
t.after(() => api.close())
const backup = fixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const missing = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
})
const wrong = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
})
assert.equal(missing.status, 404)
assert.equal(wrong.status, 404)
assert.equal(await missing.text(), await wrong.text())
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id, deleteSecret: backup.deleteSecret })
})
assert.equal(deleted.status, 204)
assert.equal((await readdir(api.rootDir)).length, 0)
})
test('allows only configured origins and supports preflight', async (t) => {
const api = await startApi()
t.after(() => api.close())
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
assert.equal(denied.status, 403)
const preflight = await request(api, '/v1/backups', {
method: 'OPTIONS',
headers: {
origin: allowedOrigin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type'
}
})
assert.equal(preflight.status, 204)
})
test('separately rate limits uploads while restores and health remain available', async (t) => {
const api = await startApi({ uploadRateLimit: { max: 1, windowMs: 60_000 } })
t.after(() => api.close())
const first = fixture()
const second = fixture()
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
assert.equal((await create(first)).status, 201)
assert.equal((await create(second)).status, 429)
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: first.payload.id })
})
assert.equal(restored.status, 200)
assert.equal((await request(api, '/health')).status, 200)
})
test('rate limits invalid request bodies before validation', async (t) => {
const api = await startApi({ requestRateLimit: { max: 1, windowMs: 60_000 } })
t.after(() => api.close())
const sendInvalid = () => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invalid: 'x'.repeat(300_000) })
})
assert.equal((await sendInvalid()).status, 400)
assert.equal((await sendInvalid()).status, 429)
})
test('ignores forwarded client addresses from untrusted socket peers', async (t) => {
const api = await startApi({
trustedProxy: true,
trustedProxyAddresses: [],
rateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
const restore = (forwarded) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
body
})
assert.equal((await restore('198.51.100.1')).status, 404)
assert.equal((await restore('198.51.100.2')).status, 429)
const created = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': '198.51.100.3' },
body: JSON.stringify(fixture().payload)
})
assert.equal(created.status, 201)
})
test('uses the last forwarded address from an explicitly trusted proxy', async (t) => {
const api = await startApi({
trustedProxy: true,
trustedProxyAddresses: ['127.0.0.1'],
rateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
const restore = (forwarded) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
body
})
assert.equal((await restore('198.51.100.1, 203.0.113.9')).status, 404)
assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429)
})
test('stores only server-derived source IP and keeps it out of public restore responses', async t => {
for (const [trustedProxy, forwarded, expected] of [
[false, '198.51.100.2', '127.0.0.1'],
[true, '198.51.100.2, 203.0.113.9', '203.0.113.9'],
[true, '2001:db8::42', '2001:db8::42'],
[true, 'invalid', '127.0.0.1']
]) {
const api = await startApi({ trustedProxy, trustedProxyAddresses: ['127.0.0.1'] })
t.after(() => api.close())
const backup = fixture()
const create = payload => request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(payload) })
assert.equal((await create({ ...backup.payload, sourceIp: '192.0.2.1' })).status, 400)
const created = await create(backup.payload)
assert.equal(created.status, 201)
const metadata = await created.json()
assert.equal(metadata.sourceIp, expected)
const file = join(api.rootDir, `${backup.payload.id}.json`)
const stored = JSON.parse(await readFile(file, 'utf8'))
assert.equal(stored.sourceIp, expected)
assert.equal(stored.createdAt, metadata.createdAt)
const restoreRecord = () => request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: backup.payload.id }) })
assert.deepEqual(await (await restoreRecord()).json(), { blob: backup.payload.blob })
delete stored.sourceIp
stored.version = 2
await writeFile(file, JSON.stringify(stored))
assert.equal((await restoreRecord()).status, 200)
}
})
test('keeps concurrency leases until storage mutations finish', async (t) => {
const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 })
t.after(() => api.close())
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: join(api.rootDir, '.storage.lock')
})
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const first = create(fixture())
await new Promise((resolve) => setTimeout(resolve, 40))
const second = create(fixture())
let secondStatus
try {
secondStatus = await Promise.race([
second.then((response) => response.status),
new Promise((resolve) => setTimeout(() => resolve('pending'), 100))
])
} finally {
await release()
}
assert.equal((await first).status, 201)
if (secondStatus === 'pending') await second
assert.equal(secondStatus, 429)
})
test('times out incomplete request bodies', async (t) => {
const api = await startApi({ bodyTimeoutMs: 30 })
t.after(() => api.close())
const response = await new Promise((resolve, reject) => {
const socket = createConnection(new URL(api.baseUrl).port, '127.0.0.1')
let data = ''
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk) => { data += chunk })
socket.on('end', () => resolve(data))
socket.once('connect', () => {
socket.write('POST /v1/backups HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n{')
})
})
assert.match(response, /^HTTP\/1\.1 408 /)
assert.match(response, /request_timeout/)
})
test('caches health readiness instead of writing on every request', async (t) => {
const api = await startApi({ healthCacheMs: 60_000 })
t.after(() => api.close())
assert.equal((await request(api, '/health')).status, 200)
await rm(api.rootDir, { recursive: true, force: true })
assert.equal((await request(api, '/health')).status, 200)
await assert.rejects(stat(api.rootDir), { code: 'ENOENT' })
})
test('cleans orphaned temporary files and enforces a record limit', async (t) => {
const api = await startApi({ maxRecords: 1 })
t.after(() => api.close())
const orphan = join(api.rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
await writeFile(orphan, 'orphan')
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
assert.equal((await create(fixture())).status, 201)
assert.equal((await create(fixture())).status, 507)
const files = await readdir(api.rootDir)
assert.equal(files.some((name) => name.endsWith('.tmp')), false)
assert.equal(files.filter((name) => name.endsWith('.json')).length, 1)
})
test('enforces atomic storage capacity without blocking existing restores', async (t) => {
const api = await startApi({ maxStorageBytes: 420 })
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
await new Promise((resolve, reject) => {
secondServer.once('error', reject)
secondServer.listen(0, '127.0.0.1', resolve)
})
t.after(() => api.close())
t.after(() => new Promise((resolve) => secondServer.close(resolve)))
const first = fixture()
const second = fixture()
const create = (backup, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const results = await Promise.all([create(first), create(second, `http://127.0.0.1:${secondServer.address().port}`)])
assert.deepEqual(results.map((response) => response.status).sort(), [201, 507])
assert.equal((await readdir(api.rootDir)).length, 1)
})
test('never accepts record ids in URLs and stores no client delete secret', async (t) => {
const api = await startApi()
t.after(() => api.close())
const backup = fixture()
assert.equal((await request(api, `/v1/backups/${backup.payload.id}`)).status, 404)
assert.equal((await request(api, `/v1/backups?backup=${backup.payload.id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})).status, 404)
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const files = await readdir(api.rootDir)
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
assert.equal(stored.includes(backup.deleteSecret), false)
})
-177
View File
@@ -1,177 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { createDoodstreamOtpCoordinator, selectUploadAuth } = require('../lib/account-auth');
test('doodstream prefers the API key even when username/password are also set', () => {
const auth = selectUploadAuth('doodstream.com', {
apiKey: 'KEY123', username: 'u', password: 'p'
});
assert.deepEqual(auth, { apiKey: 'KEY123' }); // API path — no username leaks through
});
test('doodstream with only username/password uses web login (keyless fallback)', () => {
const auth = selectUploadAuth('doodstream.com', { username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with empty apiKey + creds falls back to web login (no false API route)', () => {
const auth = selectUploadAuth('doodstream.com', { apiKey: '', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with nothing usable returns empty', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', { apiKey: '', username: '', password: '' }), {});
});
test('voe.sx is unaffected by the doodstream special-case: username/password wins', () => {
// voe also supports both, but the empty-form bug is doodstream-specific; do
// not change voe routing.
const auth = selectUploadAuth('voe.sx', { apiKey: 'VKEY', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('authType=api forces the API key for any hoster', () => {
assert.deepEqual(selectUploadAuth('voe.sx', { authType: 'api', apiKey: 'K', username: 'u', password: 'p' }), { apiKey: 'K' });
});
test('api-key-only account (no creds) uses the key', () => {
assert.deepEqual(selectUploadAuth('byse.sx', { apiKey: 'BKEY' }), { apiKey: 'BKEY' });
});
test('null / non-object account does not throw', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
});
function otpRequired(message = 'OTP erforderlich') {
const error = new Error(message);
error.otpRequired = true;
return error;
}
test('explicit web login is not overridden by a stored API key', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', { authType: 'login', username: 'user', password: 'secret', apiKey: 'KEY' }), { username: 'user', password: 'secret' });
});
test('upload reuses the confirmed OTP session without another login', async () => {
let logins = 0;
const uploadSession = {};
const coordinator = createDoodstreamOtpCoordinator({
createUploader: () => ({ async login() { logins++; }, cloneSession: () => uploadSession })
});
const health = await coordinator.check({ username: 'user', password: 'secret' });
assert.equal(health.status, 'ok');
assert.equal(await coordinator.acquire({ username: 'user', password: 'secret' }), uploadSession);
assert.equal(logins, 1);
});
test('concurrent and repeated Doodstream checks request only one OTP', async () => {
let loginCalls = 0;
let releaseLogin;
const coordinator = createDoodstreamOtpCoordinator({
createUploader: () => ({
login: () => new Promise((resolve, reject) => {
loginCalls++;
releaseLogin = () => reject(otpRequired('OTP gesendet'));
})
})
});
const first = coordinator.check({ username: 'user', password: 'secret' });
const second = coordinator.check({ username: 'user', password: 'secret' });
assert.equal(loginCalls, 1);
releaseLogin();
assert.deepEqual(await first, { status: 'otp_required', message: 'OTP gesendet' });
assert.deepEqual(await second, { status: 'otp_required', message: 'OTP gesendet' });
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { status: 'otp_required', message: 'OTP gesendet' });
assert.equal(loginCalls, 1);
});
test('Doodstream OTP verification reuses the challenged uploader session', async () => {
let created = 0;
const calls = [];
const coordinator = createDoodstreamOtpCoordinator({
createUploader: () => {
created++;
return {
async login(_username, _password, otp) {
calls.push(otp || '');
if (!otp) throw otpRequired('OTP gesendet');
}
};
}
});
assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required');
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
status: 'ok',
message: 'Login erfolgreich'
});
assert.equal(created, 1);
assert.deepEqual(calls, ['', '123456']);
});
test('a rejected OTP remains pending without requesting another code', async () => {
let created = 0;
let calls = 0;
const coordinator = createDoodstreamOtpCoordinator({
createUploader: () => {
created++;
return {
async login(_username, _password, otp) {
calls++;
if (!otp) throw otpRequired('OTP gesendet');
throw new Error('Code ungültig');
}
};
}
});
await coordinator.check({ username: 'user', password: 'secret' });
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '000000' }), {
status: 'otp_required',
message: 'Code ungültig'
});
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), {
status: 'otp_required',
message: 'Code ungültig'
});
assert.equal(created, 1);
assert.equal(calls, 2);
});
test('explicit OTP resend is rate-limited and starts one fresh session', async () => {
let currentTime = 1000;
let created = 0;
const coordinator = createDoodstreamOtpCoordinator({
now: () => currentTime,
resendCooldownMs: 60000,
createUploader: () => {
created++;
return { login: async () => { throw otpRequired('OTP gesendet'); } };
}
});
await coordinator.check({ username: 'user', password: 'secret' });
await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true });
assert.equal(created, 1);
currentTime += 60000;
await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true });
assert.equal(created, 2);
});
test('expired OTP submission cannot create a replacement challenge implicitly', async () => {
let currentTime = 1000;
let created = 0;
const coordinator = createDoodstreamOtpCoordinator({
now: () => currentTime,
challengeTtlMs: 1000,
createUploader: () => {
created++;
return { login: async () => { throw otpRequired('OTP gesendet'); } };
}
});
await coordinator.check({ username: 'user', password: 'secret' });
currentTime += 1001;
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
status: 'otp_required',
message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.'
});
assert.equal(created, 1);
});
-263
View File
@@ -1,263 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const {
classifyAccountFailure,
createAccountCooldownController,
createAccountPicker,
enabledAccountsFor
} = require('../lib/account-rotation');
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
function picks(pick, hoster, n) { return Array.from({ length: n }, () => { const a = pick(hoster); return a ? a.id : null; }); }
test('rotate OFF: always the first enabled account (primary, unchanged behavior)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a1', 'a1', 'a1']);
});
test('rotate ON, 3 accounts: round-robin per call and wraps around', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 7), ['a1', 'a2', 'a3', 'a1', 'a2', 'a3', 'a1']);
});
test('rotate ON, single enabled account: no-op (length must be > 1 to rotate)', () => {
const hosters = { 'byse.sx': [acc('a1')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a1', 'a1', 'a1']);
});
test('rotate ON skips a disabled account, keeps the rest in order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('rotate ON skips an account without credentials', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { creds: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('no usable account → null (disabled hoster or missing hoster)', () => {
const hosters = { 'byse.sx': [acc('a1', { enabled: false })] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.strictEqual(pick('byse.sx'), null);
assert.strictEqual(pick('voe.sx'), null);
});
test('rotation index is independent per hoster (interleaved calls)', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const settings = { 'byse.sx': { rotateAccounts: true }, 'voe.sx': { rotateAccounts: true } };
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds });
assert.strictEqual(pick('byse.sx').id, 'b1');
assert.strictEqual(pick('voe.sx').id, 'v1');
assert.strictEqual(pick('byse.sx').id, 'b2');
assert.strictEqual(pick('voe.sx').id, 'v2');
assert.strictEqual(pick('byse.sx').id, 'b1');
});
test('rotate ON for byse only: voe still uses its primary', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'voe.sx', 2), ['v1', 'v1']);
assert.deepStrictEqual(picks(pick, 'byse.sx', 2), ['b1', 'b2']);
});
test('user scenario: 100 files across 2 active accounts → even 50/50 alternating split', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
const ids = picks(pick, 'byse.sx', 100);
assert.strictEqual(ids.filter(x => x === 'a1').length, 50);
assert.strictEqual(ids.filter(x => x === 'a2').length, 50);
assert.deepStrictEqual(ids.slice(0, 5), ['a1', 'a2', 'a1', 'a2', 'a1']);
});
test('enabledAccountsFor filters disabled + no-creds and preserves order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3', { creds: false }), acc('a4')] };
assert.deepStrictEqual(enabledAccountsFor(hosters, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']);
assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []);
});
test('seeded index resumes mid-cycle (restart / persisted cursor)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 1 } });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a2', 'a3', 'a1', 'a2']);
});
test('drip-feed: fresh picker per call seeded from prior indices keeps rotating (no per-batch reset)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const settings = { 'byse.sx': { rotateAccounts: true } };
let cursors = {};
const landed = [];
for (let i = 0; i < 6; i++) {
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds, indices: cursors });
landed.push(pick('byse.sx').id);
cursors = pick.indices();
}
assert.deepStrictEqual(landed, ['a1', 'a2', 'a3', 'a1', 'a2', 'a3']);
});
test('dirty() is true only after an actual rotation advance', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
const offPick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
offPick('byse.sx'); offPick('voe.sx');
assert.strictEqual(offPick.dirty(), false);
const singlePick = createAccountPicker({ hosters: { 'byse.sx': [acc('a1')] }, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
singlePick('byse.sx');
assert.strictEqual(singlePick.dirty(), false);
const onPick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
onPick('voe.sx');
assert.strictEqual(onPick.dirty(), false);
onPick('byse.sx');
assert.strictEqual(onPick.dirty(), true);
});
test('indices() carries forward unrotated seeded hosters alongside advanced ones', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'voe.sx': 5 } });
pick('byse.sx');
assert.deepStrictEqual(pick.indices(), { 'voe.sx': 5, 'byse.sx': 1 });
});
test('persisted cursor wraps correctly after the enabled-account count shrinks', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']);
});
test('temporary account failures escalate through 15, 30, 60, and 120 minute cooldowns', () => {
let now = 1_000_000;
const scheduled = [];
const controller = createAccountCooldownController({
now: () => now,
setTimer: (callback, delay) => { scheduled.push({ callback, delay }); return scheduled.length; },
clearTimer: () => {}
});
const expectedMinutes = [15, 30, 60, 120, 120];
for (let index = 0; index < expectedMinutes.length; index++) {
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(record.failures, index + 1);
assert.equal(record.pausedUntil, now + expectedMinutes[index] * 60_000);
assert.equal(scheduled.at(-1).delay, expectedMinutes[index] * 60_000);
now = record.pausedUntil;
assert.deepEqual(controller.releaseExpired(), [`byse.sx:acc-1`]);
}
});
test('parallel duplicate failures count as one strike until the active cooldown expires', () => {
let now = 50_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => 1,
clearTimer: () => {}
});
const first = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
const duplicate = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
now = first.pausedUntil;
controller.releaseExpired();
const next = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(duplicate.failures, 1);
assert.equal(duplicate.pausedUntil, first.pausedUntil);
assert.equal(next.failures, 2);
assert.equal(next.pausedUntil, now + 30 * 60_000);
});
test('a successful upload resets cooldown escalation to the first level', () => {
let now = 5_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => 1,
clearTimer: () => {}
});
const first = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
now = first.pausedUntil;
controller.releaseExpired();
controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(controller.markSuccess('voe.sx', 'acc-1'), true);
const afterSuccess = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
assert.equal(afterSuccess.failures, 1);
assert.equal(afterSuccess.pausedUntil, now + 15 * 60_000);
});
test('manual account pauses never expire and can be reset explicitly', () => {
let now = 10_000;
const controller = createAccountCooldownController({
now: () => now,
setTimer: () => { throw new Error('manual pauses must not schedule timers'); },
clearTimer: () => {}
});
const record = controller.markFailure({ hoster: 'doodstream.com', accountId: 'acc-1', mode: 'manual' });
now += 24 * 60 * 60_000;
assert.equal(record.pausedUntil, null);
assert.deepEqual(controller.releaseExpired(), []);
assert.deepEqual(controller.activeKeys(), ['doodstream.com:acc-1']);
assert.equal(controller.reset('doodstream.com', 'acc-1'), true);
assert.deepEqual(controller.activeKeys(), []);
});
test('automatic expiry clears the runtime account and publishes the remaining state', () => {
let now = 2_000;
let scheduled;
const cleared = [];
const published = [];
const controller = createAccountCooldownController({
now: () => now,
setTimer: (callback, delay) => { scheduled = { callback, delay }; return 1; },
clearTimer: () => {},
onClearAccount: (hoster, accountId) => cleared.push(`${hoster}:${accountId}`),
onChange: (records, cause) => published.push({ records, cause })
});
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
now = record.pausedUntil;
scheduled.callback();
assert.deepEqual(cleared, ['byse.sx:acc-1']);
assert.deepEqual(published.at(-1), { records: [], cause: 'expired' });
assert.deepEqual(controller.activeKeys(), []);
});
test('account failure classification keeps credential and OTP errors manual', () => {
const otp = new Error('Doodstream Login: OTP required');
otp.otpRequired = true;
assert.equal(classifyAccountFailure(otp), 'manual');
assert.equal(classifyAccountFailure(new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort')), 'manual');
assert.equal(classifyAccountFailure(new Error('HTTP 401 Unauthorized')), 'manual');
assert.equal(classifyAccountFailure(new Error('Account banned')), 'manual');
});
test('account failure classification cools down quota and rate-limit errors without pausing transient failures', () => {
const quota = new Error('not enough disk space on your account');
quota.accountError = true;
assert.equal(classifyAccountFailure(quota), 'cooldown');
assert.equal(classifyAccountFailure(new Error('Maximum storage space of the account used up.')), 'cooldown');
assert.equal(classifyAccountFailure(new Error('HTTP 429 Too Many Requests')), 'cooldown');
const transient = new Error('HTTP 503 Service Unavailable');
transient.transientNetwork = true;
assert.equal(classifyAccountFailure(transient), 'none');
});
test('account failure classification does not punish unknown, confirmation, or bare WAF errors', () => {
assert.equal(classifyAccountFailure(new Error('Upload wurde nicht bestätigt')), 'none');
assert.equal(classifyAccountFailure(new Error('Unbekannter Parserfehler')), 'none');
const waf = new Error('HTTP 403');
waf.status = 403;
assert.equal(classifyAccountFailure(waf), 'none');
});
test('account failure classification cools down stale sessions and explicit account errors', () => {
assert.equal(classifyAccountFailure(new Error('CSRF-Token nicht gefunden')), 'cooldown');
assert.equal(classifyAccountFailure(new Error('Session expired')), 'cooldown');
const accountError = new Error('Provider account unavailable');
accountError.accountError = true;
assert.equal(classifyAccountFailure(accountError), 'cooldown');
assert.equal(classifyAccountFailure(new Error('Doodstream: sess_id nicht gefunden nach Login')), 'cooldown');
});
-82
View File
@@ -1,82 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
getAccountGroupStatus,
getAccountPausePresentation,
getAccountStatusPresentation,
formatAccountPauseRemaining,
subscribeAccountPauseSnapshots
} = require('../renderer/account-status');
test('mixed account results use warning group status', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 2, error: 1, checking: 0, unchecked: 0 }), 'warn');
});
test('group is red only when every active account has an error', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 0, error: 3, checking: 0, unchecked: 0 }), 'error');
});
test('group is green when every active account is ready', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 3, error: 0, checking: 0, unchecked: 0 }), 'ok');
});
test('warning-only accounts keep the group orange', () => {
assert.equal(getAccountGroupStatus({ total: 2, disabled: 0, ok: 0, warn: 2, error: 0, checking: 0, unchecked: 0 }), 'warn');
});
test('OTP-required account exposes warning presentation', () => {
assert.deepEqual(getAccountStatusPresentation('otp_required'), {
statusClass: 'warn',
label: 'OTP erforderlich',
requiresOtp: true
});
});
test('timed account pause presentation exposes a stable countdown', () => {
assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 905_000 }, 5_000), {
mode: 'cooldown',
remainingSeconds: 900,
expired: false
});
});
test('manual account pause presentation requires action and timed pauses expire at zero', () => {
assert.deepEqual(getAccountPausePresentation({ mode: 'manual', pausedUntil: null }, 5_000), {
mode: 'manual',
remainingSeconds: null,
expired: false
});
assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 5_000 }, 5_000), {
mode: 'cooldown',
remainingSeconds: 0,
expired: true
});
});
test('account pause countdown uses fixed two-digit seconds and supports two-hour cooldowns', () => {
assert.equal(formatAccountPauseRemaining(900), '15:00');
assert.equal(formatAccountPauseRemaining(3599), '59:59');
assert.equal(formatAccountPauseRemaining(7200), '120:00');
});
test('account pause subscription is installed before the initial snapshot is requested', async () => {
const order = [];
let push;
let resolveInitial;
const initial = new Promise(resolve => { resolveInitial = resolve; });
const applied = [];
const api = {
onSessionFailedAccountsChanged: callback => { order.push('subscribe'); push = callback; },
getSessionFailedAccountStates: () => { order.push('load'); return initial; }
};
const pending = subscribeAccountPauseSnapshots(api, snapshot => applied.push(snapshot));
push({ revision: 2, accounts: [{ accountId: 'live' }] });
resolveInitial({ revision: 1, accounts: [] });
await pending;
assert.deepEqual(order, ['subscribe', 'load']);
assert.deepEqual(applied, [
{ revision: 2, accounts: [{ accountId: 'live' }] },
{ revision: 1, accounts: [] }
]);
});
-82
View File
@@ -1,82 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { getAutoResumeJobs, createAutoResumeController } = require('../renderer/auto-resume');
test('auto resume includes only restored waiting jobs', () => {
const jobs = [
{ id: 'queued', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'preview', status: 'preview', file: 'b', hoster: 'h' },
{ id: 'error', status: 'error', file: 'c', hoster: 'h' },
{ id: 'skipped', status: 'skipped', file: 'd', hoster: 'h' },
{ id: 'done', status: 'done', file: 'e', hoster: 'h' },
{ id: 'invalid', status: 'queued', file: '', hoster: 'h' }
];
assert.deepEqual(getAutoResumeJobs(jobs).map(job => job.id), ['queued', 'preview']);
});
test('countdown is visible, cancelable, and starts exactly once', () => {
let tick;
let cleared = 0;
let starts = 0;
const updates = [];
const controller = createAutoResumeController({
delaySeconds: 2,
setIntervalFn: callback => { tick = callback; return 7; },
clearIntervalFn: id => { assert.equal(id, 7); cleared++; },
onTick: (seconds, count) => updates.push([seconds, count]),
onStart: jobIds => { assert.deepEqual(jobIds, ['one', 'two', 'three']); starts++; }
});
assert.equal(controller.schedule(['one', 'two', 'three']), true);
assert.deepEqual(updates, [[2, 3]]);
tick();
assert.deepEqual(updates, [[2, 3], [1, 3]]);
tick();
tick();
assert.equal(starts, 1);
assert.equal(cleared, 1);
assert.equal(controller.pending, false);
});
test('cancel prevents the scheduled start', () => {
let tick;
let starts = 0;
let canceled = 0;
const controller = createAutoResumeController({
setIntervalFn: callback => { tick = callback; return 9; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: () => { starts++; },
onCancel: () => { canceled++; }
});
controller.schedule(['restored']);
assert.equal(controller.cancel(), true);
tick();
assert.equal(starts, 0);
assert.equal(canceled, 1);
});
test('countdown starts only jobs captured when it was scheduled', () => {
let tick;
let startedJobIds = [];
const jobs = [
{ id: 'restored-a', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'restored-b', status: 'preview', file: 'b', hoster: 'h' }
];
const plannedJobIds = getAutoResumeJobs(jobs).map(job => job.id);
const controller = createAutoResumeController({
delaySeconds: 1,
setIntervalFn: callback => { tick = callback; return 11; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: jobIds => {
startedJobIds = getAutoResumeJobs(jobs, jobIds).map(job => job.id);
}
});
assert.equal(controller.schedule(plannedJobIds), true);
jobs.push({ id: 'added-during-countdown', status: 'queued', file: 'c', hoster: 'h' });
tick();
assert.deepEqual(startedJobIds, ['restored-a', 'restored-b']);
});
-453
View File
@@ -1,453 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
normalizeAutomationSettings,
countAutomaticQueueJobs,
planAtomicAdmissions,
rollDailyTelemetry,
applyTelemetryDelta,
deriveAutomationState,
isPathWithinAutomationFolder,
classifyProcessedCandidates,
classifyAutomationCompletionLedger,
createAutomationCompletionWriter,
mergeAutomationCompletions,
removeAutomationCompletions
} = require('../lib/automation-control');
test('automation defaults use 15000 jobs and a five minute reconciliation interval', () => {
assert.deepEqual(normalizeAutomationSettings({}), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
});
test('automation settings normalize invalid limits intervals and pause timestamps', () => {
assert.deepEqual(normalizeAutomationSettings({
queueLimitJobs: -1,
reconcileIntervalMinutes: 10,
paused: true,
pausedAt: '1700'
}), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: true,
pausedAt: 1700
});
assert.deepEqual(normalizeAutomationSettings({
queueLimitJobs: 42.9,
reconcileIntervalMinutes: '15',
pausedAt: 1700
}), {
queueLimitJobs: 42,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
assert.deepEqual(normalizeAutomationSettings(null), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15);
});
test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => {
const invalidLimits = [null, '', ' ', false, true, {}, undefined, NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '0.0', '00', '+0', '-0'];
assert.deepEqual(
invalidLimits.map((queueLimitJobs) => normalizeAutomationSettings({ queueLimitJobs }).queueLimitJobs),
invalidLimits.map(() => 15000)
);
assert.equal(normalizeAutomationSettings({ queueLimitJobs: 0 }).queueLimitJobs, 0);
assert.equal(normalizeAutomationSettings({ queueLimitJobs: ' 0 ' }).queueLimitJobs, 0);
});
test('automation settings clamp positive queue limit fractions to one', () => {
assert.deepEqual(
[0.5, '0.5', 0.125].map((queueLimitJobs) => normalizeAutomationSettings({ queueLimitJobs }).queueLimitJobs),
[1, 1, 1]
);
});
test('capacity counts only executable and running queue jobs', () => {
const statuses = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
assert.equal(countAutomaticQueueJobs(statuses.map((status, index) => ({ id: String(index), status }))), 5);
assert.equal(countAutomaticQueueJobs(null), 0);
});
test('admission keeps every eligible host job for a file atomic', () => {
const plan = planAtomicAdmissions({
candidates: [
{ path: 'C:\\watch\\a.mkv', mtimeMs: 1000, eligibleJobCount: 4 },
{ path: 'C:\\watch\\b.mkv', mtimeMs: 2000, eligibleJobCount: 2 }
],
currentJobCount: 14997,
queueLimitJobs: 15000
});
assert.deepEqual(plan.admittedPaths, ['C:\\watch\\b.mkv']);
assert.deepEqual(plan.deferredPaths, ['C:\\watch\\a.mkv']);
assert.equal(plan.plannedJobs, 2);
assert.equal(plan.availableSlots, 1);
});
test('admission uses stable mtime and path ordering without mutating candidates', () => {
const candidates = [
{ path: 'b', mtimeMs: 10, eligibleJobCount: 1 },
{ path: 'c', mtimeMs: 5, eligibleJobCount: 1 },
{ path: 'a', mtimeMs: 10, eligibleJobCount: 1 }
];
const snapshot = structuredClone(candidates);
const plan = planAtomicAdmissions({ candidates, currentJobCount: 0, queueLimitJobs: 2 });
assert.deepEqual(plan.admittedPaths, ['c', 'a']);
assert.deepEqual(plan.deferredPaths, ['b']);
assert.deepEqual(candidates, snapshot);
});
test('unlimited admission accepts all eligible files', () => {
const plan = planAtomicAdmissions({
candidates: [{ path: 'a', mtimeMs: 1, eligibleJobCount: 20000 }],
currentJobCount: 50000,
queueLimitJobs: 0
});
assert.deepEqual(plan.admittedPaths, ['a']);
assert.deepEqual(plan.deferredPaths, []);
assert.equal(plan.availableSlots, null);
});
test('admission tolerates malformed candidates and numeric inputs', () => {
assert.deepEqual(planAtomicAdmissions({ candidates: null, currentJobCount: -4, queueLimitJobs: '3' }), {
admittedPaths: [],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 3
});
assert.deepEqual(planAtomicAdmissions(null), {
admittedPaths: [],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 15000
});
});
test('admission allows only numeric and trimmed string zero to disable the queue limit', () => {
const invalidLimits = [null, '', ' ', false, true, {}, undefined, NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '0.0', '00', '+0', '-0'];
const candidate = { path: 'large.mkv', mtimeMs: 1, eligibleJobCount: 16000 };
assert.deepEqual(
invalidLimits.map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
invalidLimits.map(() => ({
admittedPaths: [],
deferredPaths: ['large.mkv'],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 15000
}))
);
assert.deepEqual(
[0, ' 0 '].map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
[0, ' 0 '].map(() => ({
admittedPaths: ['large.mkv'],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 16000,
availableSlots: null
}))
);
});
test('admission keeps positive queue limit fractions bounded to one job', () => {
const candidate = { path: 'two-jobs.mkv', mtimeMs: 1, eligibleJobCount: 2 };
assert.deepEqual(
[0.5, '0.5', 0.125].map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
[0.5, '0.5', 0.125].map(() => ({
admittedPaths: [],
deferredPaths: ['two-jobs.mkv'],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 1
}))
);
});
test('daily telemetry resets atomically on the local calendar day boundary', () => {
const before = { dateKey: '2026-08-25', detected: 8, queued: 4, skipped: 2, deferred: 1 };
const now = new Date(2026, 7, 26, 0, 0, 1).getTime();
assert.deepEqual(rollDailyTelemetry(before, now), {
dateKey: '2026-08-26',
detected: 0,
queued: 0,
skipped: 0,
deferred: 0,
lastDetectedName: '',
lastDetectedAt: null,
lastError: '',
lastErrorAt: null
});
});
test('daily telemetry normalizes malformed same-day counters without mutating input', () => {
const now = new Date(2026, 7, 26, 12, 0, 0).getTime();
const telemetry = { dateKey: '2026-08-26', detected: -2, queued: '3', lastError: 'network' };
const snapshot = structuredClone(telemetry);
assert.deepEqual(rollDailyTelemetry(telemetry, now), {
dateKey: '2026-08-26',
detected: 0,
queued: 3,
skipped: 0,
deferred: 0,
lastDetectedName: '',
lastDetectedAt: null,
lastError: 'network',
lastErrorAt: null
});
assert.deepEqual(telemetry, snapshot);
assert.equal(rollDailyTelemetry(null, now).dateKey, '2026-08-26');
});
test('telemetry deltas increment counters and update event details immutably', () => {
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
const telemetry = {
dateKey: '2026-08-26',
detected: 1,
queued: 2,
skipped: 3,
deferred: 4,
lastDetectedName: '',
lastDetectedAt: null,
lastError: 'old',
lastErrorAt: 10
};
const result = applyTelemetryDelta(telemetry, {
detected: 2,
queued: 3,
skipped: -1,
deferred: '2',
lastDetectedName: 'episode.mkv',
lastError: ''
}, now);
assert.deepEqual(result, {
dateKey: '2026-08-26',
detected: 3,
queued: 5,
skipped: 3,
deferred: 6,
lastDetectedName: 'episode.mkv',
lastDetectedAt: now,
lastError: '',
lastErrorAt: null
});
assert.equal(telemetry.detected, 1);
assert.equal(telemetry.lastError, 'old');
});
test('telemetry and deltas normalize every counter to a finite nonnegative integer', () => {
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
const telemetry = rollDailyTelemetry({
dateKey: '2026-08-26',
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -4,
deferred: 3.9
}, now);
assert.deepEqual({
detected: telemetry.detected,
queued: telemetry.queued,
skipped: telemetry.skipped,
deferred: telemetry.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 3 });
const changed = applyTelemetryDelta(telemetry, {
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -2,
deferred: 2.8
}, now);
assert.deepEqual({
detected: changed.detected,
queued: changed.queued,
skipped: changed.skipped,
deferred: changed.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 5 });
assert.equal(Object.values(changed).filter(value => typeof value === 'number').every(value => Number.isFinite(value)), true);
});
test('pause has higher display priority than disconnect error and queue limit', () => {
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
});
test('automation state follows inactive disconnected error queue-limited and active priority', () => {
assert.equal(deriveAutomationState({ enabled: false, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'inactive');
assert.equal(deriveAutomationState({ enabled: true, folderPath: '', reachable: false, error: 'x', queueLimited: true }), 'inactive');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'disconnected');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, error: 'x', queueLimited: true }), 'error');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, queueLimited: true }), 'queue-limited');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true }), 'active');
assert.equal(deriveAutomationState(null), 'inactive');
});
test('watched-folder membership respects Windows casing and recursive scope', () => {
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\episode.mkv', 'c:/watch', false), true);
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', false), false);
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', true), true);
assert.equal(isPathWithinAutomationFolder('C:\\Watcher\\episode.mkv', 'c:/watch', true), false);
assert.equal(isPathWithinAutomationFolder('', 'c:/watch', true), false);
});
test('exact queue and history paths mark candidates processed case-insensitively', () => {
const result = classifyProcessedCandidates({
candidates: [
{ path: 'C:\\Watch\\queue.mkv' },
{ path: 'C:\\Watch\\history.mkv' },
{ path: 'C:\\Watch\\new.mkv' }
],
queuePaths: ['c:/watch/QUEUE.mkv'],
historyRows: [{ file: 'c:/watch/HISTORY.mkv' }],
uploadLogRows: []
});
assert.deepEqual(result, {
processedPaths: ['C:\\Watch\\queue.mkv', 'C:\\Watch\\history.mkv'],
ambiguousPaths: [],
unprocessedPaths: ['C:\\Watch\\new.mkv']
});
});
test('unique basename evidence marks one candidate processed', () => {
const result = classifyProcessedCandidates({
candidates: [{ path: 'C:\\watch\\unique.mkv' }, { path: 'C:\\watch\\other.mkv' }],
uploadLogRows: [{ filename: 'UNIQUE.MKV' }]
});
assert.deepEqual(result.processedPaths, ['C:\\watch\\unique.mkv']);
assert.deepEqual(result.unprocessedPaths, ['C:\\watch\\other.mkv']);
});
test('ambiguous same-name log evidence never marks a candidate processed', () => {
const result = classifyProcessedCandidates({
candidates: [
{ path: 'C:\\one\\episode.mkv', name: 'episode.mkv' },
{ path: 'D:\\two\\episode.mkv', name: 'episode.mkv' }
],
queuePaths: [],
historyRows: [],
uploadLogRows: [{ fileName: 'episode.mkv', hoster: 'doodstream.com' }]
});
assert.deepEqual(result.processedPaths, []);
assert.deepEqual(result.ambiguousPaths.sort(), ['C:\\one\\episode.mkv', 'D:\\two\\episode.mkv'].sort());
});
test('processed classification tolerates malformed collections and does not mutate candidates', () => {
const candidates = [{ path: 'C:\\watch\\episode.mkv', name: 'episode.mkv' }];
const snapshot = structuredClone(candidates);
assert.deepEqual(classifyProcessedCandidates({
candidates,
queuePaths: null,
historyRows: null,
uploadLogRows: null
}), {
processedPaths: [],
ambiguousPaths: [],
unprocessedPaths: ['C:\\watch\\episode.mkv']
});
assert.deepEqual(candidates, snapshot);
assert.deepEqual(classifyProcessedCandidates(null), {
processedPaths: [],
ambiguousPaths: [],
unprocessedPaths: []
});
});
test('durable completion ledger excludes only unchanged completed hosters', () => {
const candidate = {
path: 'C:\\Watch\\Episode.mkv',
size: 1048576,
mtimeMs: 1787828400123.75,
eligibleHosters: ['doodstream.com', 'voe.sx', 'byse.sx']
};
const completionRows = [
{ path: 'c:/watch/episode.mkv', size: 1048576, mtimeMs: 1787828400123, hoster: 'DOODSTREAM.COM', completedAt: 10 },
{ path: 'C:\\WATCH\\EPISODE.MKV', size: 1048576, mtimeMs: 1787828400123.9, hoster: 'voe.sx', completedAt: 20 }
];
assert.deepEqual(classifyAutomationCompletionLedger({ candidates: [candidate], completionRows }), {
processedPaths: [],
completedByPath: [{ path: candidate.path, hosters: ['doodstream.com', 'voe.sx'] }],
remainingByPath: [{ path: candidate.path, hosters: ['byse.sx'] }]
});
const complete = classifyAutomationCompletionLedger({
candidates: [candidate],
completionRows: completionRows.concat({
path: candidate.path,
size: candidate.size,
mtimeMs: candidate.mtimeMs,
hoster: 'byse.sx',
completedAt: 30
})
});
assert.deepEqual(complete.processedPaths, [candidate.path]);
assert.deepEqual(complete.remainingByPath, [{ path: candidate.path, hosters: [] }]);
const changed = classifyAutomationCompletionLedger({
candidates: [{ ...candidate, mtimeMs: candidate.mtimeMs + 1 }],
completionRows
});
assert.deepEqual(changed.completedByPath, [{ path: candidate.path, hosters: [] }]);
assert.deepEqual(changed.remainingByPath, [{ path: candidate.path, hosters: candidate.eligibleHosters }]);
});
test('completion ledger replaces only the same path and hoster without evicting unrelated entries', () => {
const existing = [
{ path: 'C:\\watch\\a.mkv', size: 1, mtimeMs: 1, hoster: 'voe.sx', completedAt: 10 },
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 }
];
const merged = mergeAutomationCompletions(existing, [
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'VOE.SX', completedAt: 30 },
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
]);
assert.deepEqual(merged, [
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 },
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'voe.sx', completedAt: 30 },
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
]);
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'C:\\WATCH\\A.MKV', hoster: 'voe.sx' }]), [merged[0], merged[2]]);
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'c:/watch/c.mkv' }]), [merged[0], merged[1]]);
assert.throws(() => mergeAutomationCompletions(existing, [
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
], 2), /zu viele Einträge/);
});
test('completion writer coalesces successful jobs and retains a failed batch for retry', async () => {
const scheduled = [];
const writes = [];
const persisted = [];
const failures = [];
let fail = true;
const writer = createAutomationCompletionWriter({
schedule: callback => scheduled.push(callback),
onPersisted: entries => persisted.push(structuredClone(entries)),
onError: (error, entries) => failures.push({ message: error.message, entries: structuredClone(entries) }),
save: async entries => {
if (fail) throw new Error('disk unavailable');
writes.push(structuredClone(entries));
}
});
const first = { path: 'C:\\watch\\episode.mkv', size: 1, mtimeMs: 2, hoster: 'voe.sx', completedAt: 3 };
const newer = { ...first, completedAt: 4 };
writer.add(first);
writer.add(newer);
assert.equal(scheduled.length, 1);
await assert.rejects(writer.flush(), /disk unavailable/);
assert.deepEqual(persisted, []);
assert.deepEqual(failures, [{ message: 'disk unavailable', entries: [newer] }]);
fail = false;
await writer.flush();
assert.deepEqual(writes, [[newer]]);
assert.deepEqual(persisted, [[newer]]);
assert.equal(writer.pendingCount(), 0);
});
-85
View File
@@ -1,85 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { encrypt, decrypt } = require('../lib/backup-crypto');
describe('backup-crypto', () => {
const sampleConfig = {
hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } },
hosterSettings: { 'doodstream.com': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
};
it('encrypt then decrypt round-trips', () => {
const buf = encrypt(sampleConfig);
const result = decrypt(buf);
assert.deepStrictEqual(result, sampleConfig);
});
it('decrypt with corrupted data throws', () => {
const buf = encrypt(sampleConfig);
buf[buf.length - 1] ^= 0xff; // flip last byte
// With no password: app-key fails → needsPassword surfaces.
assert.throws(() => decrypt(buf), (err) => err.needsPassword === true);
// With a password: both app-key and password fail → Falsches Passwort.
assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/);
});
it('decrypt with invalid magic throws', () => {
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
assert.throws(() => decrypt(buf), /Keine gültige/);
});
it('decrypt with too-short buffer throws', () => {
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
});
it('handles empty config gracefully', () => {
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
const buf = encrypt(empty);
assert.deepStrictEqual(decrypt(buf), empty);
});
it('decrypts legacy password-encrypted buffer when password is provided', () => {
// Reproduce the old format: same envelope, but key derived from user password.
const crypto = require('crypto');
const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8');
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]);
// Without password → should throw needsPassword
assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true);
// With correct password → should decrypt
assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig);
// With wrong password → should throw (not needsPassword)
assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/);
});
it('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig);
const b = encrypt(sampleConfig);
assert.ok(!a.equals(b), 'two encryptions should differ');
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a), decrypt(b));
});
it('password-protected backups require the exact password', () => {
const buf = encrypt(sampleConfig, 'correct horse battery staple');
assert.equal(buf.subarray(0, 4).toString('ascii'), 'MHU2');
assert.throws(() => decrypt(buf), (error) => error.needsPassword === true);
assert.throws(() => decrypt(buf, 'wrong password'), /Falsches Passwort/);
assert.deepStrictEqual(decrypt(buf, 'correct horse battery staple'), sampleConfig);
});
it('rejects an empty password instead of silently using the built-in key', () => {
assert.throws(() => encrypt(sampleConfig, ' '), /Passwort/);
});
});
-234
View File
@@ -1,234 +0,0 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
const undici = require('undici');
const _origUndiciRequest = undici.request;
undici.request = (...a) => requestRouter(...a);
delete require.cache[require.resolve('../lib/hosters')];
const hostersMod = require('../lib/hosters');
const { uploadFile } = hostersMod;
let tmpFile;
let origFetch;
before(() => {
tmpFile = path.join(os.tmpdir(), `byse-itest-${process.pid}.mkv`);
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
origFetch = global.fetch;
});
after(() => {
global.fetch = origFetch;
undici.request = _origUndiciRequest;
delete require.cache[require.resolve('../lib/hosters')];
try { fs.unlinkSync(tmpFile); } catch {}
});
function stubByseUploadServer() {
global.fetch = async (url) => {
if (/upload\/server/.test(String(url))) {
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.byse.sx/upload/01' }) };
}
return { status: 200, text: async () => '{"status":200}' };
};
}
test('byse "Not video file format" (suspect) DOES poll recovery and claims the async-registered file', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}'
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'BIGMKV77', title: path.basename(tmpFile) }] } });
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
assert.strictEqual(res.file_code, 'BIGMKV77');
assert.ok(listCalls >= 2, 'suspect rejection must still run the recovery poll (live 2026-06-09: >2.7GB MKVs got this status while registering fine)');
});
test('byse "Not video file format" with empty poll throws err.suspectReject so rotation can try other accounts', async () => {
stubByseUploadServer();
const abort = new AbortController();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
if (listCalls >= 2) abort.abort();
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
(err) => err.fileRejected === true && err.suspectReject === true && /Not video file format/i.test(err.message)
);
assert.ok(listCalls >= 2, 'poll must have started before giving up');
});
test('byse "Not video file format" with probe-confirmed NON-video skips the recovery poll (genuine rejection)', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null, { probeIsVideoLike: false }),
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
);
assert.strictEqual(listCalls, 1, 'probe says non-video → the rejection is genuine, no 15-attempt poll');
});
test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery polling', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Duplicate' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.fileRejected === true && err.suspectReject !== true && /Duplicate/i.test(err.message)
);
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on a genuine rejection');
});
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}'
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'RECOVERED99', title: path.basename(tmpFile) }] } });
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
};
};
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
assert.strictEqual(res.file_code, 'RECOVERED99');
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
});
function stubBysePost(response) {
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return response();
};
}
test('byse upload POST 502 (HTML gateway body) is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 502,
headers: { 'content-type': 'text/html' },
body: { text: async () => '<!doctype html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>' }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true && /kein JSON \(HTTP 502\)/.test(err.message)
);
});
test('byse upload POST non-2xx JSON 503 is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 503,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 503, msg: 'Service Unavailable' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
});
test('byse upload POST 2xx envelope {status:500} is transient; {status:403} stays account-level', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 500, msg: 'Internal Server Error' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 403, msg: 'Forbidden' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork !== true
);
});
-144
View File
@@ -1,144 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeCoalescedSet } = require('../lib/coalesced-set');
// Synchronous scheduler stand-in: collects callbacks instead of running
// them, so tests can drive the timing explicitly.
function makeManualScheduler() {
const queue = [];
const fn = (cb) => queue.push(cb);
fn.flush = () => {
while (queue.length) {
const cb = queue.shift();
cb();
}
};
fn.queueLength = () => queue.length;
return fn;
}
test('throws if apply callback missing', () => {
assert.throws(() => makeCoalescedSet());
assert.throws(() => makeCoalescedSet({}));
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
});
test('multiple adds in one tick coalesce into one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({
apply: (drop) => calls.push([...drop].sort()),
scheduler: sched
});
cs.add('a'); cs.add('b'); cs.add('c');
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
assert.equal(cs.pendingSize(), 3);
sched.flush();
assert.deepEqual(calls, [['a', 'b', 'c']]);
assert.equal(cs.pendingSize(), 0);
});
test('duplicate adds are deduplicated', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('a'); cs.add('a'); cs.add('a');
sched.flush();
assert.deepEqual(calls, [['a']]);
});
test('two batches in series stay independent', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('x'); cs.add('y');
sched.flush();
cs.add('z');
sched.flush();
assert.deepEqual(calls, [['x', 'y'], ['z']]);
});
test('add after flush re-schedules a new microtask', () => {
const sched = makeManualScheduler();
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
cs.add('a');
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.equal(sched.queueLength(), 0);
assert.equal(cs.isScheduled(), false);
cs.add('b');
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
});
test('drainSync flushes synchronously without waiting for scheduler', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('p'); cs.add('q');
cs.drainSync();
assert.deepEqual(calls, [['p', 'q']]);
assert.equal(cs.pendingSize(), 0);
// Pending microtask was for the same ids — when it runs, pending is empty
// → apply NOT called twice.
sched.flush();
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
});
test('drainSync on empty set is a no-op', () => {
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++ });
cs.drainSync();
assert.equal(called, 0);
});
test('throwing apply does not lock out subsequent batches', () => {
const sched = makeManualScheduler();
let attempt = 0;
const cs = makeCoalescedSet({
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
scheduler: sched
});
cs.add('a');
// First flush throws inside apply but is swallowed; coalescer must still work.
sched.flush();
cs.add('b');
sched.flush();
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
});
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
cs.add('z');
// Wait one microtask
await Promise.resolve();
assert.deepEqual(calls, [['z']]);
});
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
const sched = makeManualScheduler();
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
cs.add('a');
cs.drainSync();
assert.equal(called, 1);
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
sched.flush();
assert.equal(called, 1);
});
test('large burst of 5000 adds coalesces to one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.deepEqual(calls, [5000]);
});
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('dev runner cannot let an old child exit clear the current Electron process', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
assert.match(source, /const startedChild = spawn\(electron/u);
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
});
-62
View File
@@ -1,62 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { createAgent } = require('../lib/diagnostics-agent');
function stubCollectors() {
const calls = [];
const mk = (name) => (a) => { calls.push([name, a]); return { name, a }; };
return {
calls,
getSystemInfo: mk('getSystemInfo'),
serverHealth: mk('serverHealth'),
getConfigRedacted: mk('getConfigRedacted'),
listLogs: mk('listLogs'),
readLog: mk('readLog'),
getAppEvents: mk('getAppEvents'),
listErrors: mk('listErrors'),
getQueueState: mk('getQueueState'),
getHistory: mk('getHistory'),
getRotationState: mk('getRotationState'),
getHealth: mk('getHealth')
};
}
test('agent rejects unknown ops and any write/exec-shaped op', () => {
const agent = createAgent(stubCollectors());
for (const bad of ['delete_log', 'write_config', 'run_health_check', 'exec', 'eval', '__proto__', 'set_setting', 'restart']) {
const r = agent.handle(bad, {});
assert.equal(r.ok, false, `${bad} must be rejected`);
assert.match(r.error, /unknown or non-readonly/);
}
});
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
const agent = createAgent(stubCollectors());
for (const proto of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'toLocaleString']) {
const r = agent.handle(proto, {});
assert.equal(r.ok, false, `${proto} (inherited) must NOT be treated as an op`);
}
for (const bad of [null, undefined, 42, {}, ['read_log']]) {
assert.equal(agent.handle(bad, {}).ok, false, `non-string op ${JSON.stringify(bad)} must be rejected`);
}
});
test('agent maps each whitelisted op to its collector and is read-only only', () => {
const stub = stubCollectors();
const agent = createAgent(stub);
assert.equal(agent.handle('server_health', { errorLimit: 5 }).ok, true);
assert.equal(agent.handle('read_log', { name: 'debug' }).ok, true);
assert.equal(agent.handle('tail_log', { name: 'debug' }).ok, true, 'tail_log aliases read_log');
assert.equal(agent.handle('get_config_redacted', {}).ok, true);
const ops = new Set(agent.ops);
assert.ok(!ops.has('run_health_check'), 'no live probe op in this build');
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
});
test('agent surfaces a collector ok:false verbatim and never throws', () => {
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
const thrown = agent.handle('get_system_info', {});
assert.equal(thrown.ok, false);
assert.match(thrown.error, /boom/);
});
-179
View File
@@ -1,179 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const support = require('../lib/support-bundle');
const stats = require('../lib/stats');
const { createCollectors } = require('../lib/diagnostics-collectors');
const { createAgent } = require('../lib/diagnostics-agent');
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
const fixtureAlpha = ['SECRET', 'TOKEN', '123456'].join('');
const fixtureBeta = ['abcdef', '123456'].join('');
const fixtureGamma = ['LIVE', 'KEY', '99999'].join('');
const fixtureDelta = ['HUNTER', '2', 'SECRET'].join('');
const fixtureEpsilon = ['BYSE', 'KEY', '1234567'].join('');
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
const paths = {
fileuploader: path.join(dir, 'fileuploader.log'),
uploadAudit: path.join(dir, 'upload-audit.log'),
debug: path.join(dir, 'debug.log'),
accountRotation: path.join(dir, 'account-rotation.log'),
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = {
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureDelta }], 'byse.sx': [{ id: 'b1', apiKey: fixtureEpsilon }] },
hosterSettings: {},
globalSettings: {
webhookUrl: ['https://discord.com/api/webhooks/', '12345', fixtureZeta].join('/'),
diagnostics: { enabled: true, port: 9110, token: fixtureAlpha, bindAddress: '127.0.0.1' },
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
},
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
rotationCursors: { 'voe.sx': 1 }
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => paths,
support, stats,
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
});
return { dir, paths, config, collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta };
}
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
const { collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta } = makeFixture();
const out = collectors.getConfigRedacted({ section: 'all' });
const json = JSON.stringify(out);
assert.ok(!json.includes(fixtureDelta), 'password must be redacted');
assert.ok(!json.includes(fixtureEpsilon), 'apiKey must be redacted');
assert.ok(!json.includes(fixtureAlpha), 'diag token must be redacted');
assert.ok(!json.includes(fixtureZeta), 'webhook secret must be redacted');
});
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
loadHistory: () => [
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'a.mkv', results: [{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/a' }] }] },
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'b.mkv', results: [{ hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/b' }] }] }
],
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const out = c.getHistory({ limit: 10 });
assert.equal(out.totalBatches, 2, 'must report real history from loadHistory, not the empty load().history');
assert.equal(out.returned, 2);
});
test('getHistory falls back to loadConfig().history when loadHistory is absent (legacy mode)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [{ timestamp: '2026-01-01T00:00:00.000Z', files: [] }] }),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
});
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
assert.equal(audit.name, 'uploadAudit');
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
});
test('rotated audit backups are listed and readable with the rotation naming convention', () => {
const { collectors, paths, fixtureAlpha } = makeFixture();
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
assert.ok(listed.variants.some(variant => variant.backup === 1));
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
assert.equal(backup.path, backupPath);
assert.ok(!backup.content.includes(fixtureAlpha));
});
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
const { paths } = makeFixture();
const fs2 = require('fs');
fs2.writeFileSync(paths.debug, ['ERROR upload failed', 'info all good', 'WARN timeout hit', 'a'.repeat(120) + '! catastrophic bait'].join('\n'));
const { collectors } = (() => {
const support2 = require('../lib/support-bundle');
const stats2 = require('../lib/stats');
const c = require('../lib/diagnostics-collectors').createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
getAllLogPaths: () => paths, support: support2, stats: stats2,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
return { collectors: c };
})();
const alt = collectors.readLog({ name: 'debug', grep: 'error|timeout' });
assert.equal(alt.matchedLines, 2, 'matches the ERROR and timeout lines case-insensitively');
assert.ok(alt.content.includes('ERROR upload failed') && alt.content.includes('WARN timeout hit'));
assert.ok(!alt.content.includes('info all good'), 'non-matching line excluded');
const t0 = Date.now();
const redos = collectors.readLog({ name: 'debug', grep: '(a+)+$' });
assert.ok(Date.now() - t0 < 1000, 'catastrophic-looking grep must return promptly (literal substring, no backtracking)');
assert.equal(redos.matchedLines, 0, '"(a+)+$" is treated as a literal substring, matching nothing here');
});
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
const { collectors } = makeFixture();
const q = collectors.getQueueState({});
assert.equal(q.source, 'persisted');
assert.equal(q.stale, true);
assert.equal(q.counts.error, 1);
});
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
const config = {
hosters: {}, hosterSettings: {},
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: 'upload rejected: token=OPAQUE_NONconfig_TOKEN_9988' }
] } },
history: [], rotationCursors: {}
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const q = collectors.getQueueState({});
const json = JSON.stringify(q);
assert.ok(!json.includes('OPAQUE_NONconfig_TOKEN_9988'), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
});
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
const { collectors } = makeFixture();
const e = collectors.listErrors({});
assert.equal(e.total, 1, 'only the non-done result is an error');
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
});
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const { collectors } = makeFixture();
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
});
-120
View File
@@ -1,120 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const os = require('os');
const WebSocket = require('ws');
const RemoteServer = require('../lib/remote-server');
const TOKEN = 'a'.repeat(64);
function firstLanIpv4() {
for (const entry of Object.values(os.networkInterfaces())) {
for (const net of (entry || [])) {
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
}
}
return null;
}
function startAgent(onDiagnosticRequest, extra) {
const srv = new RemoteServer();
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
.then(() => srv);
}
function connect(port) {
return new WebSocket(`ws://127.0.0.1:${port}`);
}
function once(ws, type) {
return new Promise((resolve, reject) => {
ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.type === type) resolve(m); });
ws.on('close', (code) => reject(new Error('closed ' + code)));
ws.on('error', reject);
});
}
test('diagnostic client: auth -> diag-request -> reqId-correlated diag-response', async () => {
const agent = await startAgent((msg, _client, reply) => {
assert.equal(msg.op, 'server_health');
reply({ ok: true, data: { hello: 'world', echo: msg.args } });
});
const port = agent.getPort();
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId);
ws.send(JSON.stringify({ type: 'diag-request', reqId: 'r1', op: 'server_health', args: { errorLimit: 3 } }));
const resp = await once(ws, 'diag-response');
assert.equal(resp.reqId, 'r1');
assert.equal(resp.ok, true);
assert.equal(resp.data.hello, 'world');
assert.equal(resp.data.echo.errorLimit, 3);
assert.equal(agent.getLastAccess() !== null, true, 'access timestamp recorded');
ws.close(); agent.stop();
});
test('a diagnostic client NEVER triggers the screen-capture window', async () => {
let captureCreated = false;
const agent = await startAgent(() => {}, { onCreateCaptureWindow: () => { captureCreated = true; } });
const ws = connect(agent.getPort());
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
await once(ws, 'auth-ok');
await new Promise((r) => setTimeout(r, 50));
assert.equal(captureCreated, false, 'diagnosticMode must not spawn the capture window');
ws.close(); agent.stop();
});
test('allowlist gate (wiring): a non-loopback peer is closed 4005 when not allowlisted (fail-closed)', () => {
const srv = new RemoteServer();
const closeCodeFor = (remoteAddress, allowlist) => {
srv._config = { allowlist, token: TOKEN, diagnosticMode: true };
let closed = null;
srv._handleConnection({ close: (c) => { closed = c; }, on: () => {} }, { socket: { remoteAddress } });
return closed;
};
assert.equal(closeCodeFor('100.64.0.9', []), 4005, 'empty allowlist => non-loopback rejected (fail-closed)');
assert.equal(closeCodeFor('203.0.113.5', ['100.64.0.0/10']), 4005, 'peer outside the allowlist CIDR rejected');
});
test('a loopback diagnostic client connects even with a non-matching allowlist (loopback is always allowed)', async () => {
const agent = await startAgent(() => {}, { allowlist: ['100.64.0.0/10'] });
const ws = connect(agent.getPort());
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId);
ws.close(); agent.stop();
});
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
const lan = firstLanIpv4();
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
const port = agent.getPort();
const ws = new WebSocket(`ws://${lan}:${port}`);
try {
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
} finally {
ws.close(); agent.stop();
}
});
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
const agent = await startAgent(() => {});
const port = agent.getPort();
for (let i = 0; i < 5; i++) {
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: 'wrong', role: 'diagnostic' }));
await new Promise((r) => ws.on('close', r));
}
const ws = connect(port);
const closeCode = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
assert.equal(closeCode, 4003, 'locked out after 5 failed attempts');
agent.stop();
});
-105
View File
@@ -1,105 +0,0 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
// Mock the undici transport BEFORE requiring hosters so the destructured
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
// we override per-test. This exercises the FULL doodstream API upload + recovery
// orchestration against the doc-verified response shapes — the gap between the
// already-tested parseDoodstreamResult helper and the real uploadFile path.
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
// undici.request on the module object and refresh the hosters cache instead.)
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
const undici = require('undici');
const _origUndiciRequest = undici.request;
undici.request = (...a) => requestRouter(...a);
delete require.cache[require.resolve('../lib/hosters')];
const hostersMod = require('../lib/hosters');
const { uploadFile } = hostersMod;
let tmpFile;
let origFetch;
before(() => {
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
origFetch = global.fetch;
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
});
after(() => {
global.fetch = origFetch;
undici.request = _origUndiciRequest; // restore real transport for other test files
delete require.cache[require.resolve('../lib/hosters')];
try { fs.unlinkSync(tmpFile); } catch {}
});
// getUploadServer hits /api/upload/server via global fetch.
function stubUploadServer() {
global.fetch = async (url) => {
if (/upload\/server/.test(String(url))) {
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
}
return { status: 200, text: async () => '{"status":200}' };
};
}
// Build an undici-style router. uploadBody is the POST result; listBodies is a
// queue consumed by successive /api/file/list calls (baseline, then polls).
function routeWith(uploadBody, listBodies = []) {
return async (url, opts) => {
const u = String(url);
if (/\/api\/file\/list/.test(u)) {
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
// Upload POST: drain the streamed body so the file handle closes.
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
};
}
test('doodstream API upload: filecode returned directly is used', async () => {
stubUploadServer();
requestRouter = routeWith({
status: 200,
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
});
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'DOODCODE1234');
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
});
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
stubUploadServer();
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
[
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
]
);
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'RECOVER9999');
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
});
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
stubUploadServer();
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
[] // every file/list returns empty
);
await assert.rejects(
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
(err) => {
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
return true;
}
);
});
-364
View File
@@ -1,364 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const DoodstreamUploader = require('../lib/doodstream-upload');
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
// status ("OK" on success, an error string when the backend refuses the file).
// These tests pin the parse/error behaviour of _parseUploadResponse without
// touching the network — _fetch is stubbed to return the upload_result page.
function cdnForm({ fn = '', st = 'OK' } = {}) {
return `<HTML><BODY><Form name='F1' action='https://cdn.example/' method='POST'>` +
`<textarea name="op">upload_result</textarea>` +
`<textarea name="fn">${fn}</textarea>` +
`<textarea name="st">${st}</textarea>` +
`</Form></BODY></HTML>`;
}
const EMPTY_RESULT = '<textarea id="copy_dl" readonly class="form-control" rows="5"></textarea>';
const LINK_RESULT = (code) => `<textarea id="copy_dl" readonly class="form-control" rows="5">https://myvidplay.com/d/${code}</textarea>`;
function uploaderWithResult(resultHtml) {
const up = new DoodstreamUploader();
up._lastUploadUrl = 'https://cdn.example/upload/01';
// Stub the second-step submit so no real request goes out.
up._fetch = async () => ({ text: async () => resultHtml });
return up;
}
test('rejected file: empty fn + non-OK st surfaces the real status', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })),
(err) => {
assert.match(err.message, /lehnt Datei ab/);
assert.match(err.message, /file already exists/);
return true;
}
);
});
test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })),
(err) => {
assert.match(err.message, /kein Filecode/);
assert.match(err.message, /st=OK/);
assert.match(err.message, /fehlt\/leer/);
assert.match(err.message, /cdn\.example/);
return true;
}
);
});
test('valid fn but empty result page: still resolves via fn (no regression)', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' }));
assert.equal(res.file_code, '7mnp8xna3123');
assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123');
});
test('happy path: link in result page wins', async () => {
const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9'));
const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' }));
assert.equal(res.file_code, 'jjsuhr931ds9');
});
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
const up = new DoodstreamUploader();
const html = `
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
<input type="hidden" name="sess_id" value="TOK">
<input name="file" type="file" size="30" id="filepc">
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
<input type="text" name="file_title" class="form-control">
<button type="submit" name="submit_btn" class="btn">Upload</button>
</form>`;
const f = up._parseUploadFormFields(html);
assert.equal(f.sess_id, 'TOK');
assert.equal(f.fakefilepc, '');
assert.equal(f.file_title, '');
assert.ok('submit_btn' in f);
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
});
test('_parseUploadFormFields returns {} for markup without a form', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
assert.deepEqual(up._parseUploadFormFields(''), {});
});
// --- deriveApiKey: pull + validate the account API key from the web session ---
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
const up = new DoodstreamUploader();
const html = `
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
`;
const cands = up._extractApiKeyCandidates(html);
// The token whose preceding context mentions "API" must rank first.
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
});
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._extractApiKeyCandidates(''), []);
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
});
test('deriveApiKey returns the candidate that validates against the API', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
const key = await up.deriveApiKey();
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
});
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
up._validateApiKey = async () => false;
assert.equal(await up.deriveApiKey(), null);
assert.equal(up.apiKey, '');
});
test('deriveApiKey short-circuits when a key is already set', async () => {
const up = new DoodstreamUploader();
up.apiKey = 'PRESET';
let fetched = false;
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
assert.equal(await up.deriveApiKey(), 'PRESET');
assert.equal(fetched, false);
});
// --- _fetch: transient network blips on the small requests self-heal ---
test('_fetch retries a transient network failure then succeeds', async () => {
const up = new DoodstreamUploader();
const origFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls++;
if (calls === 1) throw new TypeError('fetch failed');
return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' };
};
try {
const res = await up._fetch('https://example.test/x');
assert.equal(calls, 2); // failed once, retried, succeeded
assert.equal(await res.text(), 'ok');
} finally {
globalThis.fetch = origFetch;
}
});
// --- _getUploadServer: discovery must never fall back to a hardcoded node ---
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
}
test('OTP verification keeps the challenged cookie session without another bootstrap request', async () => {
const up = new DoodstreamUploader();
const originalFetch = globalThis.fetch;
let bootstrapCalls = 0;
let loginCalls = 0;
up._fetch = async () => {
bootstrapCalls++;
return fakeRes(bootstrapCalls === 1 ? 'ok' : `<home-upload :upload="{ utype: 'reg', sess_id: 'SESSION_456-X' }"></home-upload>`);
};
globalThis.fetch = async (url, options) => {
loginCalls++;
assert.match(url, /\?op=login_ajax&/u);
assert.equal(options.method, 'GET');
assert.equal(options.body, undefined);
if (loginCalls === 1) {
assert.equal(options.headers.Cookie, undefined);
return {
status: 200,
headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null },
text: async () => JSON.stringify({ status: 'otp_sent', message: 'Verification code has been sent' })
};
}
assert.equal(options.headers.Cookie, 'otp_session=SESSION123');
assert.match(url, /loginotp=123456/u);
return {
status: 200,
headers: { getSetCookie: () => [], get: () => null },
text: async () => JSON.stringify({ status: 'redirect', message: '/?op=my_account' })
};
};
try {
await assert.rejects(() => up.login('user', 'secret'), error => error.otpRequired === true);
await up.login('user', 'secret', '123456');
assert.equal(bootstrapCalls, 2);
assert.equal(loginCalls, 2);
assert.equal(up.sessId, 'SESSION_456-X');
} finally {
globalThis.fetch = originalFetch;
}
});
test('_findSessId accepts the current Vue upload data and URL-safe tokens', () => {
const up = new DoodstreamUploader();
assert.equal(
up._findSessId(`<home-upload :upload="{ utype: 'reg', sess_id: 'abc_DEF-123' }"></home-upload>`),
'abc_DEF-123'
);
assert.equal(
up._findSessId('<home-upload :upload="{ &quot;sess_id&quot;: &quot;abc_DEF-456&quot; }"></home-upload>'),
'abc_DEF-456'
);
assert.equal(up._findSessId(`<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`), '');
});
test('login follows the session-establishing redirect before loading the upload page', async (t) => {
const up = new DoodstreamUploader();
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
const parsed = new URL(url);
const route = parsed.pathname + (parsed.searchParams.get('op') || '');
requests.push(route);
if (parsed.searchParams.get('op') === 'login_ajax') {
return new Response(JSON.stringify({ status: 'redirect', message: '/finish-login?ticket=TEST' }));
}
if (parsed.pathname === '/finish-login') {
return new Response('', { headers: { 'set-cookie': 'session=TEST; Path=/' } });
}
if (parsed.searchParams.get('op') === 'upload') {
assert.equal(options.headers.Cookie, 'session=TEST');
return new Response('<input name="sess_id" value="TEST_SESSION">');
}
return new Response('');
});
await up.login('user', 'password', '123456');
assert.deepEqual(requests, ['/', '/login_ajax', '/finish-login', '/upload']);
assert.equal(up.sessId, 'TEST_SESSION');
});
test('login cannot report success when the redirected session is still a guest', async (t) => {
const up = new DoodstreamUploader();
t.mock.method(globalThis, 'fetch', async url => new Response(
new URL(url).searchParams.get('op') === 'login_ajax'
? JSON.stringify({ status: 'redirect', message: '/dashboard' })
: `<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`
));
await assert.rejects(up.login('user', 'password'), /sess_id nicht gefunden/);
assert.equal(up.sessId, '');
});
test('login redirect chains cannot forward session cookies to another origin', async (t) => {
const up = new DoodstreamUploader();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('', { status: 302, headers: { location: 'https://other.example/' } });
});
await assert.rejects(up._fetch('https://doodstream.com/dashboard', { allowedOrigin: 'https://doodstream.com' }), /Weiterleitungsziel/);
assert.equal(calls, 1);
});
test('authenticated dashboard verifies web login without an upload token or API calls', async (t) => {
const up = new DoodstreamUploader();
t.mock.method(globalThis, 'fetch', async (url) => {
const target = new URL(url);
assert.equal(target.hostname, 'doodstream.com');
if (target.searchParams.get('op') === 'login_ajax') {
return new Response(JSON.stringify({ status: 'redirect', message: '/dashboard' }), {
headers: { 'set-cookie': 'xfsts=SESSION; Path=/' }
});
}
return new Response('<a href="/settings">Settings</a><a href="/videos">Videos</a><a href="/?op=logout">Logout</a>');
});
await up.login('user', 'secret', '123456');
assert.equal(up.apiKey, '');
assert.equal(up.sessId, '');
assert.equal(up.cookies.get('xfsts'), 'SESSION');
});
test('an unverified settings token cannot make a missing-session login pass', async (t) => {
const up = new DoodstreamUploader();
t.mock.method(globalThis, 'fetch', async url => {
const target = new URL(url);
if (target.hostname === 'doodapi.co') return new Response(JSON.stringify({ status: 403 }));
if (target.searchParams.get('op') === 'login_ajax') return new Response(JSON.stringify({ status: 'success' }));
if (target.pathname === '/settings') return new Response('<label>API Key</label><input value="INVALIDKEY12345678901234567890">');
return new Response('');
});
await assert.rejects(up.login('user', 'secret'), /sess_id nicht gefunden/);
assert.equal(up.apiKey, '');
});
test('web server unavailability is a hoster failure rather than a missing login session', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => new Response(JSON.stringify({ success: false, message: 'No servers available for uploads' }));
await assert.rejects(up._getUploadServer(), error => error.hosterTransient === true && /No servers available/.test(error.message));
});
test('parallel web uploads receive independent cookie and upload-session state', () => {
const up = new DoodstreamUploader();
up.cookies.set('xfsts', 'AUTH_SESSION');
up.sessId = 'UPLOAD_SESSION';
const first = up.cloneSession();
const second = up.cloneSession();
first.cookies.set('xfsts', 'CHANGED');
first.sessId = 'CHANGED';
assert.equal(second.cookies.get('xfsts'), 'AUTH_SESSION');
assert.equal(second.sessId, 'UPLOAD_SESSION');
assert.equal(up.cookies.get('xfsts'), 'AUTH_SESSION');
});
test('getUploadServer: parses the current upload_get_srv response', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
assert.match(url, /op=upload_get_srv/);
return fakeRes(JSON.stringify({ success: true, server: { srv_url: 'https://node42.cloudatacdn.com', disk_id: '01' } }), { ctype: 'application/json' });
};
assert.match(await up._getUploadServer(), /^https:\/\/node42\.cloudatacdn\.com\/upload\/01\?t=\d+$/u);
});
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
};
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
});
test('getUploadServer: parses current form-action node and refreshes sess_id from the same page', async () => {
const up = new DoodstreamUploader();
up.sessId = 'stale-from-login';
up._fetch = async (url) => {
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH_123-X" method="post"><input type="hidden" name="sess_id" value="FRESH_123-X"></form>');
};
const url = await up._getUploadServer();
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH_123-X');
assert.equal(up.sessId, 'FRESH_123-X'); // critical: form-field token must match the node URL token
});
test('getUploadServer: un-escapes &amp; in the form-action query string', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&amp;b=2" method="post"></form>');
};
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
});
test('getUploadServer: throws (no silent dead fallback) when discovery fails', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => fakeRes('<html><body>login required</body></html>', { status: 200 });
await assert.rejects(
() => up._getUploadServer(),
(err) => {
assert.match(err.message, /konnte Upload-Server nicht ermitteln/);
assert.doesNotMatch(err.message, /tr1128ve\.cloudatacdn\.com/); // never the hardcoded node
return true;
}
);
});
-59
View File
@@ -1,59 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { walkFolderAsync } = require('../lib/file-discovery');
function directory(name) {
return { name, isDirectory: () => true, isFile: () => false };
}
function file(name) {
return { name, isDirectory: () => false, isFile: () => true };
}
test('folder discovery preserves UNC and Unicode paths', async () => {
const root = '\\\\server\\share\\Übertragungen';
const child = path.win32.join(root, 'Staffel 1');
const target = path.win32.join(child, 'Folge äöü.mkv');
const fsPromises = {
readdir: async dir => dir === root ? [directory('Staffel 1')] : [file('Folge äöü.mkv')],
stat: async value => ({ size: value === target ? 1234 : 0 })
};
const result = await walkFolderAsync(root, { fsPromises, pathImpl: path.win32 });
assert.deepEqual(result, [{ path: target, name: 'Folge äöü.mkv', size: 1234 }]);
});
test('folder discovery does not truncate long absolute paths', async () => {
const root = `C:\\${'sehr-langer-ordner\\'.repeat(18)}ziel`;
const target = path.win32.join(root, 'video.mp4');
const result = await walkFolderAsync(root, {
fsPromises: {
readdir: async () => [file('video.mp4')],
stat: async () => ({ size: 77 })
},
pathImpl: path.win32
});
assert.equal(result[0].path, target);
assert.ok(result[0].path.length > 260);
});
test('nonrecursive folder discovery never reads child directories', async () => {
const root = 'C:\\watch';
const child = path.win32.join(root, 'nested');
const reads = [];
const result = await walkFolderAsync(root, {
recursive: false,
fsPromises: {
readdir: async (dir) => {
reads.push(dir);
if (dir === root) return [file('root.mkv'), directory('nested')];
if (dir === child) return [file('nested.mkv')];
return [];
},
stat: async () => ({ size: 1 })
},
pathImpl: path.win32
});
assert.deepEqual(reads, [root]);
assert.deepEqual(result.map((entry) => entry.name), ['root.mkv']);
});
-115
View File
@@ -1,115 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat } = require('../lib/file-probe');
function tmpWrite(name, buf) {
const p = path.join(os.tmpdir(), `mhu-probe-${Date.now()}-${name}`);
fs.writeFileSync(p, buf);
return p;
}
test('detectKind recognizes ISO-MP4 (ftyp box at offset 4)', () => {
const buf = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(8, 0)]);
assert.strictEqual(detectKind(buf), 'mp4-iso');
assert.strictEqual(isVideoLikeKind('mp4-iso'), true);
});
test('detectKind recognizes Matroska / WebM EBML header', () => {
const buf = Buffer.from([0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00]);
assert.strictEqual(detectKind(buf), 'matroska');
assert.strictEqual(isVideoLikeKind('matroska'), true);
});
test('detectKind recognizes AVI (RIFF...AVI )', () => {
const buf = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('AVI ', 'ascii')]);
assert.strictEqual(detectKind(buf), 'avi');
});
test('detectKind recognizes FLV', () => {
const buf = Buffer.concat([Buffer.from('FLV', 'ascii'), Buffer.from([0x01])]);
assert.strictEqual(detectKind(buf), 'flv');
});
test('detectKind recognizes ASF (WMV)', () => {
const buf = Buffer.from([0x30, 0x26, 0xB2, 0x75, 0x00, 0x00]);
assert.strictEqual(detectKind(buf), 'asf-wmv');
});
test('detectKind recognizes MPEG-PS (00 00 01 BA)', () => {
const buf = Buffer.from([0x00, 0x00, 0x01, 0xBA, 0x00]);
assert.strictEqual(detectKind(buf), 'mpeg-ps');
});
test('detectKind recognizes JPEG (non-video)', () => {
const buf = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
assert.strictEqual(detectKind(buf), 'jpeg');
assert.strictEqual(isVideoLikeKind('jpeg'), false);
});
test('detectKind recognizes HTML response (non-video)', () => {
const buf = Buffer.from('<!DOCTYPE html><html><head>', 'ascii');
assert.strictEqual(detectKind(buf), 'html');
assert.strictEqual(isVideoLikeKind('html'), false);
});
test('detectKind returns empty for zero-length and unknown for noise', () => {
assert.strictEqual(detectKind(Buffer.alloc(0)), 'empty');
assert.strictEqual(detectKind(Buffer.from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])), 'unknown');
});
test('probeFileHead reads first bytes and returns hex + kind for an MP4-like file', async () => {
const mp4Head = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(16, 0xAA)]);
const p = tmpWrite('fake.mp4', mp4Head);
try {
const res = await probeFileHead(p, 64);
assert.strictEqual(res.ok, true);
assert.strictEqual(res.kind, 'mp4-iso');
assert.strictEqual(res.isVideoLike, true);
assert.ok(res.headHex.startsWith('0000002066747970'));
assert.strictEqual(res.bytesRead, mp4Head.length);
} finally {
fs.unlinkSync(p);
}
});
test('probeFileHead returns ok:false with kind=unreadable for missing file', async () => {
const res = await probeFileHead(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.mp4`), 32);
assert.strictEqual(res.ok, false);
assert.strictEqual(res.kind, 'unreadable');
assert.ok(res.error);
});
test('summarizeFileStat returns size + mtime for a real file', () => {
const p = tmpWrite('stat.bin', Buffer.alloc(123, 0xCC));
try {
const stat = summarizeFileStat(p);
assert.strictEqual(stat.size, 123);
assert.strictEqual(stat.isFile, true);
assert.ok(stat.mtime);
} finally {
fs.unlinkSync(p);
}
});
test('summarizeFileStat returns error for missing file', () => {
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
assert.ok(stat.error);
});
test('detectKind requires TS sync-byte periodicity — GIF and G-prefixed text are NOT mpeg-ts', () => {
const ts = Buffer.alloc(377, 0xFF);
ts[0] = 0x47; ts[188] = 0x47; ts[376] = 0x47;
assert.strictEqual(detectKind(ts), 'mpeg-ts');
assert.strictEqual(isVideoLikeKind('mpeg-ts'), true);
const gif = Buffer.concat([Buffer.from('GIF89a', 'ascii'), Buffer.alloc(400, 0x00)]);
assert.strictEqual(detectKind(gif), 'gif');
assert.strictEqual(isVideoLikeKind('gif'), false);
const gText = Buffer.concat([Buffer.from('Gewinnerliste 2026\n', 'ascii'), Buffer.alloc(400, 0x20)]);
assert.notStrictEqual(detectKind(gText), 'mpeg-ts');
assert.strictEqual(isVideoLikeKind(detectKind(gText)), false);
});
-839
View File
@@ -1,839 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const FolderMonitor = require('../lib/folder-monitor');
const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control');
function createWatcherHarness() {
const calls = [];
const timers = createManualTimers();
const watch = (folderPath, options) => {
const watcher = new EventEmitter();
watcher.close = async () => {};
calls.push({ folderPath, options, watcher });
return watcher;
};
return { calls, monitor: new FolderMonitor({ watch, ...timers }) };
}
test('test scan works while inactive, active or paused without changing monitor state', async () => {
const folderPath = path.join(os.tmpdir(), 'monitor-read-only-test');
for (const state of ['inactive', 'active', 'paused']) {
const { monitor, events } = createScanHarness({ files: [
{ path: path.join(folderPath, 'video.mkv'), size: 12, mtimeMs: 1 },
{ path: path.join(folderPath, 'note.txt'), size: 2, mtimeMs: 2 }
] });
if (state === 'active') monitor.start({ folderPath: 'different-live-folder' });
if (state === 'paused') monitor.configure({ folderPath: 'different-paused-folder', paused: true });
const before = monitor.status();
const generation = monitor._generation;
const settings = monitor._settings;
const statuses = events.statuses.length;
const seen = [...monitor._seenFiles];
const result = await monitor.testScan({ enabled: false, folderPath, extensions: 'mkv', filterMode: 'include', recursive: true });
assert.equal(result.reachable, true);
assert.deepEqual(result.files.map(file => file.filterMatched), [true, false]);
assert.deepEqual(monitor.status(), before);
assert.equal(monitor._generation, generation);
assert.equal(monitor._settings, settings);
assert.deepEqual([...monitor._seenFiles], seen);
assert.equal(events.statuses.length, statuses);
assert.equal(events.newFiles.length, 0);
monitor.stop();
}
});
test('test scan uses independent settings for concurrent read-only requests', async () => {
const visited = [];
const monitor = new FolderMonitor({
access: async () => {},
walkFolder: async (folder, options) => { visited.push([folder, options.recursive]); return []; },
watch: () => { throw new Error('No watcher may be started'); },
setIntervalFn: () => { throw new Error('No timer may be started'); }
});
await Promise.all([
monitor.testScan({ folderPath: 'first', recursive: false }),
monitor.testScan({ folderPath: 'second', recursive: true })
]);
assert.deepEqual(visited, [['first', false], ['second', true]]);
assert.equal(monitor.running, false);
assert.equal(monitor.status().folderPath, '');
});
test('test scan distinguishes missing, unavailable and unreadable folders safely', async () => {
const missing = await new FolderMonitor().testScan({});
assert.equal(missing.error, 'Kein Ordnerpfad angegeben');
const unavailable = await new FolderMonitor({ access: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' });
assert.equal(unavailable.error, 'Ordner nicht erreichbar');
const failed = await new FolderMonitor({ access: async () => {}, walkFolder: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' });
assert.equal(failed.error, 'Ordnerscan fehlgeschlagen');
});
test('test scan IPC reads persisted settings instead of depending on the active watcher', async () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8');
const start = source.indexOf("ipcMain.handle('folder-monitor:test-scan'");
const end = source.indexOf('\n});', start) + 4;
const settings = { enabled: false, folderPath: 'test-folder' };
let handler;
const expected = { files: [], reachable: true };
vm.runInNewContext(source.slice(start, end), {
ipcMain: { handle: (_name, callback) => { handler = callback; } },
configStore: { load: () => ({ globalSettings: { folderMonitor: settings } }) },
folderMonitor: { testScan: snapshot => { assert.equal(snapshot, settings); return expected; } }
});
assert.equal(await handler(), expected);
});
function createManualTimers() {
const intervals = new Set();
const timeouts = new Set();
const intervalDelays = [];
return {
setIntervalFn(callback, delay) {
intervals.add(callback);
intervalDelays.push(delay);
return callback;
},
clearIntervalFn(callback) {
intervals.delete(callback);
},
setTimeoutFn(callback) {
timeouts.add(callback);
return callback;
},
clearTimeoutFn(callback) {
timeouts.delete(callback);
},
async runInterval() {
for (const callback of [...intervals]) await callback();
},
async runTimeouts() {
for (const callback of [...timeouts]) {
timeouts.delete(callback);
await callback();
}
},
intervalDelays
};
}
function createSilentWatch() {
return () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
return watcher;
};
}
function createScanHarness({ files = [] } = {}) {
const events = { newFiles: [], statuses: [] };
const timers = createManualTimers();
const stats = new Map(files.map((file) => [file.path, { mtimeMs: file.mtimeMs }]));
const monitor = new FolderMonitor({
watch: createSilentWatch(),
walkFolder: async () => files.map(({ path: filePath, name, size }) => ({ path: filePath, name, size })),
access: async () => {},
stat: async (filePath) => stats.get(filePath),
now: () => 1234,
...timers
});
monitor.on('new-files', (paths) => events.newFiles.push(paths));
monitor.on('status', (status) => events.statuses.push(status));
return { monitor, events, runInterval: timers.runInterval };
}
function createDeferredScanHarness() {
let calls = 0;
let releaseFirstScan;
const timers = createManualTimers();
const firstScan = new Promise((resolve) => { releaseFirstScan = resolve; });
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
stat: async () => ({ mtimeMs: 1 }),
walkFolder: async () => {
calls++;
if (calls === 1) await firstScan;
return [];
},
...timers
});
monitor.start({ folderPath: 'C:\\incoming', reconcileIntervalMinutes: 5 });
return { monitor, releaseFirstScan, scanCalls: () => calls };
}
function createReachabilityHarness(initiallyReachable) {
let reachable = initiallyReachable;
let calls = 0;
const statusEvents = [];
const timers = createManualTimers();
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {
if (!reachable) throw new Error('unreachable');
},
walkFolder: async () => {
calls++;
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statusEvents.push(status));
return {
monitor,
setReachable(value) { reachable = value; },
statusEvents,
scanCalls: () => calls,
runInterval: timers.runInterval
};
}
test('existing files are included only on the first start of the same watch scope', () => {
const { calls, monitor } = createWatcherHarness();
const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false };
monitor.start(settings);
monitor.start(settings);
assert.equal(calls[0].options.ignoreInitial, false);
assert.equal(calls[1].options.ignoreInitial, true);
});
test('existing files remain ignored unless the option is enabled', () => {
const { calls, monitor } = createWatcherHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
assert.equal(calls[0].options.ignoreInitial, true);
assert.equal(calls[1].options.ignoreInitial, false);
});
test('a changed folder or filter creates a new initial scope', () => {
const { calls, monitor } = createWatcherHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' });
assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]);
});
test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => {
const { calls, monitor } = createWatcherHarness();
let completed = 0;
monitor.on('initial-scan-complete', () => { completed++; });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
calls[0].watcher.emit('ready');
calls[0].watcher.emit('ready');
assert.equal(completed, 1);
});
test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => {
const { monitor, events } = createScanHarness({
files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
]
});
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 });
const result = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.deepEqual(result.files, [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' }
]);
assert.equal(result.files.length, 2);
assert.equal(result.files.filter(file => file.filterMatched).length, 1);
assert.equal(events.newFiles.length, 0);
});
test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => {
const { monitor, events } = createScanHarness({
files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
]
});
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 });
await monitor.scan({ emitFiles: true, trigger: 'startup' });
await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }],
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }]
]);
assert.equal(monitor.status().seenCount, 0);
});
test('reconciliation intervals accept only finite numeric positive-list values', () => {
const cases = [
[1, 60000],
[5, 300000],
[15, 900000],
[30, 1800000],
[60, 3600000],
[-1, 300000],
[Number.POSITIVE_INFINITY, 300000],
['1', 300000],
['15', 300000],
[2, 300000],
[undefined, 300000]
];
for (const [reconcileIntervalMinutes, expectedDelay] of cases) {
const timers = createManualTimers();
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes });
assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes));
monitor.stop();
}
});
test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => {
let now = 1000;
const timers = createManualTimers();
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
now: () => now,
...timers
});
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
now = 2000;
await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
await monitor.pause();
assert.equal(monitor.status().startedAt, null);
assert.equal(monitor.status().nextReconcileAt, null);
now = 4000;
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().nextReconcileAt, 304000);
now = 304000;
await timers.runInterval();
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().lastScanAt, 304000);
assert.equal(monitor.status().nextReconcileAt, 604000);
});
test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => {
const timers = createManualTimers();
let watcherStarts = 0;
const monitor = new FolderMonitor({
watch: () => {
watcherStarts++;
return createSilentWatch()();
},
access: async () => {},
walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
const scan = await monitor.scan({ emitFiles: false, trigger: 'test' });
const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' });
assert.deepEqual(result, { includesExisting: false, paused: true });
assert.equal(watcherStarts, 0);
assert.equal(timers.intervalDelays.length, 0);
assert.equal(monitor.status().paused, true);
assert.equal(monitor.status().folderPath, 'C:\\watch');
assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']);
assert.equal(productive.cancelled, true);
});
test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => {
const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness();
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' });
const third = monitor.scan({ emitFiles: true, trigger: 'manual' });
releaseFirstScan();
await Promise.all([first, second, third]);
assert.equal(scanCalls(), 2);
});
test('disconnect preserves configuration and reconnect performs one immediate scan', async () => {
const { monitor, setReachable, statusEvents, scanCalls, runInterval } = createReachabilityHarness(false);
monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 });
await runInterval();
assert.equal(statusEvents.at(-1).reachable, false);
assert.equal(monitor.status().folderPath, 'Z:\\watch');
setReachable(true);
await runInterval();
assert.equal(statusEvents.at(-1).reachable, true);
assert.equal(scanCalls(), 1);
});
test('reachable reconciliation intervals scan the configured folder', async () => {
const { monitor, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
await runInterval();
await runInterval();
assert.equal(scanCalls(), 2);
});
test('pause stops watcher and reconciliation until explicit resume', async () => {
const { monitor, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
await monitor.pause();
await runInterval();
assert.equal(scanCalls(), 0);
assert.equal(monitor.status().paused, true);
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(scanCalls(), 1);
assert.equal(monitor.status().paused, false);
});
test('resume can activate watcher and interval before a separately gated reconciliation', async () => {
const { monitor, scanCalls } = createReachabilityHarness(true);
const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 };
monitor.start(settings);
await monitor.pause();
const result = await monitor.resume(settings, { reconcile: false });
assert.equal(scanCalls(), 0);
assert.equal(result.reconciled, false);
assert.equal(monitor.status().running, true);
assert.equal(monitor.status().paused, false);
});
test('repeated pause emits one status change and keeps reconciliation stopped', async () => {
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCountBeforePause = statusEvents.length;
await monitor.pause();
await monitor.pause();
await runInterval();
assert.equal(statusEvents.length, statusCountBeforePause + 1);
assert.equal(statusEvents.at(-1).paused, true);
assert.equal(scanCalls(), 0);
});
test('stop emits an immutable non-running status snapshot', () => {
const { monitor } = createWatcherHarness();
const statuses = [];
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCount = statuses.length;
monitor.stop();
assert.equal(statuses.length, statusCount + 1);
assert.equal(statuses.at(-1).running, false);
assert.equal(Object.isFrozen(statuses.at(-1)), true);
});
test('pause invalidates a running scan before it can publish late state or files', async () => {
let releaseWalk;
let markWalkStarted;
const walkPending = new Promise((resolve) => { releaseWalk = resolve; });
const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; });
const timers = createManualTimers();
const statuses = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
markWalkStarted();
await walkPending;
return [{ path: 'C:\\watch\\late.mkv', name: 'late.mkv', size: 1 }];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.on('new-files', (files) => newFiles.push(files));
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 });
const scan = monitor.scan({ emitFiles: true, trigger: 'manual' });
await walkStarted;
await monitor.pause();
const statusCountAfterPause = statuses.length;
releaseWalk();
const result = await scan;
assert.equal(result.cancelled, true);
assert.equal(statuses.length, statusCountAfterPause);
assert.deepEqual(newFiles, []);
assert.equal(monitor.status().scanning, false);
});
test('stop cancels a running scan and its pending follow-up', async () => {
let releaseWalk;
let markWalkStarted;
let walkCalls = 0;
const walkPending = new Promise((resolve) => { releaseWalk = resolve; });
const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; });
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
walkCalls++;
if (walkCalls === 1) {
markWalkStarted();
await walkPending;
}
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
await walkStarted;
const followUp = monitor.scan({ emitFiles: true, trigger: 'manual' });
monitor.stop();
const statusCountAfterStop = statuses.length;
releaseWalk();
const results = await Promise.all([first, followUp]);
assert.equal(walkCalls, 1);
assert.equal(results.every((result) => result.cancelled === true), true);
assert.equal(statuses.length, statusCountAfterStop);
});
test('late watcher add callbacks are ignored after pause', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
await monitor.pause();
watchers[0].emit('add', 'C:\\watch\\late.mkv');
await timers.runTimeouts();
assert.deepEqual(newFiles, []);
});
test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: 'C:\\watch\\same.mkv', name: 'same.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', 'C:\\watch\\same.mkv');
await timers.runTimeouts();
await monitor.pause();
await monitor.resume(settings);
watchers[1].emit('add', 'C:\\watch\\same.mkv');
await timers.runTimeouts();
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [
['C:\\watch\\same.mkv'],
['C:\\watch\\same.mkv']
]);
assert.equal(monitor.status().seenCount, 1);
});
test('watcher add paused before batch timeout is emitted exactly once by resume scan', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\pending.mkv';
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: filePath, name: 'pending.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', filePath);
assert.deepEqual(newFiles, []);
await monitor.pause();
assert.deepEqual(newFiles, []);
await monitor.resume(settings);
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]);
assert.equal(monitor.status().seenCount, 0);
});
test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\delivered.mkv';
let pausePromise;
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => [{ path: filePath, name: 'delivered.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => {
newFiles.push(files);
pausePromise = monitor.pause();
});
const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
monitor.start(settings);
watchers[0].emit('add', filePath);
await timers.runTimeouts();
await pausePromise;
assert.deepEqual(newFiles, [[filePath]]);
await monitor.resume(settings);
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]);
assert.equal(monitor.status().seenCount, 1);
});
test('pause rollback never deletes historical seen state from a dedupe-off batch', async () => {
const timers = createManualTimers();
const watchers = [];
const newFiles = [];
const filePath = 'C:\\watch\\historical.mkv';
let discoverExisting = false;
const monitor = new FolderMonitor({
watch: () => {
const watcher = new EventEmitter();
watcher.close = async () => {};
watchers.push(watcher);
return watcher;
},
access: async () => {},
walkFolder: async () => discoverExisting ? [{ path: filePath, name: 'historical.mkv', size: 1 }] : [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('new-files', (files) => newFiles.push(files));
const dedupeOn = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 };
const dedupeOff = { ...dedupeOn, skipDuplicates: false };
monitor.start(dedupeOn);
watchers[0].emit('add', filePath);
await timers.runTimeouts();
assert.deepEqual(newFiles, [[filePath]]);
await monitor.pause();
await monitor.resume(dedupeOff);
watchers[1].emit('add', filePath);
assert.deepEqual(newFiles, [[filePath]]);
await monitor.pause();
await monitor.resume(dedupeOn);
watchers[2].emit('add', filePath);
await timers.runTimeouts();
assert.deepEqual(newFiles, [[filePath]]);
assert.equal(monitor.status().seenCount, 1);
});
test('dry scan leaves the public status byte-identical and does not consume reconnect state', async () => {
let reachable = false;
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {
if (!reachable) throw new Error('offline');
},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 });
await monitor.scan({ emitFiles: true, trigger: 'interval' });
reachable = true;
const before = JSON.stringify(monitor.status());
const statusCount = statuses.length;
const dry = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.equal(dry.reachable, true);
assert.equal(JSON.stringify(monitor.status()), before);
assert.equal(statuses.length, statusCount);
const productive = await monitor.scan({ emitFiles: true, trigger: 'reconnect' });
assert.equal(productive.reconnected, true);
});
test('walk failure clears scan state, sanitizes the error and preserves one follow-up', async () => {
let releaseFailure;
let walkCalls = 0;
const failurePending = new Promise((resolve) => { releaseFailure = resolve; });
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => {
walkCalls++;
if (walkCalls === 1) {
await failurePending;
throw new Error('token=secret-value at C:\\private\\file.mkv');
}
return [];
},
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' });
const third = monitor.scan({ emitFiles: true, trigger: 'manual' });
releaseFailure();
await Promise.all([first, second, third]);
assert.equal(walkCalls, 2);
assert.equal(statuses.some((status) => status.error === 'Ordnerscan fehlgeschlagen'), true);
assert.equal(statuses.some((status) => status.error.includes('secret-value') || status.error.includes('C:\\private')), false);
assert.equal(monitor.status().scanning, false);
});
test('new-files listener failure terminates the productive scan with a sanitized error', async () => {
const { monitor } = createScanHarness({
files: [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1, mtimeMs: 1 }]
});
monitor.on('new-files', () => {
throw new Error('apiKey=listener-secret');
});
monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
const result = await monitor.scan({ emitFiles: true, trigger: 'manual' });
assert.equal(result.error, 'Ordnerscan fehlgeschlagen');
assert.equal(monitor.status().error, 'Ordnerscan fehlgeschlagen');
assert.equal(monitor.status().error.includes('listener-secret'), false);
assert.equal(monitor.status().scanning, false);
});
test('interval callback contains unexpected scan rejection', async () => {
const timers = createManualTimers();
const statuses = [];
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
monitor.on('status', (status) => statuses.push(status));
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
monitor.scan = async () => { throw new Error('token=interval-secret'); };
await assert.doesNotReject(() => timers.runInterval());
assert.equal(statuses.at(-1).error, 'Ordnerscan fehlgeschlagen');
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
});
test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
const detached = `${root}-detached`;
const timers = createManualTimers();
const queuedPaths = new Set();
const admissions = [];
let currentJobCount = 14998;
try {
fs.mkdirSync(path.join(root, 'nested'));
const fourJobPath = path.join(root, 'four-jobs.mkv');
const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv');
fs.writeFileSync(fourJobPath, Buffer.from('four'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored'));
fs.writeFileSync(twoJobPath, Buffer.from('two'));
fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded'));
fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z'));
fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.on('new-files', (files) => {
const descriptors = files.map(file => typeof file === 'string' ? {
path: file,
name: path.basename(file),
mtimeMs: fs.statSync(file).mtimeMs
} : file).filter(file => file.filterMatched !== false);
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
const unprocessed = new Set(processed.unprocessedPaths);
const candidates = descriptors
.filter(file => unprocessed.has(file.path))
.map(file => ({ ...file, eligibleJobCount: file.name === 'four-jobs.mkv' ? 4 : 2 }));
const plan = planAtomicAdmissions({ candidates, currentJobCount, queueLimitJobs: 15000 });
for (const filePath of plan.admittedPaths) queuedPaths.add(filePath);
currentJobCount += plan.plannedJobs;
admissions.push({
trigger: monitor.status().lastScanTrigger,
admittedPaths: plan.admittedPaths,
deferredPaths: plan.deferredPaths,
currentJobCount
});
});
monitor.start({
folderPath: root,
recursive: true,
extensions: 'mkv',
filterMode: 'include',
skipDuplicates: true,
reconcileIntervalMinutes: 5
});
const first = await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.deepEqual(first.files.filter(file => file.filterMatched).map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']);
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
assert.deepEqual(admissions[0], {
trigger: 'startup',
admittedPaths: [twoJobPath],
deferredPaths: [fourJobPath],
currentJobCount: 15000
});
currentJobCount = 14996;
await timers.runInterval();
assert.deepEqual(admissions[1], {
trigger: 'interval',
admittedPaths: [fourJobPath],
deferredPaths: [],
currentJobCount: 15000
});
fs.renameSync(root, detached);
await timers.runInterval();
assert.equal(monitor.status().reachable, false);
assert.equal(admissions.length, 2);
fs.renameSync(detached, root);
await timers.runInterval();
assert.equal(monitor.status().reachable, true);
assert.equal(admissions.at(-1).trigger, 'reconnect');
assert.deepEqual(admissions.at(-1).admittedPaths, []);
await timers.runInterval();
assert.equal(admissions.filter(entry => entry.trigger === 'reconnect').length, 1);
assert.equal(admissions.at(-1).trigger, 'interval');
assert.deepEqual([...queuedPaths].sort(), [fourJobPath, twoJobPath].sort());
await monitor.pause();
} finally {
if (fs.existsSync(detached)) fs.renameSync(detached, root);
fs.rmSync(root, { recursive: true, force: true });
}
});
-95
View File
@@ -1,95 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store');
function batch(timestamp, okRows, extras = {}) {
const results = [];
for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` });
if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' });
if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' });
return { timestamp, files: [{ name: 'clip.mp4', results }] };
}
const DAY = 86400000;
test('countHistoryRows counts every visible history result', () => {
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
assert.strictEqual(countHistoryRows(h), 6);
});
test('count policy prunes histories made only from failed or aborted uploads', () => {
const h = [
batch('2026-01-01', 0, { error: 60 }),
batch('2026-01-02', 0, { aborted: 60 }),
batch('2026-01-03', 0, { error: 60 })
];
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
assert.strictEqual(countHistoryRows(pruned), 120);
});
test('retention "all" returns the array unchanged', () => {
const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)];
assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h);
});
test('count policy keeps newest whole batches up to the row target', () => {
const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 3);
assert.strictEqual(countHistoryRows(pruned), 1200);
});
test('count policy drops older batches once target reached (newest first)', () => {
const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 2);
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
});
test('count policy always keeps the newest batch even if it alone exceeds N', () => {
const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)];
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(pruned[0].timestamp, '2026-01-02');
});
test('time policy drops batches older than the cutoff', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(new Date(now - 10 * DAY).toISOString(), 5),
batch(new Date(now - 3 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '7d', now);
assert.strictEqual(pruned.length, 2);
});
test('time policy keeps batches with missing or invalid timestamp', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(undefined, 5),
batch('not-a-date', 5),
batch(new Date(now - 99 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '30d', now);
assert.strictEqual(pruned.length, 3);
assert.ok(pruned.includes(h[0]));
assert.ok(pruned.includes(h[1]));
assert.ok(!pruned.includes(h[2]));
});
test('count policy shrinks a realistic 41-batch / >1000-row history', () => {
const h = [];
for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300));
assert.strictEqual(countHistoryRows(h), 41 * 1300);
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(countHistoryRows(pruned), 1300);
});
test('empty history is returned as-is for any policy', () => {
assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []);
assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []);
});
-17
View File
@@ -1,17 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { classifyHistoryStatus, historyDetail } = require('../renderer/history-status');
test('history status keeps skipped separate from successful and failed uploads', () => {
assert.equal(classifyHistoryStatus('done'), 'success');
assert.equal(classifyHistoryStatus('error'), 'error');
assert.equal(classifyHistoryStatus('aborted'), 'error');
assert.equal(classifyHistoryStatus('skipped'), 'skipped');
assert.equal(classifyHistoryStatus('unexpected'), 'error');
});
test('history detail shows a skipped reason instead of a fake link', () => {
assert.equal(historyDetail({ status: 'skipped', error: 'Datei zu groß', download_url: 'https://example.invalid/wrong' }), 'Datei zu groß');
assert.equal(historyDetail({ status: 'done', download_url: 'https://example.invalid/ok' }), 'https://example.invalid/ok');
});
-95
View File
@@ -1,95 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { __test } = require('../lib/hosters');
describe('hosters helpers', () => {
it('extracts VOE file_code from nested result payloads', () => {
assert.deepEqual(__test.parseVoeResult({ result: { file: { file_code: 'abc123' } } }), {
download_url: 'https://voe.sx/abc123',
embed_url: 'https://voe.sx/e/abc123',
file_code: 'abc123'
});
});
it('extracts VOE file_code from flat fallback payloads', () => {
assert.deepEqual(__test.parseVoeResult({ file_code: 'xyz789' }), {
download_url: 'https://voe.sx/xyz789',
embed_url: 'https://voe.sx/e/xyz789',
file_code: 'xyz789'
});
});
it('extracts upload server URLs from nested API responses', () => {
const url = __test.extractUploadServerUrl({
result: {
server: {
upload_url: 'https://delivery-hydra.voe-network.net/upload/01'
}
}
}, 'https://voe.sx');
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
});
it('parseDoodstreamResult tolerates null/non-object payload without throwing', () => {
// Direct callers may bypass uploadFile's normalisation. The parser must
// never throw on bad input — empty fields are the contract.
for (const bad of [null, undefined, 'string', 42, true]) {
const r = __test.parseDoodstreamResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
assert.equal(arr.file_code, 'AB1');
assert.equal(arr.download_url, 'https://x/1');
assert.equal(arr.embed_url, 'https://x/e/1');
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
assert.equal(obj.file_code, 'OBJ1');
assert.equal(obj.download_url, 'https://x/2');
});
it('parseByseResult tolerates null/non-object payload without throwing', () => {
for (const bad of [null, undefined, 'string', 42, []]) {
const r = __test.parseByseResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseByseResult handles malformed files entries (null, missing fields)', () => {
// Files array with a null first element (server returned [null])
const a = __test.parseByseResult({ files: [null] });
assert.equal(a.file_code, null);
// Files array with object missing both filecode and status
const b = __test.parseByseResult({ files: [{}] });
assert.equal(b.file_code, null);
});
it('parseByseResult throws fileRejected for non-OK status with empty filecode', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'Not video file format' }] }),
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
);
});
it('parseByseResult flips to accountError for storage-exhausted phrasing', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'not enough disk space on your account' }] }),
(err) => err.accountError === true
);
});
it('parseByseResult succeeds with valid filecode in files[0]', () => {
const r = __test.parseByseResult({ files: [{ filecode: 'GOOD123', status: 'OK' }] });
assert.equal(r.file_code, 'GOOD123');
assert.equal(r.download_url, 'https://byse.sx/d/GOOD123');
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
});
});
-408
View File
@@ -1,408 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const espree = require('espree');
const { normalizeLanguage, translateText } = require('../renderer/i18n');
test('online backup metadata displays source IP without exposing full keys', () => {
assert.equal(translateText('IP unbekannt', 'en'), 'IP unknown');
assert.equal(translateText('IP unknown', 'de'), 'IP unbekannt');
const source = fs.readFileSync(path.join(__dirname, '../renderer/app.js'), 'utf8');
assert.ok(source.includes('`IP: ${entry.sourceIp}`'));
assert.ok(source.includes('key.textContent = entry.displayKey;'));
});
test('online backup selection defaults to unlimited in both languages', () => {
assert.equal(translateText('Unbegrenzt (Standard)', 'en'), 'Unlimited (default)');
assert.equal(translateText('Unlimited (default)', 'de'), 'Unbegrenzt (Standard)');
assert.equal(translateText('7 Tage', 'en'), '7 days');
const source = fs.readFileSync(path.join(__dirname, '../renderer/app.js'), 'utf8');
const select = source.match(/<select id="onlineBackupRetentionSelect">([\s\S]*?)<\/select>/)[1];
assert.match(select, /<option value="forever" selected>Unbegrenzt \(Standard\)<\/option>/);
assert.equal((select.match(/ selected/g) || []).length, 1);
assert.match(source, /const retention = retentionSelect\?\.value \|\| 'forever';/);
});
test('translates permanent source deletion controls to English', () => {
assert.equal(translateText('Quelldatei nach vollständigem Upload dauerhaft löschen', 'en'), 'Permanently delete source file after complete upload');
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
});
test('translates the remaining upload size label', () => {
assert.equal(translateText('Verbleibende Größe', 'en'), 'Remaining size');
assert.equal(translateText('Remaining size', 'de'), 'Verbleibende Größe');
});
test('translates compact online backup labels in both directions', () => {
assert.equal(translateText('Neuer Schlüssel', 'en'), 'Your new key');
assert.equal(translateText('Your new key', 'de'), 'Neuer Schlüssel');
assert.equal(translateText('Schlüssel importieren', 'en'), 'Import existing key');
assert.equal(translateText('Import existing key', 'de'), 'Schlüssel importieren');
});
test('translates managed online backup controls in both directions', () => {
const pairs = [
['Auf diesem Gerät erstellt', 'Created on this device'],
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
['Schlüssel kopieren', 'Copy key'],
['Online-Backup löschen', 'Delete online backup'],
['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'],
['Schlüssel gelöscht', 'Key deleted'],
['Importieren', 'Import'],
['Neuer Schlüssel erstellt.', 'New key created.'],
['Erneut laden', 'Reload'],
['Gespeicherter Online-Schlüsselbund ist beschädigt', 'Stored online keyring is damaged'],
['Sichere Schlüsselspeicherung ist nicht verfügbar', 'Secure key storage is unavailable'],
['Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', 'Stored online backup key could not be decrypted'],
['Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein', 'Stored online backup ID does not match its key'],
['Gespeicherte Online-Sicherungskennung ist mehrdeutig', 'Stored online backup ID is ambiguous'],
['Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen', 'Online keyring was loaded from a recovery file']
];
for (const [german, english] of pairs) {
assert.equal(translateText(german, 'en'), english);
assert.equal(translateText(english, 'de'), german);
}
});
test('translates the account check timestamp label', () => {
assert.equal(translateText('geprüft', 'en'), 'checked');
assert.equal(translateText('checked', 'de'), 'geprüft');
assert.equal(translateText('Neuen Code anfordern', 'en'), 'Request new code');
assert.equal(translateText('The OTP request has expired. Request a new code.', 'de'), 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.');
});
test('translates settings search result labels in both directions', () => {
assert.equal(translateText('Nach erfolgreichem Upload löschen', 'en'), 'Delete after successful upload');
assert.equal(translateText('Delete after successful upload', 'de'), 'Nach erfolgreichem Upload löschen');
assert.equal(translateText('Suchergebnisse', 'en'), 'Search results');
});
test('translates every automation control center label in both directions', () => {
const pairs = [
['Ordnerüberwachung testen', 'Test folder monitoring'],
['Maximale automatische Queue-Größe', 'Maximum automatic queue size'],
['Abgleichintervall', 'Reconciliation interval'],
['Abschließen und pausieren', 'Finish and pause'],
['Fortsetzen', 'Resume'],
['Queue-Limit erreicht', 'Queue limit reached'],
['Ordner getrennt', 'Folder disconnected'],
['Pausiert', 'Paused'],
['Wegen Queue-Limit zurückgestellt', 'Deferred by queue limit'],
['Überwachung läuft seit', 'Monitoring since'],
['Ordner erreichbar', 'Folder reachable'],
['Letzte erkannte Datei', 'Last detected file'],
['Heute erkannt', 'Detected today'],
['Heute eingereiht', 'Queued today'],
['Heute übersprungen', 'Skipped today'],
['Aktuelle Queue-Auslastung', 'Current queue usage'],
['Letzter Abgleich', 'Last reconciliation'],
['Nächster Abgleich', 'Next reconciliation'],
['Letzter Fehler', 'Last error'],
['Nie', 'Never'],
['Ja', 'Yes'],
['Nein', 'No'],
['Keine Datei erkannt', 'No file detected'],
['Test der Ordnerüberwachung', 'Folder monitoring test'],
['Ordner wird geprüft…', 'Scanning folder…'],
['Der Test verändert weder Queue noch Einstellungen.', 'The test does not change the queue or settings.'],
['Prüft den aktuellen Ordner schreibgeschützt mit denselben Regeln.', 'Checks the current folder read-only with the same rules.'],
['Ordnerüberwachung konnte nicht getestet werden.', 'Folder monitoring could not be tested.'],
['Gefundene Dateien', 'Files found'],
['Passend zum Dateifilter', 'Matching file filter'],
['Bereits verarbeitet', 'Already processed'],
['Fehlend, leer oder nicht lesbar', 'Missing, empty, or unreadable'],
['Durch Größenlimits ausgeschlossen', 'Excluded by size limits'],
['Entstehende Upload-Jobs', 'Resulting upload jobs'],
['Verfügbare Jobs bis zum Queue-Limit', 'Available jobs before queue limit'],
['Aktuell zurückzustellende Dateien', 'Files currently deferred'],
['0 = unbegrenzt', '0 = unlimited'],
['Unbegrenzt', 'Unlimited'],
['1 Minute', '1 minute'],
['5 Minuten', '5 minutes'],
['15 Minuten', '15 minutes'],
['30 Minuten', '30 minutes'],
['60 Minuten', '60 minutes'],
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
['Automatik ist pausiert', 'Automation is paused'],
['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'],
['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'],
['Upload konnte nicht gestartet werden.', 'Upload could not be started.'],
['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'],
['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'],
['Upload-Start wurde verworfen', 'Upload start was discarded'],
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
['Ordner nicht erreichbar', 'Folder unavailable'],
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
['Keine Ordnerkonfiguration vorhanden', 'No folder configuration is available']
];
for (const [german, english] of pairs) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('translates account cooldown and manual pause labels', () => {
const pairs = [
['Pausiert noch', 'Paused '],
['Pausiert Aktion nötig', 'Paused action required'],
['Account automatisch wieder aktiv', 'Account automatically active again'],
['Account wieder aktiv nächste Batch verwendet ihn', 'Account active again the next batch will use it']
];
for (const [german, english] of pairs) {
assert.equal(translateText(german, 'en'), english);
assert.equal(translateText(english, 'de'), german);
}
});
test('translates the failure detail clipboard action', () => {
assert.equal(translateText('Fehlerdetails kopieren', 'en'), 'Copy failure details');
assert.equal(translateText('Failure details copied', 'de'), 'Fehlerdetails kopiert');
assert.equal(translateText('Antwortauszug', 'en'), 'Response excerpt');
});
test('English is the fallback language and German remains selectable', () => {
assert.equal(normalizeLanguage(), 'en');
assert.equal(normalizeLanguage('fr'), 'en');
assert.equal(normalizeLanguage('en'), 'en');
assert.equal(normalizeLanguage('de'), 'de');
});
test('translations cover static labels and interpolated status text in both languages', () => {
assert.equal(translateText('Einstellungen', 'en'), 'Settings');
assert.equal(translateText('Update v2.1.0 verfügbar', 'en'), 'Update v2.1.0 available');
assert.equal(translateText('Update v2.1.50 verfügbar (22.09.2026 - 19:30)', 'en'), 'Update v2.1.50 available (22.09.2026 - 19:30)');
assert.equal(translateText('Update v2.1.50 available (22.09.2026 - 19:30)', 'de'), 'Update v2.1.50 verfügbar (22.09.2026 - 19:30)');
assert.equal(translateText('Settings', 'de'), 'Einstellungen');
assert.equal(translateText('Update v2.1.0 available', 'de'), 'Update v2.1.0 verfügbar');
assert.equal(translateText('Alle Status', 'en'), 'Any status');
assert.equal(translateText('Any status', 'de'), 'Alle Status');
assert.equal(translateText('Filter', 'en'), 'Filters');
});
test('sidebar hierarchy uses distinct English and German kicker labels', () => {
assert.equal(translateText('Arbeitsbereich', 'en'), 'Workspace');
assert.equal(translateText('Accounts verwalten', 'en'), 'Manage accounts');
assert.equal(translateText('Archiv', 'en'), 'Archive');
assert.equal(translateText('Workspace', 'de'), 'Arbeitsbereich');
assert.equal(translateText('Manage accounts', 'de'), 'Accounts verwalten');
assert.equal(translateText('Archive', 'de'), 'Archiv');
});
test('runtime queue, account, toast, and shutdown copy translates completely', () => {
const cases = [
['Wartet', 'Waiting'],
['Abgebrochen', 'Canceled'],
['Warteschlange angehalten', 'Queue paused'],
['Fehlgeschlagen: Verbindung verloren', 'Failed: Connection lost'],
['Retry 2/3 · Primär nicht verfügbar', 'Retry 2/3 · Primary unavailable'],
['Link kopiert', 'Link copied'],
['Kopiert!', 'Copied!'],
['Sende…', 'Sending…'],
['Lade…', 'Loading…'],
['Upload-Statistiken', 'Upload statistics'],
['Ruhezustand in 30s...', 'Sleep in 30s...'],
['Herunterfahren in 30s...', 'Shut down in 30s...']
];
for (const [german, english] of cases) {
assert.equal(translateText(german, 'en'), english);
assert.equal(translateText(english, 'de'), german);
}
});
test('English copy uses complete actions and correct singular plurals', () => {
assert.equal(translateText('Fehlgeschlagene erneut', 'en'), 'Retry failed uploads');
assert.equal(translateText('Suche Aktualisierungen', 'en'), 'Check for updates');
assert.equal(translateText('1 Job zum erneuten Upload zurückgesetzt', 'en'), '1 job reset for upload');
assert.equal(translateText('2 Jobs zum erneuten Upload zurückgesetzt', 'en'), '2 jobs reset for upload');
assert.equal(translateText('1 Verlaufseintrag wird dauerhaft entfernt.', 'en'), '1 history entry will be permanently removed.');
assert.equal(translateText('2 Verlaufseinträge werden dauerhaft entfernt.', 'en'), '2 history entries will be permanently removed.');
assert.equal(translateText('Wiederholungen', 'en'), 'Retries');
assert.equal(translateText('Maximale Geschwindigkeit (MB/s)', 'en'), 'Maximum speed (MB/s)');
assert.equal(translateText('Neustart unter (kB/s)', 'en'), 'Restart below (kB/s)');
assert.equal(translateText('Maximale Größe (MB)', 'en'), 'Maximum size (MB)');
assert.equal(translateText('Aktiv auf Port 9100 — 1 Client verbunden', 'en'), 'Active on port 9100 — 1 client connected');
assert.equal(translateText('Aktiv auf Port 9100 — 2 Clients verbunden', 'en'), 'Active on port 9100 — 2 clients connected');
});
test('translates duplicate desktop drop feedback to English', () => {
assert.equal(translateText('Auswahl ist bereits in den Upload-Aufträgen.', 'en'), 'The selection is already in the upload jobs.');
});
test('rare account, backup, update, and confirmation states translate in both directions', () => {
const cases = [
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'],
['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'],
['Login oder API Key fehlt', 'Login or API key is missing'],
['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'],
['Account-Check fehlgeschlagen', 'Account check failed'],
['Upload-Server-Check lieferte kein gültiges JSON', 'Upload server check did not return valid JSON'],
['API Key gültig, Upload-Server verfügbar', 'API key is valid, upload server available'],
['API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)', 'API key is valid, but the API currently provides no server (the uploader uses a fallback)'],
['API Key gültig, Upload-Server aktuell nicht geliefert', 'API key is valid, but the API currently provides no upload server'],
['Username oder Passwort fehlt', 'Username or password is missing'],
['Upload-URL wurde nicht erkannt', 'Upload URL was not recognized'],
['API Key gültig, aktuell kein Server verfügbar', 'API key is valid, but no server is currently available'],
['API Key ungültig oder Server nicht erreichbar', 'API key is invalid or the server is unreachable'],
['Login ok, aber Upload-Seite liefert kein CSRF-Token', 'Login successful, but the upload page did not provide a CSRF token'],
['API Key fehlt', 'API key is missing'],
['API lieferte kein gültiges JSON', 'API did not return valid JSON'],
['API Key gültig', 'API key is valid'],
['Clouddrop Auth fehlgeschlagen', 'Clouddrop authentication failed'],
['Account-ID fehlt im Check-Payload', 'Account ID is missing from the check payload'],
['Kein Health-Check für diesen Hoster', 'No health check is available for this host'],
['Health-Check fehlgeschlagen', 'Health check failed'],
['Hoster fehlt', 'Host is missing'],
['Validierung fehlgeschlagen', 'Validation failed'],
['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'],
['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'],
['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'],
['Unbekannter Fehler', 'Unknown error'],
['Kein Log-Pfad gefunden', 'No log path was found'],
['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'],
['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'],
['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'],
['Ein Update wird bereits vorbereitet', 'An update is already being prepared'],
['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'],
['Update abgebrochen', 'Update canceled'],
['Filter zurücksetzen', 'Reset filters'],
['Download abbrechen', 'Cancel download'],
['Abbrechen…', 'Cancelling…'],
['Download abgebrochen', 'Download canceled'],
['Wiederholen', 'Retry'],
['Update konnte nicht abgebrochen werden', 'The update could not be canceled'],
['Ausgewählte Einträge entfernen?', 'Remove selected entries?'],
['Export fehlgeschlagen', 'Export failed'],
['Backup exportiert', 'Backup exported'],
['Online-Backup importiert', 'Online backup imported'],
['Passwort', 'Password'],
['Backup importiert', 'Backup imported'],
['Import fehlgeschlagen', 'Import failed'],
['Upload-Start fehlgeschlagen', 'Failed to start upload'],
['erneut versuchbar', 'retryable'],
['manuell', 'manual'],
['Abgebrochen.', 'Canceled.'],
['API-Token neu erzeugen?', 'Generate a new API token?'],
['Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', 'The current token will become invalid immediately. Connected clients must use the new token.'],
['Neu erzeugen', 'Generate new'],
['Verbindungs-Code neu erzeugen?', 'Generate a new connection code?'],
['Der bisherige Diagnose-Code wird sofort ungültig.', 'The current diagnostics code will become invalid immediately.'],
['Aktivieren', 'Enable'],
['Bitte den OTP-Code eingeben.', 'Enter the OTP code.'],
['Login fehlgeschlagen', 'Login failed'],
['Aufbewahrung ändern?', 'Change retention?'],
['Update fehlgeschlagen', 'Update failed']
];
for (const [german, english] of cases) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('interpolated rare errors translate without leaking German copy', () => {
const cases = [
['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'],
['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'],
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'],
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'],
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt']
];
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
});
test('main-process user-facing copy contains no mojibake', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
assert.doesNotMatch(source, /Ã|Â|â€/u);
});
test('library and updater errors exposed in the UI translate to English', () => {
const cases = [
['Ungültiges Backup-Format', 'Invalid backup format'],
['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'],
['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'],
['Online-Sicherungsdienst ist nicht erreichbar', 'The online backup service is unavailable'],
['Online-Sicherung wurde nicht gefunden', 'Online backup was not found'],
['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'],
['Kein Ordnerpfad angegeben', 'No folder path was provided'],
['Kein Update verfügbar', 'No update available'],
['Update-Asset unvollständig (URL oder Name fehlt)', 'The update asset is incomplete (URL or name is missing)'],
['Heruntergeladene Datei ist keine gültige EXE', 'The downloaded file is not a valid EXE'],
['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'],
['Datei nicht gefunden', 'File not found'],
['Netzwerkfehler', 'Network error']
];
for (const [german, english] of cases) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('interpolated host and updater errors translate their user-facing copy', () => {
const cases = [
['Clouddrop: Datei nicht lesbar: video.mkv', 'Clouddrop: File cannot be read: video.mkv'],
['Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). Details', 'Doodstream: Could not determine the upload server (endpoint changed?). Details'],
['Doodstream Upload fehlgeschlagen: rejected', 'Doodstream upload failed: rejected'],
['Upload zu VOE wurde vom Server abgelehnt.', 'The upload to VOE was rejected by the server.'],
['Vidmoly Upload abgelehnt: rejected', 'Vidmoly upload rejected: rejected'],
['VOE Upload-Fehler: rejected', 'VOE upload error: rejected'],
['Download fehlgeschlagen: HTTP 503', 'Download failed: HTTP 503']
];
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
});
test('German runtime error copy in the application source has an English translation', () => {
const files = [
path.join(__dirname, '..', 'main.js'),
path.join(__dirname, '..', 'renderer', 'app.js'),
...fs.readdirSync(path.join(__dirname, '..', 'lib'))
.filter(file => file.endsWith('.js'))
.map(file => path.join(__dirname, '..', 'lib', file))
];
const germanCopy = /[äöüß]|\b(?:der|die|das|ein|eine|kein|keine|nicht|fehlgeschlagen|ungültig|beschädigt|wurde|werden|konnte|können|verfügbar|gefunden|erhalten|abgelehnt|Antwort|Datei|Einstellungen|Upload-Server|Prüfung|Passwort|Session|Verlauf|Sicherung|Fehler)\b/i;
const candidates = [];
const addCandidate = (file, node) => {
if (node?.type === 'Literal' && typeof node.value === 'string') candidates.push([file, node.loc.start.line, node.value]);
if (node?.type === 'TemplateLiteral') {
if (node.expressions.some(expression => expression.type === 'ConditionalExpression')) return;
let sample = '';
node.quasis.forEach((part, index) => {
sample += part.value.cooked;
if (index < node.expressions.length) sample += 'X';
});
candidates.push([file, node.loc.start.line, sample]);
}
};
const walk = (file, node) => {
if (!node || typeof node !== 'object') return;
if (node.type === 'NewExpression' && ['Error', 'AggregateError'].includes(node.callee?.name)) addCandidate(file, node.arguments?.[0]);
if (node.type === 'Property' && ['error', 'message', 'reason'].includes(node.key?.name || node.key?.value)) addCandidate(file, node.value);
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(child => walk(file, child));
else if (value && typeof value === 'object' && value.type) walk(file, value);
}
};
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const tree = espree.parse(source, { ecmaVersion: 'latest', sourceType: 'script', loc: true });
walk(path.relative(path.join(__dirname, '..'), file), tree);
}
const untranslated = candidates
.filter(([, , value]) => germanCopy.test(value) && translateText(value, 'en') === value)
.map(([file, line, value]) => `${file}:${line} ${value}`);
assert.deepEqual(untranslated, []);
});
-71
View File
@@ -1,71 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('inspects duplicates, unavailable files, accepted files, and configured size-limit pairs', async () => {
const { inspectImportEntries, summarizeImportPlan } = require('../lib/import-preflight');
const inspection = await inspectImportEntries([
{ path: 'C:\\queue\\duplicate.mkv', name: 'duplicate.mkv' },
{ path: 'C:\\queue\\accepted.mkv', name: 'accepted.mkv' },
{ path: 'C:\\queue\\empty.mkv', name: 'empty.mkv' },
{ path: 'C:\\queue\\missing.mkv', name: 'missing.mkv' }
], {
existingPaths: ['C:\\queue\\duplicate.mkv'],
inspectPath: async filePath => {
if (filePath.endsWith('accepted.mkv')) return { exists: true, readable: true, size: 2 * 1024 * 1024, mtimeMs: 1787828400123 };
if (filePath.endsWith('empty.mkv')) return { exists: true, readable: true, size: 0 };
return { exists: false };
}
});
const plan = summarizeImportPlan({
inspection,
selectedHosters: ['doodstream.com', 'voe.sx'],
hosterSettings: {
'doodstream.com': { maxSizeMb: 1 },
'voe.sx': { maxSizeMb: 0 }
}
});
assert.deepEqual({
candidates: inspection.candidateCount,
duplicates: inspection.duplicateCount,
unavailable: inspection.unavailableCount,
accepted: inspection.acceptedCount
}, { candidates: 4, duplicates: 1, unavailable: 2, accepted: 1 });
assert.deepEqual(plan, {
candidateCount: 4,
duplicateCount: 1,
unavailableCount: 2,
acceptedCount: 1,
targetCount: 2,
jobCount: 1,
sizeLimitedJobCount: 1
});
assert.equal(inspection.accepted[0].mtimeMs, 1787828400123);
});
test('5 GB host limit excludes oversized import pairs but preserves other hosts and exact boundary', () => {
const { isImportPairEligible } = require('../lib/import-preflight');
const settings = { 'doodstream.com': { maxSizeMb: 5120 }, 'voe.sx': { maxSizeMb: 0 } };
assert.equal(isImportPairEligible({ size: 5 * 1024 ** 3 }, 'doodstream.com', settings), true);
assert.equal(isImportPairEligible({ size: 5 * 1024 ** 3 + 1 }, 'doodstream.com', settings), false);
assert.equal(isImportPairEligible({ size: 5 * 1024 ** 3 + 1 }, 'voe.sx', settings), true);
});
test('connects the import preflight through the main process, preload, renderer, and hoster dialog', () => {
const root = path.join(__dirname, '..');
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
const renderer = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
const html = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
const css = fs.readFileSync(path.join(root, 'renderer', 'styles.css'), 'utf8');
assert.match(main, /ipcMain\.handle\('inspect-import-files'/);
assert.match(preload, /inspectImportFiles/);
assert.match(renderer, /coordinateImportEntries/);
assert.match(renderer, /isImportPairEligible/);
assert.match(renderer, /toLocaleString\(getUiLocale\(\)\)/);
assert.match(html, /id="importPlanSummary"/);
assert.match(css, /hoster-modal-list \+ \.import-plan-summary \+ \.modal-hint/);
});
-52
View File
@@ -1,52 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeIp, isLoopbackIp, matchIpRule, evaluateClientAllowed } = require('../lib/ip-allowlist');
test('normalizeIp strips ::ffff: and lowercases', () => {
assert.equal(normalizeIp('::ffff:100.64.0.5'), '100.64.0.5');
assert.equal(normalizeIp('::FFFF:127.0.0.1'), '127.0.0.1');
assert.equal(normalizeIp(' 100.64.0.5 '), '100.64.0.5');
});
test('loopback is always allowed, even with a non-matching allowlist', () => {
for (const ip of ['127.0.0.1', '::1', '::ffff:127.0.0.1', '', 'localhost', '127.5.5.5']) {
assert.equal(evaluateClientAllowed(ip, ['203.0.113.5']), true, `${ip} loopback`);
}
});
test('fail-closed: empty allowlist rejects every non-loopback peer', () => {
for (const ip of ['100.64.0.5', '203.0.113.5', '10.0.0.2', '::ffff:192.168.1.9']) {
assert.equal(evaluateClientAllowed(ip, []), false, `${ip} must be rejected with empty allowlist`);
}
});
test('exact IP allow + reject', () => {
assert.equal(evaluateClientAllowed('203.0.113.5', ['203.0.113.5']), true);
assert.equal(evaluateClientAllowed('203.0.113.6', ['203.0.113.5']), false);
});
test('CIDR matching incl. the Tailscale CGNAT range 100.64.0.0/10', () => {
assert.equal(evaluateClientAllowed('100.64.0.5', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.127.255.254', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.128.0.1', ['100.64.0.0/10']), false, 'just outside the /10');
assert.equal(evaluateClientAllowed('::ffff:100.64.0.5', ['100.64.0.0/10']), true, 'mapped v4 in CIDR');
assert.equal(evaluateClientAllowed('10.0.0.5', ['10.0.0.0/24']), true);
assert.equal(evaluateClientAllowed('10.0.1.5', ['10.0.0.0/24']), false);
});
test('wildcard rules allow everything', () => {
assert.equal(evaluateClientAllowed('8.8.8.8', ['*']), true);
assert.equal(evaluateClientAllowed('8.8.8.8', ['0.0.0.0/0']), true);
});
test('matchIpRule rejects malformed rules and out-of-range octets', () => {
assert.equal(matchIpRule('1.2.3.4', 'not-an-ip'), false);
assert.equal(matchIpRule('1.2.3.4', '1.2.3.0/33'), false);
assert.equal(matchIpRule('1.2.3.999', '1.2.3.0/24'), false);
});
test('isLoopbackIp recognizes loopback forms', () => {
assert.equal(isLoopbackIp('127.0.0.1'), true);
assert.equal(isLoopbackIp('::1'), true);
assert.equal(isLoopbackIp('100.64.0.1'), false);
});
-168
View File
@@ -1,168 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, isManagedUploadLogFileName } = require('../lib/log-mode');
// --- normalizeLogMode ---
test('normalizeLogMode: default for empty/null/undefined is "single"', () => {
assert.equal(normalizeLogMode(), 'single');
assert.equal(normalizeLogMode(null), 'single');
assert.equal(normalizeLogMode({}), 'single');
});
test('normalizeLogMode: explicit logMode wins for all three valid values', () => {
assert.equal(normalizeLogMode({ logMode: 'single' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily');
assert.equal(normalizeLogMode({ logMode: 'session' }), 'session');
});
test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => {
// The legacy boolean field was named after a misnomer — it actually toggled
// per-day logging. Mapping it to "session" would silently flip every existing
// per-day user onto per-session, which is exactly the bug the migration trap
// exists to prevent.
assert.equal(normalizeLogMode({ sessionLog: true }), 'daily');
});
test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => {
assert.equal(normalizeLogMode({ sessionLog: false }), 'single');
});
test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => {
// Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override.
assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session');
assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single');
});
test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => {
assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single');
assert.equal(normalizeLogMode({ logMode: '' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily');
});
// --- resolveLogFileName ---
test('resolveLogFileName: single mode → bare basename + ext', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }),
'fileuploader.log'
);
});
test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }),
'fileuploader-2026-05-28.log'
);
});
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
'26-05-2026-mdu-session-22-44.log'
);
});
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
const { formatSessionStamp } = require('../lib/log-mode');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
});
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
});
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
'fileuploader.log'
);
});
test('resolveLogFileName: unknown mode is treated as single', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }),
'fileuploader.log'
);
});
test('managed upload-log discovery includes session logs and excludes unrelated logs', () => {
assert.equal(typeof isManagedUploadLogFileName, 'function');
const options = { baseName: 'fileuploader', ext: '.log' };
assert.equal(isManagedUploadLogFileName('fileuploader.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader.1.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.2.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader-session-2026-08-27_05-40-59-1234.log', options), true);
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-111111.3.log', options), true);
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.log', options), true);
assert.equal(isManagedUploadLogFileName('FILEUPLOADER-2026-08-27.LOG', options), true);
assert.equal(isManagedUploadLogFileName('27-08-2026-MDU-SESSION-05-40-599797.LOG', options), true);
assert.equal(isManagedUploadLogFileName('upload-audit.log', options), false);
assert.equal(isManagedUploadLogFileName('account-rotation.log', options), false);
assert.equal(isManagedUploadLogFileName('upload-debug.log', options), false);
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.txt', options), false);
});
// --- stripModeStampFromFileName ---
const { stripModeStampFromFileName } = require('../lib/log-mode');
test('stripModeStampFromFileName: leaves bare names alone', () => {
assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader');
});
test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => {
assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log');
});
test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => {
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'),
'fileuploader.log'
);
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'),
'fileuploader.log'
);
});
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
});
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
// This is the exact bug shape: persist the resolved path, then on next call
// re-resolve from the saved base — must produce the same file, not a doubled
// session-stamped one. The fix is the strip; this test guards against
// regressing _persistFallbackLogPath into the 3.3.35 bug.
const sessionId = '03-06-2026-mdu-session-18-16';
const dailyDate = new Date(2026, 5, 3);
for (const mode of ['daily', 'session']) {
const date = mode === 'daily' ? dailyDate : new Date();
const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId });
const stripped = stripModeStampFromFileName(initial);
// After strip, the base should be back to the bare name.
assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`);
// Re-resolving from the bare base gives the same final filename — no doubling.
const reBase = stripped.replace(/\.log$/, '');
const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId });
assert.equal(second, initial, `${mode}: round-trip must be idempotent`);
}
});
// --- format helpers ---
test('formatDateStamp: zero-pads month and day', () => {
assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03');
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
});
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
});
-52
View File
@@ -1,52 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { hosterLogToFileEnabled } = require('../lib/log-policy');
test('enabled by default when settings missing entirely', () => {
assert.equal(hosterLogToFileEnabled(null, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled(undefined, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled('not-an-object', 'voe.sx'), true);
});
test('enabled when hoster has no settings entry', () => {
assert.equal(hosterLogToFileEnabled({}, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'byse.sx': { logToFile: false } }, 'voe.sx'), true);
});
test('enabled when hoster entry has no logToFile key (back-compat with old configs)', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { retries: 3 } }, 'voe.sx'), true);
});
test('enabled when logToFile is explicitly true', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: true } }, 'voe.sx'), true);
});
test('DISABLED only when logToFile is explicitly false', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: false } }, 'voe.sx'), false);
});
test('truthy-but-not-true values do not accidentally disable', () => {
// Only the strict boolean false disables — guards against e.g. a stored 0/""
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: 0 } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: '' } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: null } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: undefined } }, 'voe.sx'), true);
});
test('per-hoster independence: one off, others on', () => {
const settings = {
'voe.sx': { logToFile: false },
'byse.sx': { logToFile: true },
'doodstream.com': { retries: 3 }
};
assert.equal(hosterLogToFileEnabled(settings, 'voe.sx'), false);
assert.equal(hosterLogToFileEnabled(settings, 'byse.sx'), true);
assert.equal(hosterLogToFileEnabled(settings, 'doodstream.com'), true);
assert.equal(hosterLogToFileEnabled(settings, 'clouddrop.cc'), true); // not present → on
});
test('malformed hoster entry (string/number) defaults to on', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 'broken' }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 42 }, 'voe.sx'), true);
});
-134
View File
@@ -1,134 +0,0 @@
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { maybeRotateLogFile } = require('../lib/log-rotation');
let tmpDir;
let logFile;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-log-rotation-'));
logFile = path.join(tmpDir, 'fileuploader.log');
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
function writeBytes(p, n, fill = 'a') {
fs.writeFileSync(p, fill.repeat(n), 'utf-8');
}
test('returns false and skips rotation when file does not exist', () => {
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.existsSync(logFile), false);
});
test('returns false when file is below the size cap', () => {
writeBytes(logFile, 50);
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.statSync(logFile).size, 50, 'live file untouched');
assert.equal(fs.existsSync(logFile + '.1'), false, 'no .1 created');
});
test('rotates live file to .1 when over cap', () => {
writeBytes(logFile, 200, 'X');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
assert.equal(fs.existsSync(logFile), false, 'live file moved away');
const expectedBackup = path.join(tmpDir, 'fileuploader.1.log');
assert.equal(fs.existsSync(expectedBackup), true, '.1 backup exists');
assert.equal(fs.statSync(expectedBackup).size, 200);
});
test('shifts existing backups up: .1 → .2, .2 → .3 on rotation', () => {
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Live file → .1 (latest live data)
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
// Old .1 → .2
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// Old .2 → .3
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.3.log')).size, 10);
});
test('drops oldest backup when at maxBackups limit', () => {
// Pre-populate all three backup slots.
writeBytes(path.join(tmpDir, 'fileuploader.3.log'), 5, 'C'); // oldest, will be dropped
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Old .3 (5 bytes 'C') gone, replaced by old .2.
const f3 = fs.statSync(path.join(tmpDir, 'fileuploader.3.log'));
assert.equal(f3.size, 10, 'old .2 became new .3 (the C-file was dropped)');
// .2 = old .1
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// .1 = the live file we just rotated
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
});
test('is idempotent — second call on still-large file rotates again', () => {
writeBytes(logFile, 200, 'X');
maybeRotateLogFile(logFile, 100, 3);
// Simulate fresh writes after the first rotation
writeBytes(logFile, 200, 'Y');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// The .Y file is now .1, the .X file moved to .2
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'Y');
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.2.log'), 'utf-8')[0], 'X');
});
test('maxBackups=1: only keeps a single .1 backup, never .2', () => {
writeBytes(logFile, 200, 'L');
maybeRotateLogFile(logFile, 100, 1);
writeBytes(logFile, 200, 'M');
maybeRotateLogFile(logFile, 100, 1);
// .1 holds the latest rotated content (M)
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'M');
// .2 must NOT exist
assert.equal(fs.existsSync(path.join(tmpDir, 'fileuploader.2.log')), false);
});
test('invalid maxBytes (0, negative, NaN) is a no-op', () => {
writeBytes(logFile, 1000, 'X');
for (const max of [0, -1, NaN]) {
const r = maybeRotateLogFile(logFile, max);
assert.equal(r, false, `maxBytes=${max} should be no-op`);
}
assert.equal(fs.existsSync(logFile), true);
assert.equal(fs.existsSync(logFile + '.1'), false);
});
test('logs through provided debug callback on rotation', () => {
writeBytes(logFile, 200, 'X');
const messages = [];
maybeRotateLogFile(logFile, 100, 3, (m) => messages.push(m));
assert.ok(messages.length >= 1, 'at least one log message');
assert.ok(messages.some(m => m.includes('rotated')), `expected "rotated" in: ${messages.join(' | ')}`);
});
test('handles file without extension correctly', () => {
const noExtFile = path.join(tmpDir, 'plainlog');
writeBytes(noExtFile, 200, 'P');
const result = maybeRotateLogFile(noExtFile, 100, 3);
assert.equal(result, true);
// base = the full path, ext = '', so backup name is "plainlog.1"
assert.equal(fs.existsSync(path.join(tmpDir, 'plainlog.1')), true);
assert.equal(fs.existsSync(noExtFile), false);
});
-896
View File
@@ -1,896 +0,0 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { afterEach, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const secretStore = require('../lib/secret-store');
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
const directories = [];
const timestamp = '2026-08-22T10:00:00.000Z';
afterEach(() => {
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function encrypt(value) {
return `enc:v1:${Buffer.from(value).toString('base64')}`;
}
function decrypt(value) {
return Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8');
}
function isCanonicalEnvelope(value) {
if (typeof value !== 'string' || !value.startsWith('enc:v1:')) return false;
const encoded = value.slice('enc:v1:'.length);
if (!encoded || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)) return false;
return Buffer.from(encoded, 'base64').toString('base64') === encoded;
}
function fixture(options = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
directories.push(directory);
const filePath = path.join(directory, 'online-backup-keyring.json');
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
return {
directory,
filePath,
backupPath: `${filePath}.bak`,
keyring: createOnlineBackupKeyring({
filePath,
encryptField: options.encryptField || encrypt,
decryptField: options.decryptField || decrypt,
isEncrypted: options.isEncrypted || isCanonicalEnvelope,
fsImpl: options.fsImpl,
now: options.now
})
};
}
function validKey() {
return createOnlineBackup({}, '2.1.31').key;
}
function keyWithRecordId(sourceKey, fill) {
const idBytes = parseOnlineBackupKey(sourceKey).idBytes;
const masterKey = Buffer.alloc(32, fill);
const context = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
const checksum = crypto.createHash('sha256').update(context).update(idBytes).update(masterKey).digest().subarray(0, 4);
return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`;
}
function writeKeyring(filePath, keys, generation = null) {
const document = generation === null
? { version: 1, keys }
: { version: 2, generation, keys };
fs.writeFileSync(filePath, JSON.stringify(document));
}
describe('encrypted online backup keyring', () => {
it('persists valid source IPs across reloads alongside legacy entries', async () => {
const { keyring, filePath } = fixture();
const first = { ...keyring.prepare(validKey(), timestamp), sourceIp: '2001:db8::42' };
await keyring.commit(first);
await keyring.commit(keyring.prepare(validKey(), timestamp));
const listed = await keyring.list();
assert.equal(listed.issues.length, 0);
assert.equal(listed.entries.find(entry => entry.id === first.id).sourceIp, '2001:db8::42');
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).keys.find(entry => entry.id === first.id).sourceIp, '2001:db8::42');
await assert.rejects(keyring.commit({ ...keyring.prepare(validKey(), timestamp), sourceIp: 'invalid' }));
const removal = await keyring.prepareRemove(first.id);
await keyring.commitRemove(removal);
assert.equal((await keyring.list()).entries.length, 1);
});
it('persists the spec keys schema without plaintext and returns frozen sanitized entries', async () => {
const { filePath, keyring } = fixture();
const key = validKey();
const prepared = keyring.prepare(key, timestamp);
await keyring.commit(prepared);
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const snapshot = await keyring.list();
assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']);
assert.equal(document.version, 2);
assert.equal(document.generation, 1);
assert.equal(document.keys.length, 1);
assert.equal(document.keys[0].expiresAt, null);
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
assert.deepEqual(snapshot.issues, []);
assert.deepEqual(snapshot.entries, [{
id: parseOnlineBackupKey(key).id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: timestamp,
expiresAt: null
}]);
assert.equal(Object.isFrozen(snapshot), true);
assert.equal(Object.isFrozen(snapshot.entries), true);
assert.equal(Object.isFrozen(snapshot.entries[0]), true);
assert.equal(await keyring.getKey(prepared.id), key);
});
it('removes expired local keys from the managed list and encrypted keyring', async () => {
let currentTime = new Date(timestamp).getTime();
const { filePath, keyring } = fixture({ now: () => currentTime });
const finite = validKey();
const unlimited = validKey();
const expiresAt = '2026-08-23T10:00:00.000Z';
await keyring.commit(keyring.prepare(finite, timestamp, expiresAt));
await keyring.commit(keyring.prepare(unlimited, timestamp, null));
assert.deepEqual((await keyring.list()).entries.map(entry => entry.expiresAt), [expiresAt, null]);
currentTime = new Date(expiresAt).getTime();
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(unlimited).id]);
assert.equal(await keyring.getKey(parseOnlineBackupKey(finite).id), null);
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.equal(document.keys.some(entry => entry.id === parseOnlineBackupKey(finite).id), false);
});
it('keeps a valid v1 primary authoritative over an older v1 backup and migrates to v2', async () => {
const { filePath, backupPath, keyring } = fixture();
const backupKey = validKey();
const primaryKey = validKey();
const addedKey = validKey();
writeKeyring(backupPath, [{
id: parseOnlineBackupKey(backupKey).id,
encryptedKey: encrypt(backupKey),
createdAt: timestamp
}]);
writeKeyring(filePath, [{
id: parseOnlineBackupKey(primaryKey).id,
encryptedKey: encrypt(primaryKey),
createdAt: '2026-08-22T11:00:00.000Z'
}]);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(primaryKey).id]);
assert.equal(await keyring.commit(keyring.prepare(addedKey, '2026-08-22T12:00:00.000Z')), true);
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']);
assert.equal(document.version, 2);
assert.equal(document.generation, 1);
assert.equal(fs.readFileSync(filePath, 'utf8').includes(primaryKey), false);
assert.equal(fs.readFileSync(filePath, 'utf8').includes(backupKey), false);
assert.equal(fs.readFileSync(filePath, 'utf8').includes(addedKey), false);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
parseOnlineBackupKey(addedKey).id,
parseOnlineBackupKey(primaryKey).id
]);
});
it('requires a canonical encrypted envelope before decrypting stored values', async () => {
const key = validKey();
const id = parseOnlineBackupKey(key).id;
for (const encryptedKey of [key, 'enc:v2:YWJjZA==', 'enc:v1:YWJjZA']) {
let decryptCalls = 0;
const { filePath, keyring } = fixture({
decryptField: value => {
decryptCalls++;
return value;
}
});
writeKeyring(filePath, [{ id, encryptedKey, createdAt: timestamp }]);
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries, []);
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
assert.equal(decryptCalls, 0);
}
});
it('rejects plaintext even when the real secret store would pass legacy values through', async () => {
const { filePath, keyring } = fixture({
decryptField: secretStore.decryptField,
isEncrypted: secretStore.isEncrypted
});
const key = validKey();
writeKeyring(filePath, [{ id: parseOnlineBackupKey(key).id, encryptedKey: key, createdAt: timestamp }]);
assert.equal(secretStore.decryptField(key), key);
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries, []);
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
await assert.rejects(
keyring.getKey(parseOnlineBackupKey(key).id),
error => error.code === 'KEYRING_STRUCTURE_INVALID' && !error.message.includes(key)
);
});
it('types secure-storage, decryption, ID-mismatch, and structure failures without secrets', async () => {
const key = validKey();
const otherKey = validKey();
const cases = [
{
expected: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
decryptField: () => { throw new secretStore.SecretStoreError('SECRET_STORE_UNAVAILABLE', `unavailable ${key}`); },
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp }
},
{
expected: 'KEYRING_DECRYPT_FAILED',
decryptField: () => { throw new secretStore.SecretStoreError('SECRET_STORE_DECRYPT_FAILED', `decrypt ${key}`); },
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp }
},
{
expected: 'KEYRING_ID_MISMATCH',
decryptField: decrypt,
entry: { id: parseOnlineBackupKey(otherKey).id, encryptedKey: encrypt(key), createdAt: timestamp }
},
{
expected: 'KEYRING_STRUCTURE_INVALID',
decryptField: decrypt,
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: 'not-a-date' }
}
];
for (const testCase of cases) {
const { filePath, keyring } = fixture({ decryptField: testCase.decryptField });
writeKeyring(filePath, [testCase.entry]);
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries, []);
assert.deepEqual(snapshot.issues, [testCase.expected]);
assert.equal(JSON.stringify(snapshot).includes(key), false);
assert.equal(JSON.stringify(snapshot).includes(testCase.entry.encryptedKey), false);
}
});
it('keeps readable entries available while reporting neighboring corruption', async () => {
const { filePath, keyring } = fixture();
const readable = validKey();
const broken = validKey();
writeKeyring(filePath, [
{ id: parseOnlineBackupKey(readable).id, encryptedKey: encrypt(readable), createdAt: timestamp },
{ id: parseOnlineBackupKey(broken).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: timestamp }
]);
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(readable).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
assert.equal(await keyring.getKey(parseOnlineBackupKey(readable).id), readable);
await assert.rejects(
keyring.getKey(parseOnlineBackupKey(broken).id),
error => error.code === 'KEYRING_STRUCTURE_INVALID'
);
});
it('does not expose duplicate IDs and blocks an ambiguous removal plan', async () => {
const { filePath, keyring } = fixture();
const first = validKey();
const second = keyWithRecordId(first, 0x5a);
const id = parseOnlineBackupKey(first).id;
writeKeyring(filePath, [
{ id, encryptedKey: encrypt(first), createdAt: timestamp },
{ id, encryptedKey: encrypt(second), createdAt: '2026-08-22T11:00:00.000Z' }
]);
const snapshot = await keyring.list();
assert.deepEqual(snapshot.entries, []);
assert.deepEqual(snapshot.issues, ['KEYRING_DUPLICATE_ID']);
await assert.rejects(keyring.getKey(id), error => error.code === 'KEYRING_DUPLICATE_ID');
await assert.rejects(keyring.prepareRemove(id), error => error.code === 'KEYRING_DUPLICATE_ID');
});
it('fully validates a unique removal plan before committing exactly that plan', async () => {
const { keyring } = fixture();
const removed = validKey();
const retained = validKey();
await keyring.commit(keyring.prepare(removed, timestamp));
await keyring.commit(keyring.prepare(retained, '2026-08-22T11:00:00.000Z'));
const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
assert.equal(plan.id, parseOnlineBackupKey(removed).id);
assert.equal(plan.key, removed);
assert.equal(await keyring.commitRemove(plan), true);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(retained).id]);
});
it('rebases a stale removal plan onto the current state without dropping newer keys', async () => {
const { filePath, keyring } = fixture();
const removed = validKey();
const added = validKey();
await keyring.commit(keyring.prepare(removed, timestamp));
const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
await keyring.commit(keyring.prepare(added, '2026-08-22T11:00:00.000Z'));
assert.equal(await keyring.commitRemove(plan), true);
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.equal(document.version, 2);
assert.equal(document.generation, 3);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(added).id]);
assert.equal(await keyring.getKey(parseOnlineBackupKey(removed).id), null);
});
it('treats a stale removal plan whose target is already absent as idempotent', async () => {
const { keyring } = fixture();
const removed = validKey();
await keyring.commit(keyring.prepare(removed, timestamp));
const stalePlan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
const currentPlan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
assert.equal(await keyring.commitRemove(currentPlan), true);
assert.equal(await keyring.commitRemove(stalePlan), false);
assert.deepEqual((await keyring.list()).entries, []);
});
it('invalidates a stale removal plan when the same ID now belongs to a different key', async () => {
const { filePath, keyring } = fixture();
const original = validKey();
const replacement = keyWithRecordId(original, 0x5a);
const id = parseOnlineBackupKey(original).id;
await keyring.commit(keyring.prepare(original, timestamp));
const stalePlan = await keyring.prepareRemove(id);
const currentPlan = await keyring.prepareRemove(id);
assert.equal(await keyring.commitRemove(currentPlan), true);
assert.equal(await keyring.commit(keyring.prepare(replacement, '2026-08-22T11:00:00.000Z')), true);
const generationBefore = JSON.parse(fs.readFileSync(filePath, 'utf8')).generation;
await assert.rejects(
keyring.commitRemove(stalePlan),
error => error.code === 'KEYRING_REMOVE_PLAN_INVALID'
);
await assert.rejects(
keyring.commitRemove(stalePlan),
error => error.code === 'KEYRING_REMOVE_PLAN_INVALID'
);
assert.equal(await keyring.getKey(id), replacement);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).generation, generationBefore);
assert.equal(generationBefore, 3);
});
it('blocks removal before remote work when any neighboring entry is corrupt', async () => {
const { filePath, keyring } = fixture();
const valid = validKey();
const invalid = validKey();
writeKeyring(filePath, [
{ id: parseOnlineBackupKey(valid).id, encryptedKey: encrypt(valid), createdAt: timestamp },
{ id: parseOnlineBackupKey(invalid).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: timestamp }
]);
await assert.rejects(
keyring.prepareRemove(parseOnlineBackupKey(valid).id),
error => error.code === 'KEYRING_STRUCTURE_INVALID'
);
});
it('does not replace the encrypted value or creation timestamp for duplicate commits', async () => {
let encryptionCount = 0;
const encryptField = value => `enc:v1:${Buffer.from(`${++encryptionCount}:${value}`).toString('base64')}`;
const decryptField = value => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8').replace(/^\d+:/u, '');
const { filePath, keyring } = fixture({ encryptField, decryptField });
const key = validKey();
const first = keyring.prepare(key, timestamp);
const duplicate = keyring.prepare(key, '2026-08-22T12:00:00.000Z');
await keyring.commit(first);
const original = fs.readFileSync(filePath, 'utf8');
await keyring.commit(duplicate);
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
assert.deepEqual((await keyring.list()).entries, [{
id: parseOnlineBackupKey(key).id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: timestamp,
expiresAt: null
}]);
});
it('sorts newer entries first', async () => {
const { keyring } = fixture();
const older = validKey();
const newer = validKey();
await keyring.commit(keyring.prepare(older, timestamp));
await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z'));
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
parseOnlineBackupKey(newer).id,
parseOnlineBackupKey(older).id
]);
});
it('returns success after a validated recovery commit when primary publication fails', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
await first.keyring.commit(first.keyring.prepare(older, timestamp));
const fsImpl = {
...fs.promises,
rename: async (source, target) => {
if (target === first.filePath) throw new Error('primary publication failed');
return fs.promises.rename(source, target);
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
parseOnlineBackupKey(newer).id,
parseOnlineBackupKey(older).id
]);
});
it('rejects a recovery staging write failure without exposing the new key', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
await first.keyring.commit(first.keyring.prepare(older, timestamp));
const fsImpl = {
...fs.promises,
open: async (target, flags, mode) => {
const handle = await fs.promises.open(target, flags, mode);
if (!String(target).endsWith('.staging.tmp')) return handle;
return {
writeFile: async () => { throw new Error('recovery staging write failed'); },
sync: () => handle.sync(),
close: () => handle.close()
};
},
unlink: async target => {
if (String(target).endsWith('.staging.tmp')) throw Object.assign(new Error('staging cleanup failed'), { code: 'EBUSY' });
return fs.promises.unlink(target);
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
await assert.rejects(
keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')),
/recovery staging write failed/u
);
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.staging.tmp')), true);
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.recovery.tmp')), false);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(older).id]);
assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), null);
});
it('rejects a recovery staging revalidation mismatch before publishing a candidate', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
await first.keyring.commit(first.keyring.prepare(older, timestamp));
const fsImpl = {
...fs.promises,
readFile: async (target, encoding) => {
const contents = await fs.promises.readFile(target, encoding);
if (!String(target).endsWith('.staging.tmp')) return contents;
const document = JSON.parse(contents);
document.generation++;
return JSON.stringify(document);
},
unlink: async target => {
if (String(target).endsWith('.staging.tmp')) throw Object.assign(new Error('staging cleanup failed'), { code: 'EBUSY' });
return fs.promises.unlink(target);
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
await assert.rejects(
keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')),
error => error.code === 'KEYRING_STRUCTURE_INVALID'
);
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.staging.tmp')), true);
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.recovery.tmp')), false);
assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), null);
});
it('cannot throw and later expose a rolled-back key when cleanup fails', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
await first.keyring.commit(first.keyring.prepare(older, timestamp));
let readdirCalls = 0;
const fsImpl = {
...fs.promises,
readdir: async (...args) => {
readdirCalls++;
if (readdirCalls === 2) throw Object.assign(new Error('cleanup failed'), { code: 'EIO' });
return fs.promises.readdir(...args);
},
rename: async (source, target) => {
if (target === first.filePath || target === first.backupPath) throw new Error('publication failed');
return fs.promises.rename(source, target);
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true);
assert.equal(await keyring.getKey(parseOnlineBackupKey(newer).id), newer);
});
it('file-syncs and revalidates recovery staging before best-effort publication', async () => {
const events = [];
const fsImpl = {
...fs.promises,
open: async (target, flags, mode) => {
const handle = await fs.promises.open(target, flags, mode);
const label = path.basename(String(target));
return {
writeFile: async (...args) => {
events.push(`write:${label}`);
return handle.writeFile(...args);
},
sync: async () => {
events.push(`sync:${label}`);
return handle.sync();
},
close: async () => {
events.push(`close:${label}`);
return handle.close();
}
};
},
rename: async (source, target) => {
events.push(`rename:${path.basename(source)}>${path.basename(target)}`);
return fs.promises.rename(source, target);
}
};
const { filePath, backupPath, keyring } = fixture({ fsImpl });
await keyring.commit(keyring.prepare(validKey(), timestamp));
const renames = events.filter(event => event.startsWith('rename:'));
assert.equal(renames.length, 3);
const stagingRename = renames.find(rename => rename.includes('.staging.tmp>') && rename.endsWith('.recovery.tmp'));
const primaryRename = renames.find(rename => rename.includes('.primary.tmp>') && rename.endsWith(`>${path.basename(filePath)}`));
const backupRename = renames.find(rename => rename.includes('.recovery.tmp>') && rename.endsWith(`>${path.basename(backupPath)}`));
const stagingSource = stagingRename.slice('rename:'.length).split('>')[0];
const primarySource = primaryRename.slice('rename:'.length).split('>')[0];
assert.ok(events.indexOf(`write:${stagingSource}`) < events.indexOf(`sync:${stagingSource}`));
assert.ok(events.indexOf(`sync:${stagingSource}`) < events.indexOf(`close:${stagingSource}`));
assert.ok(events.indexOf(`close:${stagingSource}`) < events.indexOf(stagingRename));
assert.ok(events.indexOf(stagingRename) < events.indexOf(`write:${primarySource}`));
assert.ok(events.indexOf(`close:${primarySource}`) < events.indexOf(primaryRename));
assert.ok(events.indexOf(stagingRename) < events.indexOf(backupRename));
assert.equal(fs.existsSync(filePath), true);
assert.equal(fs.existsSync(backupPath), true);
});
it('does not depend on a Windows directory fsync commit boundary', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
directories.push(directory);
const filePath = path.join(directory, 'online-backup-keyring.json');
let directoryOpens = 0;
const fsImpl = {
...fs.promises,
open: async (target, flags, mode) => {
const handle = await fs.promises.open(target, flags, mode);
if (path.resolve(String(target)) !== path.resolve(directory)) return handle;
directoryOpens++;
return {
sync: async () => { throw Object.assign(new Error('unsupported'), { code: 'EPERM' }); },
close: () => handle.close()
};
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
assert.equal(await keyring.commit(keyring.prepare(validKey(), timestamp)), true);
assert.equal(directoryOpens, 0);
});
it('rejects unsafe or loose v2 generation documents', async () => {
const key = validKey();
const entry = { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp };
for (const generation of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1']) {
const { filePath, keyring } = fixture();
fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation, keys: [entry] }));
await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID');
}
const { filePath, keyring } = fixture();
fs.writeFileSync(filePath, JSON.stringify({ version: 2, generation: 1, keys: [entry], extra: true }));
await assert.rejects(keyring.list(), error => error.code === 'KEYRING_STRUCTURE_INVALID');
});
it('recovers a validated backup without presenting corruption as an empty keyring', async () => {
const first = fixture();
const key = validKey();
await first.keyring.commit(first.keyring.prepare(key, timestamp));
fs.writeFileSync(first.filePath, '{damaged-primary');
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope
});
const snapshot = await recovered.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(key).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
});
it('recovers a valid v1 backup when the v1 primary is cryptographically unusable', async () => {
const first = fixture();
const backupKey = validKey();
const brokenKey = validKey();
writeKeyring(first.backupPath, [{
id: parseOnlineBackupKey(backupKey).id,
encryptedKey: encrypt(backupKey),
createdAt: timestamp
}]);
writeKeyring(first.filePath, [{
id: parseOnlineBackupKey(brokenKey).id,
encryptedKey: 'enc:v1:YWJjZA==',
createdAt: '2026-08-22T11:00:00.000Z'
}]);
const snapshot = await first.keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(backupKey).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
assert.equal(JSON.stringify(snapshot).includes(backupKey), false);
assert.equal(JSON.stringify(snapshot).includes(brokenKey), false);
});
it('loads a valid v1 backup when no primary exists', async () => {
const first = fixture();
const backupKey = validKey();
writeKeyring(first.backupPath, [{
id: parseOnlineBackupKey(backupKey).id,
encryptedKey: encrypt(backupKey),
createdAt: timestamp
}]);
const snapshot = await first.keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(backupKey).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
assert.equal(JSON.stringify(snapshot).includes(backupKey), false);
});
it('uses modification time only to choose among valid v1 recovery candidates', async () => {
const first = fixture();
const backupKey = validKey();
const recoveryKey = validKey();
fs.writeFileSync(first.filePath, '{broken-primary');
writeKeyring(first.backupPath, [{
id: parseOnlineBackupKey(backupKey).id,
encryptedKey: encrypt(backupKey),
createdAt: timestamp
}]);
const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(recoveryTemp, [{
id: parseOnlineBackupKey(recoveryKey).id,
encryptedKey: encrypt(recoveryKey),
createdAt: '2026-08-22T11:00:00.000Z'
}]);
const older = new Date('2026-08-22T12:00:00.000Z');
const newer = new Date('2026-08-22T13:00:00.000Z');
fs.utimesSync(first.backupPath, older, older);
fs.utimesSync(recoveryTemp, newer, newer);
const snapshot = await first.keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(recoveryKey).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
assert.equal(JSON.stringify(snapshot).includes(backupKey), false);
assert.equal(JSON.stringify(snapshot).includes(recoveryKey), false);
});
it('recovers a cryptographically valid backup when the primary only passes structural validation', async () => {
const first = fixture();
const key = validKey();
await first.keyring.commit(first.keyring.prepare(key, timestamp));
writeKeyring(first.filePath, [{
id: parseOnlineBackupKey(key).id,
encryptedKey: 'enc:v1:YWJjZA',
createdAt: timestamp
}]);
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope
});
const snapshot = await recovered.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(key).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
assert.equal(await recovered.getKey(parseOnlineBackupKey(key).id), key);
});
it('selects the highest valid generation regardless of equal modification times', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
const olderEntry = first.keyring.prepare(older, timestamp);
const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z');
writeKeyring(first.filePath, [olderEntry, newerEntry], 2);
const staleTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(staleTemp, [olderEntry], 1);
const sameTime = new Date('2026-08-22T12:00:00.000Z');
fs.utimesSync(first.filePath, sameTime, sameTime);
fs.utimesSync(staleTemp, sameTime, sameTime);
assert.deepEqual((await first.keyring.list()).entries.map(entry => entry.id), [
parseOnlineBackupKey(newer).id,
parseOnlineBackupKey(older).id
]);
assert.deepEqual((await first.keyring.list()).issues, []);
});
it('selects a higher-generation recovery candidate over an equal-time primary', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
const olderEntry = first.keyring.prepare(older, timestamp);
const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z');
writeKeyring(first.filePath, [olderEntry], 1);
const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(recoveryTemp, [olderEntry, newerEntry], 2);
const sameTime = new Date('2026-08-22T12:00:00.000Z');
fs.utimesSync(first.filePath, sameTime, sameTime);
fs.utimesSync(recoveryTemp, sameTime, sameTime);
const snapshot = await first.keyring.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [
parseOnlineBackupKey(newer).id,
parseOnlineBackupKey(older).id
]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
});
it('blocks conflicting v2 canonical payloads at the same positive generation', async () => {
const first = fixture();
const primaryKey = validKey();
const backupKey = validKey();
writeKeyring(first.filePath, [{
id: parseOnlineBackupKey(primaryKey).id,
encryptedKey: encrypt(primaryKey),
createdAt: timestamp
}], 4);
writeKeyring(first.backupPath, [{
id: parseOnlineBackupKey(backupKey).id,
encryptedKey: encrypt(backupKey),
createdAt: timestamp
}], 4);
await assert.rejects(
first.keyring.list(),
error => error.code === 'KEYRING_STRUCTURE_INVALID'
);
});
it('skips a cryptographically invalid recovery temp in favor of the validated backup', async () => {
const first = fixture();
const key = validKey();
await first.keyring.commit(first.keyring.prepare(key, timestamp));
fs.writeFileSync(first.filePath, '{damaged-primary');
const invalidTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(invalidTemp, [{
id: parseOnlineBackupKey(key).id,
encryptedKey: 'enc:v1:YWJjZA',
createdAt: timestamp
}]);
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope
});
const snapshot = await recovered.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(key).id]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
});
it('recovers a higher-generation commit temp after power loss before primary replacement', async () => {
const first = fixture();
const older = validKey();
const newer = validKey();
const olderEntry = first.keyring.prepare(older, timestamp);
const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z');
await first.keyring.commit(olderEntry);
const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
writeKeyring(recoveryTemp, [olderEntry, newerEntry], 2);
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const recovered = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope
});
const snapshot = await recovered.list();
assert.deepEqual(snapshot.entries.map(entry => entry.id), [
parseOnlineBackupKey(newer).id,
parseOnlineBackupKey(older).id
]);
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
});
it('commits successfully once the primary is published even when later cleanup fails', async () => {
const first = fixture();
const firstKey = validKey();
const secondKey = validKey();
await first.keyring.commit(first.keyring.prepare(firstKey, timestamp));
let readdirCalls = 0;
const fsImpl = {
...fs.promises,
readdir: async (...args) => {
readdirCalls++;
if (readdirCalls === 2) throw Object.assign(new Error('cleanup failed'), { code: 'EIO' });
return fs.promises.readdir(...args);
}
};
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const keyring = createOnlineBackupKeyring({
filePath: first.filePath,
encryptField: encrypt,
decryptField: decrypt,
isEncrypted: isCanonicalEnvelope,
fsImpl
});
assert.equal(await keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z')), true);
assert.equal(await keyring.getKey(parseOnlineBackupKey(secondKey).id), secondKey);
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
parseOnlineBackupKey(secondKey).id,
parseOnlineBackupKey(firstKey).id
]);
});
it('types invalid documents and never includes plaintext or ciphertext in errors', async () => {
const { filePath, backupPath, keyring } = fixture();
const key = validKey();
fs.writeFileSync(filePath, `{invalid-${key}`);
fs.writeFileSync(backupPath, '{invalid-backup');
await assert.rejects(
keyring.list(),
error => error.code === 'KEYRING_STRUCTURE_INVALID'
&& !error.message.includes(key)
&& !error.message.includes('invalid-backup')
);
});
});
-504
View File
@@ -1,504 +0,0 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { afterEach, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { createOnlineBackupManager } = require('../lib/online-backup-manager');
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
const key = `MHU2-${'K'.repeat(70)}`;
const id = 'managed-backup-id';
const record = { id, blob: 'encrypted-blob', deleteVerifier: 'delete-verifier' };
const settings = { globalSettings: { alwaysOnTop: true } };
const directories = [];
afterEach(() => {
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function createFixture(overrides = {}) {
const events = [];
const state = new Map();
const entries = new Map();
if (overrides.initialKey !== null) {
state.set(id, overrides.initialKey || key);
entries.set(id, {
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: '2026-08-22T10:00:00.000Z',
expiresAt: null
});
}
let createdAt;
let expiresAt;
let createArguments;
const removalPlans = new WeakMap();
const keyring = {
list: async () => {
events.push('list');
if (overrides.listError) throw overrides.listError;
return {
entries: [...entries.values()],
issues: overrides.listIssues || []
};
},
prepare: (value, timestamp, expiration) => {
events.push('prepare');
if (overrides.prepareError) throw overrides.prepareError;
createdAt = timestamp;
expiresAt = expiration ?? null;
return { id, encryptedKey: 'encrypted-key', createdAt: timestamp, expiresAt };
},
commit: async (entry) => {
events.push('commit');
if (overrides.commitError) throw overrides.commitError;
state.set(entry.id, key);
entries.set(entry.id, {
id: entry.id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: entry.createdAt,
expiresAt: entry.expiresAt ?? null,
...(entry.sourceIp ? { sourceIp: entry.sourceIp } : {})
});
},
getKey: async (entryId) => {
events.push('getKey');
if (overrides.getKeyError) throw overrides.getKeyError;
return state.get(entryId) || null;
},
prepareRemove: async (entryId) => {
events.push('prepareRemove');
if (overrides.prepareRemoveError) throw overrides.prepareRemoveError;
const value = state.get(entryId);
if (!value) return null;
const plan = { id: entryId, key: value };
removalPlans.set(plan, entryId);
return plan;
},
commitRemove: async (plan) => {
events.push('commitRemove');
if (overrides.commitRemoveError) throw overrides.commitRemoveError;
const entryId = removalPlans.get(plan);
if (!entryId) throw new Error('invalid plan');
removalPlans.delete(plan);
state.delete(entryId);
entries.delete(entryId);
return true;
}
};
const manager = createOnlineBackupManager({
keyring,
loadSettings: async () => settings,
appVersion: () => '2.1.31',
createBackup: (...args) => {
createArguments = args;
const expiration = args[3] === 'forever' ? null : new Date(new Date(args[2]).getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
return { key, record, expiresAt: expiration };
},
uploadBackup: async (uploadedRecord) => {
events.push('upload');
assert.equal(uploadedRecord, record);
if (overrides.uploadError) throw overrides.uploadError;
return overrides.uploadMetadata;
},
deleteBackup: async (value) => {
events.push(`delete:${value}`);
if (overrides.deleteError) throw overrides.deleteError;
return overrides.deletionOutcome || { deleted: true, notFound: false };
},
copyText: (value) => events.push(`copy:${value}`)
});
return {
events,
keyring,
manager,
state,
get createdAt() {
return createdAt;
},
get expiresAt() {
return expiresAt;
},
get createArguments() {
return createArguments;
}
};
}
function codedError(code, secret = key) {
const error = new Error(`failure ${secret}`);
error.code = code;
return error;
}
function encrypted(value) {
return `enc:v1:${Buffer.from(value).toString('base64')}`;
}
function realKeyring(keys) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-manager-keyring-'));
directories.push(directory);
const filePath = path.join(directory, 'online-backup-keys.json');
fs.writeFileSync(filePath, JSON.stringify({ version: 1, keys }));
return createOnlineBackupKeyring({
filePath,
encryptField: encrypted,
decryptField: value => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8'),
isEncrypted: value => typeof value === 'string'
&& value.startsWith('enc:v1:')
&& Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('base64') === value.slice('enc:v1:'.length)
});
}
function keyWithRecordId(sourceKey, fill) {
const idBytes = parseOnlineBackupKey(sourceKey).idBytes;
const masterKey = Buffer.alloc(32, fill);
const checksum = crypto.createHash('sha256')
.update(Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8'))
.update(idBytes)
.update(masterKey)
.digest()
.subarray(0, 4);
return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`;
}
function deleteVerifier(value) {
const parsed = parseOnlineBackupKey(value);
const deleteSecret = Buffer.from(crypto.hkdfSync(
'sha256',
parsed.masterKey,
parsed.idBytes,
Buffer.from('MHU-ONLINE-DELETE-V1', 'utf8'),
32
));
return crypto.createHash('sha256').update(deleteSecret).digest('base64url');
}
describe('transactional online backup manager', () => {
it('keeps server-provided source IP in the committed and listed metadata', async () => {
const fixture = createFixture({ initialKey: null, uploadMetadata: { sourceIp: '2001:db8::1' } });
const created = await fixture.manager.createManaged();
assert.equal(created.ok, true);
assert.equal(created.entry.sourceIp, '2001:db8::1');
const listed = await fixture.manager.listManaged();
assert.equal(listed.entries[0].sourceIp, '2001:db8::1');
assert.ok(!JSON.stringify(listed).includes(key));
});
it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => {
const fixture = createFixture({ initialKey: null });
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt, 'forever', null]);
assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
assert.deepEqual(result, {
ok: true,
entry: {
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: fixture.createdAt,
expiresAt: fixture.expiresAt
}
});
assert.equal(JSON.stringify(result).includes(key), false);
});
it('keeps a committed create successful without a post-commit keyring read', async () => {
const fixture = createFixture({ initialKey: null });
fixture.keyring.list = async () => {
fixture.events.push('list');
throw new Error('list unavailable after commit');
};
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
assert.deepEqual(result, {
ok: true,
entry: {
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: fixture.createdAt,
expiresAt: fixture.expiresAt
}
});
});
it('does not upload when preparing encrypted local state fails', async () => {
const fixture = createFixture({
initialKey: null,
prepareError: new Error(`prepare failed with ${key}`)
});
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare']);
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
assert.equal(JSON.stringify(result).includes(key), false);
});
it('does not commit when the upload fails', async () => {
const fixture = createFixture({
initialKey: null,
uploadError: new Error(`upload failed with ${key}`)
});
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload']);
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
assert.equal(JSON.stringify(result).includes(key), false);
});
it('rolls the remote backup back with the in-memory key when commit fails', async () => {
const fixture = createFixture({
initialKey: null,
commitError: new Error(`commit failed with ${key}`)
});
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]);
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
assert.equal(JSON.stringify(result).includes(key), false);
});
it('keeps a commit failure sanitized when best-effort rollback also fails', async () => {
const fixture = createFixture({
initialKey: null,
commitError: new Error(`commit failed with ${key}`),
deleteError: new Error(`rollback failed with ${key}`)
});
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]);
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
assert.equal(JSON.stringify(result).includes(key), false);
});
it('decrypts a managed key only inside copy and returns no key', async () => {
const fixture = createFixture();
const result = await fixture.manager.copyManaged(id);
assert.deepEqual(fixture.events, ['getKey', `copy:${key}`]);
assert.deepEqual(result, { ok: true });
assert.equal(JSON.stringify(result).includes(key), false);
});
it('returns typed sanitized keyring errors instead of a false not-found copy result', async () => {
const fixture = createFixture({
getKeyError: codedError('KEYRING_SECURE_STORAGE_UNAVAILABLE')
});
const result = await fixture.manager.copyManaged(id);
assert.deepEqual(result, {
ok: false,
code: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
error: 'Sichere Schlüsselspeicherung ist nicht verfügbar'
});
assert.equal(result.notFound, undefined);
assert.equal(JSON.stringify(result).includes(key), false);
});
it('removes the local entry after a successful remote deletion', async () => {
const fixture = createFixture();
const result = await fixture.manager.deleteManaged(id);
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`, 'commitRemove']);
assert.deepEqual(result, { ok: true, removedId: id, notFound: false });
assert.equal(fixture.state.has(id), false);
assert.equal(JSON.stringify(result).includes(key), false);
});
it('removes a stale local entry and reports a missing remote backup', async () => {
const fixture = createFixture({ deletionOutcome: { deleted: false, notFound: true } });
const result = await fixture.manager.deleteManaged(id);
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`, 'commitRemove']);
assert.deepEqual(result, { ok: true, removedId: id, notFound: true });
assert.equal(fixture.state.has(id), false);
});
it('keeps the local entry when remote deletion fails', async () => {
const fixture = createFixture({ deleteError: new Error(`network failed with ${key}`) });
const result = await fixture.manager.deleteManaged(id);
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`]);
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' });
assert.equal(fixture.state.has(id), true);
assert.equal(JSON.stringify(result).includes(key), false);
});
it('blocks remote deletion when a valid target has a corrupt neighboring entry', async () => {
const valid = createOnlineBackup({}, '2.1.31').key;
const corrupt = createOnlineBackup({}, '2.1.31').key;
const keyring = realKeyring([
{ id: parseOnlineBackupKey(valid).id, encryptedKey: encrypted(valid), createdAt: '2026-08-22T10:00:00.000Z' },
{ id: parseOnlineBackupKey(corrupt).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: '2026-08-22T11:00:00.000Z' }
]);
let remoteCalls = 0;
const manager = createOnlineBackupManager({
keyring,
loadSettings: async () => settings,
appVersion: () => '2.1.31',
deleteBackup: async () => { remoteCalls++; return { deleted: true, notFound: false }; },
copyText: () => {}
});
const result = await manager.deleteManaged(parseOnlineBackupKey(valid).id);
assert.deepEqual(result, {
ok: false,
code: 'KEYRING_STRUCTURE_INVALID',
error: 'Gespeicherter Online-Schlüsselbund ist beschädigt'
});
assert.equal(remoteCalls, 0);
});
it('blocks duplicate IDs with different delete verifiers before remote deletion', async () => {
const first = createOnlineBackup({}, '2.1.31').key;
const second = keyWithRecordId(first, 0x33);
const entryId = parseOnlineBackupKey(first).id;
assert.notEqual(deleteVerifier(first), deleteVerifier(second));
const keyring = realKeyring([
{ id: entryId, encryptedKey: encrypted(first), createdAt: '2026-08-22T10:00:00.000Z' },
{ id: entryId, encryptedKey: encrypted(second), createdAt: '2026-08-22T11:00:00.000Z' }
]);
let remoteCalls = 0;
const manager = createOnlineBackupManager({
keyring,
loadSettings: async () => settings,
appVersion: () => '2.1.31',
deleteBackup: async () => { remoteCalls++; return { deleted: true, notFound: false }; },
copyText: () => {}
});
const result = await manager.deleteManaged(entryId);
assert.deepEqual(result, {
ok: false,
code: 'KEYRING_DUPLICATE_ID',
error: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig'
});
assert.equal(remoteCalls, 0);
});
it('does not call copy or remote deletion dependencies for unknown IDs', async () => {
const fixture = createFixture({ initialKey: null });
const copied = await fixture.manager.copyManaged('unknown-id');
const deleted = await fixture.manager.deleteManaged('unknown-id');
assert.deepEqual(fixture.events, ['getKey', 'prepareRemove']);
assert.deepEqual(copied, {
ok: false,
notFound: true,
error: 'Online-Sicherungsschlüssel wurde nicht gefunden'
});
assert.deepEqual(deleted, {
ok: false,
notFound: true,
error: 'Online-Sicherungsschlüssel wurde nicht gefunden'
});
});
it('serializes concurrent create and delete mutations in request order', async () => {
const fixture = createFixture({ initialKey: null });
let releaseUpload;
fixture.manager = createOnlineBackupManager({
keyring: fixture.keyring,
loadSettings: async () => settings,
appVersion: () => '2.1.31',
createBackup: (_settings, _version, exportedAt) => {
fixture.createdTimestamp = exportedAt;
return { key, record };
},
uploadBackup: async () => {
fixture.events.push('upload:start');
await new Promise((resolve) => {
releaseUpload = resolve;
});
fixture.events.push('upload:end');
},
deleteBackup: async (value) => {
fixture.events.push(`delete:${value}`);
return { deleted: true, notFound: false };
},
copyText: () => {}
});
const creating = fixture.manager.createManaged();
const deleting = fixture.manager.deleteManaged(id);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(fixture.events, ['prepare', 'upload:start']);
releaseUpload();
const [created, deleted] = await Promise.all([creating, deleting]);
assert.equal(created.ok, true);
assert.deepEqual(deleted, { ok: true, removedId: id, notFound: false });
assert.deepEqual(fixture.events, [
'prepare',
'upload:start',
'upload:end',
'commit',
'prepareRemove',
`delete:${key}`,
'commitRemove'
]);
});
it('lists sanitized entries and contains dependency failures at the public boundary', async () => {
const fixture = createFixture();
assert.deepEqual(await fixture.manager.listManaged(), {
ok: true,
entries: [{
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: '2026-08-22T10:00:00.000Z',
expiresAt: null
}]
});
fixture.keyring.list = async () => {
throw new Error(`list failed with ${key}`);
};
const failed = await fixture.manager.listManaged();
assert.deepEqual(failed, { ok: false, error: 'Online-Sicherungen konnten nicht geladen werden' });
assert.equal(JSON.stringify(failed).includes(key), false);
});
it('keeps readable list entries with a typed warning and never reports damaged nonempty state as empty success', async () => {
const readable = createFixture({ listIssues: ['KEYRING_DECRYPT_FAILED'] });
assert.deepEqual(await readable.manager.listManaged(), {
ok: true,
entries: [{
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: '2026-08-22T10:00:00.000Z',
expiresAt: null
}],
warningCode: 'KEYRING_DECRYPT_FAILED',
warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden'
});
const damaged = createFixture({ initialKey: null, listIssues: ['KEYRING_ID_MISMATCH'] });
assert.deepEqual(await damaged.manager.listManaged(), {
ok: false,
entries: [],
code: 'KEYRING_ID_MISMATCH',
error: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein'
});
});
});
-103
View File
@@ -1,103 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { generateKeyPairSync } = require('node:crypto');
const { mkdtemp, readFile, writeFile, rm } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const { spawnSync } = require('node:child_process');
const { createOnlineBackup, downloadRecoveryPublicKey, uploadOnlineBackup, downloadOnlineBackup, deleteOnlineBackup } = require('../lib/online-backup');
const { recoverBackupKey, recoveryPublicKey } = require('../lib/online-backup-recovery');
const pair = generateKeyPairSync('rsa', { modulusLength: 3072, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
test('recovery binds the original key to its record and validates ciphertext and expiry', () => {
const backup = createOnlineBackup({ language: 'de' }, 'test', undefined, 'forever', pair.publicKey);
const record = { ...backup.record, expiresAt: null };
assert.equal(recoverBackupKey(record.id, record, pair.privateKey), backup.key);
assert.throws(() => recoverBackupKey(record.id, { ...record, expiresAt: '2020-01-01T00:00:00.000Z' }, pair.privateKey));
assert.throws(() => recoverBackupKey(record.id, { ...record, expiresAt: 'invalid' }, pair.privateKey));
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: undefined }, pair.privateKey));
assert.throws(() => recoverBackupKey('x'.repeat(22), record, pair.privateKey));
assert.throws(() => recoverBackupKey(record.id, { ...record, blob: 'AAAA' }, pair.privateKey));
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: { ...record.recovery, keyId: 'wrong' } }, pair.privateKey));
assert.throws(() => recoverBackupKey(record.id, { ...record, recovery: { ...record.recovery, wrappedKey: 'A'.repeat(512) } }, pair.privateKey));
assert.throws(() => recoveryPublicKey(pair.privateKey));
const weak = generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({ type: 'spki', format: 'pem' });
assert.throws(() => recoveryPublicKey(weak));
});
test('server persists recovery without plaintext secrets, supports normal import and offline recovery', async t => {
const { createBackupServer } = await import('../services/backup-api/src/server.mjs');
const rootDir = await mkdtemp(join(tmpdir(), 'mhu-recovery-'));
let now = Date.now();
const server = createBackupServer({ rootDir, recoveryPublicKey: pair.publicKey, now: () => now });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(rootDir, { recursive: true, force: true }); });
const url = `http://127.0.0.1:${server.address().port}`;
const publicKey = await downloadRecoveryPublicKey(url);
assert.equal(publicKey, pair.publicKey);
const backup = createOnlineBackup({ language: 'de', password: 'test-secret' }, 'test', undefined, '1d', publicKey);
assert.deepEqual(await uploadOnlineBackup(backup.record, url), { sourceIp: '127.0.0.1' });
const recordPath = join(rootDir, `${backup.record.id}.json`);
const raw = await readFile(recordPath, 'utf8');
assert.ok(!raw.includes(backup.key));
assert.ok(!raw.includes('test-secret'));
const record = JSON.parse(raw);
assert.equal(record.version, 4);
assert.equal(record.sourceIp, '127.0.0.1');
assert.equal(recoverBackupKey(backup.record.id, record, pair.privateKey), backup.key);
assert.equal((await downloadOnlineBackup(backup.key, url)).settings.language, 'de');
const privateFile = join(rootDir, 'private.pem');
const output = join(rootDir, 'result.txt');
await writeFile(privateFile, pair.privateKey);
const cli = join(__dirname, '../scripts/backup-recovery.cjs');
const result = spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, output], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal((await readFile(output, 'utf8')).trim(), backup.key);
assert.ok(!result.stdout.includes(backup.key));
const detailedOutput = join(rootDir, 'details.txt');
assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, detailedOutput, '--details']).status, 0);
const details = await readFile(detailedOutput, 'utf8');
assert.match(details, /^\d{2}\.\d{2}\.\d{4} - \d{2}:\d{2} \| IP: 127\.0\.0\.1 \| MHU2-/);
assert.ok(details.trim().endsWith(backup.key));
const legacyRecord = { ...record, version: 3 };
delete legacyRecord.sourceIp;
await writeFile(recordPath, JSON.stringify(legacyRecord));
assert.equal((await downloadOnlineBackup(backup.key, url)).settings.language, 'de');
const legacyDetails = join(rootDir, 'legacy-details.txt');
assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, legacyDetails, '--details']).status, 0);
assert.ok((await readFile(legacyDetails, 'utf8')).includes(' | IP: unbekannt | '));
assert.equal(spawnSync(process.execPath, [cli, 'recover', recordPath, privateFile, output]).status, 1);
const other = createOnlineBackup({}, 'test', undefined, 'forever', publicKey);
await uploadOnlineBackup(other.record, url);
assert.equal((await deleteOnlineBackup(other.key, url)).deleted, true);
await assert.rejects(uploadOnlineBackup({ ...other.record, recovery: { ...other.record.recovery, wrappedKey: 'bad' } }, url));
await assert.rejects(uploadOnlineBackup({ ...other.record, recovery: { ...other.record.recovery, keyId: Buffer.alloc(32).toString('base64url') } }, url));
now += 86400 * 1000;
await assert.rejects(downloadOnlineBackup(backup.key, url));
});
test('missing or invalid public configuration fails closed without uploading', async () => {
await assert.rejects(downloadRecoveryPublicKey('https://example.test', { fetchImpl: async () => new Response('{}', { status: 503 }) }));
await assert.rejects(downloadRecoveryPublicKey('https://example.test', { fetchImpl: async () => new Response('{"publicKey":"bad"}') }));
const { createOnlineBackupManager } = require('../lib/online-backup-manager');
let uploaded = false;
const manager = createOnlineBackupManager({ loadSettings: async () => ({}), loadRecoveryPublicKey: async () => { throw new Error('unavailable'); }, uploadBackup: async () => { uploaded = true; } });
assert.equal((await manager.createManaged()).ok, false);
assert.equal(uploaded, false);
});
test('offline setup creates a matching key pair and never replaces existing keys', async t => {
const root = await mkdtemp(join(tmpdir(), 'mhu-recovery-setup-'));
t.after(() => rm(root, { recursive: true, force: true }));
const directory = join(root, 'keys');
const cli = join(__dirname, '../scripts/backup-recovery.cjs');
const result = spawnSync(process.execPath, [cli, 'init', directory], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
const publicPem = await readFile(join(directory, 'recovery-public.pem'), 'utf8');
const privatePem = await readFile(join(directory, 'recovery-private.pem'), 'utf8');
const backup = createOnlineBackup({}, 'test', undefined, 'forever', publicPem);
assert.equal(recoverBackupKey(backup.record.id, { ...backup.record, expiresAt: null }, privatePem), backup.key);
assert.equal(spawnSync(process.execPath, [cli, 'init', directory]).status, 1);
assert.equal(await readFile(join(directory, 'recovery-private.pem'), 'utf8'), privatePem);
assert.ok(!result.stdout.includes('PRIVATE KEY'));
});
-71
View File
@@ -1,71 +0,0 @@
const { once } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { after, before, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
uploadOnlineBackup
} = require('../lib/online-backup');
let rootDir;
let server;
let baseUrl;
before(async () => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-backup-contract-'));
const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'services', 'backup-api', 'src', 'server.mjs')).href;
const { createBackupServer } = await import(moduleUrl);
server = createBackupServer({ rootDir });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
if (server) await new Promise((resolve) => server.close(resolve));
if (rootDir) fs.rmSync(rootDir, { recursive: true, force: true });
});
describe('online backup client and service contract', () => {
it('keeps older keys valid and stores ciphertext only', async () => {
const firstSettings = {
hosters: { 'byse.sx': [{ id: 'first', apiKey: 'first-secret' }] },
hosterSettings: { 'byse.sx': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
history: []
};
const secondSettings = {
hosters: { 'byse.sx': [{ id: 'second', apiKey: 'second-secret' }] },
hosterSettings: { 'byse.sx': { retries: 7 } },
globalSettings: { alwaysOnTop: true },
history: []
};
const first = createOnlineBackup(firstSettings, '2.0.3');
const second = createOnlineBackup(secondSettings, '2.0.3');
await uploadOnlineBackup(first.record, baseUrl);
await uploadOnlineBackup(second.record, baseUrl);
assert.deepEqual((await downloadOnlineBackup(first.key, baseUrl)).settings, firstSettings);
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
const stored = fs.readdirSync(rootDir)
.filter((name) => name.endsWith('.json'))
.map((name) => fs.readFileSync(path.join(rootDir, name), 'utf8'))
.join('\n');
assert.equal(stored.includes('first-secret'), false);
assert.equal(stored.includes('second-secret'), false);
assert.equal(stored.includes(first.key), false);
assert.equal(stored.includes(second.key), false);
await deleteOnlineBackup(first.key, baseUrl);
await assert.rejects(downloadOnlineBackup(first.key, baseUrl), /nicht gefunden/i);
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
await deleteOnlineBackup(second.key, baseUrl);
});
});
-223
View File
@@ -1,223 +0,0 @@
const http = require('node:http');
const { once } = require('node:events');
const { afterEach, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const servers = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
});
function settings() {
return {
hosters: {
'doodstream.com': [{ id: 'account-1', authType: 'api', apiKey: 'secret-api-key', enabled: true }]
},
hosterSettings: {
'doodstream.com': { retries: 3, parallelCount: 5 }
},
globalSettings: {
alwaysOnTop: true,
webhookUrl: 'https://example.invalid/private-webhook'
},
history: []
};
}
describe('online backup key', () => {
it('creates a unique 75-character MHU key and restores every snapshot independently', () => {
const { createOnlineBackup, restoreOnlineBackup } = require('../lib/online-backup');
const first = createOnlineBackup(settings(), '2.0.3', '2026-08-09T00:00:00.000Z');
const secondSettings = settings();
secondSettings.globalSettings.alwaysOnTop = false;
const second = createOnlineBackup(secondSettings, '2.0.3', '2026-08-09T00:01:00.000Z');
assert.match(first.key, /^MHU2-[A-Za-z0-9_-]{70}$/);
assert.equal(first.key.length, 75);
assert.notEqual(second.key, first.key);
assert.deepEqual(restoreOnlineBackup(first.key, first.record.blob).settings, settings());
assert.equal(restoreOnlineBackup(second.key, second.record.blob).settings.globalSettings.alwaysOnTop, false);
});
it('never places credentials or the decryption secret in the server record', () => {
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const serialized = JSON.stringify(created.record);
const parsed = parseOnlineBackupKey(created.key);
assert.equal(serialized.includes('secret-api-key'), false);
assert.equal(serialized.includes('private-webhook'), false);
assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), false);
assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'expiresInSeconds', 'id']);
assert.equal(created.record.expiresInSeconds, null);
});
it('supports the allowed validity periods with unlimited validity as the default', () => {
const { createOnlineBackup, normalizeOnlineBackupRetention } = require('../lib/online-backup');
const createdAt = '2026-08-09T00:00:00.000Z';
const expected = new Map([
['1d', [86_400, '2026-08-10T00:00:00.000Z']],
['3d', [259_200, '2026-08-12T00:00:00.000Z']],
['7d', [604_800, '2026-08-16T00:00:00.000Z']],
['31d', [2_678_400, '2026-09-09T00:00:00.000Z']],
['forever', [null, null]]
]);
assert.equal(normalizeOnlineBackupRetention(), 'forever');
const defaultBackup = createOnlineBackup(settings(), '2.1.41', createdAt);
assert.equal(defaultBackup.record.expiresInSeconds, null);
assert.equal(defaultBackup.expiresAt, null);
for (const [retention, [seconds, expiresAt]] of expected) {
const created = createOnlineBackup(settings(), '2.1.41', createdAt, retention);
assert.equal(created.record.expiresInSeconds, seconds);
assert.equal(created.expiresAt, expiresAt);
}
assert.throws(() => normalizeOnlineBackupRetention('30d'), /Gültigkeitsdauer/i);
});
it('rejects corrupted keys, ciphertext and oversized settings', () => {
const { createOnlineBackup, parseOnlineBackupKey, restoreOnlineBackup } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const keyTail = created.key.endsWith('A') ? 'B' : 'A';
const blobTail = created.record.blob.endsWith('A') ? 'B' : 'A';
assert.throws(() => parseOnlineBackupKey(`${created.key.slice(0, -1)}${keyTail}`), /Schlüssel/i);
assert.throws(() => restoreOnlineBackup(created.key, `${created.record.blob.slice(0, -1)}${blobTail}`), /entschlüsselt|beschädigt/i);
assert.throws(() => createOnlineBackup({ huge: 'x'.repeat(600_000) }, '2.0.3'), /zu groß/i);
});
});
describe('online backup transport', () => {
it('uses only POST bodies and never sends the master key or record id in URLs', async () => {
const {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
parseOnlineBackupKey,
uploadOnlineBackup
} = require('../lib/online-backup');
let stored = null;
let deleteRequest = null;
const requestedUrls = [];
const server = http.createServer(async (request, response) => {
requestedUrls.push(String(request.url || ''));
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
if (request.method === 'POST' && request.url === '/v1/backups') {
stored = body;
response.writeHead(201, { 'content-type': 'application/json' });
response.end('{"created":true}');
return;
}
if (request.method === 'POST' && request.url === '/v1/backups/restore' && stored) {
assert.equal(body.id, stored.id);
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ blob: stored.blob }));
return;
}
if (request.method === 'POST' && request.url === '/v1/backups/delete' && stored) {
deleteRequest = body;
response.writeHead(204);
response.end();
return;
}
response.writeHead(404, { 'content-type': 'application/json' });
response.end('{"error":"not_found"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = createOnlineBackup(settings(), '2.0.3');
await uploadOnlineBackup(created.record, baseUrl);
const restored = await downloadOnlineBackup(created.key, baseUrl);
assert.deepEqual(await deleteOnlineBackup(created.key, baseUrl), { deleted: true, notFound: false });
assert.deepEqual(restored.settings, settings());
assert.equal(JSON.stringify(stored).includes(parseOnlineBackupKey(created.key).masterKey.toString('base64url')), false);
assert.match(deleteRequest.deleteSecret, /^[A-Za-z0-9_-]{43}$/);
assert.deepEqual(requestedUrls, ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete']);
assert.equal(requestedUrls.join(' ').includes(stored.id), false);
});
it('returns an idempotent outcome when the backup is already missing', async () => {
const { createOnlineBackup, deleteOnlineBackup } = require('../lib/online-backup');
const server = http.createServer((_request, response) => {
response.writeHead(404, { 'content-type': 'application/json' });
response.end('{"error":"not_found"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const missingBaseUrl = `http://127.0.0.1:${server.address().port}`;
const { key } = createOnlineBackup(settings(), '2.0.3');
assert.deepEqual(await deleteOnlineBackup(key, missingBaseUrl), { deleted: false, notFound: true });
});
it('uses the generic deletion error without reflecting the server response body', async () => {
const { createOnlineBackup, deleteOnlineBackup } = require('../lib/online-backup');
const server = http.createServer((_request, response) => {
response.writeHead(500, { 'content-type': 'application/json' });
response.end('{"leaked":"server-secret-value"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const { key } = createOnlineBackup(settings(), '2.0.3');
await assert.rejects(
deleteOnlineBackup(key, baseUrl),
(error) => error.message === 'Online-Sicherung konnte nicht gelöscht werden'
&& !error.message.includes('server-secret-value')
);
});
it('does not reflect server response bodies into client errors', async () => {
const { createOnlineBackup, uploadOnlineBackup } = require('../lib/online-backup');
const server = http.createServer((_request, response) => {
response.writeHead(500, { 'content-type': 'application/json' });
response.end('{"leaked":"server-secret-value"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const created = createOnlineBackup(settings(), '2.0.3');
await assert.rejects(
uploadOnlineBackup(created.record, `http://127.0.0.1:${server.address().port}`),
(error) => !String(error.message).includes('server-secret-value')
);
});
it('keeps the timeout active until the response body is fully read', async () => {
const { createOnlineBackup, downloadOnlineBackup } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const fetchImpl = async (_url, options) => ({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: {
getReader: () => ({
read: () => new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
}),
cancel: async () => {}
})
}
});
const outcome = await Promise.race([
assert.rejects(
downloadOnlineBackup(created.key, 'http://127.0.0.1:8788', { fetchImpl, timeoutMs: 20 }),
/antwortet nicht/i
).then(() => 'timed-out'),
new Promise((resolve) => setTimeout(() => resolve('hung'), 120))
]);
assert.equal(outcome, 'timed-out');
});
});
-56
View File
@@ -1,56 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { selectOrphanTmps } = require('../lib/orphan-tmp');
const BASE = 'electron-config.json';
const aliveSet = new Set([100, 200]);
const isAlive = (pid) => aliveSet.has(pid);
test('selects only dead-pid <base>.<pid>.tmp orphans', () => {
const files = [
'electron-config.json',
'electron-config.json.bak',
'electron-config.json.tmp',
'electron-config.json.100.tmp',
'electron-config.json.200.tmp',
'electron-config.json.999.tmp',
'electron-config.json.4242.tmp',
'something-else.500.tmp',
'electron-config.json.abc.tmp'
];
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive });
assert.deepEqual(orphans.sort(), ['electron-config.json.4242.tmp', 'electron-config.json.999.tmp']);
});
test('never selects the current process tmp', () => {
const files = ['electron-config.json.7.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('never selects the FIXED <base>.tmp (used by async _atomicWrite)', () => {
const files = ['electron-config.json.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('never selects the live config or its .bak', () => {
const files = ['electron-config.json', 'electron-config.json.bak'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('alive pid (incl. EPERM-as-alive) is skipped, preventing deletion of a concurrent instance tmp', () => {
const files = ['electron-config.json.100.tmp', 'electron-config.json.300.tmp'];
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: (p) => p === 100 });
assert.deepEqual(orphans, ['electron-config.json.300.tmp']);
});
test('robust to junk / missing inputs', () => {
assert.deepEqual(selectOrphanTmps(null, { baseName: BASE, currentPid: 1, isAlive }), []);
assert.deepEqual(selectOrphanTmps(['x', 42, null, undefined], { baseName: BASE, currentPid: 1, isAlive }), []);
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], {}), []);
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], { baseName: '', currentPid: 1, isAlive }), []);
});
test('does not match a different base that shares a prefix', () => {
const files = ['electron-config.json.backup.5.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const screenshotFiles = [
'assets/product-overview.png',
'docs/screenshots/upload-workspace.png',
'docs/screenshots/account-management.png',
'docs/screenshots/automation-settings.png',
'docs/screenshots/history.png'
];
const currentVersion = require('../package.json').version;
function createStage() {
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
const tracked = spawnSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' });
assert.equal(tracked.status, 0, tracked.stderr);
for (const relativePath of tracked.stdout.trim().split(/\r?\n/u)) {
const destination = path.join(stage, relativePath);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(root, relativePath), destination);
}
return stage;
}
function verify(stage, version = currentVersion, sourceOnly = true) {
const args = ['scripts/verify-public-release.mjs'];
if (sourceOnly) args.push('--source-only');
args.push('--version', version);
return spawnSync(process.execPath, args, {
cwd: stage,
encoding: 'utf8'
});
}
test('public release verifier accepts only the exact source manifest and target version', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
const baseline = verify(stage);
assert.equal(baseline.status, 0, baseline.stderr);
assert.equal(
baseline.stdout,
`public-release-source-ok files=164 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
);
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
const extra = verify(stage);
assert.equal(extra.status, 1);
assert.match(extra.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
fs.rmSync(path.join(stage, 'tests', 'unexpected.json'));
fs.copyFileSync(path.join(stage, screenshotFiles[1]), path.join(stage, 'docs', 'screenshots', 'unexpected.png'));
const extraDocumentation = verify(stage);
assert.equal(extraDocumentation.status, 1);
assert.match(extraDocumentation.stderr, /docs\/screenshots\/unexpected\.png\tsource-layout-allowlist/);
fs.rmSync(path.join(stage, 'docs', 'screenshots', 'unexpected.png'));
const wrongVersion = verify(stage, '2.0.5');
assert.equal(wrongVersion.status, 1);
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
});
test('public release verifier requires and validates every approved screenshot', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
const baseline = verify(stage, currentVersion, false);
assert.equal(baseline.status, 0, baseline.stderr);
for (const relativePath of screenshotFiles) {
const absolutePath = path.join(stage, relativePath);
const original = fs.readFileSync(absolutePath);
const invalid = Buffer.from(original);
invalid.writeUInt32BE(999, 16);
fs.writeFileSync(absolutePath, invalid);
const invalidScreenshot = verify(stage, currentVersion, false);
assert.equal(invalidScreenshot.status, 1);
assert.ok(invalidScreenshot.stderr.includes(`${relativePath}\tproduct-screenshot`), invalidScreenshot.stderr);
fs.writeFileSync(absolutePath, original);
}
const missingPath = screenshotFiles.at(-1);
fs.rmSync(path.join(stage, missingPath));
const missingScreenshot = verify(stage, currentVersion, false);
assert.equal(missingScreenshot.status, 1);
assert.ok(missingScreenshot.stderr.includes(`${missingPath}\trequired-screenshot`), missingScreenshot.stderr);
});
-114
View File
@@ -1,114 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
const rnd = lcg(0x9e3779b1);
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 3000; iter++) {
const useSavedAt = rnd() < 0.7;
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 6);
for (let i = 0; i < nJobs; i++) {
const name = pick(names);
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
// different folder) actually occur and exercise the guard.
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
}
const log = [];
const nLog = Math.floor(rnd() * 5);
for (let i = 0; i < nLog; i++) {
const hasTs = rnd() < 0.8;
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
}
const logKeys = new Set();
const maxTs = new Map();
for (const e of log) {
const k = key(e.fileName, e.hoster);
logKeys.add(k);
if (typeof e.ts === 'number' && isFinite(e.ts)) {
const prev = maxTs.get(k);
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
}
}
const filesPerKey = new Map();
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
filesPerKey.get(k).add(job.file || '');
}
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
const keptIds = new Set(kept.map(j => j.id));
const removedIds = new Set(removed.map(j => j.id));
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
const doneInLog = job.status === 'done' && logKeys.has(k);
const unambiguous = filesPerKey.get(k).size <= 1;
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
const shouldRemove = doneInLog || afterSnap;
assert.equal(removedIds.has(job.id), shouldRemove,
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
}
}
});
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
const rnd = lcg(0x1234abcd);
for (let iter = 0; iter < 500; iter++) {
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
// basename + hoster, genuinely pending. Y must survive.
const jobs = [
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
}
});
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
const rnd = lcg(0xdeadbeef);
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx'];
const names = ['a.mkv', 'b.mp4'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 1000; iter++) {
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 5);
for (let i = 0; i < nJobs; i++) {
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
}
const log = [];
const nLog = Math.floor(rnd() * 4);
for (let i = 0; i < nLog; i++) {
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
}
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
}
});
-253
View File
@@ -1,253 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog, completedSelectionKeys } = require('../lib/queue-dedup');
function job(status, fileName, hoster) {
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
}
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
// hosters, every fileName|hoster present in the lifetime upload log.
const jobs = [
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
];
const log = [
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 0, 'no pending job may be removed');
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
});
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
const jobs = [
job('done', 'a.mkv', 'doodstream.com'),
job('preview', 'a.mkv', 'voe.sx'),
job('error', 'b.mkv', 'doodstream.com'),
job('aborted', 'c.mkv', 'doodstream.com')
];
const log = [
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
{ fileName: 'a.mkv', hoster: 'voe.sx' },
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].hoster, 'doodstream.com');
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
assert.equal(kept.length, 3);
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
assert.ok(kept.some(j => j.status === 'error'));
assert.ok(kept.some(j => j.status === 'aborted'));
});
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('case-insensitive match on fileName and hoster', () => {
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
});
test('empty/missing inputs do not throw', () => {
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
const jobs = [job('done', 'x.mkv', 'voe.sx')];
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
});
const T = (s) => Date.parse(s.replace(' ', 'T'));
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
assert.equal(kept.length, 0);
});
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
assert.equal(kept.length, 1);
});
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
const savedAt = T('2026-06-19 12:00:00') + 800;
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
});
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'newest matching log entry decides');
});
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
const jobs = [job('done', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1);
});
test('ts-gate ambiguity guard: a pending same-basename file in a DIFFERENT folder is NOT lost when a sibling completes after the snapshot', () => {
// X (C:/A/clip.mp4) was uploaded after the snapshot and logged. Y is a
// genuinely-different file (C:/B/clip.mp4), same basename + hoster, still
// pending. The log records only basenames, so the ts-rule must not drop Y.
const jobs = [
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0, 'ambiguous key -> ts-rule suppressed, no pending file lost');
assert.equal(kept.length, 2);
});
test('ts-gate ambiguity guard: the done-in-log rule still applies on an ambiguous key', () => {
// Even when the key is ambiguous, a job that is actually 'done' and in the log
// is still decluttered (pre-existing rule, unchanged by the guard).
const jobs = [
{ status: 'done', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].file, 'C:/A/clip.mp4');
assert.ok(kept.some(j => j.file === 'C:/B/clip.mp4'), 'the distinct pending file survives');
});
test('ts-gate: a unique-path ghost still drops (guard does not weaken the common case)', () => {
const jobs = [{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' }];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'single job for the key -> unambiguous -> ghost dropped as before');
});
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
const f = 'Einfach mal die Fresse halten!!!.mp4';
const jobs = [
job('preview', f, 'doodstream.com'),
job('preview', f, 'voe.sx'),
job('preview', f, 'vidmoly.me'),
job('preview', f, 'byse.sx')
];
const savedAt = T('2026-06-19 12:00:00');
const log = [
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
});
test('completedSelectionKeys: a selectedFile that completed after the snapshot yields its full-path|hoster key', () => {
const selectedFiles = [{ path: 'C:/dl/done.mp4', name: 'done.mp4' }, { path: 'C:/dl/pending.mp4', name: 'pending.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'done.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/done.mp4|voe.sx'], 'only the completed file is seeded; pending is not');
});
test('completedSelectionKeys: per-hoster — a file done on voe but not byse only seeds the voe key', () => {
const selectedFiles = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const hosters = ['voe.sx', 'byse.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/a.mp4|voe.sx'], 'the still-pending byse upload is NOT seeded');
});
test('completedSelectionKeys: ambiguous basename across folders seeds NOTHING (no lost re-preview)', () => {
const selectedFiles = [{ path: 'C:/A/clip.mp4', name: 'clip.mp4' }, { path: 'C:/B/clip.mp4', name: 'clip.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, [], 'ambiguous -> neither path is suppressed, both re-preview (safe direction)');
});
test('completedSelectionKeys: an OLDER completion (pre-snapshot re-queue) is NOT seeded', () => {
const selectedFiles = [{ path: 'C:/dl/reup.mp4', name: 'reup.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'reup.mp4', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), []);
});
test('completedSelectionKeys: no savedAt / junk inputs -> empty (legacy + robustness)', () => {
const sf = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(sf, ['voe.sx'], log, undefined), []);
assert.deepEqual(completedSelectionKeys(null, ['voe.sx'], log, 1), []);
assert.deepEqual(completedSelectionKeys(sf, null, log, 1), []);
assert.deepEqual(completedSelectionKeys([], [], log, 1), []);
assert.deepEqual(completedSelectionKeys([{ name: 'x' }], ['voe.sx'], log, 1), [], 'entry without path is skipped');
});
test('completedSelectionKeys: derives basename from path when name is missing', () => {
const selectedFiles = [{ path: 'C:/dl/sub/movie.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'movie.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), ['C:/dl/sub/movie.mp4|voe.sx']);
});
-83
View File
@@ -1,83 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function makeJobs(n, hoster, status, offset = 0) {
const jobs = [];
for (let i = 0; i < n; i++) {
const fileName = `clip_${String(i + offset).padStart(4, '0')}.mp4`;
jobs.push({ id: `j-${hoster}-${i + offset}`, file: `D:/inbox/${fileName}`, fileName, hoster, status });
}
return jobs;
}
test('user report: 300 queued, ~200 finished mid-session before a hard kill — only the finished drop', () => {
const hoster = 'byse.sx';
const snapshot = new Date(2026, 5, 19, 22, 0, 0);
const savedAt = snapshot.getTime();
const restoredJobs = makeJobs(300, hoster, 'preview');
const completionBase = new Date(2026, 5, 19, 22, 5, 0).getTime();
const logEntries = [];
for (let i = 0; i < 200; i++) {
const d = new Date(completionBase + i * 1000);
logEntries.push(parseUploadLogLine(
formatUploadLogLine(d, hoster, `https://byse.sx/d/x${i}`, `clip_${String(i).padStart(4, '0')}.mp4`)
));
}
const { kept, removed } = partitionRestoredJobsByLog(restoredJobs, logEntries, savedAt);
assert.equal(removed.length, 200, 'the 200 completed-after-snapshot files are dropped as ghosts');
assert.equal(kept.length, 100, 'the 100 never-finished files stay queued');
assert.ok(kept.every(j => Number(j.fileName.slice(5, 9)) >= 200), 'kept are exactly indices 200..299');
const keptNames = new Set(kept.map(j => j.fileName));
assert.ok(removed.every(j => !keptNames.has(j.fileName)));
});
test('multi-hoster batch: per-hoster completion is independent (a file done on voe but not byse keeps byse)', () => {
const savedAt = new Date(2026, 5, 19, 22, 0, 0).getTime();
const done = new Date(2026, 5, 19, 22, 3, 0);
const jobs = [
...makeJobs(3, 'voe.sx', 'preview'),
...makeJobs(3, 'byse.sx', 'preview')
];
const logEntries = [
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0000.mp4')),
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0001.mp4')),
parseUploadLogLine(formatUploadLogLine(done, 'byse.sx', 'l', 'clip_0000.mp4'))
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
assert.equal(removed.length, 3);
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0000.mp4'));
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0001.mp4'));
assert.ok(removed.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0000.mp4'));
assert.ok(kept.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0001.mp4'), 'byse clip_0001 not logged -> kept');
});
test('clean idle close (snapshot AFTER completion) keeps an intentional re-queue of an old file', () => {
const hoster = 'voe.sx';
const yesterday = new Date(2026, 5, 18, 12, 0, 0);
const logEntries = [parseUploadLogLine(formatUploadLogLine(yesterday, hoster, 'link', 'reupload_me.mp4'))];
const savedAt = new Date(2026, 5, 19, 9, 0, 0).getTime();
const jobs = [{ id: 'r1', file: 'D:/x/reupload_me.mp4', fileName: 'reupload_me.mp4', hoster, status: 'preview' }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
assert.equal(removed.length, 0, 'an upload older than the snapshot is a deliberate re-queue and survives');
assert.equal(kept.length, 1);
});
test('legacy snapshot without savedAt (pre-v3.3.80 config) falls back to done-only dedup', () => {
const hoster = 'voe.sx';
const logEntries = [
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), hoster, 'l', 'done.mp4')),
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 1, 0), hoster, 'l', 'preview.mp4'))
];
const jobs = [
{ id: 'a', file: 'D:/x/done.mp4', fileName: 'done.mp4', hoster, status: 'done' },
{ id: 'b', file: 'D:/x/preview.mp4', fileName: 'preview.mp4', hoster, status: 'preview' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries);
assert.equal(removed.length, 1, 'only the done job is decluttered when no savedAt is available');
assert.equal(removed[0].id, 'a');
assert.ok(kept.some(j => j.id === 'b'), 'the preview survives the legacy path');
});
-115
View File
@@ -1,115 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
const j = (id, status) => ({ id, status });
test('returns null on empty / non-array input', () => {
assert.equal(pruneOldestTerminalJobs([], 5), null);
assert.equal(pruneOldestTerminalJobs(null, 5), null);
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
});
test('returns null when all jobs are non-terminal regardless of limit', () => {
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
});
test('returns null when terminal count is at or under the limit', () => {
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
});
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
const jobs = [
j('t1', 'done'), // oldest terminal — should be dropped
j('t2', 'done'), // should be dropped
j('queued1', 'queued'),
j('t3', 'error'), // newest of the dropped block
j('uploading1', 'uploading'),
j('t4', 'done'), // kept (within limit window)
j('t5', 'skipped'), // kept
j('t6', 'aborted'), // kept
];
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
const result = pruneOldestTerminalJobs(jobs, 3);
assert.notEqual(result, null);
const droppedIds = result.dropped.map(x => x.id).sort();
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
// Non-terminal jobs always kept; surviving terminals are the newest 3
const keptIds = result.kept.map(x => x.id);
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
});
test('respects insertion order (oldest by index, not by status)', () => {
const jobs = [
j('older-error', 'error'),
j('newer-done', 'done'),
j('newest-aborted', 'aborted'),
];
const result = pruneOldestTerminalJobs(jobs, 1);
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
});
test('drops everything terminal when limit is 0', () => {
const jobs = [
j('q', 'queued'),
j('d1', 'done'),
j('d2', 'done'),
j('e1', 'error'),
];
const result = pruneOldestTerminalJobs(jobs, 0);
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
assert.deepEqual(result.kept.map(x => x.id), ['q']);
});
test('rejects negative or non-finite limits', () => {
const jobs = [j('a', 'done'), j('b', 'done')];
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
'Infinity is technically not finite; safer to treat as no-op');
});
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
assert.ok(TERMINAL_STATUSES.has('done'));
assert.ok(TERMINAL_STATUSES.has('skipped'));
assert.ok(TERMINAL_STATUSES.has('error'));
assert.ok(TERMINAL_STATUSES.has('aborted'));
assert.equal(TERMINAL_STATUSES.size, 4);
// Non-terminal must not be in the set
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
}
});
test('handles malformed entries (null / missing status) without throwing', () => {
const jobs = [
null,
j('a', 'done'),
{ id: 'no-status' }, // no status
j('b', 'done'),
];
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
// because they aren't terminal. The function must not throw on them.
const result = pruneOldestTerminalJobs(jobs, 1);
assert.notEqual(result, null);
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
assert.equal(result.kept.length, 3);
});
test('large queue: keeps the newest `limit` terminals', () => {
const jobs = [];
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
const result = pruneOldestTerminalJobs(jobs, 500);
assert.notEqual(result, null);
assert.equal(result.dropped.length, 4500);
assert.equal(result.kept.length, 500);
// First kept = done-4500 (the 4501st original entry)
assert.equal(result.kept[0].id, 'done-4500');
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
});
-38
View File
@@ -1,38 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateQueueStats } = require('../renderer/queue-stats');
test('queue totals keep skipped and aborted jobs out of remaining work', () => {
const stats = calculateQueueStats([
{ status: 'done', bytesTotal: 100, bytesUploaded: 100 },
{ status: 'error', bytesTotal: 100, bytesUploaded: 20 },
{ status: 'skipped', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'aborted', bytesTotal: 100, bytesUploaded: 10 },
{ status: 'queued', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 100, bytesUploaded: 40 }
]);
assert.deepEqual({ total: stats.total, remaining: stats.remaining, done: stats.done, errors: stats.errors, skipped: stats.skipped, aborted: stats.aborted }, {
total: 6,
remaining: 2,
done: 1,
errors: 1,
skipped: 1,
aborted: 1
});
assert.equal(stats.remainingSize, 160);
});
test('remaining bytes decrease with progress and return when a failed job is queued again', () => {
const jobs = [
{ status: 'queued', bytesTotal: 1024, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 2048, bytesUploaded: 512 },
{ status: 'error', bytesTotal: 4096, bytesUploaded: 1024 }
];
assert.equal(calculateQueueStats(jobs).bytesRemaining, 2560);
jobs[1].bytesUploaded = 1536;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 1536);
jobs[2].status = 'queued';
jobs[2].bytesUploaded = 0;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 5632);
});
-71
View File
@@ -1,71 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
const Module = require('node:module');
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'electron') {
return {
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`test-protected:${value}`),
decryptString: value => value.toString().replace(/^test-protected:/, '')
}
};
}
return originalLoad.call(this, request, parent, isMain);
};
const ConfigStore = require('../lib/config-store');
require('../lib/secret-store').encryptField('test-initialization');
Module._load = originalLoad;
// Minimal app mock for ConfigStore
function createTestConfigStore() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
const mockApp = {
isPackaged: false,
getPath: (name) => tmpDir,
getPath: () => tmpDir
};
const store = new ConfigStore(mockApp);
store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir };
}
describe('remote config defaults', () => {
it('should include remote settings in defaults', () => {
const { store } = createTestConfigStore();
const config = store.load();
const remote = config.globalSettings.remote;
assert.strictEqual(remote.enabled, false);
assert.strictEqual(remote.port, 9100);
assert.strictEqual(typeof remote.token, 'string');
assert.strictEqual(remote.token, '');
assert.strictEqual(remote.allowInput, true);
});
it('should deep-merge remote settings with existing config', async () => {
const { store } = createTestConfigStore();
// Save config with partial remote settings
await store.save({
globalSettings: {
remote: { enabled: true, port: 9200 }
}
});
const config = store.load();
const remote = config.globalSettings.remote;
// Saved values preserved
assert.strictEqual(remote.enabled, true);
assert.strictEqual(remote.port, 9200);
// Defaults merged in
assert.strictEqual(remote.allowInput, true);
assert.strictEqual(remote.token, '');
});
});
-42
View File
@@ -1,42 +0,0 @@
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
// Test the module can be required and has the expected API
describe('RemoteServer', () => {
it('should export a class with start/stop methods', () => {
const RemoteServer = require('../lib/remote-server');
assert.strictEqual(typeof RemoteServer, 'function');
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
});
it('should start and stop without errors', async () => {
const RemoteServer = require('../lib/remote-server');
const server = new RemoteServer();
// Mock mainWindow
const mockMainWindow = {
isDestroyed: () => false,
getTitle: () => 'Test Window',
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
webContents: {
sendInputEvent: () => {}
}
};
await server.start({
port: 0, // random available port
host: '127.0.0.1',
token: 'test-token-123',
allowInput: true,
mainWindow: mockMainWindow,
onSignalingToCapture: () => {},
onCreateCaptureWindow: () => {},
onDestroyCaptureWindow: () => {}
});
assert.strictEqual(server.getClientCount(), 0);
server.stop();
});
});
-158
View File
@@ -1,158 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const secretStorePath = require.resolve('../lib/secret-store');
function withSecretStore(safeStorage, action) {
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { safeStorage };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[secretStorePath];
try {
return action(require(secretStorePath));
} finally {
Module._load = originalLoad;
delete require.cache[secretStorePath];
}
}
function availableSafeStorage(overrides = {}) {
return {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`protected:${value}`),
decryptString: value => value.toString().replace(/^protected:/, ''),
...overrides
};
}
test('encrypts and decrypts fields when secure storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const encrypted = secretStore.encryptField('secret');
assert.match(encrypted, /^enc:v1:/);
assert.equal(secretStore.decryptField(encrypted), 'secret');
});
});
test('recognizes only canonical enc:v1 envelopes as encrypted', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const canonical = `enc:v1:${Buffer.from('protected:secret').toString('base64')}`;
assert.equal(secretStore.isEncrypted(canonical), true);
assert.equal(secretStore.isEncrypted('secret'), false);
assert.equal(secretStore.isEncrypted(canonical.replace('enc:v1:', 'enc:v2:')), false);
assert.equal(secretStore.isEncrypted(canonical.replace(/=+$/u, '')), false);
assert.equal(secretStore.isEncrypted(`${canonical}\n`), false);
});
});
test('refuses plaintext storage by default when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('never allows plaintext storage when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.encryptField('secret', { allowPlaintext: true }),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
const config = { hosters: { example: [{ password: 'secret' }] } };
assert.throws(
() => secretStore.encryptCredentials(config, { allowPlaintext: true }),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('refuses plaintext storage by default when encryption fails', () => {
const failure = new Error('encryption failed');
withSecretStore(availableSafeStorage({ encryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
assert.throws(
() => secretStore.encryptField('secret', { allowPlaintext: true }),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
});
});
test('throws an identifiable error for encrypted values without secure storage', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:cHJvdGVjdGVkOnNlY3JldA=='),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('retries secure storage discovery after transient unavailability', () => {
let available = false;
let checks = 0;
const encrypted = `enc:v1:${Buffer.from('protected:secret').toString('base64')}`;
withSecretStore(availableSafeStorage({
isEncryptionAvailable: () => {
checks++;
return available;
}
}), secretStore => {
assert.throws(
() => secretStore.decryptField(encrypted),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
available = true;
assert.equal(secretStore.decryptField(encrypted), 'secret');
assert.equal(checks, 2);
});
});
test('throws an identifiable error when decryption fails', () => {
const failure = new Error('decryption failed');
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:aW52YWxpZA=='),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
&& error.cause === failure
);
});
});
test('rejects malformed enc:v1 values instead of treating them as legacy plaintext', () => {
let decryptCalls = 0;
withSecretStore(availableSafeStorage({
decryptString: () => {
decryptCalls++;
return 'unexpected';
}
}), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:YWJjZA'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
);
assert.equal(decryptCalls, 0);
});
});
test('keeps legacy plaintext values readable without secure storage', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
});
});
-174
View File
@@ -1,174 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const Semaphore = require('../lib/semaphore');
describe('Semaphore', () => {
it('clamps limit to at least 1', () => {
assert.equal(new Semaphore(0).limit, 1);
assert.equal(new Semaphore(-5).limit, 1);
assert.equal(new Semaphore(undefined).limit, 1);
assert.equal(new Semaphore(3).limit, 3);
});
it('acquire resolves immediately when slots available', async () => {
const sem = new Semaphore(2);
await sem.acquire();
await sem.acquire();
assert.equal(sem.active, 2);
});
it('acquire blocks when all slots taken', async () => {
const sem = new Semaphore(1);
await sem.acquire();
let resolved = false;
const p = sem.acquire().then(() => { resolved = true; });
// Give microtask a chance to resolve
await new Promise(r => setTimeout(r, 10));
assert.equal(resolved, false, 'should not resolve while slot is taken');
assert.equal(sem.pending, 1);
sem.release();
await p;
assert.equal(resolved, true);
});
it('FIFO ordering', async () => {
const sem = new Semaphore(1);
await sem.acquire(); // take the one slot
const order = [];
const p1 = sem.acquire().then(() => order.push(1));
const p2 = sem.acquire().then(() => order.push(2));
const p3 = sem.acquire().then(() => order.push(3));
assert.equal(sem.pending, 3);
sem.release(); await p1;
sem.release(); await p2;
sem.release(); await p3;
assert.deepEqual(order, [1, 2, 3]);
});
it('release with no waiters decrements active', async () => {
const sem = new Semaphore(2);
await sem.acquire();
assert.equal(sem.active, 1);
sem.release();
assert.equal(sem.active, 0);
});
it('release never goes below 0', () => {
const sem = new Semaphore(2);
sem.release();
assert.equal(sem.active, 0);
sem.release();
assert.equal(sem.active, 0);
});
it('acquire rejects immediately if signal already aborted', async () => {
const sem = new Semaphore(2);
const ac = new AbortController();
ac.abort();
await assert.rejects(sem.acquire(ac.signal), /Aborted/);
assert.equal(sem.active, 0, 'no slot should be acquired');
});
it('abort while waiting in queue removes entry and rejects', async () => {
const sem = new Semaphore(1);
await sem.acquire(); // take the slot
const ac = new AbortController();
const p = sem.acquire(ac.signal);
assert.equal(sem.pending, 1);
ac.abort();
await assert.rejects(p, /Aborted/);
assert.equal(sem.pending, 0, 'entry should be removed from queue');
// Release original slot - should not cause issues
sem.release();
assert.equal(sem.active, 0);
});
it('abort listener is cleaned up when slot is granted via release', async () => {
const sem = new Semaphore(1);
await sem.acquire();
const ac = new AbortController();
let rejected = false;
const p = sem.acquire(ac.signal).catch(() => { rejected = true; });
sem.release(); // grants slot to the waiter
await p;
// Now abort after the slot was already granted
ac.abort();
await new Promise(r => setTimeout(r, 10));
assert.equal(rejected, false, 'reject should not fire after slot was granted');
});
it('updateLimit wakes waiters', async () => {
const sem = new Semaphore(1);
await sem.acquire();
const resolved = [];
const p1 = sem.acquire().then(() => resolved.push(1));
const p2 = sem.acquire().then(() => resolved.push(2));
sem.updateLimit(3);
await Promise.all([p1, p2]);
assert.deepEqual(resolved, [1, 2]);
});
it('updateLimit to lower value does not kill active slots', async () => {
const sem = new Semaphore(3);
await sem.acquire();
await sem.acquire();
await sem.acquire();
assert.equal(sem.active, 3);
sem.updateLimit(1);
assert.equal(sem.active, 3, 'existing active slots should not be evicted');
sem.release();
sem.release();
sem.release();
assert.equal(sem.active, 0);
// Now only 1 slot should be available
await sem.acquire();
let blocked = false;
const p = sem.acquire().then(() => { blocked = true; });
await new Promise(r => setTimeout(r, 10));
assert.equal(blocked, false, 'should block at limit 1');
sem.release();
await p;
});
it('pending getter tracks queue size', async () => {
const sem = new Semaphore(1);
assert.equal(sem.pending, 0);
await sem.acquire();
sem.acquire(); // blocked
sem.acquire(); // blocked
assert.equal(sem.pending, 2);
sem.release();
await new Promise(r => setTimeout(r, 5));
assert.equal(sem.pending, 1);
});
it('release without acquire clamps active to 0', () => {
const sem = new Semaphore(2);
assert.equal(sem.active, 0);
sem.release();
assert.equal(sem.active, 0, 'should not go negative');
sem.release();
assert.equal(sem.active, 0, 'should still be 0');
});
});
-53
View File
@@ -1,53 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('serialized runner', () => {
it('flush waits for an already running save and later work stays ordered', async () => {
const { createSerializedRunner } = require('../lib/serialized-runner');
let releaseFirst;
const calls = [];
const runner = createSerializedRunner(async (value) => {
calls.push(`start:${value}`);
if (value === 'first') await new Promise((resolve) => { releaseFirst = resolve; });
calls.push(`end:${value}`);
return value;
});
const first = runner.run('first');
await new Promise((resolve) => setImmediate(resolve));
const second = runner.run('second');
let flushed = false;
const flush = runner.flush().then(() => { flushed = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(flushed, false);
assert.deepEqual(calls, ['start:first']);
releaseFirst();
assert.equal(await first, 'first');
assert.equal(await second, 'second');
await flush;
assert.equal(flushed, true);
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
});
it('queues work before readiness and drains every task in arrival order', async () => {
const { createReadySerializedRunner } = require('../lib/serialized-runner');
const calls = [];
const runner = createReadySerializedRunner(async (value) => {
calls.push(value);
return value;
});
const first = runner.run('first');
const second = runner.run('second');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runner.isReady, false);
assert.deepEqual(calls, []);
assert.equal(runner.ready(), true);
assert.equal(runner.ready(), false);
assert.deepEqual(await Promise.all([first, second]), ['first', 'second']);
assert.deepEqual(calls, ['first', 'second']);
assert.equal(runner.isReady, true);
});
});
-31
View File
@@ -1,31 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildSessionReport, buildSessionReportCsv } = require('../lib/session-report');
test('erstellt einen Hosterbericht mit Erfolgsquote, Dauer, Bytes und Fehlern', () => {
const report = buildSessionReport({
id: 'batch-1',
timestamp: '2026-08-12T12:00:00.000Z',
files: [{
name: 'video.mp4',
size: 1024,
results: [
{ hoster: 'byse.sx', status: 'done', durationSec: 4, attempt: 1 },
{ hoster: 'doodstream.com', status: 'error', durationSec: 3, attempt: 2, error: 'HTTP 503' }
]
}]
});
assert.equal(report.totals.total, 2);
assert.equal(report.totals.succeeded, 1);
assert.equal(report.hosters['byse.sx'].bytes, 1024);
assert.equal(report.hosters['doodstream.com'].attempts, 2);
assert.deepEqual(report.hosters['doodstream.com'].errors, { 'HTTP 503': 1 });
assert.match(buildSessionReportCsv(report), /doodstream\.com/);
});
test('entschärft Formeln und Geheimnisse im CSV-Bericht', () => {
const report = buildSessionReport({ files: [{ size: 1, results: [{ hoster: 'byse.sx', status: 'error', error: '=CMD() token=secret' }] }] });
assert.match(buildSessionReportCsv(report), /1× =CMD\(\) token=\[redacted\]/);
});
-83
View File
@@ -1,83 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('settings backup snapshot', () => {
it('copies accounts and settings while excluding history, queue and rotation state', () => {
const { createPortableSettingsSnapshot } = require('../lib/settings-backup');
const input = {
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
hosterSettings: { 'voe.sx': { retries: 7 } },
globalSettings: {
language: 'de',
autoHealthCheckEnabled: false,
alwaysOnTop: true,
pendingQueue: [{ file: 'private.mkv' }],
uploadRecovery: { jobs: ['private.mkv'] },
lastBrowseDirectory: 'Z:\\private',
folderMonitor: {
enabled: true,
folderPath: 'D:\\watch',
recursive: true,
paused: true,
pausedAt: 123,
telemetry: { detected: 8 }
}
},
history: [{ file: 'done.mkv' }],
rotationCursors: { 'voe.sx': 4 }
};
const snapshot = createPortableSettingsSnapshot(input);
assert.deepEqual(snapshot, {
hosters: input.hosters,
hosterSettings: input.hosterSettings,
globalSettings: {
language: 'de',
autoHealthCheckEnabled: false,
alwaysOnTop: true,
pendingQueue: null,
uploadRecovery: null,
lastBrowseDirectory: '',
folderMonitor: {
enabled: true,
folderPath: 'D:\\watch',
recursive: true,
paused: false,
pausedAt: null
}
},
history: []
});
assert.notEqual(snapshot.hosters, input.hosters);
});
it('preserves configured paths, disables a missing monitored folder and reports both missing paths', () => {
const { prepareImportedSettings } = require('../lib/settings-backup');
const snapshot = {
hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] },
hosterSettings: { 'byse.sx': { parallelCount: 6 } },
globalSettings: {
alwaysOnTop: true,
logFilePath: 'Z:\\missing\\upload.log',
folderMonitor: { enabled: true, folderPath: 'Z:\\missing\\watch' },
pendingQueue: [{ file: 'do-not-restore.mkv' }]
}
};
const warnings = [];
const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value, warnings });
assert.equal(imported.globalSettings.logFilePath, 'Z:\\missing\\upload.log');
assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: 'Z:\\missing\\watch', paused: false, pausedAt: null });
assert.equal(imported.globalSettings.pendingQueue, null);
assert.equal(imported.globalSettings.uploadRecovery, null);
assert.equal(imported.globalSettings.lastBrowseDirectory, '');
assert.deepEqual(imported.history, []);
assert.deepEqual(warnings, [
'Log-Dateipfad (Ordner nicht gefunden)',
'Ordnerüberwachung (Ordner nicht gefunden und deaktiviert)'
]);
assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i);
});
});
-19
View File
@@ -1,19 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('settings import gate', () => {
it('blocks upload starts for the complete import transition', () => {
const { createSettingsImportGate } = require('../lib/settings-import-gate');
let uploadRunning = false;
const gate = createSettingsImportGate(() => uploadRunning);
gate.begin();
assert.equal(gate.canStartUpload(), false);
assert.throws(() => gate.begin(), /bereits importiert/i);
gate.end();
assert.equal(gate.canStartUpload(), true);
uploadRunning = true;
assert.throws(() => gate.begin(), /laufender Uploads/i);
});
});
-205
View File
@@ -1,205 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const policy = require('../lib/source-cleanup-policy');
function queueFixture() {
return [
{
id: 'job-doodstream',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'doodstream.com',
status: 'done'
},
{
id: 'job-voe',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'voe.sx',
status: 'done'
},
{
id: 'job-vidmoly',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'vidmoly.me',
status: 'error'
},
{
id: 'job-byse',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'byse.sx',
status: 'preview'
}
];
}
test('prepareGroups creates one Windows manifest with a stable token and immutable hosters', () => {
const queueJobs = queueFixture();
let tokenCalls = 0;
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => {
tokenCalls += 1;
return 'cleanup-1';
}, 'win32');
assert.equal(tokenCalls, 1);
assert.equal(prepared.groups.length, 1);
assert.equal(prepared.touchedJobs.length, 4);
assert.deepEqual(prepared.groups[0], {
token: 'cleanup-1',
file: 'C:\\Uploads\\Movie.MKV',
requiredHosters: ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'],
completedHosters: ['doodstream.com', 'voe.sx'],
fingerprint: null,
jobs: [
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done' },
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done' },
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error' },
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview' }
]
});
assert.deepEqual(queueJobs.map((job) => job.sourceCleanupToken), [
'cleanup-1',
'cleanup-1',
'cleanup-1',
'cleanup-1'
]);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
}
assert.doesNotThrow(() => JSON.stringify(prepared.groups));
});
test('prepareGroups reuses persisted metadata across a partial retry', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
queueJobs[0].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
queueJobs[1].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
queueJobs[0].status = 'preview';
queueJobs[1].status = 'preview';
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.equal(prepared.groups[0].token, 'cleanup-1');
assert.deepEqual(prepared.groups[0].requiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
assert.deepEqual(prepared.groups[0].completedHosters, ['doodstream.com', 'voe.sx']);
assert.equal(prepared.groups[0].jobs.find((job) => job.hoster === 'vidmoly.me').status, 'error');
});
test('prepareGroups extends an active manifest when a new hoster is added', () => {
const queueJobs = queueFixture().slice(0, 2);
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
queueJobs.push({ id: 'job-new', file: 'C:\\Uploads\\Movie.MKV', hoster: 'byse.sx', status: 'preview' });
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => 'cleanup-2', 'win32');
assert.equal(prepared.groups[0].token, 'cleanup-1');
assert.deepEqual(prepared.groups[0].requiredHosters, ['doodstream.com', 'voe.sx', 'byse.sx']);
});
test('keeps case-distinct Windows paths in separate groups', () => {
const queueJobs = [
{ id: 'upper', file: 'C:\\CaseSensitive\\Movie.mkv', hoster: 'voe.sx', status: 'preview' },
{ id: 'lower', file: 'C:\\CaseSensitive\\movie.mkv', hoster: 'byse.sx', status: 'preview' }
];
const prepared = policy.prepareGroups(queueJobs, queueJobs, (file) => `token-${file}`, 'win32');
assert.equal(prepared.groups.length, 2);
assert.deepEqual(prepared.groups.map((group) => group.requiredHosters), [['voe.sx'], ['byse.sx']]);
});
test('removing a failed job never relaxes the stored requirements', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const failedJob = queueJobs[2];
const remainingJobs = queueJobs.filter((job) => job !== failedJob);
policy.removeRequirement(remainingJobs, failedJob, 'win32');
const prepared = policy.prepareGroups(remainingJobs, [remainingJobs[2]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.deepEqual(prepared.groups[0].requiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
for (const job of remainingJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
}
});
test('removeRequirement drops only an explicitly discarded unstarted hoster', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
assert.equal(touchedJobs.length, 4);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me'
]);
}
});
test('markCompleted preserves successful hosters for later retries', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupCompletedHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me'
]);
}
});
test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const fingerprint = {
type: 'file',
size: 1048576,
mtimeMs: 1786467600000,
birthtimeMs: 1786467500000,
dev: 2114,
ino: 9001
};
const touchedJobs = policy.applyFingerprints(queueJobs, { 'cleanup-1': fingerprint });
const prepared = policy.prepareGroups(queueJobs, [queueJobs[3]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.equal(touchedJobs.length, 4);
assert.deepEqual(prepared.groups[0].fingerprint, fingerprint);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupFingerprint, fingerprint);
assert.notEqual(job.sourceCleanupFingerprint, fingerprint);
}
});
-85
View File
@@ -1,85 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const SourceDeleteJournal = require('../lib/source-delete-journal');
test('restores a staged source after an interrupted deletion', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
await fs.promises.writeFile(file, 'source');
await journal.plan({ token: 'cleanup-1', file, stagedFile });
await fs.promises.rename(file, stagedFile);
const outcomes = await journal.recover();
assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source');
assert.equal(outcomes[0].outcome, 'restored');
assert.deepEqual(await journal.recover(), []);
});
test('clears a completed deletion record when neither source nor stage remains', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
await journal.plan({ token: 'cleanup-2', file: path.join(directory, 'source.bin'), stagedFile: path.join(directory, '.staged') });
const outcomes = await journal.recover();
assert.equal(outcomes[0].outcome, 'cleared');
assert.deepEqual(await journal.recover(), []);
});
test('continues recovery after one malformed active entry', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
await journal.plan({ token: 'broken', file: null, stagedFile: null });
await fs.promises.writeFile(stagedFile, 'source');
await journal.plan({ token: 'valid', file, stagedFile });
const outcomes = await journal.recover();
assert.equal(outcomes.find((entry) => entry.token === 'broken').outcome, 'recovery-failed');
assert.equal(outcomes.find((entry) => entry.token === 'valid').outcome, 'restored');
assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source');
});
test('retries transient Windows rename locks during recovery', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
const journalPath = path.join(directory, 'journal.jsonl');
let renameCalls = 0;
const waits = [];
const injectedFs = {
promises: {
mkdir: (...args) => fs.promises.mkdir(...args),
open: (...args) => fs.promises.open(...args),
readFile: (...args) => fs.promises.readFile(...args),
lstat: (...args) => fs.promises.lstat(...args),
rename: async (...args) => {
renameCalls += 1;
if (renameCalls < 3) throw Object.assign(new Error('locked'), { code: 'EBUSY' });
return fs.promises.rename(...args);
}
}
};
const writer = new SourceDeleteJournal(journalPath);
await fs.promises.writeFile(stagedFile, 'source');
await writer.plan({ token: 'cleanup-retry', file, stagedFile });
const journal = new SourceDeleteJournal(journalPath, null, { fs: injectedFs, wait: async (delay) => waits.push(delay) });
const outcomes = await journal.recover();
assert.equal(outcomes[0].outcome, 'restored');
assert.equal(renameCalls, 3);
assert.deepEqual(waits, [100, 250]);
});
-469
View File
@@ -1,469 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createSourceFileCleanup } = require('../lib/source-file-cleanup');
async function makeSource(t, name = 'source.bin') {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-source-cleanup-'));
const file = path.join(directory, name);
await fs.promises.writeFile(file, Buffer.from('original source data'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
return { directory, file };
}
function group(file, overrides = {}) {
return {
token: 'cleanup-1',
file,
requiredHosters: ['voe.sx', 'byse.sx'],
completedHosters: [],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }
],
...overrides
};
}
function makeCleanup(overrides = {}) {
const audits = [];
const waits = [];
const cleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => true,
audit: (event) => audits.push(event),
journal: { plan: async () => {}, clear: async () => {} },
wait: async (milliseconds) => waits.push(milliseconds),
...overrides
});
return { cleanup, audits, waits };
}
async function settleDone(cleanup, manifest) {
for (const job of manifest.jobs) {
await cleanup.settle({
token: manifest.token,
jobId: job.jobId,
file: manifest.file,
hoster: job.hoster,
status: 'done'
});
}
}
async function exists(file) {
try {
await fs.promises.lstat(file);
return true;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
test('fingerprints a regular file and keeps the registered manifest immutable', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
const fingerprints = await cleanup.registerGroups([manifest]);
assert.equal(fingerprints['cleanup-1'].type, 'file');
assert.equal(fingerprints['cleanup-1'].size, Buffer.byteLength('original source data'));
assert.equal(typeof fingerprints['cleanup-1'].mtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].birthtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].dev, 'number');
assert.equal(typeof fingerprints['cleanup-1'].ino, 'number');
manifest.requiredHosters.splice(1, 1);
manifest.completedHosters.push('byse.sx');
manifest.jobs[1].hoster = 'voe.sx';
await cleanup.settle({
token: 'cleanup-1',
jobId: 'job-voe',
file,
hoster: 'voe.sx',
status: 'done'
});
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: 'pending' }]);
});
test('extends a registered group before finalization', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
await cleanup.registerGroups([group(file, {
requiredHosters: ['voe.sx'],
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' }]
})]);
await cleanup.registerGroups([group(file)]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
assert.equal(audits[0].blockingStatuses[0].hoster, 'byse.sx');
});
test('deletes only when enabled, history persisted, queue persisted, and every required hoster is done', async (t) => {
const cases = [
{ name: 'setting disabled', enabled: false, historyPersisted: true, queuePersisted: true, outcome: 'setting-disabled' },
{ name: 'history missing', enabled: true, historyPersisted: false, queuePersisted: true, outcome: 'blocked' },
{ name: 'queue missing', enabled: true, historyPersisted: true, queuePersisted: false, outcome: 'blocked' }
];
for (const entry of cases) {
await t.test(entry.name, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.name}.bin`);
const { cleanup, audits } = makeCleanup({ isEnabled: () => entry.enabled });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({
historyPersisted: entry.historyPersisted,
queuePersisted: entry.queuePersisted
});
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, entry.outcome);
});
}
const { file } = await makeSource(t, 'all-barriers.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('collects terminal states and blocks error, aborted, skipped, and pending jobs', async (t) => {
const cases = [
{ status: 'error', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' }) },
{ status: 'aborted', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'aborted' }) },
{ status: 'skipped', apply: (cleanup) => cleanup.markSkipped('job-byse', 'missing-account') },
{ status: 'pending', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'uploading' }) }
];
for (const entry of cases) {
await t.test(entry.status, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.status}.bin`);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
await entry.apply(cleanup, file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: entry.status }]);
});
}
});
test('combines previous successes with a successful retry without relaxing other requirements', async (t) => {
await t.test('last retry completes the immutable group', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-completes.bin');
const { cleanup } = makeCleanup();
const manifest = group(file, { completedHosters: ['voe.sx'] });
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
});
await t.test('an unselected failed requirement still blocks deletion', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-partial.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file, {
requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'],
completedHosters: ['voe.sx'],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' },
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' }
]
});
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'vidmoly.me', status: 'error' }]);
});
});
test('audits a changed or missing source without deleting a replacement', async (t) => {
await t.test('source changed', async (subtest) => {
const { file } = await makeSource(subtest, 'changed.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.writeFile(file, Buffer.from('replacement source data with a different size'));
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-changed');
});
await t.test('source missing', async (subtest) => {
const { file } = await makeSource(subtest, 'missing.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.unlink(file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-missing');
});
});
test('rejects non-regular sources and audits the unsafe type', async (t) => {
const { directory } = await makeSource(t);
const unsafePath = path.join(directory, 'folder-source');
await fs.promises.mkdir(unsafePath);
const { cleanup, audits } = makeCleanup();
const manifest = group(unsafePath);
const fingerprints = await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(fingerprints['cleanup-1'], null);
assert.equal((await fs.promises.lstat(unsafePath)).isDirectory(), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'unsafe-source-type');
});
test('runs unlink exactly once across repeated and concurrent finalization', async (t) => {
const { file } = await makeSource(t);
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
unlinkCalls += 1;
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await Promise.all([
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }),
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true })
]);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 1);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('retries EBUSY and EPERM with the bounded Windows delay schedule', async (t) => {
const { file } = await makeSource(t);
const failures = ['EBUSY', 'EPERM', 'EBUSY', 'EPERM', 'EBUSY'];
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
const code = failures[unlinkCalls];
unlinkCalls += 1;
if (code) throw Object.assign(new Error(code), { code });
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
assert.equal(audits[2].attempts, 6);
});
test('swallows cleanup failures, preserves the source, and audits one final failure', async (t) => {
await t.test('retryable failure exhausts all delays', async (subtest) => {
const { file } = await makeSource(subtest, 'locked.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('locked'), { code: 'EBUSY' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 6);
});
await t.test('non-retryable failure stops immediately', async (subtest) => {
const { file } = await makeSource(subtest, 'denied.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 1);
assert.deepEqual(waits, []);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 1);
});
});
test('preserves the original Windows path casing for filesystem operations', async (t) => {
const { file } = await makeSource(t, 'SourceCase.BIN');
const observed = [];
const injectedFs = {
promises: {
lstat: async (target) => {
observed.push(target);
return fs.promises.lstat(target);
},
rename: (...args) => fs.promises.rename(...args),
unlink: (...args) => fs.promises.unlink(...args)
}
};
const { cleanup } = makeCleanup({ fs: injectedFs, platform: 'win32' });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(observed[0], file);
assert.equal(await exists(file), false);
});
test('blocks both case-colliding Windows source groups', async (t) => {
const { directory, file } = await makeSource(t, 'Movie.mkv');
const secondFile = path.join(directory, 'movie.mkv');
await fs.promises.writeFile(secondFile, Buffer.from('other source data'));
const { cleanup } = makeCleanup({ platform: 'win32' });
const first = group(file, { token: 'cleanup-upper' });
const second = group(secondFile, { token: 'cleanup-lower' });
await cleanup.registerGroups([first, second]);
await settleDone(cleanup, first);
await settleDone(cleanup, second);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked', 'blocked']);
assert.equal(await exists(file), true);
assert.equal(await exists(secondFile), true);
});
test('blocks deletion when the audit approval cannot be persisted', async (t) => {
const { file } = await makeSource(t, 'audit-failure.bin');
const { cleanup } = makeCleanup({ audit: async () => false });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('restores the staged source when the deletion commit audit fails', async (t) => {
const { file } = await makeSource(t, 'audit-commit-failure.bin');
let auditCalls = 0;
const { cleanup } = makeCleanup({ audit: async () => ++auditCalls !== 2 });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('rechecks the staged file and restores a replacement without deleting it', async (t) => {
const { file } = await makeSource(t, 'stage-race.bin');
let unlinkCalls = 0;
let firstRename = true;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: async (from, to) => {
await fs.promises.rename(from, to);
if (firstRename) {
firstRename = false;
await fs.promises.writeFile(to, Buffer.from('replacement data'));
}
},
unlink: async (...args) => {
unlinkCalls += 1;
return fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 0);
assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data');
assert.equal(audits.at(-1).outcome, 'source-changed');
});
-40
View File
@@ -1,40 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { updateSpeedHistory, createInitialSpeedHistoryState } = require('../lib/speed-history');
test('initial speed history contains a drawable zero baseline', () => {
assert.deepEqual(createInitialSpeedHistoryState(), { display: 0, history: [0, 0] });
});
test('speed history smooths rising and falling samples independently', () => {
const state = { display: 0, history: [] };
updateSpeedHistory(state, 1000);
assert.equal(state.display, 450);
updateSpeedHistory(state, 0);
assert.equal(state.display, 396);
assert.deepEqual(state.history, [450, 396]);
});
test('speed history stays bounded and discards its oldest samples', () => {
const state = { display: 0, history: [] };
updateSpeedHistory(state, 100, 3);
updateSpeedHistory(state, 200, 3);
updateSpeedHistory(state, 300, 3);
updateSpeedHistory(state, 400, 3);
assert.equal(state.history.length, 3);
assert.equal(state.history.at(-1), state.display);
assert.ok(state.history[0] > 0);
});
test('speed history normalizes invalid and negative target values', () => {
const state = { display: 50, history: [] };
updateSpeedHistory(state, -100);
updateSpeedHistory(state, Number.NaN);
assert.deepEqual(state.history, [44, 38.72]);
});
File diff suppressed because it is too large Load Diff
-186
View File
@@ -1,186 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory,
mergeSkippedIntoSummary
} = require('../lib/stats');
function makeBatch(timestamp, results) {
return {
id: 'b-' + timestamp,
timestamp: new Date(timestamp).toISOString(),
files: [{ name: 'foo.mp4', size: 1, results }]
};
}
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
const history = [
makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
]),
makeBatch(2, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'byse.sx', status: 'done' }
])
];
const s = summarizePerHoster(history);
assert.strictEqual(s['voe.sx'].ok, 2);
assert.strictEqual(s['voe.sx'].fail, 1);
assert.strictEqual(s['voe.sx'].total, 3);
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
assert.strictEqual(s['byse.sx'].ok, 1);
assert.strictEqual(s['byse.sx'].fail, 1);
assert.strictEqual(s['byse.sx'].rate, 0.5);
});
test('summarizePerHoster honors sinceMs cutoff', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { sinceMs: 3000 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster honors lastNBatches (newest first)', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { lastNBatches: 1 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster(null), {});
assert.deepStrictEqual(summarizePerHoster([]), {});
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
});
test('summarizePerHoster reports skipped uploads without lowering the host success rate', () => {
const history = [makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'x' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
])];
const summary = summarizePerHoster(history)['voe.sx'];
assert.deepStrictEqual({ ok: summary.ok, fail: summary.fail, skipped: summary.skipped, total: summary.total }, { ok: 1, fail: 1, skipped: 1, total: 3 });
assert.strictEqual(summary.rate, 0.5);
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
});
test('classifyErrorCategory: account-error phrases', () => {
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
});
test('classifyErrorCategory: hoster-transient phrases', () => {
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
});
test('classifyErrorCategory: network phrases', () => {
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
});
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
assert.strictEqual(isRetryableCategory('aborted'), false);
});
test('automation completion persistence failures are never retried as uploads', () => {
const category = classifyErrorCategory('Automatik-Abschlussnachweis konnte nicht gespeichert werden');
assert.strictEqual(category, 'local-persistence');
assert.strictEqual(isRetryableCategory(category), false);
});
test('classifyErrorCategory: unknown for everything else', () => {
assert.strictEqual(classifyErrorCategory(''), 'unknown');
assert.strictEqual(classifyErrorCategory(null), 'unknown');
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
});
test('summarizeBatchErrors buckets results by category', () => {
const summary = {
files: [
{ name: 'a.mp4', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
] },
{ name: 'b.mp4', results: [
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
] }
]
};
const buckets = summarizeBatchErrors(summary);
assert.strictEqual(buckets['file-rejected'].length, 1);
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
assert.strictEqual(buckets['hoster-transient'].length, 1);
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
assert.strictEqual(buckets['network'].length, 1);
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
assert.strictEqual(buckets['account-error'].length, 0);
});
test('summarizeBatchErrors excludes skipped results from error and retry buckets', () => {
const buckets = summarizeBatchErrors({
files: [{ name: 'a.mp4', results: [{ hoster: 'voe.sx', status: 'skipped', error: 'Kein gültiger Account' }] }]
});
assert.strictEqual(Object.values(buckets).flat().length, 0);
});
test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', () => {
const summary = {
id: 'batch-1',
timestamp: '2026-08-11T12:00:00.000Z',
total: 1,
succeeded: 1,
failed: 0,
skipped: 0,
files: [{ name: 'ok.mp4', size: 5, results: [{ jobId: 'ok', hoster: 'voe.sx', status: 'done' }] }]
};
const merged = mergeSkippedIntoSummary(summary, [{
jobId: 'skip',
file: 'C:\\incoming\\skip.mp4',
fileName: 'skip.mp4',
hoster: 'byse.sx',
reason: 'Kein gültiger Account'
}]);
assert.strictEqual(merged.total, 2);
assert.strictEqual(merged.succeeded, 1);
assert.strictEqual(merged.failed, 0);
assert.strictEqual(merged.skipped, 1);
assert.deepStrictEqual(merged.files[1], {
name: 'skip.mp4',
size: 0,
results: [{ jobId: 'skip', hoster: 'byse.sx', status: 'skipped', error: 'Kein gültiger Account' }]
});
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
assert.strictEqual(isRetryableCategory('unknown'), true);
assert.strictEqual(isRetryableCategory('file-rejected'), false);
assert.strictEqual(isRetryableCategory('account-error'), false);
assert.strictEqual(isRetryableCategory('aborted'), false);
assert.strictEqual(isRetryableCategory('local-persistence'), false);
});
-202
View File
@@ -1,202 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
hosters: {
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
},
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
};
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
});
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
const field = ['to', 'ken'].join('');
const cases = [
`boom ${field}=${['bearer', 'tok', 'qwerty12345'].join('_')}`,
`response auth_${field}: ${['aGVsbG8t', 'd29ybGQt', 'MTIz'].join('')}`,
`refresh_${field} = ${['abc123', 'DEF456', 'ghi789'].join('')}`,
`using Bearer ${['aaaa', 'bbbb', 'cccc', 'dddd', 'eeee', 'ffff'].join('')}`,
`Authorization: Bearer ${['deadbeef', 'cafef00d', 'ba5e'].join('')}`
];
for (const line of cases) {
const out = redactLogText(line, []);
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
}
});
test('redactLogText leaves benign "token" prose alone', () => {
const benign = 'token bucket refill rate is 5 per second';
assert.equal(redactLogText(benign, []), benign);
});
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
const credential = ['Sup3r', 'Proxy', 'Pass'].join('');
const out = redactLogText(`proxy https://admin:${credential}@proxy.internal:8080/path`, []);
assert.ok(!out.includes(credential), 'basic-auth password must be redacted');
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
assert.ok(out.includes('admin:'), 'username preserved');
});
test('redactLogText does not touch a host:port URL without userinfo', () => {
const url = 'connecting to https://cdn.voe.sx:8080/upload now';
assert.equal(redactLogText(url, []), url);
});
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
const basic = ['dXNlcjpw', 'YXNzd29y', 'ZDEyMw=='].join('');
const jwt = [
['eyJhbGci', 'OiJIUzI1NiJ9'].join(''),
['eyJzdWIi', 'OiIxMjM0', 'NTY3ODkwIn0'].join(''),
['dozjgNry', 'P4J3jVmN', 'Hl0w5N'].join('')
].join('.');
const sessionA = ['SESSION', 'secret', 'value', '99887766'].join('');
const sessionB = ['json', 'Session', 'Secret', '123456'].join('');
const cases = [
{ line: `Authorization: Basic ${basic}`, secret: basic.replace(/==$/, '') },
{ line: `jwt ${jwt}`, secret: jwt.split('.').slice(0, 2).join('.') },
{ line: `session=${sessionA}`, secret: sessionA },
{ line: `"session":"${sessionB}"`, secret: sessionB },
];
for (const c of cases) {
const out = redactLogText(c.line, []);
assert.ok(!out.includes(c.secret), `must redact: ${c.line} -> ${out}`);
assert.ok(out.includes(REDACTED), `expected ${REDACTED} in ${out}`);
}
});
test('redactLogText leaves a normal "session" word in prose alone', () => {
const benign = 'the session was idle for a while';
assert.equal(redactLogText(benign, []), benign);
});
test('redactLogText removes complete local paths from structured and free-form log text', () => {
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
const stagedPath = ['E:', 'Staging', 'source.pending-delete'].join('\\');
const uncPath = ['', '', 'fileserver', 'private-share', 'secret.bin'].join('\\');
const input = [
`source ${profilePath}`,
`failed at ${drivePath}`,
JSON.stringify({ stagedFile: stagedPath }),
`network source ${uncPath}`
].join('\n');
const out = redactLogText(input, []);
for (const value of ['ProfileFixture', 'episode.mkv', 'Private Folder', 'source.mkv', 'source.pending-delete', 'fileserver', 'private-share', 'secret.bin']) {
assert.ok(!out.includes(value), `private path fragment survived: ${value}`);
}
assert.ok((out.match(/<redacted-path>/g) || []).length >= 4);
});
test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input));
sanitizeConfig(input);
assert.deepStrictEqual(input, clone);
});
test('sanitizeConfig leaves empty/missing credentials alone', () => {
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
});
test('sanitizeConfig handles null/undefined input', () => {
assert.strictEqual(sanitizeConfig(null), null);
assert.strictEqual(sanitizeConfig(undefined), undefined);
});
test('collectFile tails when file exceeds maxBytes', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
const bigLine = 'x'.repeat(1000) + '\n';
fs.writeFileSync(tmp, bigLine.repeat(100));
try {
const section = collectFile(tmp, 'big.log', 5000);
assert.match(section, /truncated: skipped first \d+ bytes/);
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
} finally {
fs.unlinkSync(tmp);
}
});
test('collectFile returns placeholder for missing file', () => {
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
assert.match(section, /<file does not exist yet>/);
});
test('collectFile returns placeholder for null path', () => {
const section = collectFile(null, 'no-path');
assert.match(section, /<no path configured>/);
});
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
fs.writeFileSync(tmp, 'line one\nline two\n');
try {
const text = buildSupportBundleText({
header: { Version: '3.3.41', Platform: 'win32' },
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
files: [{ label: 'debug.log', path: tmp }]
});
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /Version: 3\.3\.41/);
assert.match(text, /Platform: win32/);
assert.match(text, /=== Config \(sanitized/);
assert.match(text, /"apiKey": "<redacted>"/);
assert.match(text, /=== debug\.log/);
assert.match(text, /line one\nline two/);
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText handles empty file list and missing header', () => {
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /=== Config/);
});
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
const configuredSecret = ['configured', 'Secret', '123456'].join('');
const bearerSecret = ['opaque', 'Bearer', '987654321'].join('');
const cookieSecret = ['session', 'Cookie', '1122334455'].join('');
const querySecret = ['query', 'Secret', '6677889900'].join('');
const privatePath = ['C:', 'Users', 'ProfileFixture', 'Private', 'episode.mkv'].join('\\');
const stagedPath = ['D:', 'Private', 'episode.pending-delete'].join('\\');
fs.writeFileSync(tmp, `# SOURCE-CLEANUP ${JSON.stringify({ file: privatePath, stagedFile: stagedPath })}\ntoken=${configuredSecret}\nAuthorization: Bearer ${bearerSecret}\nCookie: sid=${cookieSecret}\nhttps://example.invalid/upload?api_key=${querySecret}\n`);
try {
const text = buildSupportBundleText({
sanitizedConfig: { globalSettings: { logFilePath: privatePath, pendingQueue: { selectedFiles: [{ path: privatePath }] } } },
secrets: [configuredSecret],
files: [{ label: 'upload-audit.log', path: tmp }]
});
assert.ok(!text.includes(configuredSecret));
assert.ok(!text.includes(bearerSecret));
assert.ok(!text.includes(cookieSecret));
assert.ok(!text.includes(querySecret));
assert.ok(!text.includes('ProfileFixture'));
assert.ok(!text.includes('episode.mkv'));
assert.ok(!text.includes('episode.pending-delete'));
assert.ok(!text.includes(tmp));
assert.match(text, /<redacted>/);
} finally {
fs.unlinkSync(tmp);
}
});
-28
View File
@@ -1,28 +0,0 @@
const LOOPBACK_HOST = '127.0.0.1';
function listenOnLoopback(server, port = 0) {
return new Promise((resolve, reject) => {
const handleError = error => reject(error);
server.once('error', handleError);
server.listen(port, LOOPBACK_HOST, () => {
server.off('error', handleError);
resolve(server.address());
});
});
}
function installLoopbackRemoteServerGuard(RemoteServer, onListening = () => {}) {
const originalStart = RemoteServer.prototype.start;
const guardedStart = async function (options) {
const result = await originalStart.call(this, { ...options, host: LOOPBACK_HOST });
const address = this._wss && this._wss.address();
onListening(address && typeof address === 'object' ? address.address : '');
return result;
};
RemoteServer.prototype.start = guardedStart;
return () => {
if (RemoteServer.prototype.start === guardedStart) RemoteServer.prototype.start = originalStart;
};
}
module.exports = { listenOnLoopback, installLoopbackRemoteServerGuard };
-286
View File
@@ -1,286 +0,0 @@
const { describe, it, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
describe('suspect-reject alternate accounts', () => {
let UploadManager;
let mockUploadFile;
let mockProbe;
function suspectErr() {
const e = new Error('Byse lehnte Datei ab: Not video file format');
e.fileRejected = true;
e.suspectReject = true;
return e;
}
beforeEach(() => {
delete require.cache[require.resolve('../lib/upload-manager')];
const hosters = require('../lib/hosters');
mockUploadFile = mock.fn(async () => ({ download_url: 'https://byse.sx/d/ok', embed_url: null, file_code: 'ok' }));
hosters.uploadFile = (...a) => mockUploadFile(...a);
hosters.prefetchBaseline = async () => null;
const fileProbe = require('../lib/file-probe');
mockProbe = mock.fn(async () => ({ ok: true, kind: 'matroska', isVideoLike: true, headHex: '1a45dfa3' }));
fileProbe.probeFileHead = (...a) => mockProbe(...a);
const fs = require('fs');
const fakeSize = (p) => {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
};
const origStatSync = fs.statSync;
fs.statSync = function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStatSync.call(this, p);
};
const origStat = fs.promises.stat;
fs.promises.stat = async function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager');
});
function poolMgr(pool, settings) {
return new UploadManager({ 'byse.sx': { retries: 0, ...(settings || {}) } }, {}, { 'byse.sx': pool });
}
it('tries the file on the next pool account after a suspect rejection and succeeds without blacklisting', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
return { download_url: 'https://byse.sx/d/alt', embed_url: null, file_code: 'alt' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.succeeded, 1);
assert.equal(summary.failed, 0);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2']);
assert.ok(rotEvents.includes('suspect-reject-alt'));
assert.equal(mgr.getFailedAccountKeys().length, 0, 'suspect rejection must not blacklist any account');
});
it('fails the file when every pool account gives the suspect rejection — each tried exactly once, none blacklisted', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
]);
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3']);
assert.ok(rotEvents.includes('suspect-reject-exhausted'));
assert.equal(mgr.getFailedAccountKeys().length, 0);
});
it('skips pool accounts already marked failed and lands on the last one', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' },
{ id: 'acc4', apiKey: 'key4' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key3') throw suspectErr();
return { download_url: 'https://byse.sx/d/four', embed_url: null, file_code: 'four' };
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch(
[{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key3', accountId: 'acc3' }],
{ primeFailedAccounts: ['byse.sx:acc1', 'byse.sx:acc2'] }
);
assert.equal(summary.succeeded, 1);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key3', 'key4'], 'failed acc1/acc2 skipped, fourth account finally gets the file');
});
it('does NOT try alternates when the probe says the file is not a video', async () => {
mockProbe.mock.mockImplementation(async () => ({ ok: true, kind: 'rar', isVideoLike: false, headHex: '52617221' }));
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/archive.rar', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(mockUploadFile.mock.calls.length, 1, 'genuine non-video rejection must not burn uploads on other accounts');
assert.ok(rotEvents.includes('skip-rotation-file-rejected'));
});
it('records a user cancel during the alternates walk as aborted, not error', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
mgr.cancel();
const e = new Error('This operation was aborted');
throw e;
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.files[0].results[0].status, 'aborted');
});
it('marks an alternate failed on a genuine account error so later suspect files skip it', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1 });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
if (apiKey === 'key2') {
const e = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
e.accountError = true;
throw e;
}
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/big1.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/big2.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 2);
assert.ok(mgr.getFailedAccountKeys().includes('byse.sx:acc2'), 'dead alternate must be remembered');
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3'], 'second same-size file still gets one real attempt on the primary (memo arms only on the 2nd rejection), then skips the dead alternate and lands on the good account');
});
it('size memo short-circuits a later LARGER file once the account has two confirmed rejections', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1 });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 3);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3', 'key3'], 'files 1+2 each get a real attempt on the 1GB-rejecting primary (arming the memo at count 2); the larger 3rd file then short-circuits the primary straight to the good account');
assert.ok(rotEvents.includes('suspect-memo-skip'), 'the larger third file must skip its primary via the armed size memo');
});
it('sizeMemoEnabled:false disables the pre-skip — the larger third file still gets a real attempt on its primary', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1, sizeMemoEnabled: false });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 3);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.equal(keys.filter(k => k === 'key1').length, 3, 'with the memo off every file gets a real attempt on the primary — including the larger third');
assert.ok(!rotEvents.includes('suspect-memo-skip'), 'the disabled memo must never short-circuit a primary');
});
it('plain fileRejected without suspect flag keeps the old fast-fail behavior', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async () => {
const e = new Error('Byse lehnte Datei ab: Duplicate');
e.fileRejected = true;
throw e;
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(mockUploadFile.mock.calls.length, 1);
});
it('a transient 5xx (byse 502) retries the SAME account and fails clean — no blacklist, no failover cascade', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { retries: 2 });
mgr._sleep = async () => {};
mockUploadFile.mock.mockImplementation(async () => {
const e = new Error('Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html>');
e.transientNetwork = true;
throw e;
});
let accountFailed = 0;
mgr.on('account-failed', () => { accountFailed++; });
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(accountFailed, 0, 'a transient 502 must never emit account-failed');
assert.equal(mgr.getFailedAccountKeys().length, 0, 'no account blacklisted on a transient 502');
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.ok(keys.length >= 2, 'the 502 is retried on the same account');
assert.ok(keys.every(k => k === 'key1'), 'every attempt stays on the primary — no cascade to key2/key3');
});
});
-133
View File
@@ -1,133 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { makeThrottleTimer } = require('../lib/throttle-timer');
function fakeClock() {
let t = 0;
let timers = [];
return {
now: () => t,
schedule: (cb, ms) => {
const h = { at: t + ms, cb, dead: false };
timers.push(h);
return h;
},
clear: (h) => { if (h) h.dead = true; },
advance: (ms) => {
const target = t + ms;
for (;;) {
let next = null;
for (const h of timers) {
if (!h.dead && h.at <= target && (next === null || h.at < next.at)) next = h;
}
if (!next) break;
t = next.at;
next.dead = true;
next.cb();
}
t = target;
timers = timers.filter(h => !h.dead);
}
};
}
test('idle debounce: fires once after the delay window', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
c.advance(499);
assert.equal(fired, 0);
c.advance(1);
assert.equal(fired, 1);
});
test('idle debounce: rapid requests reset the timer (last wins)', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
c.advance(200);
tt.request(() => fired++, 500);
c.advance(300);
assert.equal(fired, 0, 'should not fire at original 500 — was reset');
c.advance(200);
assert.equal(fired, 1, 'fires 500ms after the second request');
});
test('STARVATION repro: continuous requests with no maxWait NEVER fire', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
for (let i = 0; i < 30; i++) {
tt.request(() => fired++, 500);
c.advance(100);
}
assert.equal(fired, 0, 'this is exactly the bug the maxWait fix addresses');
});
test('maxWait: continuous requests still force a fire within the window', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
const fireAtTimes = [];
for (let i = 0; i < 30; i++) {
tt.request(() => { fired++; fireAtTimes.push(c.now()); }, 500, 2000);
c.advance(100);
}
assert.ok(fired >= 1, 'maxWait guarantees at least one fire under continuous load');
assert.ok(fireAtTimes.every(t => t > 0), 'fires happened, not starved');
assert.ok(fireAtTimes.some(t => t <= 2000), 'first fire no later than maxWait');
});
test('maxWait: after a forced fire a fresh burst starts (periodic fires)', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
for (let i = 0; i < 60; i++) {
tt.request(() => fired++, 500, 2000);
c.advance(100);
}
assert.ok(fired >= 2, `~6000ms of continuous load with 2000ms maxWait should fire multiple times, got ${fired}`);
});
test('flushSync fires the pending fn immediately and clears it', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 5000);
assert.ok(tt.isPending());
tt.flushSync();
assert.equal(fired, 1);
assert.ok(!tt.isPending());
c.advance(10000);
assert.equal(fired, 1, 'no double fire after flushSync');
});
test('cancel drops the pending fn — no fire', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
tt.cancel();
assert.ok(!tt.isPending());
c.advance(10000);
assert.equal(fired, 0);
});
test('last-write-wins: a later request with a DIFFERENT fn replaces the earlier one', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
const fired = [];
tt.request(() => fired.push('persist'), 500, 20000);
c.advance(100);
tt.request(() => fired.push('clear'), 0);
c.advance(100);
assert.deepEqual(fired, ['clear'], 'only the latest fn fires; the persist was dropped');
});
test('flushSync with nothing pending is a no-op', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
assert.doesNotThrow(() => tt.flushSync());
});
-101
View File
@@ -1,101 +0,0 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const Throttle = require('../lib/throttle');
describe('Throttle', () => {
it('unlimited mode (0) returns immediately', async () => {
const t = new Throttle(0);
const start = Date.now();
await t.consume(10_000_000);
assert.ok(Date.now() - start < 50, 'should be instant');
});
it('unlimited with falsy values', async () => {
for (const val of [undefined, null, false, 0]) {
const t = new Throttle(val);
const start = Date.now();
await t.consume(1_000_000);
assert.ok(Date.now() - start < 50, `should be instant for ${val}`);
}
});
it('small consume within initial token budget resolves immediately', async () => {
const t = new Throttle(1024 * 1024); // 1 MB/s
const start = Date.now();
await t.consume(100); // 100 bytes, well within 1MB budget
assert.ok(Date.now() - start < 50);
});
it('large consume exceeding tokens introduces delay', async () => {
const t = new Throttle(1000); // 1000 bytes/sec
// Drain initial tokens
await t.consume(1000);
const start = Date.now();
await t.consume(500); // needs ~500ms of refill
const elapsed = Date.now() - start;
assert.ok(elapsed >= 400, `expected >=400ms, got ${elapsed}ms`);
assert.ok(elapsed < 2000, `expected <2000ms, got ${elapsed}ms`);
});
it('aborted signal stops consumption early', async () => {
const t = new Throttle(100); // 100 bytes/sec
await t.consume(100); // drain budget
const ac = new AbortController();
setTimeout(() => ac.abort(), 100);
const start = Date.now();
await t.consume(10000, ac.signal); // would take ~100s without abort
const elapsed = Date.now() - start;
assert.ok(elapsed < 1000, `should abort quickly, took ${elapsed}ms`);
});
it('updateRate changes behavior', async () => {
const t = new Throttle(100);
await t.consume(100); // drain
t.updateRate(0); // switch to unlimited
const start = Date.now();
await t.consume(999999);
assert.ok(Date.now() - start < 50, 'should be instant after switching to unlimited');
});
it('_refill does not exceed maxBps', () => {
const t = new Throttle(1000);
t.tokens = 0;
t.lastRefill = Date.now() - 60000; // simulate 60 seconds elapsed
t._refill();
assert.ok(t.tokens <= 1000, `tokens should not exceed maxBps, got ${t.tokens}`);
});
it('concurrent consume calls share the token pool', async () => {
const t = new Throttle(2000); // 2000 bytes/sec, initial tokens = 2000
// Two concurrent consumes of 1000 each - should both fit in initial budget
const start = Date.now();
await Promise.all([t.consume(1000), t.consume(1000)]);
assert.ok(Date.now() - start < 100, 'both should resolve from initial budget');
// Third consume should need to wait for refill
const start2 = Date.now();
await t.consume(500);
const elapsed = Date.now() - start2;
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
});
it('consume(0) resolves immediately', async () => {
const t = new Throttle(100);
const start = Date.now();
await t.consume(0);
assert.ok(Date.now() - start < 50);
});
it('updateRate to unlimited (0) makes consume instant', async () => {
const t = new Throttle(100); // very slow
t.updateRate(0); // unlimited
const start = Date.now();
await t.consume(1_000_000);
assert.ok(Date.now() - start < 50, 'unlimited rate should be instant');
});
});
-113
View File
@@ -1,113 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeThrottledCache } = require('../lib/throttled-cache');
function fakeClock(start = 0) {
let t = start;
const fn = () => t;
fn.advance = (ms) => { t += ms; };
fn.set = (ms) => { t = ms; };
return fn;
}
test('returns undefined when empty', () => {
const c = makeThrottledCache(100);
assert.equal(c.get('any', {}), undefined);
});
test('returns the set value within the window', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [1, 2, 3];
c.set('sig-a', input, 'value-1');
assert.equal(c.get('sig-a', input), 'value-1');
clock.advance(50);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
clock.advance(49);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
});
test('expires exactly at refreshMs boundary', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
c.set('s', {}, 'v');
clock.advance(100);
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
});
test('miss on different signature', () => {
const c = makeThrottledCache(1000, fakeClock());
const input = {};
c.set('sig-a', input, 'v');
assert.equal(c.get('sig-b', input), undefined);
});
test('miss on different input identity even with same signature', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('sig-a', { a: 1 }, 'v');
// Different object identity — the cache compares by ===, not by contents
assert.equal(c.get('sig-a', { a: 1 }), undefined);
});
test('overwrite by re-setting same signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [];
c.set('s', input, 'old');
clock.advance(50);
c.set('s', input, 'new');
// The new entry has a fresh timestamp → still valid for another 100 ms
clock.advance(99);
assert.equal(c.get('s', input), 'new');
});
test('clear empties the cache', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('s', {}, 'v');
c.clear();
assert.equal(c.get('s', {}), undefined);
assert.equal(c.peek(), null);
});
test('peek reports age and signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(1000, clock);
c.set('mysig', {}, 'v');
clock.advance(42);
const p = c.peek();
assert.equal(p.sig, 'mysig');
assert.equal(p.age, 42);
assert.equal(p.ts, 0);
});
test('throws on invalid refreshMs', () => {
assert.throws(() => makeThrottledCache(-1));
assert.throws(() => makeThrottledCache(NaN));
assert.throws(() => makeThrottledCache('100'));
});
test('refreshMs=0 means every call misses', () => {
const clock = fakeClock();
const c = makeThrottledCache(0, clock);
const input = {};
c.set('s', input, 'v');
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
assert.equal(c.get('s', input), undefined);
});
test('default clock is Date.now when none provided', () => {
const c = makeThrottledCache(10000);
const input = {}; // single ref — get and set must use the SAME identity
c.set('x', input, 'v');
assert.equal(c.get('x', input), 'v');
});
test('large input arrays are tracked by identity, not value', () => {
const c = makeThrottledCache(1000, fakeClock());
const arr1 = new Array(10000).fill(0);
const arr2 = new Array(10000).fill(0);
c.set('s', arr1, 'cached');
assert.equal(c.get('s', arr1), 'cached');
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
});
-47
View File
@@ -1,47 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const net = require('node:net');
const RemoteServer = require('../lib/remote-server');
let networkSafety = {};
try {
networkSafety = require('./support/ui-network-safety');
} catch {}
test('Electron UI test listeners bind only to loopback', async () => {
assert.equal(typeof networkSafety.listenOnLoopback, 'function');
const server = net.createServer();
try {
await networkSafety.listenOnLoopback(server);
const address = server.address();
assert.equal(address.address, '127.0.0.1');
assert.ok(address.port > 0);
} finally {
if (server.listening) await new Promise(resolve => server.close(resolve));
}
});
test('Electron UI remote server guard keeps the real server on loopback', async () => {
assert.equal(typeof networkSafety.installLoopbackRemoteServerGuard, 'function');
const observedAddresses = [];
const restore = networkSafety.installLoopbackRemoteServerGuard(RemoteServer, address => observedAddresses.push(address));
const server = new RemoteServer();
try {
await server.start({
port: 0,
token: 'ui-network-safety-token',
allowInput: true,
onSignalingToCapture: () => {},
onCreateCaptureWindow: () => {},
onDestroyCaptureWindow: () => {}
});
assert.equal(server._wss.address().address, '127.0.0.1');
assert.deepEqual(observedAddresses, ['127.0.0.1']);
} finally {
server.stop();
restore();
}
});
-2905
View File
File diff suppressed because it is too large Load Diff
-478
View File
@@ -1,478 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const crypto = require('node:crypto');
const Module = require('node:module');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState, abortUpdate } = require('../lib/updater');
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
test('bridge title resolves product version instead of transport tag', () => {
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
assert.equal(isNewer('2.0.1', '2.0.1'), false);
assert.equal(isNewer('2.0.2', '2.0.1'), true);
});
test('release arguments reject a malformed transport tag', async () => {
const { parseReleaseArgs } = await import(releasePlanUrl);
assert.throws(
() => parseReleaseArgs(['2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run']),
/--transport-tag must match vX\.Y\.Z/
);
});
test('matching GitHub release notes replace the private release body', async () => {
const calls = [];
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ tag_name: 'v2.1.0', body: '## Public changes\n\n- English UI' }) };
});
assert.equal(notes, '## Public changes\n\n- English UI');
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://api.github.com/repos/Sucukdeluxe/Multi-Hoster-Upload/releases/tags/v2.1.0');
});
test('GitHub release-note failures preserve the private release body', async () => {
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async () => ({ ok: false }));
assert.equal(notes, 'Private fallback');
});
test('update preparation writes a verified installer without launching it', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
let reads = 0;
const progress = [];
try {
const prepared = await prepareUpdate(value => progress.push(value), {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: installer.length,
remoteVersion: '2.2.0',
latestYmlUrl: 'https://update.invalid/latest.yml'
},
tempDir,
fetchImpl: async url => url.endsWith('latest.yml')
? {
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
}
: {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
reads++;
return reads === 1 ? { done: false, value: installer } : { done: true };
}
})
}
}
});
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
assert.deepEqual(fs.readFileSync(prepared.installerPath), installer);
assert.equal(progress.at(-1).stage, 'prepared');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('buffered installer downloads yield between progress updates so the renderer can paint', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-progress-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
const chunks = [
installer.subarray(0, 32 * 1024),
installer.subarray(32 * 1024, 64 * 1024),
installer.subarray(64 * 1024, 96 * 1024),
installer.subarray(96 * 1024)
];
const progress = [];
let readerIndex = 0;
let preparationFinished = false;
let rendererObservedProgressBeforeFinish = false;
let rendererObservationScheduled = false;
let nowMs = 0;
try {
await prepareUpdate(value => {
progress.push(value);
if (value.stage !== 'downloading' || rendererObservationScheduled) return;
rendererObservationScheduled = true;
setImmediate(() => {
rendererObservedProgressBeforeFinish = !preparationFinished;
});
}, {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
remoteVersion: '2.2.0',
latestYmlUrl: 'https://update.invalid/latest.yml'
},
tempDir,
now: () => nowMs,
fetchImpl: async url => url.endsWith('latest.yml')
? {
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
}
: {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
nowMs += 1000;
return readerIndex < chunks.length
? { done: false, value: chunks[readerIndex++] }
: { done: true };
}
})
}
}
});
preparationFinished = true;
await new Promise(resolve => setImmediate(resolve));
assert.equal(rendererObservedProgressBeforeFinish, true);
assert.deepEqual(
progress.filter(value => value.stage === 'downloading').map(value => value.percent),
[25, 50, 75, 100]
);
assert.deepEqual(
progress.filter(value => value.stage === 'downloading').map(value => [value.bytesPerSecond, value.etaSeconds]),
[[32768, 3], [32768, 2], [32768, 1], [32768, 0]]
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('cancelling an active installer download reports an aborted state', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-abort-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
const progress = [];
let reads = 0;
try {
await assert.rejects(
prepareUpdate(value => {
progress.push(value);
if (value.stage === 'downloading') abortUpdate();
}, {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: installer.length,
remoteVersion: '2.2.0',
latestYmlUrl: 'https://update.invalid/latest.yml'
},
tempDir,
fetchImpl: async url => url.endsWith('latest.yml')
? {
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
}
: {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
reads++;
return reads === 1
? { done: false, value: installer.subarray(0, installer.length / 2) }
: { done: false, value: installer.subarray(installer.length / 2) };
}
})
}
}
}),
/Update abgebrochen/
);
assert.equal(progress.at(-1).stage, 'aborted');
assert.equal(progress.at(-1).error, 'Update abgebrochen');
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('update preparation refreshes a cached release before downloading the installer', async () => {
const updaterPath = require.resolve('../lib/updater');
const originalLoad = Module._load;
const originalFetch = global.fetch;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
const sha512 = crypto.createHash('sha512').update(installer).digest('base64');
const oldVersion = '2.1.23';
const latestVersion = '2.1.24';
const createRelease = version => ({
name: `Multi-Hoster-Upload v${version}`,
tag_name: `v${version}`,
html_url: `https://update.invalid/releases/${version}`,
published_at: '2026-09-22T17:30:00Z',
body: `Release ${version}`,
assets: [
{
name: `Multi-Hoster-Upload Setup ${version}.exe`,
size: installer.length,
browser_download_url: `https://update.invalid/${version}/setup.exe`
},
{
name: 'latest.yml',
browser_download_url: `https://update.invalid/${version}/latest.yml`
}
]
});
const createResponse = payload => ({
ok: true,
status: 200,
text: async () => JSON.stringify(payload),
json: async () => payload
});
const createInstallerResponse = () => ({
ok: true,
status: 200,
body: {
getReader: () => {
let served = false;
return {
read: async () => {
if (served) return { done: true };
served = true;
return { done: false, value: installer };
}
};
}
}
});
const createManifestResponse = version => ({
ok: true,
status: 200,
text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n`
});
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[updaterPath];
const isolatedUpdater = require(updaterPath);
try {
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` });
throw new Error(`Unexpected initial request: ${value}`);
};
const oldCheck = await isolatedUpdater.checkForUpdate();
assert.equal(oldCheck.remoteVersion, oldVersion);
assert.equal(oldCheck.publishedAt, '2026-09-22T17:30:00Z');
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` });
if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion);
if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion);
if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse();
throw new Error(`Unexpected refreshed request: ${value}`);
};
const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir });
assert.equal(prepared.remoteVersion, latestVersion);
assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`);
} finally {
global.fetch = originalFetch;
Module._load = originalLoad;
delete require.cache[updaterPath];
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('an update found before renderer readiness remains announceable afterwards', () => {
const state = createUpdateAnnouncementState();
const update = { available: true, remoteVersion: '2.1.24' };
assert.equal(state.canAnnounce(update, false), false);
assert.equal(state.canAnnounce(update, true), true);
assert.equal(state.canAnnounce(update, true), true);
state.markAnnounced(update);
assert.equal(state.canAnnounce(update, true), false);
state.reset();
assert.equal(state.canAnnounce(update, true), true);
});
test('update preparation fails closed when checksum metadata is unavailable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
try {
await assert.rejects(
prepareUpdate(null, {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: 128 * 1024,
remoteVersion: '2.2.0',
latestYmlUrl: null
},
tempDir,
fetchImpl: async () => assert.fail('installer download must not start without checksum metadata')
}),
/Prüfsummen-Metadaten fehlen/
);
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('setup selection never falls back to a portable executable', () => {
const portable = { name: 'Multi-Hoster-Upload 2.2.0.exe' };
assert.equal(pickSetupAsset([portable], '2.2.0'), null);
assert.deepEqual(
pickSetupAsset([portable, { name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }], '2.2.0'),
{ name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }
);
});
test('checksum metadata must match the selected version, installer name, size, and SHA-512 shape', async () => {
const sha = crypto.randomBytes(64).toString('base64');
const fetchImpl = async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload Setup 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
});
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, fetchImpl);
assert.deepEqual(metadata, {
version: '2.2.0',
path: 'Multi-Hoster-Upload Setup 2.2.0.exe',
size: 456,
sha512: sha
});
});
test('checksum metadata rejects a path that belongs to another artifact', async () => {
const sha = crypto.randomBytes(64).toString('base64');
await assert.rejects(
parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
})),
/gehören nicht zum ausgewählten Installer/
);
});
test('a prepared installer launches at most once', () => {
const calls = [];
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
const prepared = { installerPath: 'C:\\Temp\\mhu-setup.exe' };
const spawnImpl = (...args) => {
calls.push(args);
return child;
};
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), true);
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), false);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], [
prepared.installerPath,
['/S', '--updated', '--force-run'],
{ detached: true, stdio: 'ignore' }
]);
assert.equal(child.unrefCalls, 1);
});
test('release plan keeps product artifacts separate from the transport tag', async () => {
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.7', '--transport-tag', 'v3.3.115', 'Update', 'visibility']));
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
assert.deepEqual({
version: plan.version,
transportTag: plan.transportTag,
releaseTitle: plan.releaseTitle,
releaseBody: plan.releaseBody,
expectedArtifacts: plan.expectedArtifacts,
latestYml
}, {
version: '2.0.7',
transportTag: 'v3.3.115',
releaseTitle: 'Multi-Hoster-Upload v2.0.7',
releaseBody: 'Update visibility',
expectedArtifacts: [
'Multi-Hoster-Upload Setup 2.0.7.exe',
'Multi-Hoster-Upload 2.0.7.exe',
'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
'latest.yml'
],
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
});
});
test('compatible existing release preserves the recovery id', async () => {
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v2.0.1',
body: 'Bridge notes',
draft: false,
prerelease: false,
assets: []
};
assert.equal(resolveExistingReleaseId(plan, release), 81);
});
test('incompatible existing release title fails closed', async () => {
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
const release = {
id: 81,
tag_name: 'v3.3.109',
name: 'Multi-Hoster-Upload v3.3.109',
body: 'Old transport release',
draft: false,
prerelease: false,
assets: []
};
assert.throws(
() => resolveExistingReleaseId(plan, release),
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
);
});
-201
View File
@@ -1,201 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
createInternalLogPathResolver,
createInternalLogWriter,
createBufferedInternalLogFlusher,
createUploadAuditWriter,
getLogOpenDirectory
} = require('../lib/upload-audit');
function createTempDirectory(t, prefix) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return directory;
}
test('internal audit and account rotation paths share the userData logs directory', (t) => {
const directory = createTempDirectory(t, 'mhu-internal-log-paths-');
const userDataPath = path.join(directory, 'user-data');
const resolveInternalLogPath = createInternalLogPathResolver({ fs, path, userDataPath });
assert.equal(resolveInternalLogPath('upload-audit.log'), path.join(userDataPath, 'logs', 'upload-audit.log'));
assert.equal(resolveInternalLogPath('account-rotation.log'), path.join(userDataPath, 'logs', 'account-rotation.log'));
});
test('internal log paths fall back to another directory below userData without touching Desktop files', (t) => {
const directory = createTempDirectory(t, 'mhu-internal-log-fallback-');
const userDataPath = path.join(directory, 'user-data');
const desktopPath = path.join(directory, 'Desktop');
const desktopAuditPath = path.join(desktopPath, 'upload-audit.log');
const desktopRotationPath = path.join(desktopPath, 'account-rotation.log');
fs.mkdirSync(userDataPath, { recursive: true });
fs.mkdirSync(desktopPath, { recursive: true });
fs.writeFileSync(path.join(userDataPath, 'logs'), 'blocked');
fs.writeFileSync(desktopAuditPath, 'existing audit');
fs.writeFileSync(desktopRotationPath, 'existing rotation');
const resolveInternalLogPath = createInternalLogPathResolver({ fs, path, userDataPath });
assert.equal(resolveInternalLogPath('upload-audit.log'), path.join(userDataPath, 'internal-logs', 'upload-audit.log'));
assert.equal(resolveInternalLogPath('account-rotation.log'), path.join(userDataPath, 'internal-logs', 'account-rotation.log'));
assert.equal(fs.readFileSync(desktopAuditPath, 'utf8'), 'existing audit');
assert.equal(fs.readFileSync(desktopRotationPath, 'utf8'), 'existing rotation');
});
test('internal rotation writer retries inside userData and reports the file that accepted the write', async (t) => {
const directory = createTempDirectory(t, 'mhu-internal-log-writer-');
const userDataPath = path.join(directory, 'user-data');
const primaryPath = path.join(userDataPath, 'logs', 'account-rotation.log');
const fallbackPath = path.join(userDataPath, 'internal-logs', 'account-rotation.log');
const appendTargets = [];
const rotationTargets = [];
const testFs = {
mkdirSync: fs.mkdirSync,
appendFileSync: fs.appendFileSync,
promises: {
appendFile: async (targetPath, ...args) => {
appendTargets.push(targetPath);
if (targetPath === primaryPath) throw Object.assign(new Error('blocked primary'), { code: 'EACCES' });
return fs.promises.appendFile(targetPath, ...args);
}
}
};
const targets = [primaryPath, fallbackPath];
const resolveInternalLogPath = (_fileName, excludedPaths = new Set()) => targets.find(targetPath => !excludedPaths.has(targetPath)) || null;
const writer = createInternalLogWriter({
fs: testFs,
path,
fileName: 'account-rotation.log',
resolveInternalLogPath,
rotateLogFile: targetPath => rotationTargets.push(targetPath),
reportError: () => {},
retryDelays: [0, 0]
});
assert.equal(await writer.append('[rotation]\n', 'rot-log'), true);
assert.deepEqual(appendTargets, [primaryPath, fallbackPath]);
assert.deepEqual(rotationTargets, [primaryPath, fallbackPath]);
assert.equal(writer.getActivePath(), fallbackPath);
assert.equal(fs.readFileSync(fallbackPath, 'utf8'), '[rotation]\n');
});
test('synchronous rotation flush falls back completely and retains buffered lines when every target fails', (t) => {
const directory = createTempDirectory(t, 'mhu-internal-log-sync-writer-');
const userDataPath = path.join(directory, 'user-data');
const primaryPath = path.join(userDataPath, 'logs', 'account-rotation.log');
const fallbackPath = path.join(userDataPath, 'internal-logs', 'account-rotation.log');
fs.mkdirSync(primaryPath, { recursive: true });
const resolveInternalLogPath = createInternalLogPathResolver({ fs, path, userDataPath });
const writer = createInternalLogWriter({
fs,
path,
fileName: 'account-rotation.log',
resolveInternalLogPath,
rotateLogFile: () => {},
reportError: () => {}
});
const buffer = ['[rotation 1]\n', '[rotation 2]\n'];
assert.equal(writer.flushSync(buffer, 'rot-log'), true);
assert.deepEqual(buffer, []);
assert.equal(writer.getActivePath(), fallbackPath);
assert.equal(fs.readFileSync(fallbackPath, 'utf8'), '[rotation 1]\n[rotation 2]\n');
const failedUserDataPath = path.join(directory, 'failed-user-data');
const failedPrimaryPath = path.join(failedUserDataPath, 'logs', 'account-rotation.log');
const failedFallbackPath = path.join(failedUserDataPath, 'internal-logs', 'account-rotation.log');
fs.mkdirSync(failedPrimaryPath, { recursive: true });
fs.mkdirSync(failedFallbackPath, { recursive: true });
const failedWriter = createInternalLogWriter({
fs,
path,
fileName: 'account-rotation.log',
resolveInternalLogPath: createInternalLogPathResolver({ fs, path, userDataPath: failedUserDataPath }),
rotateLogFile: () => {},
reportError: () => {}
});
const retainedBuffer = ['[retained 1]\n', '[retained 2]\n'];
assert.equal(failedWriter.flushSync(retainedBuffer, 'rot-log'), false);
assert.deepEqual(retainedBuffer, ['[retained 1]\n', '[retained 2]\n']);
assert.equal(failedWriter.getActivePath(), null);
});
test('quit flush delegates the rotation buffer to the synchronous internal writer', () => {
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
assert.match(mainSource, /_rotLogFlusher\.flushSync\('rot-log'\);/);
assert.doesNotMatch(mainSource, /appendFileSync\(getRotLogPath\(\), _rotLogBuffer\.join\(''\)/);
});
test('asynchronous rotation flush restores a failed chunk ahead of newer lines without a retry loop', async () => {
const buffer = ['first\n', 'second\n'];
const scheduled = [];
let resolveAppend;
const appendCalls = [];
const syncCalls = [];
const writer = {
append(value) {
appendCalls.push(value);
return new Promise(resolve => { resolveAppend = resolve; });
},
flushSync(lines) {
syncCalls.push([...lines]);
lines.length = 0;
return true;
}
};
const flusher = createBufferedInternalLogFlusher({
buffer,
writer,
schedule: callback => scheduled.push(callback)
});
const failed = flusher.flush('rot-log');
assert.deepEqual(buffer, []);
buffer.push('third\n');
resolveAppend(false);
assert.equal(await failed, false);
assert.deepEqual(buffer, ['first\n', 'second\n', 'third\n']);
assert.deepEqual(appendCalls, ['first\nsecond\n']);
assert.deepEqual(scheduled, []);
assert.equal(flusher.flushSync('rot-log'), true);
assert.deepEqual(syncCalls, [['first\n', 'second\n', 'third\n']]);
assert.deepEqual(buffer, []);
});
test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => {
const directory = createTempDirectory(t, 'mhu-upload-log-contract-');
const userDataPath = path.join(directory, 'user-data');
const customUploadDirectory = path.join(directory, 'custom-upload-logs');
const uploadLogPath = path.join(customUploadDirectory, '13-08-2026-mdu-session-14-20-123456.log');
fs.mkdirSync(customUploadDirectory, { recursive: true });
fs.writeFileSync(uploadLogPath, 'existing upload entry\n');
const resolveInternalLogPath = () => path.join(userDataPath, 'logs', 'upload-audit.log');
const writer = createUploadAuditWriter({
fs,
path,
resolveInternalLogPath,
rotateLogFile: () => {},
reportError: () => {},
retryDelays: [0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan'), true);
assert.equal(writer.getActivePath(), path.join(userDataPath, 'logs', 'upload-audit.log'));
assert.equal(fs.readFileSync(uploadLogPath, 'utf8'), 'existing upload entry\n');
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
});
test('log opening uses the reported internal file directory before the general fallback directory', () => {
assert.equal(
getLogOpenDirectory('C:\\AppData\\Multi-Hoster\\logs\\upload-audit.log', 'C:\\Program Files\\Multi-Hoster', path.win32),
'C:\\AppData\\Multi-Hoster\\logs'
);
assert.equal(getLogOpenDirectory(null, 'C:\\Program Files\\Multi-Hoster', path.win32), 'C:\\Program Files\\Multi-Hoster');
});
-88
View File
@@ -1,88 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
test('accepts a host-confirmed file code without a public URL', () => {
const result = { file_code: 'AB1', download_url: null, embed_url: null };
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
});
test('accepts upload URLs for every supported hoster and its subdomains', () => {
const cases = [
['doodstream.com', 'https://doodstream.com/d/abc123'],
['voe.sx', 'https://cdn.voe.sx/abc123'],
['vidmoly.me', 'https://vidmoly.me/w/abc123'],
['byse.sx', 'https://media.byse.sx/d/abc123'],
['clouddrop.cc', 'https://clouddrop.cc/share/abc123']
];
for (const [hoster, downloadUrl] of cases) {
const result = { file_code: 'abc123', download_url: downloadUrl };
assert.equal(assertUploadConfirmation(result, hoster), result);
}
});
test('accepts Doodstream result links on its current public domains', () => {
const result = {
file_code: 'DOODCODE1234',
download_url: 'https://dood.to/d/DOODCODE1234',
embed_url: 'https://dood.la/e/DOODCODE1234'
};
assert.deepEqual(assertUploadConfirmation(result, 'doodstream.com'), {
...result,
download_url: 'https://doodstream.com/d/DOODCODE1234',
embed_url: 'https://doodstream.com/e/DOODCODE1234'
});
});
test('accepts the Doodstream result domain returned by the current upload service', () => {
const result = {
file_code: 'DOODCODE1234',
download_url: 'https://dsvplay.com/d/DOODCODE1234',
embed_url: 'https://dsvplay.com/e/DOODCODE1234'
};
assert.deepEqual(assertUploadConfirmation(result, 'doodstream.com'), {
...result,
download_url: 'https://doodstream.com/d/DOODCODE1234',
embed_url: 'https://doodstream.com/e/DOODCODE1234'
});
});
test('rejects an upload URL from a different domain', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
/Upload zu voe\.sx wurde nicht bestätigt/
);
});
test('rejects a syntactically invalid file code despite a valid hoster URL', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'not a code', download_url: 'https://vidmoly.me/w/not-a-code' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
test('rejects a valid hoster URL without a file code', () => {
assert.throws(
() => assertUploadConfirmation({ download_url: 'https://byse.sx/d/abc123' }, 'byse.sx'),
(err) => {
assert.match(err.message, /Upload zu byse\.sx wurde nicht bestätigt/);
assert.equal(err.diagnostic.payloadSnippet, '{"fileCodeLength":0,"urlHosts":["byse.sx"]}');
return true;
}
);
});
test('rejects an empty upload response', () => {
assert.throws(
() => assertUploadConfirmation({}, 'byse.sx'),
/Upload zu byse\.sx wurde nicht bestätigt/
);
});
test('rejects non-network links despite a valid file code', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'javascript:alert(1)' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
-27
View File
@@ -1,27 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeFailureDetails } = require('../lib/upload-diagnostics');
test('normalisiert Fehlerdiagnosen ohne URLs oder lange Antworten zu speichern', () => {
const result = normalizeFailureDetails({
http: 503,
contentType: 'text/html; charset=utf-8',
payloadSnippet: 'https://private.example/upload?key=secret '.repeat(80),
uploadUrl: 'https://private.example/upload?key=secret'
});
assert.equal(result.httpStatus, 503);
assert.equal(result.contentType, 'text/html; charset=utf-8');
assert.match(result.responseSnippet, /^private\.example\/upload/);
assert.ok(result.responseSnippet.length <= 320);
});
test('ignoriert leere und ungültige Diagnosefelder', () => {
assert.equal(normalizeFailureDetails({ http: 'x', contentType: '', payloadSnippet: '' }), null);
});
test('bereinigt Zugangsdaten und mehrere URLs aus Hosterantworten', () => {
const result = normalizeFailureDetails({ payloadSnippet: 'token=secret https://one.example/a?key=one password: hunter2 https://two.example/b' });
assert.equal(result.responseSnippet, 'token=[redacted] one.example/a password: [redacted] two.example/b');
});
-220
View File
@@ -1,220 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
formatUploadLogLine,
parseUploadLogLine,
iterateUploadLogEntries,
readUploadLogEntries,
summarizeBatchPlan,
formatUploadPlanLogLine
} = require('../lib/upload-log');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function previewJob(fileName, hoster) {
return { status: 'preview', fileName, hoster, file: `C:/dl/${fileName}` };
}
test('writer -> reader round trip: parsed ts is the same epoch frame as the source Date getTime', () => {
const d = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(d, 'voe.sx', 'https://voe.sx/x', 'a.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.hoster, 'voe.sx');
assert.equal(parsed.fileName, 'a.mkv');
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
});
test('batch plan records unique sources, destinations, and requested upload count without file paths', () => {
const jobs = [];
for (const file of ['C:/private/a.mkv', 'C:/private/b.mkv', 'C:/private/c.mkv']) {
for (const hoster of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']) {
jobs.push({ file, hoster });
}
}
const plan = summarizeBatchPlan({ jobs });
const line = formatUploadPlanLogLine(new Date('2026-08-13T12:00:00.000Z'), plan, 'start');
assert.deepEqual(plan, {
fileCount: 3,
destinationCount: 4,
plannedUploadCount: 12
});
assert.equal(line.startsWith('# UPLOAD-PLAN '), true);
assert.equal(line.includes('C:/private'), false);
assert.equal(line.includes('a.mkv'), false);
assert.equal(line.includes('doodstream.com'), false);
assert.deepEqual(JSON.parse(line.slice('# UPLOAD-PLAN '.length)), {
timestamp: '2026-08-13T12:00:00.000Z',
mode: 'start',
fileCount: 3,
destinationCount: 4,
plannedUploadCount: 12
});
assert.equal(parseUploadLogLine(line), null);
});
test('batch plan supports the legacy files and hosters payload', () => {
assert.deepEqual(summarizeBatchPlan({
files: ['C:/private/a.mkv', 'C:/private/b.mkv'],
hosters: ['voe.sx', 'doodstream.com']
}), {
fileCount: 2,
destinationCount: 2,
plannedUploadCount: 4
});
});
test('batch plan preserves a sparse requested job count instead of multiplying dimensions', () => {
assert.deepEqual(summarizeBatchPlan({ jobs: [
{ file: 'C:/private/a.mkv', hoster: 'voe.sx' },
{ file: 'C:/private/a.mkv', hoster: 'byse.sx' },
{ file: 'C:/private/b.mkv', hoster: 'voe.sx' }
] }), {
fileCount: 2,
destinationCount: 2,
plannedUploadCount: 3
});
});
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
const parsed = parseUploadLogLine(line);
const savedAt = completion.getTime() - 5000;
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
assert.equal(removed.length, 1, 'a file logged after the snapshot is a ghost and must drop');
assert.equal(kept.length, 0);
});
test('SEAM: the same real line is KEPT vs a savedAt taken AFTER completion (intentional re-upload)', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv'));
const savedAt = completion.getTime() + 5000;
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
assert.equal(removed.length, 0, 'an older upload than the snapshot is a deliberate re-queue and must survive');
assert.equal(kept.length, 1);
});
test('parseUploadLogLine skips comments, blanks and malformed lines', () => {
assert.equal(parseUploadLogLine('# fileuploader log'), null);
assert.equal(parseUploadLogLine(''), null);
assert.equal(parseUploadLogLine(' '), null);
assert.equal(parseUploadLogLine('only|three|parts|here'), null);
assert.equal(parseUploadLogLine(null), null);
assert.equal(parseUploadLogLine(42), null);
});
test('parser distinguishes confirmed uploads from filename-only rows', () => {
assert.equal(parseUploadLogLine('2026-08-27 05:40:00|voe.sx|||episode.mkv|').confirmed, false);
assert.equal(parseUploadLogLine('2026-08-27 05:40:00|voe.sx|https://voe.sx/e/code||episode.mkv|').confirmed, true);
});
test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy lines still match by name)', () => {
const parsed = parseUploadLogLine('|voe.sx|link||a.mkv|');
assert.equal(parsed.hoster, 'voe.sx');
assert.equal(parsed.fileName, 'a.mkv');
assert.equal(parsed.ts, undefined);
});
test('parseUploadLogLine: a pipe in the link does NOT shift the filename field (entry not lost)', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b', 'movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.hoster, 'byse.sx');
assert.equal(parsed.fileName, 'movie.mkv', 'filename is taken as the last non-empty field, robust to link pipes');
});
test('parseUploadLogLine: two pipes in the link still parse the correct filename', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b|c', 'movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.fileName, 'movie.mkv');
});
test('parseUploadLogLine: a leading-space filename is preserved (matches the untrimmed queue-job key)', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'voe.sx', 'https://h.io/a', ' movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.fileName, ' movie.mkv', 'filename is NOT trimmed, so it matches the OS basename verbatim');
});
test('SEAM: a leading-space filename round-trips and the gate still drops its ghost', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'l', ' spaced.mp4'));
const savedAt = completion.getTime() - 5000;
const job = { status: 'preview', fileName: ' spaced.mp4', hoster: 'voe.sx', file: 'C:/dl/ spaced.mp4' };
const { removed } = partitionRestoredJobsByLog([job], [parsed], savedAt);
assert.equal(removed.length, 1, 'leading-space filename now matches end-to-end (was a mismatch before)');
});
test('stream reader parses large logs incrementally and yields between bounded line batches', async () => {
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-stream-'));
const filePath = path.join(directory, 'fileuploader.log');
const lines = Array.from({ length: 2505 }, (_, index) => formatUploadLogLine(
new Date(2026, 7, 27, 5, 40, index % 60),
index % 2 === 0 ? 'voe.sx' : 'doodstream.com',
`https://example.invalid/${index}`,
`episode-${index}.mkv`
)).join('');
fs.writeFileSync(filePath, lines, 'utf8');
let yields = 0;
try {
const entries = await readUploadLogEntries(filePath, {
yieldEvery: 500,
yieldFn: async () => { yields++; }
});
assert.equal(entries.length, 2505);
assert.equal(entries[0].fileName, 'episode-0.mkv');
assert.equal(entries.at(-1).fileName, 'episode-2504.mkv');
assert.equal(yields, 5);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('upload-log iterator is lazy and rejects oversized lines', async () => {
let produced = 0;
async function* source() {
produced++;
yield formatUploadLogLine(new Date(2026, 7, 27, 5, 40, 0), 'voe.sx', 'https://example.invalid/1', 'one.mkv').trimEnd();
produced++;
yield formatUploadLogLine(new Date(2026, 7, 27, 5, 40, 1), 'voe.sx', 'https://example.invalid/2', 'two.mkv').trimEnd();
}
const iterator = iterateUploadLogEntries('', { lines: source(), maxLineLength: 65536 });
assert.deepEqual(await iterator.next(), {
done: false,
value: { hoster: 'voe.sx', fileName: 'one.mkv', ts: new Date(2026, 7, 27, 5, 40, 0).getTime(), confirmed: true }
});
assert.equal(produced, 1);
await iterator.return();
const oversized = iterateUploadLogEntries('', {
lines: (async function* () { yield 'x'.repeat(11); })(),
maxLineLength: 10
});
await assert.rejects(async () => { for await (const entry of oversized) void entry; }, /Zeile ist zu lang/);
let destroyed = 0;
const input = {
async *[Symbol.asyncIterator]() { yield 'x'.repeat(11); },
destroy: () => { destroyed++; }
};
const leaking = iterateUploadLogEntries('ignored.log', {
fs: { createReadStream: () => input },
maxLineLength: 10
});
await assert.rejects(async () => { for await (const entry of leaking) void entry; }, /Zeile ist zu lang/);
assert.equal(destroyed, 1);
const oversizedStream = iterateUploadLogEntries('ignored.log', {
fs: {
createReadStream: () => ({
async *[Symbol.asyncIterator]() { yield 'x'.repeat(11); },
destroy() {}
})
},
maxBytes: 10,
maxLineLength: 100
});
await assert.rejects(async () => { for await (const entry of oversizedStream) void entry; }, /Leselimit/);
});
File diff suppressed because it is too large Load Diff
-208
View File
@@ -1,208 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
createAccountSubmitter,
getAccountSubmitLabel,
submitValidatedAccount
} = require('../renderer/account-submit');
test('account submit labels stay exact for add, edit, and OTP retries', () => {
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
});
test('close and reopen cannot start a second save while the first save is pending', async () => {
const submitter = createAccountSubmitter();
let current = true;
let commits = 0;
let applies = 0;
let saveStarted;
let finishSave;
const started = new Promise(resolve => { saveStarted = resolve; });
const saving = new Promise(resolve => { finishSave = resolve; });
const first = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
saveStarted();
await saving;
return { accountId: 'first' };
},
afterCommit: async () => {
applies++;
},
isCurrent: () => current
});
await started;
current = false;
const second = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(second, null);
assert.equal(submitter.isBusy(), true);
finishSave();
const result = await first;
assert.equal(result.status, 'stale');
assert.equal(result.committed, true);
assert.equal(commits, 1);
assert.equal(applies, 1);
assert.equal(submitter.isBusy(), false);
});
test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
const expected = new Error('render failed');
let saves = 0;
let applies = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
saves++;
return { accountId: 'saved-account' };
},
afterCommit: async () => {
applies++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.value.accountId, 'saved-account');
assert.equal(result.postCommitError, expected);
assert.equal(saves, 1);
assert.equal(applies, 1);
});
test('ok validates and commits exactly once in one submission', async () => {
let validations = 0;
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
validations++;
return { status: 'ok', message: 'Login erfolgreich' };
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(validations, 1);
assert.equal(commits, 1);
});
test('warn validates and commits exactly once in one submission', async () => {
let commits = 0;
const validation = { status: 'warn', message: 'Login mit Warnung' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async (received) => {
commits++;
assert.equal(received, validation);
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.validation, validation);
assert.equal(commits, 1);
});
for (const status of ['error', 'skipped']) {
test(`${status} rejects without committing`, async () => {
let commits = 0;
const validation = { status, message: `${status} result` };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'rejected');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
}
test('validate throw returns error without committing', async () => {
const expected = new Error('validation failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => {
throw expected;
},
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 0);
});
test('otp_required returns challenge without committing', async () => {
let commits = 0;
const validation = { status: 'otp_required', message: 'OTP gesendet' };
const result = await submitValidatedAccount({
validate: async () => validation,
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(result.status, 'otp_required');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('stale submission is rejected immediately before commit', async () => {
let current = true;
let commits = 0;
const validation = { status: 'ok' };
const result = await submitValidatedAccount({
validate: async () => {
current = false;
return validation;
},
commit: async () => {
commits++;
},
isCurrent: () => current
});
assert.equal(result.status, 'stale');
assert.equal(result.validation, validation);
assert.equal(commits, 0);
});
test('save failure returns error after one commit attempt', async () => {
const expected = new Error('save failed');
let commits = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'error');
assert.equal(result.error, expected);
assert.equal(commits, 1);
});
-93
View File
@@ -1,93 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const VidmolyUploader = require('../lib/vidmoly-upload');
function response(status, payload) {
return { status, text: async () => typeof payload === 'string' ? payload : JSON.stringify(payload) };
}
function loginFixture({ loginStatus = 200, login = {}, session = 'test-session', probeStatus = 200, probe = { user: { usr_id: 1 } } } = {}) {
const uploader = new VidmolyUploader();
const calls = [];
uploader._fetch = async (url, options) => {
calls.push({ url, options });
if (url.endsWith('/api/auth/login')) {
if (session !== null) uploader.cookies.set('vidmoly_session', session);
return response(loginStatus, login);
}
if (url.endsWith('/api/auth/me')) return response(probeStatus, probe);
assert.equal(url, 'https://vidmoly.me');
return response(200, '');
};
return { uploader, calls };
}
test('Vidmoly verifies the website session independently of upload availability', async () => {
const { uploader, calls } = loginFixture({ probe: { user: { usr_id: 1 }, upload: { allowed: false, reason: 'disk_full' } } });
await uploader.login('test-user', 'test-password');
assert.deepEqual(calls.map(call => call.url), [
'https://vidmoly.me', 'https://vidmoly.me/api/auth/login', 'https://vidmoly.me/api/auth/me'
]);
assert.deepEqual(JSON.parse(calls[1].options.body), { login: 'test-user', password: 'test-password' });
});
test('Vidmoly does not interpret arbitrary successful response text as a password error', async () => {
await loginFixture({ login: { login: 'wrong-example' } }).uploader.login('test-user', 'test-password');
});
test('Vidmoly stops after an OTP challenge without probing or repeating login', async () => {
const { uploader, calls } = loginFixture({ login: { otp_required: 1 }, session: null });
await assert.rejects(uploader.login('test-user', 'test-password'), /OTP erforderlich/);
assert.equal(calls.length, 2);
});
test('Vidmoly rejects missing sessions, invalid users and failed session probes', async () => {
for (const options of [{ session: null }, { session: '' }, { probe: {} }, { probe: { user: {} } }, { probe: { user: [] } }, { probe: '<html>Login</html>' }, { probeStatus: 401 }, { probeStatus: 503 }]) {
await assert.rejects(loginFixture(options).uploader.login('test-user', 'test-password'), /Vidmoly Login fehlgeschlagen/);
}
await assert.rejects(loginFixture({ loginStatus: 401 }).uploader.login('test-user', 'test-password'), /Falscher Username/);
await assert.rejects(loginFixture({ loginStatus: 403 }).uploader.login('test-user', 'test-password'), /HTTP 403/);
});
test('Vidmoly preserves upload denial causes without exposing response data', async () => {
for (const [code, expected] of [
['DISK_FULL', /disk full/],
['UPLOAD_IP_BLACKLIST', /IP-Adresse gesperrt/],
['UPLOAD_DISABLED', /Account deaktiviert/]
]) {
for (const status of [200, 403]) {
const uploader = new VidmolyUploader();
uploader._fetch = async () => response(status, { message: `${code}: private-data` });
await assert.rejects(uploader.getUploadParams(), error => expected.test(error.message) && !error.message.includes('private-data'));
}
}
});
test('Vidmoly validates config responses before streaming a file', async () => {
for (const [status, payload, expected] of [
[401, {}, /HTTP 401/],
[503, '<html>Unavailable</html>', /HTTP 503/],
[200, '<html>Login</html>', /kein JSON/],
[200, {}, /unvollständig/],
[200, { sess_id: {}, upload_url: 'https://upload.example/' }, /unvollständig/],
[200, { sess_id: 'test', upload_url: 'file:///private' }, /Ungültige/],
[200, { sess_id: 'test', upload_url: 'https://user:password@upload.example/' }, /Ungültige/]
]) {
const uploader = new VidmolyUploader();
uploader._fetch = async () => response(status, payload);
await assert.rejects(uploader.getUploadParams(), expected);
}
});
test('Vidmoly uses the current website upload fields without an API key', async () => {
const uploader = new VidmolyUploader();
uploader._fetch = async url => {
assert.equal(url, 'https://vidmoly.me/api/upload/config');
return response(200, { sess_id: 'test-session', upload_url: 'https://upload.example/upload' });
};
assert.deepEqual(await uploader.getUploadParams(), {
uploadUrl: 'https://upload.example/upload',
params: { sess_id: 'test-session', tos: '1', to_json: '1', fld_id: '0' },
fileFieldName: 'file'
});
});
-184
View File
@@ -1,184 +0,0 @@
const test = require('node:test');
const assert = require('node:assert');
const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT } = require('../lib/webhook-notify');
const SAMPLE_SUMMARY = {
total: 10,
succeeded: 8,
failed: 2,
files: [
{ name: 'a.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'x' }
] },
{ name: 'b.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'done' }
] }
]
};
test('isDiscordWebhook recognizes discord URLs incl. ptb/canary/discordapp', () => {
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://discordapp.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://ptb.discord.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://canary.discord.com/api/webhooks/123/abc'));
assert.strictEqual(isDiscordWebhook('https://example.com/hook'), false);
assert.strictEqual(isDiscordWebhook(''), false);
assert.strictEqual(isDiscordWebhook(null), false);
});
test('isDiscordWebhook REJECTS incomplete discord URLs (no id/token)', () => {
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123/'), false);
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123456789/aBc-_token123'));
});
test('clampDiscordContent caps to the Discord limit with ellipsis', () => {
const short = 'hello';
assert.strictEqual(clampDiscordContent(short), short);
const long = 'x'.repeat(5000);
const clamped = clampDiscordContent(long);
assert.ok(clamped.length <= DISCORD_CONTENT_LIMIT);
assert.ok(clamped.endsWith('…'));
});
test('buildWebhookRequest: many hosters does not exceed Discord limit', () => {
const files = [{ name: 'a.mkv', results: [] }];
for (let i = 0; i < 60; i++) files[0].results.push({ hoster: `hoster-with-a-really-long-name-${i}.example.com`, status: 'done' });
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 60, succeeded: 60, failed: 0, files }, { durationSec: 60 });
const body = JSON.parse(req.body);
assert.ok(body.content.length <= DISCORD_CONTENT_LIMIT, `content ${body.content.length} must be <= ${DISCORD_CONTENT_LIMIT}`);
assert.match(body.content, /\+\d+/);
});
test('buildWebhookRequest: aborted meta changes the headline', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true, language: 'de' });
const body = JSON.parse(req.body);
assert.match(body.content, /Batch abgebrochen/);
});
test('isAllAborted: true only when every result is aborted', () => {
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'aborted' }] }] }), true);
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'done' }] }] }), false);
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'error' }] }] }), false);
assert.strictEqual(isAllAborted({ files: [] }), false);
assert.strictEqual(isAllAborted(null), false);
});
test('formatDurationShort formats h/m/s tiers', () => {
assert.strictEqual(formatDurationShort(45), '45s');
assert.strictEqual(formatDurationShort(125), '2m 5s');
assert.strictEqual(formatDurationShort(3 * 3600 + 12 * 60), '3h 12m');
assert.strictEqual(formatDurationShort(-5), '0s');
assert.strictEqual(formatDurationShort(undefined), '0s');
});
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0, skipped: 0 });
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
});
test('summarizePerHosterFromBatch handles malformed input', () => {
assert.deepStrictEqual(summarizePerHosterFromBatch(null), {});
assert.deepStrictEqual(summarizePerHosterFromBatch({}), {});
assert.deepStrictEqual(summarizePerHosterFromBatch({ files: [{ results: null }] }), {});
});
test('buildWebhookRequest produces Discord content body for discord URLs', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 3700, appVersion: '3.3.59', machineName: 'srv-1' });
assert.strictEqual(req.method, 'POST');
assert.strictEqual(req.headers['Content-Type'], 'application/json');
const body = JSON.parse(req.body);
assert.ok(typeof body.content === 'string');
assert.match(body.content, /Batch completed/);
assert.match(body.content, /srv-1/);
assert.match(body.content, /8 succeeded/);
assert.match(body.content, /2 failed/);
assert.match(body.content, /1h 1m/);
assert.match(body.content, /voe\.sx: 2\/2/);
});
test('webhooks report skipped uploads separately from failures', () => {
const summary = {
total: 2,
succeeded: 1,
failed: 0,
skipped: 1,
files: [{ name: 'a.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
] }]
};
const discord = JSON.parse(buildWebhookRequest('https://discord.com/api/webhooks/1/x', summary, { language: 'de' }).body);
assert.match(discord.content, /1 übersprungen/);
assert.doesNotMatch(discord.content, /1 Fehler/);
assert.deepStrictEqual(summarizePerHosterFromBatch(summary)['voe.sx'], { ok: 1, fail: 0, skipped: 1 });
const generic = JSON.parse(buildWebhookRequest('https://example.com/hook', summary, {}).body);
assert.equal(generic.skipped, 1);
});
test('buildWebhookRequest localizes Discord content from the selected language', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' });
const body = JSON.parse(req.body);
assert.match(body.content, /Batch fertig/);
assert.match(body.content, /2 Fehler/);
assert.match(body.content, /10 gesamt/);
});
test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' });
const body = JSON.parse(req.body);
assert.strictEqual(body.event, 'batch-done');
assert.strictEqual(body.total, 10);
assert.strictEqual(body.succeeded, 8);
assert.strictEqual(body.failed, 2);
assert.strictEqual(body.durationSec, 60);
assert.strictEqual(body.version, '3.3.59');
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
});
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {
assert.deepStrictEqual(resolveDiscordMention('@here'), { token: '@here', allowed: { parse: ['everyone'] } });
assert.deepStrictEqual(resolveDiscordMention('everyone'), { token: '@everyone', allowed: { parse: ['everyone'] } });
});
test('resolveDiscordMention: bare numeric id → user mention', () => {
assert.deepStrictEqual(resolveDiscordMention('123456789012345'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
assert.deepStrictEqual(resolveDiscordMention('<@!123456789012345>'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
});
test('resolveDiscordMention: role:id and <@&id> → role mention', () => {
assert.deepStrictEqual(resolveDiscordMention('role:99887766'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
assert.deepStrictEqual(resolveDiscordMention('<@&99887766>'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
});
test('resolveDiscordMention: empty / junk → null', () => {
assert.strictEqual(resolveDiscordMention(''), null);
assert.strictEqual(resolveDiscordMention(' '), null);
assert.strictEqual(resolveDiscordMention('not-an-id'), null);
});
test('buildWebhookRequest: discord with mention prepends token + sets allowed_mentions', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60, mention: '123456789012345' });
const body = JSON.parse(req.body);
assert.ok(body.content.startsWith('<@123456789012345> '));
assert.deepStrictEqual(body.allowed_mentions, { users: ['123456789012345'] });
});
test('buildWebhookRequest: discord without mention blocks all pings (allowed_mentions parse empty)', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60 });
const body = JSON.parse(req.body);
assert.deepStrictEqual(body.allowed_mentions, { parse: [] });
});
test('buildWebhookRequest tolerates empty summary', () => {
const req = buildWebhookRequest('https://example.com/hook', null, {});
const body = JSON.parse(req.body);
assert.strictEqual(body.total, 0);
assert.strictEqual(body.succeeded, 0);
});