import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { fixture, urlFor } from './helpers.js'; import type { Mail } from '../src/auth.js'; import { normalizeEmail } from '../src/auth.js'; import * as s from '../src/db/schema.js'; const password = 'correct horse battery staple'; test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => { const f = await fixture(); try { assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1); assert.match(await f.service.auth.password('1'), /^\$argon2id\$/); await assert.rejects(() => f.service.auth.password('')); f.cfg.production = true; assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12); await assert.rejects(() => f.service.auth.password('1234'), /12~1024/); assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/); } finally { await f.close(); } }); async function accountFixture() { const mails: Mail[] = []; const f = await fixture(false, async (mail) => { mails.push(mail); }); f.cfg.registrationMode = 'open'; return { ...f, mails }; } async function register( f: Awaited>, email: string, verify = true, ) { const c = await f.create(); const result = await f.call(c, '/auth/register', 'POST', { email, password, passwordConfirmation: password, displayName: email.split('@')[0], }); assert.equal(result.statusCode, 200, result.body); const mail = f.mails.find((m) => m.to === email)!; const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!; if (verify) assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200); const login = await f.call(c, '/auth/login', 'POST', { email, password }); assert.equal(login.statusCode, 200, login.body); c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; c.socket?.disconnect(); await f.connect(c); return { ...c, userId: login.json().account.id, token }; } async function request( f: Awaited>, a: any, email: string, text = 'remote text', ) { return legacyRequest(f, a, { email, kind: 'url', byteSize: Buffer.byteLength(urlFor(text)), count: 1, }); } // Reproduce requests already stored by the previous email system; this is not a public creation API. async function legacyRequest(f: any, a: any, input: any) { try { const result = f.service.accounts.create(a.userId, input); f.service.notifyTransfer(result.requestId); return { statusCode: 200, body: JSON.stringify(result), json: () => result }; } catch (error: any) { return { statusCode: error.status, body: error.message, json: () => ({ message: error.message }), }; } } test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만 저장, 인증과 로그인 회전', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com', false); assert.equal(f.mails.length, 1); const user = f.service.db.select().from(s.users).get()!; assert.match(user.passwordHash!, /^\$argon2id\$/); assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false); assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403); assert.equal( (await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, 200, ); assert.equal( (await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, 400, ); const oldCookie = a.cookie; const again = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password }); assert.equal(again.statusCode, 200); assert.equal((await f.call({ ...a, cookie: oldCookie }, '/auth/me')).json().account, null); assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId); } finally { await f.close(); } }); test('메일 미설정 상태에서 공개 가입을 열 수 없고 폐쇄 가입은 거절', async () => { const f = await fixture(); try { const a = await f.create(); const input = { email: 'alice@example.com', password, passwordConfirmation: password, displayName: 'Alice', }; assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 403); f.cfg.registrationMode = 'open'; assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 503); } finally { await f.close(); } }); test('기존 이메일 자료: 낯선 계정은 메타데이터 요청만 만들고 수락 후 실제 전달 및 허용 관계', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'), c = await register(f, 'charlie@example.com'); const response = await request(f, a, 'bob@example.com', '비공개 원격 본문'); assert.equal(response.statusCode, 200, response.body); assert.equal(response.json().requiresAcceptance, true); const id = response.json().requestId; assert.equal((await f.call(b, '/inbox')).json().items.length, 0); assert.equal( JSON.stringify((await f.call(b, '/transfer-requests?mode=email')).json()).includes( '비공개 원격 본문', ), false, ); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('비공개 원격 본문')] }, randomUUID(), ) ).statusCode, 409, ); assert.equal( (await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 404, ); assert.equal( ( await f.call( b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID(), ) ).statusCode, 200, ); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('비공개 원격 본문')] }, randomUUID(), ) ).statusCode, 200, ); const item = (await f.call(b, '/inbox')).json().items[0]; assert.equal(item.url, urlFor('비공개 원격 본문')); assert.equal((await f.call(c, `/access/${item.accessId}`)).statusCode, 404); assert.equal((await f.call(b, '/device/items')).json().items.length, 0); await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true }); assert.equal((await f.call(a, '/sent')).json().items[0].recipientRead, true); b.socket?.disconnect(); const second = await request(f, a, 'bob@example.com'); assert.equal(second.statusCode, 200); assert.equal(second.json().requiresAcceptance, false); assert.equal( ( await f.call( a, `/transfers/${second.json().requestId}/complete`, 'POST', { urls: [urlFor('remote text')] }, randomUUID(), ) ).statusCode, 200, ); assert.equal((await f.call(b, '/inbox')).json().items.length, 2); } finally { await f.close(); } }); test('자신에게 저장은 요청 수락 없이 내 보관함이며 계정 간 상태 격리', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'); const r = await request(f, a, 'alice@example.com'); assert.equal(r.statusCode, 200); assert.equal(r.json().requiresAcceptance, false); await f.call( a, `/transfers/${r.json().requestId}/complete`, 'POST', { urls: [urlFor('remote text')] }, randomUUID(), ); const item = (await f.call(a, '/vault')).json().items[0]; assert.equal(item.role, 'self'); assert.equal((await f.call(a, '/inbox')).json().items.length, 0); assert.equal( (await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true })).statusCode, 404, ); } finally { await f.close(); } }); test('기존 이메일 자료: 차단이 허용보다 우선하며 주소 오류는 일반화', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'); const id = (await request(f, a, 'bob@example.com')).json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID()); await f.call(b, `/account/rules/${a.userId}`, 'PUT', { action: 'block' }); const blocked = await request(f, a, 'bob@example.com'), unknown = await request(f, a, 'nobody@example.com'); assert.equal(blocked.statusCode, 400); assert.equal(unknown.statusCode, 400); assert.equal(blocked.json().message, unknown.json().message); assert.equal( ( await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('remote text')] }, randomUUID(), ) ).statusCode, 409, ); } finally { await f.close(); } }); test('비밀번호 재설정 토큰은 일회용이며 모든 기존 세션을 무효화', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'); const another = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password, }); a.cookie = a.cookie.split('; hop_account=')[0] + '; ' + String(another.headers['set-cookie']).split(';')[0]; await f.call(a, '/auth/forgot-password', 'POST', { email: 'alice@example.com' }); const mail = f.mails.at(-1)!, token = mail.text.match(/#reset-password=([A-Za-z0-9_-]+)/)![1]!, next = 'a different long password'; const response = await f.call(a, '/auth/reset-password', 'POST', { token, password: next, passwordConfirmation: next, }); assert.equal(response.statusCode, 200); assert.equal((await f.call(a, '/auth/me')).json().account, null); assert.equal( ( await f.call(a, '/auth/reset-password', 'POST', { token, password: next, passwordConfirmation: next, }) ).statusCode, 400, ); assert.equal( (await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password })).statusCode, 401, ); assert.equal( (await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password: next })) .statusCode, 200, ); } finally { await f.close(); } }); test('로그인 중 받은 자료는 받은 항목에 자동 기록하며 익명 가져오기는 제거', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await f.create(); await f.space(a, b); await f.complete(b, a, '자동 기록'); const item = (await f.call(a, '/device/items')).json().items[0]; assert.equal( (await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode, 410, ); const saved = (await f.call(a, '/inbox')).json().items[0]; assert.equal(saved.url, item.url); assert.equal(saved.expiresAt, item.expiresAt); assert.equal((await f.call(a, '/vault')).json().items.length, 0); await f.call(a, '/auth/logout', 'POST', {}); assert.equal((await f.call(a, '/inbox')).statusCode, 401); } finally { await f.close(); } }); test('즐겨찾기 영구 보관 제거와 만료된 자료의 실제 정리', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'); const id = (await request(f, a, 'bob@example.com')).json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); await f.call( a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor('remote text')] }, randomUUID(), ); const item = (await f.call(b, '/inbox')).json().items[0]; assert.equal( (await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true })) .statusCode, 410, ); f.service.db .update(s.items) .set({ expiresAt: Date.now() - 1 }) .run(); f.service.cleanup(); assert.equal((await f.call(b, '/favorites')).json().items.length, 0); assert.equal((await f.call(a, '/sent')).json().items.length, 0); await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: false }); assert.equal((await f.call(b, '/inbox')).json().items.length, 0); } finally { await f.close(); } }); test('이메일 정규화는 도메인만 소문자화하고 제공자 점·별칭을 보존', () => { assert.equal(normalizeEmail(' Alice.Smith+tag@EXAMPLE.COM '), 'Alice.Smith+tag@example.com'); assert.notEqual(normalizeEmail('Alice@example.com'), normalizeEmail('alice@example.com')); }); async function fileUpload( f: Awaited>, a: any, id: string, bytes: Buffer, ) { const boundary = 'hop-account-file'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="account.txt"\r\nContent-Type: text/plain\r\n\r\n`, ), bytes, Buffer.from(`\r\n--${boundary}--\r\n`), ]); return f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), { 'content-type': `multipart/form-data; boundary=${boundary}`, }); } test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 상한과 계정 용량을 분리', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'); f.cfg.totalGuestQuota = 1; const bytes = Buffer.from('account file'); const response = await legacyRequest(f, a, { email: 'bob@example.com', kind: 'file', byteSize: bytes.length, count: 1, files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }], }); assert.equal(response.statusCode, 200); const id = response.json().requestId; assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409); f.cfg.userQuota = bytes.length - 1; assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 413, ); f.cfg.userQuota = bytes.length; assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 200, ); const upload = await fileUpload(f, a, id, bytes); assert.equal(upload.statusCode, 200, upload.body); const item = (await f.call(b, '/inbox')).json().items[0], download = await f.call(b, `/access/${item.accessId}/download`); assert.deepEqual(download.rawPayload, bytes); assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0); assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length); assert.equal( (await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true })) .statusCode, 410, ); assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length); } finally { await f.close(); } }); test('로그인 수신 파일은 계정에 기록하되 보내는 익명 파일 소유권을 옮기지 않음', async () => { const f = await accountFixture(); try { const a = await register(f, 'alice@example.com'), b = await register(f, 'bob@example.com'); await f.space(a, b); const bytes = Buffer.from('move my file'); const r = await f.call( a, '/transfer-requests', 'POST', { targetDeviceId: b.deviceId, kind: 'file', byteSize: bytes.length, count: 1, files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }], }, randomUUID(), ); const id = r.json().requestId; await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 200); const item = (await f.call(a, '/device/items')).json().items[0]; assert.equal( (await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode, 410, ); assert.equal( f.service.storage.fileRows(id)[0]!.ownerGuestId, f.service.deviceGuest(a.deviceId)!.guestId, ); const received = (await f.call(b, '/inbox')).json().items[0]; assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes); } finally { await f.close(); } });