470 lines
17 KiB
JavaScript
470 lines
17 KiB
JavaScript
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)
|
|
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, 2)
|
|
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('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)
|
|
})
|