Files
Multi-Debrid-Downloader/services/backup-api/test/server.test.mjs
T

539 lines
20 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash, randomBytes } from 'node:crypto'
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
import { request as createHttpRequest } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import lockfile from 'proper-lockfile'
import { createBackupServer } from '../src/server.mjs'
const allowedOrigin = 'https://downloads.24-music.de'
function backupFixture() {
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(), 'mdd-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)
})
const address = server.address()
return {
rootDir,
server,
baseUrl: `http://127.0.0.1:${address.port}`,
async close() {
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await rm(rootDir, { recursive: true, force: true })
}
}
}
async function request(api, path, options = {}) {
return fetch(`${api.baseUrl}${path}`, options)
}
test('health endpoint reports readiness without exposing storage details', 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')
})
for (const [label, trustedProxy, forwarded, expected] of [
['socket peer without proxy trust', false, '203.0.113.7', '127.0.0.1'],
['trusted ingress IPv4', true, '203.0.113.7', '203.0.113.7'],
['trusted ingress IPv6', true, '2001:db8::7', '2001:db8::7'],
['invalid forwarding header', true, 'not-an-ip', '127.0.0.1']
]) {
test(`records source IP from ${label} without exposing it through restore`, async t => {
const api = await startApi({ trustedProxy })
t.after(() => api.close())
const fixture = backupFixture()
const response = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(fixture.payload) })
assert.equal(response.status, 201)
const filename = join(api.rootDir, `${fixture.payload.id}.json`)
const original = await readFile(filename, 'utf8')
const stored = JSON.parse(original)
assert.equal(stored.sourceIp, expected)
assert(Number.isFinite(Date.parse(stored.createdAt)))
const duplicate = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '192.0.2.9' }, body: JSON.stringify(fixture.payload) })
assert.equal(duplicate.status, 409)
assert.equal(await readFile(filename, 'utf8'), original)
const restore = await request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: fixture.payload.id }) })
assert.deepEqual(await restore.json(), { blob: fixture.payload.blob })
const forged = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...backupFixture().payload, sourceIp: '192.0.2.99' }) })
assert.equal(forged.status, 400)
})
}
test('health endpoint rejects an unusable storage path', async t => {
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
const rootDir = join(container, 'not-a-directory')
await writeFile(rootDir, 'occupied')
const server = createBackupServer({ rootDir, allowedOrigins: [allowedOrigin] })
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const baseUrl = `http://127.0.0.1:${server.address().port}`
t.after(async () => {
await new Promise(resolve => server.close(resolve))
await rm(container, { recursive: true, force: true })
})
const response = await fetch(`${baseUrl}/health`)
assert.equal(response.status, 503)
assert.deepEqual(await response.json(), { status: 'unavailable' })
})
test('creates and retrieves an immutable opaque backup across server restarts', async t => {
const api = await startApi()
const fixture = backupFixture()
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', origin: allowedOrigin },
body: JSON.stringify(fixture.payload)
})
assert.equal(created.status, 201)
assert.deepEqual(await created.json(), { created: true })
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 retrieved = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: allowedOrigin },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.equal(retrieved.status, 200)
assert.deepEqual(await retrieved.json(), { blob: fixture.payload.blob })
const duplicate = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: allowedOrigin },
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(96).toString('base64url') })
})
assert.equal(duplicate.status, 409)
const unchanged = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.deepEqual(await unchanged.json(), { blob: fixture.payload.blob })
})
test('validates IDs, verifiers, content type, JSON and opaque blob encoding', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
const invalidPayloads = [
{ ...fixture.payload, id: 'short' },
{ ...fixture.payload, blob: 'not+base64url' },
{ ...fixture.payload, deleteVerifier: 'short' },
{ id: fixture.payload.id, blob: fixture.payload.blob },
{ ...fixture.payload, extra: true }
]
for (const payload of invalidPayloads) {
const response = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
assert.equal(response.status, 400)
}
const malformed = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{'
})
assert.equal(malformed.status, 400)
const wrongType = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: JSON.stringify(fixture.payload)
})
assert.equal(wrongType.status, 415)
})
test('accepts a 256 KiB decoded blob and rejects one byte more', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
const accepted = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(262_144).toString('base64url') })
})
const oversizedFixture = backupFixture()
const rejected = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...oversizedFixture.payload, blob: randomBytes(262_145).toString('base64url') })
})
assert.equal(accepted.status, 201)
assert.equal(rejected.status, 413)
})
test('responds before an oversized request body finishes streaming', async t => {
const api = await startApi()
t.after(() => api.close())
const url = new URL('/v1/backups', api.baseUrl)
const client = createHttpRequest(url, {
method: 'POST',
headers: { 'content-type': 'application/json' }
})
t.after(() => client.destroy())
const responsePromise = new Promise((resolve, reject) => {
client.once('response', resolve)
client.once('error', reject)
})
client.write('A'.repeat(393_217))
const response = await Promise.race([
responsePromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not reject streaming body')), 1_000))
])
assert.equal(response.statusCode, 413)
response.resume()
})
test('deletes only with the matching client secret and uses constant not-found responses', async t => {
const api = await startApi()
t.after(() => api.close())
const fixture = backupFixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.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 unauthorized = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
const wrongSecret = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
})
assert.equal(missing.status, 404)
assert.equal(unauthorized.status, 404)
assert.equal(wrongSecret.status, 404)
const missingBody = await missing.text()
const unauthorizedBody = await unauthorized.text()
const wrongSecretBody = await wrongSecret.text()
assert.equal(missingBody, unauthorizedBody)
assert.equal(unauthorizedBody, wrongSecretBody)
assert.equal(wrongSecretBody, '{"error":"not_found"}')
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: fixture.deleteSecret })
})
assert.equal(deleted.status, 204)
assert.equal((await readdir(api.rootDir)).length, 0)
const afterDelete = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: fixture.payload.id })
})
assert.equal(afterDelete.status, 404)
})
test('allows only configured browser 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)
assert.equal(allowed.headers.get('vary'), 'Origin')
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
assert.equal(denied.status, 403)
assert.equal(denied.headers.get('access-control-allow-origin'), null)
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)
assert.equal(preflight.headers.get('access-control-allow-origin'), allowedOrigin)
assert.match(preflight.headers.get('access-control-allow-methods'), /POST/)
})
test('rate limits backup routes without limiting health checks', async t => {
const api = await startApi({ trustedProxy: true, rateLimit: { max: 2, windowMs: 60_000 } })
t.after(() => api.close())
const restore = (id, address) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': address },
body: JSON.stringify({ id })
})
const first = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const independent = await restore(randomBytes(16).toString('base64url'), '198.51.100.2')
const second = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const limited = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
const health = await request(api, '/health')
assert.equal(first.status, 404)
assert.equal(independent.status, 404)
assert.equal(second.status, 404)
assert.equal(limited.status, 429)
assert.equal(limited.headers.get('retry-after'), '60')
assert.equal(health.status, 200)
})
test('enforces an atomic global 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)
})
const secondBaseUrl = `http://127.0.0.1:${secondServer.address().port}`
t.after(() => api.close())
t.after(() => new Promise(resolve => secondServer.close(resolve)))
const first = backupFixture()
const second = backupFixture()
const create = (fixture, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const results = await Promise.all([create(first), create(second, secondBaseUrl)])
assert.deepEqual(results.map(response => response.status).sort(), [201, 507])
const stored = results[0].status === 201 ? first : second
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: stored.payload.id })
})
assert.equal(restored.status, 200)
const storedFiles = await readdir(api.rootDir)
assert.equal(storedFiles.length, 1)
assert.match(storedFiles[0], /^[A-Za-z0-9_-]{22}\.json$/)
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: stored.payload.id, deleteSecret: stored.deleteSecret })
})
assert.equal(deleted.status, 204)
const blocked = stored === first ? second : first
assert.equal((await create(blocked)).status, 201)
})
test('limits anonymous uploads separately while keeping restores available', async t => {
const api = await startApi({
rateLimit: { max: 2, windowMs: 60_000 },
uploadRateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const first = backupFixture()
const second = backupFixture()
const create = fixture => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.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)
})
test('never takes over an old storage lock that may still have an active owner', async t => {
const api = await startApi()
t.after(() => api.close())
const lockPath = join(api.rootDir, '.storage.lock')
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: lockPath,
stale: 30_000,
update: 10_000
})
t.after(() => release().catch(() => {}))
const fixture = backupFixture()
const pending = request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const early = await Promise.race([
pending.then(() => 'responded'),
new Promise(resolve => setTimeout(() => resolve('waiting'), 100))
])
assert.equal(early, 'waiting')
await release()
assert.equal((await pending).status, 201)
})
test('recovers a storage lock left by a terminated process', async t => {
const api = await startApi()
t.after(() => api.close())
const lockPath = join(api.rootDir, '.storage.lock')
await mkdir(lockPath)
const old = new Date(Date.now() - 60_000)
await utimes(lockPath, old, old)
const fixture = backupFixture()
const pending = request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const early = await Promise.race([
pending.then(response => response.status),
new Promise(resolve => setTimeout(() => resolve('timeout'), 500))
])
await pending
assert.equal(early, 201)
assert.equal((await readdir(api.rootDir)).some(name => name.includes('.stale.')), false)
})
test('revalidates delete authorization inside the storage lock', async t => {
const api = await startApi()
t.after(() => api.close())
const original = backupFixture()
const replacement = backupFixture()
replacement.payload.id = original.payload.id
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(original.payload)
})
const lockPath = join(api.rootDir, '.storage.lock')
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: lockPath,
stale: 30_000,
update: 10_000
})
t.after(() => release().catch(() => {}))
const pendingDelete = request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: original.payload.id, deleteSecret: original.deleteSecret })
})
await new Promise(resolve => setTimeout(resolve, 50))
await writeFile(join(api.rootDir, `${original.payload.id}.json`), JSON.stringify({
version: 1,
blob: replacement.payload.blob,
deleteVerifier: replacement.payload.deleteVerifier,
createdAt: new Date().toISOString()
}))
await release()
assert.equal((await pendingDelete).status, 404)
const restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: original.payload.id })
})
assert.equal(restored.status, 200)
assert.deepEqual(await restored.json(), { blob: replacement.payload.blob })
})
test('never accepts backup IDs in URLs', async t => {
const api = await startApi()
t.after(() => api.close())
const id = randomBytes(16).toString('base64url')
const read = await request(api, `/v1/backups/${id}`)
const remove = await request(api, `/v1/backups/${id}`, { method: 'DELETE' })
const query = await request(api, `/v1/backups?backup=${id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backupFixture().payload)
})
assert.equal(read.status, 404)
assert.equal(remove.status, 404)
assert.equal(query.status, 404)
})
test('stored records contain no delete secret and operational logs contain no IDs or blobs', async t => {
const messages = []
const api = await startApi({ logger: message => messages.push(String(message)) })
t.after(() => api.close())
const fixture = backupFixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(fixture.payload)
})
const files = await readdir(api.rootDir)
assert.equal(files.length, 1)
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
assert.equal(stored.includes(fixture.deleteSecret), false)
assert.equal(messages.some(message => message.includes(fixture.payload.id)), false)
assert.equal(messages.some(message => message.includes(fixture.payload.blob)), false)
})