import { test } from 'node:test'; import assert from 'node:assert/strict'; import argon2 from 'argon2'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; import { hash } from '../src/security.js'; const password = '12345678'; async function setup() { const mails = []; const f = await fixture(false, async (mail) => mails.push(mail)); f.cfg.registrationMode = 'open'; f.cfg.sessionRate = 60; return { ...f, mails }; } function useCookie(client, response) { const cookie = String(response.headers['set-cookie']).split(';')[0]; assert(cookie.startsWith('hop_account=')); client.cookie = client.cookie.replace(/;\s*hop_account=[^;]*/g, '') + '; ' + cookie; return cookie.slice('hop_account='.length); } async function register(f, email) { const client = await f.create(); const response = await f.call(client, '/auth/register', 'POST', { email, password, passwordConfirmation: password, displayName: email.split('@')[0], }); assert.equal(response.statusCode, 200, response.body); const token = f.mails.find((mail) => mail.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1]; return { ...client, token, email }; } async function verify(f, client, token = client.token) { const response = await f.call(client, '/auth/verify-email', 'POST', { token }); assert.equal(response.statusCode, 200, response.body); useCookie(client, response); client.socket.disconnect(); await f.connect(client); return response; } test('가입 7자 거절·8자 허용, 기존 짧은 비밀번호 로그인은 유지', async () => { const f = await setup(); try { const client = await f.create(); const input = { email: 'length@hop.test', password: '1234567', passwordConfirmation: '1234567', displayName: 'length', }; const rejected = await f.call(client, '/auth/register', 'POST', input); assert.equal(rejected.statusCode, 400, rejected.body); assert.equal(rejected.json().code, 'PASSWORD_INVALID'); assert.equal(f.mails.length, 0); assert.equal(f.service.db.select().from(s.users).all().length, 0); const accepted = await f.call(client, '/auth/register', 'POST', { ...input, password, passwordConfirmation: password, }); assert.equal(accepted.statusCode, 200, accepted.body); assert.equal(f.mails.length, 1); const id = randomUUID(); f.service.db .insert(s.users) .values({ id, email: 'legacy@hop.test', displayName: 'legacy', passwordHash: await argon2.hash('1234'), status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); assert.equal( (await f.call(client, '/auth/login', 'POST', { email: 'legacy@hop.test', password: '1234' })) .statusCode, 200, ); } finally { await f.close(); } }); for (const secure of [false, true]) { test(`인증 완료 후 비밀번호 재입력 없이 로그인·쿠키 보호·기기 유지 (Secure=${secure})`, async () => { const f = await setup(); try { const a = await register(f, 'verify@hop.test'); const device = (await f.call(a, '/device/me')).json().device; assert.equal((await f.call(a, '/auth/me')).json().account, null); f.cfg.production = secure; const response = await verify(f, a); assert.equal(response.json().account.email, a.email); assert.equal(response.json().account.verified, true); assert.equal(response.json().token, undefined); const header = String(response.headers['set-cookie']); assert.match(header, /HttpOnly/); assert.match(header, /SameSite=Lax/); assert.equal(/; Secure(?:;|$)/.test(header), secure); assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email); assert.equal((await f.call(a, '/inbox')).statusCode, 200); assert.equal((await f.call(a, '/device/me')).json().device.id, device.id); const sessions = f.service.db.select().from(s.accountSessions).all(); assert.equal(sessions.length, 1); assert.equal(sessions[0].tokenHash === a.cookie.split('hop_account=')[1], false); assert(sessions[0].expiresAt - sessions[0].createdAt <= 30 * 86400000); } finally { await f.close(); } }); } test('다른 계정 인증 시 현재 세션·업로드·소켓 종료, 다른 기기 세션과 기기 ID 유지·보관함 격리', async () => { const f = await setup(); try { const a = await register(f, 'previous@hop.test'); await verify(f, a); const previousToken = a.cookie.split('hop_account=')[1]; const previous = f.service.auth.session(previousToken); const oldClient = { ...a }; const own = await f.call( a, '/vault', 'POST', { kind: 'url', urls: ['https://example.com/previous-private'] }, randomUUID(), ); assert.equal(own.statusCode, 200, own.body); const second = await f.create(); const anotherLogin = await f.call(second, '/auth/login', 'POST', { email: a.email, password }); useCookie(second, anotherLogin); const b = await register(f, 'next@hop.test'); let aborted; const abort = f.service.storage.abortSession.bind(f.service.storage); f.service.storage.abortSession = (id) => { aborted = id; abort(id); }; const revoked = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('session notification missing')), 2000); a.socket.once('account:revoked', () => { clearTimeout(timer); resolve(); }); }); const switched = await f.call(a, '/auth/verify-email', 'POST', { token: b.token }); assert.equal(switched.statusCode, 200, switched.body); await revoked; const serverSocket = f.service.io.sockets.sockets.get(a.socket.id); assert.equal(serverSocket.data.accountSessionId, undefined); assert.equal(serverSocket.rooms.has(`account:${previous.user.id}`), false); assert.equal(aborted, previous.session.id); useCookie(a, switched); assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email); assert.equal((await f.call(oldClient, '/auth/me')).json().account, null); assert.equal((await f.call(oldClient, '/vault')).statusCode, 401); assert.equal((await f.call(a, '/vault')).json().items.length, 0); assert.equal((await f.call(second, '/auth/me')).json().account.email, 'previous@hop.test'); assert.equal( (await f.call(second, '/vault')).json().items[0].url, 'https://example.com/previous-private', ); assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId); } finally { await f.close(); } }); for (const invalid of ['expired', 'suspended', 'deleted', 'unknown']) { test(`유효하지 않은 인증(${invalid})은 기존 로그인과 인증 토큰 상태를 변경하지 않음`, async () => { const f = await setup(); try { const a = await register(f, 'old@hop.test'); await verify(f, a); const b = await register(f, 'invalid@hop.test'); const user = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get(); const tokenRow = f.service.db .select() .from(s.accountTokens) .where(eq(s.accountTokens.tokenHash, hash(b.token))) .get(); if (invalid === 'expired') f.service.db .update(s.accountTokens) .set({ expiresAt: Date.now() - 1 }) .where(eq(s.accountTokens.id, tokenRow.id)) .run(); if (['suspended', 'deleted'].includes(invalid)) f.service.db.update(s.users).set({ status: invalid }).where(eq(s.users.id, user.id)).run(); const response = await f.call(a, '/auth/verify-email', 'POST', { token: invalid === 'unknown' ? 'A'.repeat(43) : b.token, }); assert.equal(response.statusCode, 400, response.body); assert.equal(response.headers['set-cookie'], undefined); assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email); assert.equal( f.service.db.select().from(s.accountTokens).where(eq(s.accountTokens.id, tokenRow.id)).get() .usedAt, null, ); assert.equal( f.service.db .select() .from(s.accountSessions) .where(eq(s.accountSessions.userId, user.id)) .all().length, 0, ); } finally { await f.close(); } }); } test('동시·재사용 인증은 한 번만 로그인하고 기존 로그인 쿠키를 덮어쓰지 않음', async () => { const f = await setup(); try { const a = await register(f, 'once@hop.test'); const responses = await Promise.all([ f.call(a, '/auth/verify-email', 'POST', { token: a.token }), f.call(a, '/auth/verify-email', 'POST', { token: a.token }), ]); assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 400]); const successful = responses.find((r) => r.statusCode === 200); useCookie(a, successful); assert.equal(f.service.db.select().from(s.accountSessions).all().length, 1); const replay = await f.call(a, '/auth/verify-email', 'POST', { token: a.token }); assert.equal(replay.statusCode, 400); assert.equal(replay.headers['set-cookie'], undefined); assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email); } finally { await f.close(); } }); test('세션 발급 실패 시 인증·이전 로그아웃·토큰 소비 모두 롤백하고 같은 링크로 재시도 가능', async () => { const f = await setup(); try { const a = await register(f, 'keep@hop.test'); await verify(f, a); const b = await register(f, 'retry@hop.test'); const original = f.service.auth.createSession.bind(f.service.auth); f.service.auth.createSession = (...args) => { original(...args); throw Error('test issuance failure'); }; const failed = await f.call(a, '/auth/verify-email', 'POST', { token: b.token }); assert.equal(failed.statusCode, 500); assert.equal(failed.headers['set-cookie'], undefined); assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email); const pending = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get(); assert.equal(pending.status, 'pending'); assert.equal(pending.verifiedAt, null); assert.equal( f.service.db .select() .from(s.accountSessions) .where(eq(s.accountSessions.userId, pending.id)) .all().length, 0, ); assert.equal( f.service.db .select() .from(s.accountTokens) .where(eq(s.accountTokens.tokenHash, hash(b.token))) .get().usedAt, null, ); f.service.auth.createSession = original; await verify(f, a, b.token); assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email); } finally { await f.close(); } });