import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash, randomUUID } from 'node:crypto'; import { statfsSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import Database from 'better-sqlite3'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; import { Administration } from '../src/administration.js'; import { CHUNK_BYTES } from '../src/resumable.js'; async function signed(f, email) { const client = await f.create(), id = randomUUID(); f.service.db .insert(s.users) .values({ id, email, displayName: email, passwordHash: await f.service.auth.password('12345678'), status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' }); assert.equal(response.statusCode, 200, response.body); client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0]; return client; } async function patch(f, admin, values) { const response = await f.call(admin, '/admin/settings', 'PATCH', { revision: f.service.admin.state.revision, ...values, }); assert.equal(response.statusCode, 200, response.body); return response.json(); } async function request(f, sender, receiver, bytes = 8) { return f.call( sender, '/transfer-requests', 'POST', { targetDeviceId: receiver.deviceId, kind: 'file', count: 1, byteSize: bytes, files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }], }, randomUUID(), ); } async function accept(f, receiver, response) { assert.equal(response.statusCode, 200, response.body); const id = response.json().requestId; const result = await f.call( receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID(), ); assert.equal(result.statusCode, 200, result.body); return id; } const own = (f, sender, bytes = 8) => f.call( sender, '/vault', 'POST', { kind: 'file', files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }] }, randomUUID(), ); async function upload(f, sender, id, bytes) { return f.call( sender, `/uploads?requestId=${id}&fileIndex=0`, 'POST', Buffer.concat([ Buffer.from( '--quota\r\nContent-Disposition: form-data; name="file"; filename="quota.txt"\r\nContent-Type: text/plain\r\n\r\n', ), bytes, Buffer.from('\r\n--quota--\r\n'), ]), randomUUID(), { 'content-type': 'multipart/form-data; boundary=quota' }, ); } test('administrator limits persist, report to clients, reject invalid updates and enforce single-file/account quotas immediately', async () => { const f = await fixture(false, undefined, { maxFileBytes: 4 }); try { const admin = await signed(f, 'hello@noname.company'), user = await signed(f, 'quota@hop.test'), a = await f.create(), b = await f.create(); await f.space(a, b); assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT'); assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT'); const updated = await patch(f, admin, { perFileBytes: 16, accountBytes: 12, deviceBytes: 24, devicesTotalBytes: 32, minDiskFreeBytes: 0, }); const restored = new Administration(f.service); for (const key of [ 'perFileBytes', 'accountBytes', 'deviceBytes', 'devicesTotalBytes', 'minDiskFreeBytes', ]) assert.equal(restored.state[key], updated[key]); assert.equal((await f.call(user, '/account/storage')).json().limitBytes, 12); const id = await accept(f, b, await request(f, a, b)); // Runtime increase must also override the original multipart plugin's 4-byte limit. assert.equal((await upload(f, a, id, Buffer.from('12345678'))).statusCode, 200); const saved = await own(f, user); assert.equal(saved.statusCode, 200, saved.body); assert.equal( (await own(f, user)).json().code, 'ACCOUNT_QUOTA', 'pending reservations count toward the account limit', ); await patch(f, admin, { accountBytes: 24 }); assert.equal((await own(f, user)).statusCode, 200); assert.equal( (await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode, 200, ); const list = (await f.call(admin, '/admin/files')).json().items; const accountFile = list.find((file) => file.uploader?.email === 'quota@hop.test'); assert.ok(accountFile); assert.equal(accountFile.path.split('/')[0], accountFile.uploader.id); assert.equal(accountFile.uploader.kind, 'user'); assert.equal('name' in accountFile.uploader, false); const guestFile = list.find((file) => file.uploader?.kind === 'guest'); assert.equal(guestFile.uploader.email, null); assert.equal(guestFile.path.split('/')[0], guestFile.uploader.id); const before = f.service.admin.state.revision; for (const invalid of [ { perFileBytes: 0 }, { accountBytes: -1 }, { deviceBytes: 1.2 }, { devicesTotalBytes: 1e12 + 1 }, { minDiskFreeBytes: -1 }, ]) { assert.equal( (await f.call(admin, '/admin/settings', 'PATCH', { revision: before, ...invalid })) .statusCode, 400, ); assert.equal(f.service.admin.state.revision, before); } const audits = (await f.call(admin, '/admin/audit')).json().events; assert.ok( audits.some((event) => event.details.changes?.some( (change) => change.field === 'perFileBytes' && change.before === 4 && change.after === 16, ), ), ); } finally { await f.close(); } }); test('administrator device, combined device and minimum disk limits govern new reservations without changing existing files', async () => { const f = await fixture(); try { const admin = await signed(f, 'hello@noname.company'), a = await f.create(), b = await f.create(); await f.space(a, b); await patch(f, admin, { deviceBytes: 4 }); let pending = await request(f, a, b); assert.equal( ( await f.call( b, `/transfer-requests/${pending.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).json().code, 'GUEST_QUOTA', ); await patch(f, admin, { deviceBytes: 16, devicesTotalBytes: 4 }); assert.equal( ( await f.call( b, `/transfer-requests/${pending.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).json().code, 'TOTAL_QUOTA', ); await patch(f, admin, { devicesTotalBytes: 24 }); const disk = statfsSync(f.cfg.uploadDir), free = disk.bavail * disk.bsize; if (free + 1 <= 1e12) { await patch(f, admin, { minDiskFreeBytes: free + 1 }); assert.equal( ( await f.call( b, `/transfer-requests/${pending.json().requestId}/accept`, 'POST', {}, randomUUID(), ) ).json().code, 'DISK_FULL', ); } await patch(f, admin, { minDiskFreeBytes: 0 }); const id = await accept(f, b, pending); await patch(f, admin, { deviceBytes: 1, devicesTotalBytes: 1, perFileBytes: 1 }); assert.equal( (await upload(f, a, id, Buffer.from('12345678'))).statusCode, 200, 'accepted reservation survives reduced limits', ); const item = (await f.call(b, '/device/items')) .json() .items.find((item) => item.file?.name === 'quota.txt'); assert.deepEqual( (await f.call(b, `/access/${item.accessId}/download`)).rawPayload, Buffer.from('12345678'), ); assert.equal(f.service.admin.summary().storedFiles, 1); } finally { await f.close(); } }); test('resumable offsets and completion honor the accepted manifest after reducing the file and account limits', async () => { const f = await fixture(false, undefined, { maxFileBytes: 4 }); try { const admin = await signed(f, 'hello@noname.company'), user = await signed(f, 'resume@hop.test'); await patch(f, admin, { perFileBytes: CHUNK_BYTES + 7, accountBytes: CHUNK_BYTES + 7 }); const response = await own(f, user, CHUNK_BYTES + 7), bytes = Buffer.alloc(CHUNK_BYTES + 7, 120); assert.equal(response.statusCode, 200, response.body); const requestId = response.json().requestId, hash = (value) => createHash('sha256').update(value).digest(), fingerprint = hash( Buffer.concat([hash(bytes.subarray(0, CHUNK_BYTES)), hash(bytes.subarray(CHUNK_BYTES))]), ).toString('hex'); const started = await f.call(user, '/uploads/resumable', 'POST', { requestId, fileIndex: 0, fingerprint, }); assert.equal(started.statusCode, 200, started.body); const id = started.json().uploadId; const put = (offset, body) => f.call(user, `/uploads/resumable/${id}`, 'PUT', body, undefined, { 'content-type': 'application/octet-stream', 'upload-offset': String(offset), }); assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200); await patch(f, admin, { perFileBytes: 1, accountBytes: 1 }); assert.equal( (await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode, 200, 'offset is checked against manifest, not a stale startup limit', ); const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {}); assert.equal(completed.statusCode, 200, completed.body); assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length); assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT'); } finally { await f.close(); } }); test('offline transfer reset removes only transfer/guest data and keeps accounts, authentication, settings and user information', async () => { const f = await fixture(), folder = await mkdtemp(resolve(tmpdir(), 'hop-reset-test-')); let database; try { const admin = await signed(f, 'hello@noname.company'), user = await signed(f, 'keep@hop.test'); const saved = await own(f, user); assert.equal( (await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode, 200, ); await patch(f, admin, { accountBytes: 3e9 }); const path = resolve(folder, 'snapshot.sqlite'); await f.sqlite.backup(path); database = new Database(path); const kept = [ 'users', 'account_sessions', 'account_tokens', 'user_rules', 'user_activity', 'admin_settings', 'admin_audit', '__drizzle_migrations', ]; const before = kept.map((table) => JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()), ); const script = fileURLToPath( new URL('../../../scripts/reset-transfer-records.mjs', import.meta.url), ); const denied = spawnSync(process.execPath, [script], { env: { ...process.env, DATABASE_PATH: path }, encoding: 'utf8', }); assert.notEqual(denied.status, 0); assert.equal(database.prepare('SELECT count(*) AS count FROM files').get().count, 1); const cleared = spawnSync(process.execPath, [script, '--confirm-reset-transfer-records'], { env: { ...process.env, DATABASE_PATH: path }, encoding: 'utf8', }); assert.equal(cleared.status, 0, cleared.stderr); for (const table of [ 'files', 'items', 'item_access', 'transfer_requests', 'transfer_batches', 'quota_reservations', 'file_upload_contexts', 'guest_sessions', 'browser_devices', 'idempotency_records', ]) assert.equal( database.prepare(`SELECT count(*) AS count FROM "${table}"`).get().count, 0, table, ); kept.forEach((table, index) => assert.equal( JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()), before[index], table, ), ); assert.deepEqual(database.pragma('foreign_key_check'), []); assert.equal(f.service.admin.summary().storedFiles, 1, 'only isolated DB copy is reset'); } finally { database?.close(); await f.close(); await rm(folder, { recursive: true, force: true }); } });