import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { io } from 'socket.io-client'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; import { Administration } from '../src/administration.js'; const actorHeaders = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR' }; const headers = (ip = '8.8.8.8', country = 'KR') => ({ 'cf-connecting-ip': ip, ...(country ? { 'cf-ipcountry': country } : {}), }); async function login(f, email = 'hello@noname.company') { const c = await f.create(); f.service.db .insert(s.users) .values({ id: randomUUID(), email, displayName: email, passwordHash: await f.service.auth.password('12345678'), status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' }); assert.equal(response.statusCode, 200, response.body); c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0]; return c; } async function patch(f, admin, body) { const response = await f.call( admin, '/admin/settings', 'PATCH', { revision: f.service.admin.state.revision, ...body }, undefined, actorHeaders, ); assert.equal(response.statusCode, 200, response.body); return response.json(); } async function autoFixture(settings = {}) { const f = await fixture(), admin = await login(f), guest = await f.create(); f.cfg.clientIpSource = 'cloudflare'; await patch(f, admin, { autoBlockEnabled: true, autoLoginFailures: 3, autoRateViolations: 3, ...settings, }); return { f, admin, guest }; } test( 'existing saved settings retain blacklist rules while new whitelist and automatic protection default to empty/off', { timeout: 20000 }, async () => { const f = await fixture(); try { const legacy = { revision: 4, uploadsPaused: false, maxConcurrentUploads: 3, totalStorageLimitMb: 12000, blockedCountries: ['JP'], blockedIps: ['1.1.1.0/24'], }; f.service.db .insert(s.adminSettings) .values({ name: 'controls', value: JSON.stringify(legacy), updatedAt: Date.now() }) .run(); const restored = new Administration(f.service); assert.equal(restored.state.revision, 4); assert.deepEqual(restored.state.blockedCountries, ['JP']); assert.equal(restored.state.blacklistEnabled, true); assert.equal(restored.state.autoBlockEnabled, false); assert.deepEqual(restored.state.allowedCountries, []); assert.deepEqual(restored.state.allowedIps, []); } finally { await f.close(); } }, ); test( 'whitelist is an OR of countries and IP ranges, excludes unknown origins, preserves blacklist priority and prevents operator lockout', { timeout: 20000 }, async () => { const f = await fixture(); try { const admin = await login(f), guest = await f.create(); f.cfg.clientIpSource = 'cloudflare'; await patch(f, admin, { allowedCountries: ['KR'], allowedIps: ['1.1.1.0/24', '2001:db8::/32'], blockedIps: ['1.1.1.5'], }); for (const [ip, country, status] of [ ['8.8.8.8', 'KR', 200], ['1.1.1.6', 'JP', 200], ['9.9.9.9', 'JP', 403], ['1.1.1.5', 'JP', 403], ['9.9.9.9', null, 403], ]) assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers(ip, country))) .statusCode, status, ); assert.equal(f.service.admin.policyDenied({ ip: '2001:db8::123', countryCode: null }), false); assert.equal(f.service.admin.policyDenied({ ip: '2001:db9::123', countryCode: null }), true); const denied = await f.call( admin, '/admin/settings', 'PATCH', { revision: f.service.admin.state.revision, allowedCountries: ['JP'], allowedIps: [] }, undefined, actorHeaders, ); assert.equal(denied.json().code, 'ADMIN_SELF_BLOCK'); assert.deepEqual(f.service.admin.state.allowedCountries, ['KR']); assert.equal( (await f.call(null, '/health')).statusCode, 200, 'local container health checks survive a country-only whitelist', ); assert.equal( (await f.call(guest, '/health', 'GET', undefined, undefined, headers('9.9.9.9', 'JP'))) .statusCode, 403, 'proxied public health requests still follow the whitelist', ); await patch(f, admin, { blacklistEnabled: false }); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.5', 'JP'))) .statusCode, 200, ); await patch(f, admin, { allowedCountries: [], allowedIps: [] }); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('9.9.9.9', null))) .statusCode, 200, ); const invalid = await f.call( admin, '/admin/network-rules/validate', 'POST', { kind: 'ip', value: '1.1.1.999/24' }, undefined, actorHeaders, ); assert.equal(invalid.statusCode, 400); const normalized = await f.call( admin, '/admin/network-rules/validate', 'POST', { kind: 'country', value: 'jp' }, undefined, actorHeaders, ); assert.deepEqual(normalized.json(), { kind: 'country', value: 'JP' }); assert.equal((await f.call(guest, '/admin/automatic-blocks')).statusCode, 401); assert.equal( ( await f.call(guest, '/admin/network-rules/validate', 'POST', { kind: 'country', value: 'KR', }) ).statusCode, 401, ); } finally { await f.close(); } }, ); test( 'activating a whitelist disconnects existing excluded WebSockets and denies a new handshake', { timeout: 20000 }, async () => { const f = await fixture(); let socket; try { const admin = await login(f), guest = await f.create(); f.cfg.clientIpSource = 'cloudflare'; socket = io(f.url, { transports: ['websocket'], auth: { csrfToken: guest.csrf }, extraHeaders: { Origin: 'http://hop.test', Cookie: guest.cookie, ...headers('1.1.1.1', 'JP'), }, reconnection: false, }); await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('connect_error', reject); }); const disconnected = new Promise((resolve) => socket.once('disconnect', resolve)); await patch(f, admin, { allowedCountries: ['KR'] }); await disconnected; assert.equal(socket.connected, false); const failed = new Promise((resolve) => socket.once('connect_error', resolve)); socket.connect(); await failed; assert.equal(socket.connected, false); } finally { socket?.disconnect(); await f.close(); } }, ); test( 'automatic login bans persist, can be recovered by an existing verified admin, and unblock/expiry restore access', { timeout: 20000 }, async () => { const { f, admin, guest } = await autoFixture(); try { for (let i = 0; i < 3; i++) { const response = await f.call( guest, '/auth/login', 'POST', { email: 'missing@hop.test', password: 'incorrect' }, undefined, headers(), ); assert.equal(response.json().code, 'LOGIN_FAILED'); } assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 403, ); assert.equal(new Administration(f.service).automatic.has('8.8.8.8'), true); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.1'))) .statusCode, 200, ); assert.equal( (await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode, 200, 'admin recovery session remains usable', ); const list = ( await f.call( admin, '/admin/automatic-blocks?q=8.8', 'GET', undefined, undefined, actorHeaders, ) ).json(); assert.equal(list.total, 1); assert.equal(list.items[0].reason, 'login-failures'); const removal = await f.call( admin, '/admin/automatic-blocks/remove', 'POST', { ips: ['8.8.8.8'] }, undefined, actorHeaders, ); assert.equal(removal.statusCode, 200); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 200, ); const fake = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() }; for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(fake, 'login'); f.service.admin.automatic.active.get('8.8.8.8').expiresAt = Date.now() - 1; f.service.db .update(s.automaticBlocks) .set({ expiresAt: Date.now() - 1 }) .run(); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 200, ); f.service.admin.automatic.prune(); assert.equal(f.service.db.select().from(s.automaticBlocks).all().length, 0); await patch(f, admin, { blockedIps: ['8.8.8.8'] }); assert.equal( (await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode, 403, 'admin sessions never bypass manual blacklist', ); } finally { await f.close(); } }, ); test( 'repeated file attempts and declared byte budgets cover idempotent retries and multiple guest sessions on the same IP', { timeout: 20000 }, async () => { const { f, admin, guest } = await autoFixture({ autoUploadRequests: 3 }); try { const receiver = await f.create(); await f.space(guest, receiver); const input = { targetDeviceId: receiver.deviceId, kind: 'file', count: 1, byteSize: 4, files: [{ name: 'a.txt', size: 4, type: 'text/plain' }], }, key = randomUUID(); const first = await f.call(guest, '/transfer-requests', 'POST', input, key, headers()); assert.equal(first.statusCode, 200, first.body); for (let i = 0; i < 4; i++) assert.equal( (await f.call(guest, '/transfer-requests', 'POST', input, key, headers())).statusCode, 200, ); assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|upload').count, 1); await f.call(guest, '/transfer-requests', 'POST', input, randomUUID(), headers()); const other = await f.create(); await f.call(other, '/transfer-requests', 'POST', input, randomUUID(), headers()); const blocked = await f.call( other, '/transfer-requests', 'POST', input, randomUUID(), headers(), ); assert.equal(blocked.statusCode, 403); assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-requests'); await f.call( admin, '/admin/automatic-blocks/remove', 'POST', { ips: ['8.8.8.8'] }, undefined, actorHeaders, ); await patch(f, admin, { autoUploadRequests: 30, autoUploadBytes: 1000000 }); const huge = { ...input, byteSize: 1000001, files: [{ name: 'a.mp4', size: 1000001, type: 'video/mp4' }], }; const volume = await f.call( other, '/transfer-requests', 'POST', huge, randomUUID(), headers(), ); assert.equal(volume.statusCode, 403); assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume'); assert.equal(f.service.storage.fileRows(first.json().requestId).length, 1); } finally { await f.close(); } }, ); test( 'automatic rate protection counts deliberate limit violations but ignores normal chunk requests and untrusted origin headers', { timeout: 20000 }, async () => { const { f, guest } = await autoFixture(); try { for (let i = 0; i < 240; i++) f.service.limiter.hit('api-ip:8.8.8.8', 240); for (let i = 0; i < 3; i++) assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 429, ); assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'rate-violations'); const fake = { raw: { socket: { remoteAddress: '10.0.0.2' } }, headers: headers('1.1.1.1') }; for (let i = 0; i < 4; i++) assert.equal(f.service.admin.automatic.observe(fake, 'login'), false); assert.equal(f.service.admin.automatic.has('1.1.1.1'), false); for (let i = 0; i < 6000; i++) f.service.limiter.hit('chunk-ip:9.9.9.9', 6000); for (let i = 0; i < 4; i++) assert.equal( ( await f.call( guest, `/uploads/resumable/${randomUUID()}`, 'PUT', Buffer.from('x'), undefined, { 'content-type': 'application/octet-stream', ...headers('9.9.9.9') }, ) ).statusCode, 429, ); assert.equal(f.service.admin.automatic.has('9.9.9.9'), false); } finally { await f.close(); } }, ); test( 'signed-in vault file requests share the same IP byte budget and deduplicate retry keys', { timeout: 20000 }, async () => { const { f, guest } = await autoFixture({ autoUploadBytes: 1000000 }); try { const user = await login(f, 'user@hop.test'), key = randomUUID(), body = { kind: 'file', files: [{ name: 'vault.txt', size: 600000, type: 'text/plain' }] }; const first = await f.call(user, '/vault', 'POST', body, key, headers()); assert.equal(first.statusCode, 200, first.body); assert.equal((await f.call(user, '/vault', 'POST', body, key, headers())).statusCode, 200); const second = await f.call( user, '/vault', 'POST', { kind: 'file', files: [{ name: 'second.txt', size: 500000, type: 'text/plain' }] }, randomUUID(), headers(), ); assert.equal(second.statusCode, 403); assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume'); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 403, ); } finally { await f.close(); } }, ); test( 'successful login resets only login failures, automatic disable preserves recoverable entries, and reenabling applies them', { timeout: 20000 }, async () => { const { f, admin, guest } = await autoFixture(); try { await login(f, 'success@hop.test'); const req = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() }; f.service.admin.automatic.observe(req, 'login'); f.service.admin.automatic.observe(req, 'rate'); assert.equal( ( await f.call( guest, '/auth/login', 'POST', { email: 'success@hop.test', password: '12345678' }, undefined, headers(), ) ).statusCode, 200, ); assert.equal(f.service.admin.automatic.counters.has('8.8.8.8|login'), false); assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|rate').count, 1); for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(req, 'login'); await patch(f, admin, { autoBlockEnabled: false }); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 200, ); assert.equal(f.service.admin.automatic.has('8.8.8.8'), true); await patch(f, admin, { autoBlockEnabled: true }); assert.equal( (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode, 403, ); } finally { await f.close(); } }, );