fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue