fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -0,0 +1,438 @@
// 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<void> | 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<unknown>),
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
}))
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<boolean> {
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<string, unknown> => {
const builder: Record<string, unknown> = {}
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<void>((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<unknown> = []
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<string, Buffer>()
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<S>(
(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')
})
})