import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; import { auditChangeLines, formatCountry } from '../../web/src/displayFormat.js'; async function account(f, email) { 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, userId: response.json().account.id, email }; } const h = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR', 'cf-ipcity': 'Daejeon' }; test( 'audit shows only differences, skips no-op saves, compares compatible old entries and supports individual/all deletion with authorization', { timeout: 20000 }, async () => { const f = await fixture(); try { const admin = await account(f, 'hello@noname.company'), user = await account(f, 'user@hop.test'); let response = await f.call(admin, '/admin/settings', 'PATCH', { revision: 0, maxConcurrentUploads: 5, }); assert.equal(response.statusCode, 200); response = await f.call(admin, '/admin/settings', 'PATCH', { revision: 1, maxConcurrentUploads: 10, blockedCountries: ['CN'], }); assert.equal(response.statusCode, 200); const events = (await f.call(admin, '/admin/audit')).json().events; const latest = events.find((e) => e.details.changes?.some((c) => c.after === 10)); assert.deepEqual(latest.details.changes, [ { field: 'maxConcurrentUploads', before: 5, after: 10 }, { field: 'blockedCountries', added: ['CN'], removed: [] }, ]); assert.deepEqual(latest.details.changes.flatMap(auditChangeLines), [ '동시에 처리할 업로드 수 5 > 10', '블랙리스트 추가: 중국(CN)', ]); const count = events.length; await f.call(admin, '/admin/settings', 'PATCH', { revision: 2, maxConcurrentUploads: 10 }); assert.equal((await f.call(admin, '/admin/audit')).json().events.length, count); assert.equal( (await f.call(user, '/admin/audit/remove', 'POST', { all: true })).statusCode, 403, ); assert.equal( (await f.call(admin, '/admin/audit/remove', 'POST', { ids: [latest.id] })).statusCode, 200, ); assert.equal( (await f.call(admin, '/admin/audit')).json().events.some((e) => e.id === latest.id), false, ); const before = { uploadsPaused: false, maxConcurrentUploads: 5, blockedCountries: [], blockedIps: [], totalStorageLimitMb: 20480, }; for (const [offset, body] of [ [2000, before], [1000, { ...before, maxConcurrentUploads: 6 }], ]) f.service.db .insert(s.adminAudit) .values({ id: randomUUID(), actorUserId: admin.userId, action: 'settings-change', details: JSON.stringify(body), createdAt: Date.now() - offset, }) .run(); const old = (await f.call(admin, '/admin/audit')) .json() .events.find((e) => e.details.legacy && e.details.changes.length); assert.deepEqual(old.details.changes, [ { field: 'maxConcurrentUploads', before: 5, after: 6 }, ]); assert.equal( (await f.call(admin, '/admin/audit/remove', 'POST', { all: true })).statusCode, 200, ); assert.deepEqual((await f.call(admin, '/admin/audit')).json().events, []); assert.equal( f.service.admin.state.maxConcurrentUploads, 10, 'deleting audit entries never changes settings', ); assert.equal(formatCountry('JP'), '일본(JP)'); } finally { await f.close(); } }, ); test( 'user list keeps basic last-seen country/IP with throttled updates, expires location data and never exposes credentials', { timeout: 20000 }, async () => { const f = await fixture(); try { const admin = await account(f, 'hello@noname.company'), user = await account(f, 'user@hop.test'), guest = await f.create(); f.cfg.clientIpSource = 'cloudflare'; await f.call(user, '/auth/me', 'GET', undefined, undefined, h); const first = f.service.db .select() .from(s.userActivity) .where(eq(s.userActivity.userId, user.userId)) .get(); await f.call(user, '/auth/me', 'GET', undefined, undefined, h); assert.equal( f.service.db .select() .from(s.userActivity) .where(eq(s.userActivity.userId, user.userId)) .get().lastSeenAt, first.lastSeenAt, ); const response = await f.call( admin, '/admin/users?q=user@hop.test', 'GET', undefined, undefined, h, ), list = response.json().users; assert.equal(list.length, 1); assert.equal(list[0].email, user.email); assert.equal(list[0].countryCode, 'KR'); assert.equal(list[0].city, 'Daejeon'); assert.equal(list[0].ip, '8.8.4.4'); assert.equal(response.body.includes('passwordHash'), false); assert.equal(response.body.includes('tokenHash'), false); assert.equal((await f.call(user, '/admin/users')).statusCode, 403); assert.equal((await f.call(guest, '/admin/users')).statusCode, 401); f.service.db .update(s.userActivity) .set({ lastSeenAt: Date.now() - 31 * 86400000 }) .where(eq(s.userActivity.userId, user.userId)) .run(); const expired = ( await f.call(admin, '/admin/users?q=user@hop.test', 'GET', undefined, undefined, h) ).json().users[0]; assert.equal(expired.ip, null); assert.equal(expired.countryCode, null); assert.ok(expired.lastSeenAt); f.service.admin.cleanup(); assert.equal( f.service.db .select() .from(s.userActivity) .where(eq(s.userActivity.userId, user.userId)) .get().city, null, ); } finally { await f.close(); } }, ); test( 'admin reset sends a one-use email link; password and sessions change only when the user completes it', { timeout: 20000 }, async () => { const mail = []; const f = await fixture(false, async (message) => mail.push(message)); try { const admin = await account(f, 'hello@noname.company'), user = await account(f, 'user@hop.test'); assert.equal( (await f.call(user, `/admin/users/${admin.userId}/password-reset`, 'POST', {})).statusCode, 403, ); assert.equal( (await f.call(admin, `/admin/users/${user.userId}/password-reset`, 'POST', {})).statusCode, 200, ); assert.equal(mail.length, 1); assert.equal(mail[0].to, user.email); assert.ok((await f.call(user, '/auth/me')).json().account); const token = /#reset-password=([^\s]+)/.exec(mail[0].text)[1]; const reset = await f.call(user, '/auth/reset-password', 'POST', { token, password: 'new-password', passwordConfirmation: 'new-password', }); assert.equal(reset.statusCode, 200, reset.body); assert.equal((await f.call(user, '/auth/me')).json().account, null); const guest = await f.create(); assert.equal( (await f.call(guest, '/auth/login', 'POST', { email: user.email, password: '12345678' })) .statusCode, 401, ); assert.equal( ( await f.call(guest, '/auth/login', 'POST', { email: user.email, password: 'new-password', }) ).statusCode, 200, ); assert.equal( ( await f.call(guest, '/auth/reset-password', 'POST', { token, password: 'again-password', passwordConfirmation: 'again-password', }) ).statusCode, 400, ); } finally { await f.close(); } }, ); test( 'admin deletion confirms email, protects the administrator, revokes credentials and preserves shared recipient files', { timeout: 20000 }, async () => { const f = await fixture(); try { const admin = await account(f, 'hello@noname.company'), user = await account(f, 'user@hop.test'), recipient = await f.create(); const created = await f.call( user, '/vault', 'POST', { kind: 'file', files: [{ name: 'shared.txt', size: 4, type: 'text/plain' }] }, randomUUID(), ); assert.equal(created.statusCode, 200, created.body); const id = created.json().requestId, payload = Buffer.from( '--delete\r\nContent-Disposition: form-data; name="file"; filename="shared.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n', ); const uploaded = await f.call( user, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), { 'content-type': 'multipart/form-data; boundary=delete' }, ); assert.equal(uploaded.statusCode, 200, uploaded.body); const file = f.service.storage.fileRows(id)[0], item = f.service.db.select().from(s.items).where(eq(s.items.fileId, file.id)).get(), accessId = randomUUID(); f.service.db .insert(s.access) .values({ id: accessId, itemId: item.id, guestId: f.service.deviceGuest(recipient.deviceId).guestId, role: 'recipient', read: false, }) .run(); assert.equal( (await f.call(admin, `/admin/users/${admin.userId}/delete`, 'POST', { email: admin.email })) .statusCode, 403, ); assert.equal( ( await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', { email: 'wrong@hop.test', }) ).statusCode, 409, ); assert.equal( (await f.call(user, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email })) .statusCode, 403, ); assert.equal( (await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email })) .statusCode, 200, ); const deleted = f.service.auth.user(user.userId); assert.equal(deleted.status, 'deleted'); assert.equal(deleted.email, null); assert.equal(deleted.passwordHash, null); assert.equal((await f.call(user, '/auth/me')).json().account, null); assert.equal( (await f.call(admin, '/admin/users')).json().users.some((u) => u.id === user.userId), false, ); const download = await f.call(recipient, `/access/${accessId}/download`); assert.equal(download.statusCode, 200, download.body); assert.equal(download.body, 'test'); assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'account-delete'); } finally { await f.close(); } }, ); test( 'account deletion during password verification cannot issue a usable new login session', { timeout: 20000 }, async () => { const f = await fixture(); try { const user = await account(f, 'race@hop.test'); const login = f.service.auth.login(user.email, '12345678', '8.8.8.8'); f.service.db .update(s.users) .set({ status: 'deleted', email: null, passwordHash: null }) .where(eq(s.users.id, user.userId)) .run(); await assert.rejects(login, (error) => error.code === 'LOGIN_FAILED'); } finally { await f.close(); } }, );