// tests/main/sync/cloud-credentials-redteam-r3-1.test.ts // 로그아웃이 오프라인·5xx 로 서버 폐기에 실패해도(auth-js 가 { error } 를 돌려주고 _removeSession 을 건너뜀) // 자격 증명 포트(Cloud STT·Premium·realtime-token)가 옛 계정 토큰을 더는 내주지 않는지 검증한다. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudcred-r3-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), clients: [] as Array<{ session: null | { access_token: string; user: { id: string } } }>, })) vi.mock('electron', () => ({ app: { getPath: () => path.join(os.tmpdir(), `d3ro-cloudcred-r3-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: () => { const clientState = { session: null as null | { access_token: string; user: { id: string } } } spies.clients.push(clientState) return { auth: { refreshSession: async (...args: unknown[]) => { const result = (await spies.refreshSession!(...args)) as { data: { session: unknown } } if (result.data.session) clientState.session = result.data.session as typeof clientState.session return result }, 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: clientState.session } }), stopAutoRefresh: vi.fn(async () => undefined), }, from: () => query(), channel: () => channel, realtime: { setAuth: async () => undefined }, functions: { invoke: vi.fn(async () => ({ data: { ok: true }, error: null })), }, } }, } }) import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService' import { cloudSyncCredentials, createCloudSttGateway } from '../../../src/main/services/cloud/cloud-credentials' import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver' function session() { return { access_token: 'at-user-1', refresh_token: 'rt-new', user: { id: 'user-1', email: 'u@example.test' } } } describe('로그아웃 실패(retryable) 뒤 자격 증명 폐기', () => { beforeEach(() => { resetCloudSyncServiceForTests() vi.clearAllMocks() spies.clients.length = 0 fs.rmSync(USER_DATA, { recursive: true, force: true }) fs.mkdirSync(USER_DATA, { recursive: true }) fs.writeFileSync(TOKEN_FILE, JSON.stringify({ v: 1, rt: 'rt-1', uid: 'user-1' })) h.state.currentUserId = 'user-1' h.state.tierGate = null spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) // auth-js: 네트워크 실패면 throw 하지 않고 { error } 를 돌려주며 클라이언트 세션을 지우지 않는다 spies.signOut = vi.fn(async () => ({ error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' }, })) }) afterEach(() => { resetCloudSyncServiceForTests() fs.rmSync(USER_DATA, { recursive: true, force: true }) }) it('로그인 중에는 토큰과 Edge Function 호출이 가능하다', async () => { const sync = getCloudSyncService() await sync.init() expect(cloudSyncCredentials.hasCredentials()).toBe(true) await expect(cloudSyncCredentials.accessToken()).resolves.toBe('at-user-1') const result = await cloudSyncCredentials.invoke('realtime-token', {}) expect(result.error).toBeNull() }) it('signOut 이 { error } 를 돌려줘도 접근자는 모두 null / no session 이다', async () => { const sync = getCloudSyncService() await sync.init() // 옛 클라이언트는 여전히 세션을 들고 있다(auth-js 동작 재현) expect(spies.clients[0].session).not.toBeNull() await sync.signOut() expect(sync.isAuthenticated()).toBe(false) expect(cloudSyncCredentials.hasCredentials()).toBe(false) await expect(sync.getAccessToken()).resolves.toBeNull() await expect(cloudSyncCredentials.accessToken()).resolves.toBeNull() const invoked = await cloudSyncCredentials.invoke('realtime-token', {}) expect(invoked.error?.message).toMatch(/session/i) const streamed = await cloudSyncCredentials.invokeStream('llm-proxy', {}) expect(streamed.stream).toBeNull() expect(streamed.error).not.toBeNull() }) it('실패한 signOut 뒤 옛 클라이언트는 자동 갱신을 멈추고 새 클라이언트로 바뀐다', async () => { const sync = getCloudSyncService() await sync.init() await sync.signOut() expect(spies.clients).toHaveLength(2) expect(spies.clients[1].session).toBeNull() }) it('Cloud STT 드라이버는 로그아웃 뒤 업로드하지 않는다', async () => { const sync = getCloudSyncService() await sync.init() await sync.signOut() const fetchSpy = vi.spyOn(globalThis, 'fetch') const driver = new D3ROCloudDriver({ ...createCloudSttGateway(), getSupabaseUrl: () => 'https://example.supabase.co', getAnonKey: () => 'anon', }) await expect(driver.transcribe(Buffer.alloc(3200))).rejects.toThrow() expect(fetchSpy).not.toHaveBeenCalled() fetchSpy.mockRestore() }) })