// CloudSyncService 에서 분리한 SessionTokenStore·SyncScheduler, 로그인 콜백 가드 회귀 테스트 import { afterEach, describe, expect, it, vi } from 'vitest' import { EncryptedFileTokenStore, bindAuthEvents, type AuthSessionLike, type TokenCipher, type TokenFileSystem, } from '../../../src/main/services/sync/session-token-store' import { SyncScheduler } from '../../../src/main/services/sync/sync-scheduler' function memoryFs(): TokenFileSystem & { files: Map } { const files = new Map() return { files, existsSync: (p) => files.has(p), readFileSync: (p) => { const data = files.get(p) if (!data) throw new Error('ENOENT') return data }, writeFileSync: (p, data) => { files.set(p, Buffer.from(data)) }, unlinkSync: (p) => { files.delete(p) }, } } const reversingCipher: TokenCipher = { isEncryptionAvailable: () => true, encryptString: (plain) => Buffer.from([...plain].reverse().join(''), 'utf-8'), decryptString: (encrypted) => [...encrypted.toString('utf-8')].reverse().join(''), } const logger = { warn: vi.fn() } describe('EncryptedFileTokenStore', () => { it('암호화해 저장하고 복원하며, clear 는 파일을 지운다', () => { const fs = memoryFs() const store = new EncryptedFileTokenStore('/u/cloud-sync.token', reversingCipher, fs, logger) expect(store.load()).toBeNull() store.save('rt-1') expect(fs.files.get('/u/cloud-sync.token')?.toString('utf-8')).toBe('1-tr') expect(store.load()).toBe('rt-1') store.clear() expect(store.load()).toBeNull() }) it('OS 암호화를 못 쓰면 평문으로 저장하지 않는다', () => { const fs = memoryFs() const store = new EncryptedFileTokenStore('/t', { ...reversingCipher, isEncryptionAvailable: () => false }, fs, logger) store.save('secret') expect(fs.files.size).toBe(0) }) }) describe('bindAuthEvents — 회전된 refresh token 저장', () => { type Session = AuthSessionLike & { access_token: string } function harness(activeUser: string | null) { let emit: (event: string, session: Session | null) => void = () => undefined const unsubscribe = vi.fn() const fs = memoryFs() const store = new EncryptedFileTokenStore('/t', reversingCipher, fs, logger) const onSession = vi.fn() let active = activeUser const unbind = bindAuthEvents( (cb) => { emit = cb return { unsubscribe } }, store, () => active, onSession ) return { emit: (event: string, session: Session | null) => emit(event, session), store, onSession, unsubscribe, unbind, setActive: (u: string | null) => { active = u }, } } const session = (rt: string, user = 'u1'): Session => ({ refresh_token: rt, access_token: `at-${rt}`, user: { id: user } }) it('TOKEN_REFRESHED 마다 새 토큰을 저장하고 메모리 세션도 갱신한다', () => { const h = harness('u1') h.store.save('rt-old') h.emit('TOKEN_REFRESHED', session('rt-new')) expect(h.store.load()).toBe('rt-new') expect(h.onSession).toHaveBeenCalledWith(expect.objectContaining({ access_token: 'at-rt-new' })) }) it('로그아웃 뒤·다른 사용자·SIGNED_OUT 이벤트는 무시한다', () => { const h = harness('u1') h.store.save('rt-keep') h.emit('TOKEN_REFRESHED', session('rt-other', 'u2')) h.emit('SIGNED_OUT', null) h.emit('INITIAL_SESSION', session('rt-initial')) expect(h.store.load()).toBe('rt-keep') h.setActive(null) h.emit('TOKEN_REFRESHED', session('rt-late')) expect(h.store.load()).toBe('rt-keep') expect(h.onSession).not.toHaveBeenCalled() h.unbind() expect(h.unsubscribe).toHaveBeenCalled() }) }) describe('SyncScheduler', () => { afterEach(() => { vi.useRealTimers() }) it('디바운스는 여러 요청을 한 번으로 모은다', () => { vi.useFakeTimers() const run = vi.fn() const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 300_000, run, onHeartbeat: vi.fn() }) scheduler.scheduleFlush() scheduler.scheduleFlush() scheduler.schedulePull() vi.advanceTimersByTime(1499) expect(run).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(run.mock.calls).toEqual([['flush'], ['pull']]) scheduler.scheduleFlush() vi.advanceTimersByTime(1500) expect(run).toHaveBeenCalledTimes(3) }) it('heartbeat 는 주기마다 부르고 stop 은 대기 중인 디바운스까지 취소한다', () => { vi.useFakeTimers() const run = vi.fn() const onHeartbeat = vi.fn() const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 1000, run, onHeartbeat }) scheduler.startHeartbeat() vi.advanceTimersByTime(3000) expect(onHeartbeat).toHaveBeenCalledTimes(3) scheduler.scheduleFlush() scheduler.stop() vi.advanceTimersByTime(5000) expect(onHeartbeat).toHaveBeenCalledTimes(3) expect(run).not.toHaveBeenCalled() expect(scheduler.isHeartbeatRunning()).toBe(false) }) }) describe('로그인 콜백 가드 (login CSRF)', () => { it('앱이 시작하지 않은 로그인 콜백 토큰은 세션을 바꾸지 않고 거부한다', async () => { const { getCloudSyncService, resetCloudSyncServiceForTests } = await import('../../../src/main/services/CloudSyncService') const { initInMemoryConfig, resetInMemoryConfig } = await import('../../../src/main/services/ConfigService') initInMemoryConfig() resetCloudSyncServiceForTests() const svc = getCloudSyncService() try { await svc.init() await expect( svc.handleAuthTokens({ accessToken: 'attacker.at', refreshToken: 'attacker-rt' }) ).rejects.toThrow(/no sign-in was started/) await expect(svc.handleAuthCallback('attacker-code')).rejects.toThrow(/no sign-in was started/) expect(svc.isAuthenticated()).toBe(false) } finally { resetCloudSyncServiceForTests() resetInMemoryConfig() } }) })