d3ro-voice/apps/desktop/tests/main/sync/cloud-credentials-redteam-r3-1.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

264 lines
10 KiB
TypeScript

// 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<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>),
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<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: () => {
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()
})
})