This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
/tests/
|
||||
/services/backup-api/test/
|
||||
.dev-runner.lock
|
||||
release/
|
||||
.artifacts/
|
||||
|
||||
+2
-3
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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: [] }
|
||||
]);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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="{ "sess_id": "abc_DEF-456" }"></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 & 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&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;
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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()), []);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, '');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"/
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user