// tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts // CloudSyncService 세션 수명주기 회귀 테스트 (Supabase·DB·엔진은 가짜): // - 오프라인·5xx 로 복원이 실패하면 토큰을 지우지 않고 저장된 계정 DB 에서 오프라인으로 동작하다 다시 시도한다 // - 서버가 토큰을 확실히 거부(4xx AuthApiError)할 때만 토큰을 지우고 로컬 DB 로 되돌린다 // - 엔진이 아직 없어도(로그인 직후 티어 조회 중·오프라인) 변경을 outbox 에 기록한다 // - 로그아웃은 이 기기 세션만(scope 'local'), 외부 SIGNED_OUT 은 로그아웃 정리로 이어진다 // + 순수 정책: 복원 실패 분류, 토큰 레코드, SIGNED_OUT 훅, 시작 DB 선택 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' import { EncryptedFileTokenStore, bindAuthEvents, decodeSessionRecord, type AuthSessionLike, type TokenCipher, type TokenFileSystem, } from '../../../src/main/services/sync/session-token-store' import { classifySessionRestoreFailure, sessionRestoreRetryDelayMs, } from '../../../src/main/services/sync/session-restore-policy' import { openStartupDatabase } from '../../../src/main/db/startup-database' const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`) const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token') const h = vi.hoisted(() => { type AuthCallback = (event: string, session: unknown) => void const state = { currentUserId: '_local' as string | null, authCallback: null as AuthCallback | null, tierGate: null as Promise | null, } return { state, db: { openForUser: (userId: string) => { state.currentUserId = userId return { created: false, dbPath: `/db/${userId}` } }, openLocal: () => { state.currentUserId = '_local' return { created: false, dbPath: '/db/_local' } }, }, enqueueChange: (..._args: unknown[]) => undefined, } }) const spies = vi.hoisted(() => ({ refreshSession: null as null | ((...args: unknown[]) => Promise), signOut: null as null | ((...args: unknown[]) => Promise), })) vi.mock('electron', () => ({ app: { getPath: () => path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`), getVersion: () => '1.0.0', }, safeStorage: { isEncryptionAvailable: () => true, encryptString: (plain: string) => Buffer.from(plain, 'utf-8'), decryptString: (data: Buffer) => data.toString('utf-8'), }, })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: () => undefined, configSet: vi.fn(), onConfigChanged: () => () => undefined, })) vi.mock('../../../src/main/db', () => ({ LOCAL_USER_ID: '_local', openForUser: vi.fn((userId: string) => h.db.openForUser(userId)), openLocal: vi.fn(() => h.db.openLocal()), closeCurrent: vi.fn(() => { h.state.currentUserId = null }), getCurrentUserId: () => h.state.currentUserId, importLocalModeData: vi.fn(() => null), })) vi.mock('../../../src/main/services/sync/sync-outbox', () => ({ enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)), getSyncState: () => null, setSyncState: vi.fn(), })) vi.mock('../../../src/main/services/sync/SyncEngine', () => ({ SyncEngine: class { on(): void {} dispose(): void {} async whenIdle(): Promise { return true } async runFullSync() { return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } } async flush() { return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } } async pull() { return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } } getStatus() { return { pending: 0, parked: 0 } } }, })) vi.mock('../../../src/main/services/sync/sync-adapters', () => ({ SyncAbortedError: class SyncAbortedError extends Error {}, })) vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} })) vi.mock('../../../src/main/services/sync/device-registration', () => ({ checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })), currentDeviceInfo: () => ({}), unregisterDesktopDevice: vi.fn(async () => undefined), })) vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] })) vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} })) vi.mock('../../../src/main/services/sync/settings-sync', () => ({ SETTINGS_ROW_ID: 'settings', SYNCED_CONFIG_KEYS: [], isApplyingRemoteSettings: () => false, })) vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] })) vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) })) vi.mock('../../../src/main/services/LicenseService', () => ({ getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }), })) vi.mock('../../../src/main/services/VoiceModeService', () => ({ getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }), })) vi.mock('../../../src/main/services/MeetingModeService', () => ({ getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }), })) vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => ({ stop: vi.fn(async () => null) }), })) vi.mock('@supabase/supabase-js', () => { const query = (): Record => { const builder: Record = {} for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder builder.maybeSingle = async () => { if (h.state.tierGate) await h.state.tierGate return { data: null, error: null } } return builder } const channel = { on() { return channel }, subscribe() { return channel }, unsubscribe: async () => undefined, state: 'joined', } return { createClient: () => ({ auth: { refreshSession: (...args: unknown[]) => spies.refreshSession!(...args), signOut: (...args: unknown[]) => spies.signOut!(...args), onAuthStateChange: (cb: (event: string, session: unknown) => void) => { h.state.authCallback = cb return { data: { subscription: { unsubscribe: () => undefined } } } }, getSession: async () => ({ data: { session: null } }), stopAutoRefresh: async () => undefined, }, from: () => query(), channel: () => channel, realtime: { setAuth: async () => undefined }, }), } }) import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService' import * as db from '../../../src/main/db' import * as outbox from '../../../src/main/services/sync/sync-outbox' function session(refreshToken = 'rt-new') { return { access_token: 'at', refresh_token: refreshToken, user: { id: 'user-1', email: 'u@example.test' } } } function writeStoredToken(record: { rt: string; uid?: string }): void { fs.mkdirSync(USER_DATA, { recursive: true }) fs.writeFileSync(TOKEN_FILE, record.uid ? JSON.stringify({ v: 1, rt: record.rt, uid: record.uid }) : record.rt) } describe('CloudSyncService 세션 복원', () => { beforeEach(() => { resetCloudSyncServiceForTests() vi.clearAllMocks() fs.rmSync(USER_DATA, { recursive: true, force: true }) h.state.currentUserId = 'user-1' // bootstrap 이 저장된 계정 DB 를 먼저 열었다 h.state.authCallback = null h.state.tierGate = null spies.signOut = vi.fn(async () => ({ error: null })) }) afterEach(() => { resetCloudSyncServiceForTests() vi.useRealTimers() fs.rmSync(USER_DATA, { recursive: true, force: true }) }) it('오프라인(AuthRetryableFetchError)이면 토큰을 지우지 않고 계정 DB 에서 오프라인으로 동작하며 변경을 기록한다', async () => { writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) spies.refreshSession = vi.fn(async () => ({ data: { session: null }, error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' }, })) const sync = getCloudSyncService() const errors: string[] = [] sync.on('sync-error', ({ error }) => errors.push(error)) await sync.init() expect(fs.existsSync(TOKEN_FILE)).toBe(true) expect(sync.isOfflineAccount()).toBe(true) expect(h.state.currentUserId).toBe('user-1') expect(db.openLocal).not.toHaveBeenCalled() expect(errors).toHaveLength(1) expect(errors[0]).not.toMatch(/expired/i) sync.pushOne('history', 'h-offline') expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-offline', 'upsert') }) it('나중에 다시 시도해 복원되면 로그인 상태가 된다', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) spies.refreshSession = vi.fn(async () => ({ data: { session: null }, error: { name: 'AuthApiError', status: 503, message: 'upstream unavailable' }, })) const sync = getCloudSyncService() await sync.init() expect(sync.isAuthenticated()).toBe(false) spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) await vi.advanceTimersByTimeAsync(sessionRestoreRetryDelayMs(0)) expect(sync.isAuthenticated()).toBe(true) expect(sync.isOfflineAccount()).toBe(false) }) it('서버가 토큰을 거부(400 AuthApiError)하면 토큰을 지우고 로컬 DB 로 되돌린다', async () => { writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) spies.refreshSession = vi.fn(async () => ({ data: { session: null }, error: { name: 'AuthApiError', status: 400, code: 'refresh_token_not_found', message: 'Invalid Refresh Token' }, })) const sync = getCloudSyncService() await sync.init() expect(fs.existsSync(TOKEN_FILE)).toBe(false) expect(h.state.currentUserId).toBe('_local') expect(sync.isOfflineAccount()).toBe(false) sync.pushOne('history', 'h-local') expect(outbox.enqueueChange).not.toHaveBeenCalled() }) it('로그인 직후 티어 조회 중(엔진 생성 전)의 변경도 outbox 에 기록한다', async () => { writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) let releaseTier: () => void = () => undefined h.state.tierGate = new Promise((resolve) => { releaseTier = resolve }) spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) const sync = getCloudSyncService() const initDone = sync.init() await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(true)) sync.pushOne('history', 'h-gap') expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-gap', 'upsert') releaseTier() await initDone }) it('복원·로그인 때 토큰과 함께 계정 id 를 저장한다', async () => { writeStoredToken({ rt: 'rt-legacy' }) // 구버전: 토큰만 spies.refreshSession = vi.fn(async () => ({ data: { session: session('rt-rotated') }, error: null })) await getCloudSyncService().init() expect(decodeSessionRecord(fs.readFileSync(TOKEN_FILE, 'utf-8'))).toEqual({ refreshToken: 'rt-rotated', userId: 'user-1', }) expect(getCloudSyncService().peekStoredAccountUserId()).toBe('user-1') }) }) describe('CloudSyncService 로그아웃 범위', () => { beforeEach(() => { resetCloudSyncServiceForTests() vi.clearAllMocks() fs.rmSync(USER_DATA, { recursive: true, force: true }) h.state.currentUserId = 'user-1' h.state.tierGate = null spies.signOut = vi.fn(async () => { // 실제 auth-js 처럼 자기 signOut 도 SIGNED_OUT 을 알린다 — 다시 로그아웃 정리를 돌지 않아야 한다 h.state.authCallback?.('SIGNED_OUT', null) return { error: null } }) spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) }) afterEach(() => { resetCloudSyncServiceForTests() fs.rmSync(USER_DATA, { recursive: true, force: true }) }) it("사용자 로그아웃은 이 기기 세션만 폐기한다(scope 'local')", async () => { const sync = getCloudSyncService() await sync.init() await sync.signOut() expect(spies.signOut).toHaveBeenCalledTimes(1) expect(spies.signOut).toHaveBeenCalledWith({ scope: 'local' }) expect(sync.isAuthenticated()).toBe(false) expect(h.state.currentUserId).toBe('_local') }) it('다른 곳에서 세션이 끝나면(SIGNED_OUT) 익명 키로 계속 돌지 않고 로그아웃 정리를 한다', async () => { const sync = getCloudSyncService() await sync.init() const authChanges: Array = [] const errors: string[] = [] sync.on('auth-changed', ({ user }) => authChanges.push(user)) sync.on('sync-error', ({ error }) => errors.push(error)) h.state.authCallback?.('SIGNED_OUT', null) await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(false)) await vi.waitFor(() => expect(errors).toHaveLength(1)) expect(authChanges).toContain(null) expect(errors[0]).not.toMatch(/disconnected from your account on another device/) expect(fs.existsSync(TOKEN_FILE)).toBe(false) }) }) describe('순수 정책', () => { it('복원 실패 분류: 서버의 4xx 거부만 invalid', () => { expect(classifySessionRestoreFailure({ name: 'AuthRetryableFetchError', status: 0 })).toBe('transient') expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 400 })).toBe('invalid') expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 401 })).toBe('invalid') expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 500 })).toBe('transient') expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 429 })).toBe('transient') expect(classifySessionRestoreFailure(new TypeError('fetch failed'))).toBe('transient') expect(classifySessionRestoreFailure(null)).toBe('transient') expect(sessionRestoreRetryDelayMs(0)).toBe(15_000) expect(sessionRestoreRetryDelayMs(99)).toBe(300_000) }) it('토큰 레코드: 구버전 토큰 문자열과 새 레코드를 모두 읽는다', () => { const files = new Map() const memoryFs: TokenFileSystem = { existsSync: (p) => files.has(p), readFileSync: (p) => files.get(p) ?? Buffer.alloc(0), writeFileSync: (p, data) => { files.set(p, Buffer.from(data)) }, unlinkSync: (p) => { files.delete(p) }, } const cipher: TokenCipher = { isEncryptionAvailable: () => true, encryptString: (s) => Buffer.from(s), decryptString: (b) => b.toString(), } const store = new EncryptedFileTokenStore('/t', cipher, memoryFs, { warn: vi.fn() }) store.save('rt-only') expect(store.loadRecord()).toEqual({ refreshToken: 'rt-only', userId: null }) store.save('rt-2', 'user-9') expect(store.loadRecord()).toEqual({ refreshToken: 'rt-2', userId: 'user-9' }) expect(store.load()).toBe('rt-2') }) it('bindAuthEvents: 로그인된 동안의 SIGNED_OUT 은 onSignedOut 으로, 로그아웃 뒤에는 무시', () => { type S = AuthSessionLike let emit: (event: string, s: S | null) => void = () => undefined const onSignedOut = vi.fn() let active: string | null = 'u1' const store = { load: () => null, loadRecord: () => null, save: vi.fn(), clear: vi.fn() } bindAuthEvents( (cb) => { emit = cb return { unsubscribe: vi.fn() } }, store, () => active, vi.fn(), onSignedOut, ) emit('TOKEN_REFRESHED', { refresh_token: 'rt', user: { id: 'u1' } }) expect(store.save).toHaveBeenCalledWith('rt', 'u1') emit('SIGNED_OUT', null) expect(onSignedOut).toHaveBeenCalledTimes(1) expect(store.clear).not.toHaveBeenCalled() active = null emit('SIGNED_OUT', null) expect(onSignedOut).toHaveBeenCalledTimes(1) }) it('시작 DB: 저장된 계정이 있으면 그 계정 DB, 열지 못하면 로컬, 없으면 로컬', () => { const openForUser = vi.fn((id: string) => ({ created: false, dbPath: `/u/${id}` })) const openLocal = vi.fn(() => ({ created: false, dbPath: '/u/_local' })) const warn = vi.fn() expect(openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser, openLocal, warn }).accountUserId).toBe('acc') expect(openLocal).not.toHaveBeenCalled() const failing = vi.fn(() => { throw new Error('corrupt') }) expect( openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser: failing, openLocal, warn }).accountUserId, ).toBeNull() expect(openLocal).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalled() expect(openStartupDatabase({ storedAccountUserId: () => null, openForUser, openLocal, warn }).dbPath).toBe('/u/_local') }) })