fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 1b8fe445f3
commit 9aa7302944
30 changed files with 2614 additions and 386 deletions

View file

@ -0,0 +1,133 @@
// 이력 레드팀 r1-0: 삭제 시 녹음 WAV 정리, 대시보드 통계를 history 에서 계산
import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestDb } from '../../helpers/createTestDb'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import { getHistoryService, resetHistoryServiceForTests } from '../../../src/main/services/HistoryService'
import { isAppRecordingPath, recordingsDir } from '../../../src/main/services/history-deletion'
import { computeStreakDays, localDayKey } from '../../../src/main/services/history-stats'
import { resetAccountScopeForTests } from '../../../src/main/services/account-scope'
let testDb: ReturnType<typeof createTestDb>
const created: string[] = []
function writeRecording(name: string): string {
const dir = recordingsDir()
fs.mkdirSync(dir, { recursive: true })
const file = path.join(dir, name)
fs.writeFileSync(file, Buffer.from('RIFF'))
created.push(file)
return file
}
function insertHistory(opts: { audio?: string | null; createdAt?: number; status?: 'completed' | 'error'; words?: number; duration?: number }): string {
const id = crypto.randomUUID()
const at = opts.createdAt ?? Date.now()
testDb.db
.insert(history)
.values({
id,
originalText: 'x',
duration: opts.duration ?? 1,
wordCount: opts.words ?? 1,
status: opts.status ?? 'completed',
audioLocalPath: opts.audio ?? null,
createdAt: at,
updatedAt: at,
})
.run()
return id
}
async function waitGone(file: string): Promise<void> {
await vi.waitFor(() => expect(fs.existsSync(file)).toBe(false), { timeout: 2000, interval: 10 })
}
beforeEach(() => {
resetAccountScopeForTests()
testDb = createTestDb()
bindTestDatabase(testDb.db, '_local')
initInMemoryConfig()
resetHistoryServiceForTests()
})
afterEach(() => {
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
for (const f of created.splice(0)) fs.rmSync(f, { force: true })
resetAccountScopeForTests()
})
describe('녹음 파일 정리', () => {
it('단건 삭제는 녹음 WAV도 지운다', async () => {
const wav = writeRecording(`${crypto.randomUUID()}.wav`)
const id = insertHistory({ audio: wav })
expect(getHistoryService().delete(id)).toBe(true)
await waitGone(wav)
})
it('전체 삭제·원격 삭제 반영도 녹음을 지우지만, 녹음 폴더 밖 사용자 원본은 건드리지 않는다', async () => {
const wav1 = writeRecording(`${crypto.randomUUID()}.wav`)
const wav2 = writeRecording(`${crypto.randomUUID()}.wav`)
const outside = path.join(path.dirname(recordingsDir()), `user-source-${crypto.randomUUID()}.wav`)
fs.writeFileSync(outside, Buffer.from('RIFF'))
created.push(outside)
insertHistory({ audio: wav1 })
insertHistory({ audio: wav2 })
insertHistory({ audio: outside })
getHistoryService().deleteAll()
await waitGone(wav1)
await waitGone(wav2)
expect(fs.existsSync(outside)).toBe(true)
expect(testDb.db.select().from(history).all()).toEqual([])
})
it('동기화의 원격 삭제 반영(historyAdapter.deleteLocal)도 녹음을 지운다', async () => {
const { adapterFor } = await import('../../../src/main/services/sync/sync-adapters')
const wav = writeRecording(`${crypto.randomUUID()}.wav`)
const id = insertHistory({ audio: wav })
expect(adapterFor('history')?.deleteLocal(id)).toBe(true)
await waitGone(wav)
})
it('녹음 폴더 경계 판정', () => {
const dir = recordingsDir()
expect(isAppRecordingPath(path.join(dir, 'a.wav'), dir)).toBe(true)
expect(isAppRecordingPath(path.join(dir, '..', 'a.wav'), dir)).toBe(false)
expect(isAppRecordingPath(`${dir}-evil${path.sep}a.wav`, dir)).toBe(false)
expect(isAppRecordingPath(dir, dir)).toBe(false)
expect(isAppRecordingPath(null, dir)).toBe(false)
})
})
describe('대시보드 통계', () => {
it('동기화·가져오기로 들어온 기록도 누적 통계에 들어가고 연속 사용일을 계산한다', () => {
const day = 24 * 60 * 60 * 1000
const now = Date.now()
// create()를 거치지 않은 행(동기화 반영) — 예전 카운터에는 반영되지 않았다
insertHistory({ createdAt: now, words: 5, duration: 2 })
insertHistory({ createdAt: now - day, words: 3, duration: 1 })
insertHistory({ createdAt: now - 2 * day, words: 2, duration: 1 })
insertHistory({ createdAt: now - 2 * day, status: 'error', words: 100, duration: 50 })
const stats = getHistoryService().getStats()
expect(stats.totalSessionCount).toBe(3)
expect(stats.totalWordCount).toBe(10)
expect(stats.totalRecordingTimeMs).toBe(4000)
expect(stats.streakDays).toBe(3)
})
it('computeStreakDays: 오늘 기록이 없으면 어제부터, 끊기면 멈춘다', () => {
const today = new Date(2026, 8, 28, 15)
const key = (offset: number): string => localDayKey(new Date(2026, 8, 28 - offset))
expect(computeStreakDays([], today)).toBe(0)
expect(computeStreakDays([key(0), key(1), key(2)], today)).toBe(3)
expect(computeStreakDays([key(1), key(2)], today)).toBe(2)
expect(computeStreakDays([key(0), key(2)], today)).toBe(1)
expect(computeStreakDays([key(2), key(3)], today)).toBe(0)
})
})

View file

@ -0,0 +1,172 @@
// CloudSyncService 에서 분리한 SessionTokenStore·SyncScheduler, 로그인 콜백 가드 회귀 테스트
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EncryptedFileTokenStore,
bindAuthEvents,
type AuthSessionLike,
type TokenCipher,
type TokenFileSystem,
} from '../../../src/main/services/sync/session-token-store'
import { SyncScheduler } from '../../../src/main/services/sync/sync-scheduler'
function memoryFs(): TokenFileSystem & { files: Map<string, Buffer> } {
const files = new Map<string, Buffer>()
return {
files,
existsSync: (p) => files.has(p),
readFileSync: (p) => {
const data = files.get(p)
if (!data) throw new Error('ENOENT')
return data
},
writeFileSync: (p, data) => {
files.set(p, Buffer.from(data))
},
unlinkSync: (p) => {
files.delete(p)
},
}
}
const reversingCipher: TokenCipher = {
isEncryptionAvailable: () => true,
encryptString: (plain) => Buffer.from([...plain].reverse().join(''), 'utf-8'),
decryptString: (encrypted) => [...encrypted.toString('utf-8')].reverse().join(''),
}
const logger = { warn: vi.fn() }
describe('EncryptedFileTokenStore', () => {
it('암호화해 저장하고 복원하며, clear 는 파일을 지운다', () => {
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/u/cloud-sync.token', reversingCipher, fs, logger)
expect(store.load()).toBeNull()
store.save('rt-1')
expect(fs.files.get('/u/cloud-sync.token')?.toString('utf-8')).toBe('1-tr')
expect(store.load()).toBe('rt-1')
store.clear()
expect(store.load()).toBeNull()
})
it('OS 암호화를 못 쓰면 평문으로 저장하지 않는다', () => {
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/t', { ...reversingCipher, isEncryptionAvailable: () => false }, fs, logger)
store.save('secret')
expect(fs.files.size).toBe(0)
})
})
describe('bindAuthEvents — 회전된 refresh token 저장', () => {
type Session = AuthSessionLike & { access_token: string }
function harness(activeUser: string | null) {
let emit: (event: string, session: Session | null) => void = () => undefined
const unsubscribe = vi.fn()
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/t', reversingCipher, fs, logger)
const onSession = vi.fn()
let active = activeUser
const unbind = bindAuthEvents<Session>(
(cb) => {
emit = cb
return { unsubscribe }
},
store,
() => active,
onSession
)
return {
emit: (event: string, session: Session | null) => emit(event, session),
store,
onSession,
unsubscribe,
unbind,
setActive: (u: string | null) => {
active = u
},
}
}
const session = (rt: string, user = 'u1'): Session => ({ refresh_token: rt, access_token: `at-${rt}`, user: { id: user } })
it('TOKEN_REFRESHED 마다 새 토큰을 저장하고 메모리 세션도 갱신한다', () => {
const h = harness('u1')
h.store.save('rt-old')
h.emit('TOKEN_REFRESHED', session('rt-new'))
expect(h.store.load()).toBe('rt-new')
expect(h.onSession).toHaveBeenCalledWith(expect.objectContaining({ access_token: 'at-rt-new' }))
})
it('로그아웃 뒤·다른 사용자·SIGNED_OUT 이벤트는 무시한다', () => {
const h = harness('u1')
h.store.save('rt-keep')
h.emit('TOKEN_REFRESHED', session('rt-other', 'u2'))
h.emit('SIGNED_OUT', null)
h.emit('INITIAL_SESSION', session('rt-initial'))
expect(h.store.load()).toBe('rt-keep')
h.setActive(null)
h.emit('TOKEN_REFRESHED', session('rt-late'))
expect(h.store.load()).toBe('rt-keep')
expect(h.onSession).not.toHaveBeenCalled()
h.unbind()
expect(h.unsubscribe).toHaveBeenCalled()
})
})
describe('SyncScheduler', () => {
afterEach(() => {
vi.useRealTimers()
})
it('디바운스는 여러 요청을 한 번으로 모은다', () => {
vi.useFakeTimers()
const run = vi.fn()
const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 300_000, run, onHeartbeat: vi.fn() })
scheduler.scheduleFlush()
scheduler.scheduleFlush()
scheduler.schedulePull()
vi.advanceTimersByTime(1499)
expect(run).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(run.mock.calls).toEqual([['flush'], ['pull']])
scheduler.scheduleFlush()
vi.advanceTimersByTime(1500)
expect(run).toHaveBeenCalledTimes(3)
})
it('heartbeat 는 주기마다 부르고 stop 은 대기 중인 디바운스까지 취소한다', () => {
vi.useFakeTimers()
const run = vi.fn()
const onHeartbeat = vi.fn()
const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 1000, run, onHeartbeat })
scheduler.startHeartbeat()
vi.advanceTimersByTime(3000)
expect(onHeartbeat).toHaveBeenCalledTimes(3)
scheduler.scheduleFlush()
scheduler.stop()
vi.advanceTimersByTime(5000)
expect(onHeartbeat).toHaveBeenCalledTimes(3)
expect(run).not.toHaveBeenCalled()
expect(scheduler.isHeartbeatRunning()).toBe(false)
})
})
describe('로그인 콜백 가드 (login CSRF)', () => {
it('앱이 시작하지 않은 로그인 콜백 토큰은 세션을 바꾸지 않고 거부한다', async () => {
const { getCloudSyncService, resetCloudSyncServiceForTests } = await import('../../../src/main/services/CloudSyncService')
const { initInMemoryConfig, resetInMemoryConfig } = await import('../../../src/main/services/ConfigService')
initInMemoryConfig()
resetCloudSyncServiceForTests()
const svc = getCloudSyncService()
try {
await svc.init()
await expect(
svc.handleAuthTokens({ accessToken: 'attacker.at', refreshToken: 'attacker-rt' })
).rejects.toThrow(/no sign-in was started/)
await expect(svc.handleAuthCallback('attacker-code')).rejects.toThrow(/no sign-in was started/)
expect(svc.isAuthenticated()).toBe(false)
} finally {
resetCloudSyncServiceForTests()
resetInMemoryConfig()
}
})
})

View file

@ -0,0 +1,428 @@
// 동기화 레드팀 r1-0 회귀 테스트
// - 해제된 엔진이 다른 사용자 DB(로컬 모드)에 쓰지 않는다
// - 서버 시각 updated_at (클라이언트 시각을 보내지 않는다)
// - 계정별 명령·템플릿 범위, 템플릿 P0002 보관
// - 미룬 행(부모 없음) 재시도, 부모 실패 시 자식 커서 보존
// - push 전에 원격 삭제 반영(좀비 행 방지)
// - 명령 길이 제한(잘라 올리지 않음)
// - 엔티티 레지스트리 전수·순서
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history, meetingMemos, memoTags } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
INSTRUCTION_LIMITS,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import {
getDictationTemplateService,
resetDictationTemplateServiceForTests,
} from '../../../src/main/services/DictationTemplateService'
import {
getMeetingDocTemplateService,
resetMeetingDocTemplateServiceForTests,
} from '../../../src/main/services/MeetingDocTemplateService'
import {
createMemoryCollectionFactory,
resetAccountScopeForTests,
type UserScopedCollectionFactory,
} from '../../../src/main/services/account-scope'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import {
SyncAbortedError,
dictionaryToRemote,
historyToRemote,
instructionPushError,
meetingDocumentToRemote,
meetingMemoToRemote,
meetingToRemote,
templateUpsertError,
} from '../../../src/main/services/sync/sync-adapters'
import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox'
import { deleteOrder, SYNC_REGISTRY, upsertOrder } from '../../../src/main/services/sync/sync-registry'
import {
SYNC_ENTITIES,
SYNC_ENTITY_SPECS,
SyncRemoteError,
parentOf,
realtimeTables,
toSyncRemoteError,
type RemotePageRequest,
type RemoteRow,
} from '../../../src/main/services/sync/sync-types'
const USER = '11111111-1111-4111-8111-111111111111'
const USER_B = '33333333-3333-4333-8333-333333333333'
function uuid(): string {
return crypto.randomUUID()
}
/** fetchPage 를 테이블별로 가로챌 수 있는 원격 */
class GatedRemote extends FakeSyncRemote {
gate: ((request: RemotePageRequest) => Promise<void> | void) | null = null
override async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
const rows = await super.fetchPage(request)
if (this.gate) await this.gate(request)
return rows
}
}
let dbs: Array<ReturnType<typeof createTestDb>> = []
let factory: UserScopedCollectionFactory
function openDb(userId: string): ReturnType<typeof createTestDb> {
const created = createTestDb()
dbs.push(created)
bindTestDatabase(created.db, userId)
return created
}
beforeEach(() => {
resetAccountScopeForTests()
initInMemoryConfig()
factory = createMemoryCollectionFactory()
resetCustomInstructionServiceForTests(factory)
resetDictationTemplateServiceForTests(factory)
resetMeetingDocTemplateServiceForTests(factory)
})
afterEach(() => {
unbindTestDatabase()
resetInMemoryConfig()
for (const d of dbs) d.close()
dbs = []
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetAccountScopeForTests()
})
describe('해제된 엔진은 다른 사용자 DB에 쓰지 않는다', () => {
it('pull 도중 로그아웃(DB 전환)되면 계정 행이 로컬 모드 DB로 들어가지 않고 로컬 태그도 지우지 않는다', async () => {
const accountDb = openDb(USER)
const remote = new GatedRemote(USER)
for (let i = 0; i < 700; i++) {
remote.mobileInsert('history', { id: uuid(), original_text: `a${i}`, duration: 1, mode: 'dictation', status: 'completed' })
}
const engine = new SyncEngine({ remote, userId: USER })
let release: () => void = () => undefined
let pages = 0
remote.gate = (request) => {
if (request.table !== 'history') return
pages++
if (pages === 2) {
return new Promise<void>((resolve) => {
release = resolve
})
}
}
const running = engine.runFullSync()
// 두 번째 페이지 응답을 기다리는 동안 로그아웃: 엔진 해제 → 로컬 모드 DB로 전환
for (let i = 0; i < 200 && pages < 2; i++) await new Promise((r) => setTimeout(r, 10))
expect(pages).toBe(2)
engine.dispose()
const localDb = openDb('_local')
const localHistory = uuid()
const now = Date.now()
localDb.db.insert(history).values({ id: localHistory, originalText: 'local', duration: 1, createdAt: now, updatedAt: now }).run()
localDb.db.insert(memoTags).values({ id: uuid(), historyId: localHistory, tag: 'mine', createdAt: now }).run()
release()
await expect(running).rejects.toBeInstanceOf(SyncAbortedError)
const localRows = localDb.db.select().from(history).all()
expect(localRows.map((r) => r.id)).toEqual([localHistory])
expect(localDb.db.select().from(memoTags).all()).toHaveLength(1)
// 계정 DB는 첫 페이지까지만 반영됐고 커서도 로컬 DB에 쓰이지 않았다
expect(accountDb.db.select().from(history).all().length).toBeLessThanOrEqual(500)
})
it('해제 뒤에 줄을 선 작업은 시작하지 않는다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
engine.dispose()
await expect(engine.pull()).rejects.toBeInstanceOf(SyncAbortedError)
expect(remote.calls).toEqual([])
await expect(engine.whenIdle(100)).resolves.toBe(true)
})
it('다른 사용자 DB가 열리면 같은 엔진도 멈춘다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
openDb(USER_B)
expect(engine.isCurrent()).toBe(false)
await expect(engine.runFullSync()).rejects.toBeInstanceOf(SyncAbortedError)
})
})
describe('updated_at 은 서버 시각', () => {
it('push 페이로드에 클라이언트 updated_at 을 싣지 않는다', () => {
const at = Date.parse('2026-01-01T00:00:00Z')
const h = historyToRemote(
{
id: uuid(), title: null, originalText: 'x', polishedText: null, focusedApp: null, focusedAppName: null,
focusedAppWindowTitle: null, mode: 'dictation', status: 'completed', errorCode: null, audioLocalPath: null,
duration: 1, detectedLanguage: null, micDevice: null, wordCount: 1, sttModel: null, llmModel: null,
sttLatencyMs: null, llmLatencyMs: null, createdAt: at, updatedAt: at, appVersion: '1', summaryText: null,
isFavorite: false,
} as Parameters<typeof historyToRemote>[0],
USER
)
expect(h).not.toHaveProperty('updated_at')
const d = dictionaryToRemote(
{ id: uuid(), word: 'w', pronunciation: null, category: 'user', usageCount: 0, lastUsedAt: null, createdAt: at, updatedAt: at } as Parameters<typeof dictionaryToRemote>[0],
USER
)
expect(d).not.toHaveProperty('updated_at')
const m = meetingToRemote({ id: uuid(), title: 't', status: 'completed', startedAt: at, endedAt: null, createdAt: at, updatedAt: at } as Parameters<typeof meetingToRemote>[0], USER)
expect(m).not.toHaveProperty('updated_at')
const memo = meetingMemoToRemote({ id: uuid(), sessionId: uuid(), content: 'c', timestampMs: 1, createdAt: at, updatedAt: at } as Parameters<typeof meetingMemoToRemote>[0], USER)
expect(memo).not.toHaveProperty('updated_at')
const doc = meetingDocumentToRemote({ id: uuid(), sessionId: uuid(), templateType: 'minutes', title: 't', content: '', createdAt: at, updatedAt: at } as Parameters<typeof meetingDocumentToRemote>[0], USER)
expect(doc).not.toHaveProperty('updated_at')
})
it('다른 데스크톱이 늦게 올린 오래된 기록도 이미 동기화된 기기가 내려받는다', async () => {
const remote = new FakeSyncRemote(USER)
// 데스크톱 B: 이미 동기화돼 커서가 "지금" 근처
const dbB = openDb(USER)
remote.mobileInsert('history', { id: uuid(), original_text: 'recent', duration: 1, mode: 'dictation', status: 'completed' })
const engineB = new SyncEngine({ remote, userId: USER })
await engineB.runFullSync()
engineB.dispose()
for (let i = 0; i < 120; i++) remote.now() // 서버 시계 2분 경과
// 데스크톱 A: 몇 달 전 로컬 기록을 처음 올린다
const dbA = openDb(USER)
const old = Date.parse('2026-03-01T00:00:00Z')
const oldId = uuid()
dbA.db.insert(history).values({ id: oldId, originalText: 'old recording', duration: 1, createdAt: old, updatedAt: old }).run()
const engineA = new SyncEngine({ remote, userId: USER })
await engineA.runFullSync()
engineA.dispose()
expect(remote.find('history', oldId)).toBeDefined()
// B의 다음 pull 이 받아야 한다
bindTestDatabase(dbB.db, USER)
const engineB2 = new SyncEngine({ remote, userId: USER })
await engineB2.pull()
engineB2.dispose()
expect(dbB.db.select().from(history).where(eq(history.id, oldId)).get()?.originalText).toBe('old recording')
})
})
describe('계정별 명령·템플릿', () => {
it('다른 계정으로 로그인하면 이전 계정의 명령·템플릿이 보이지 않고 backfill 로 올라가지 않는다', async () => {
openDb(USER)
getCustomInstructionService().initialize()
const mine = getCustomInstructionService().create({ name: 'A private', description: '', prompt: 'secret prompt of A' })
const dictation = getDictationTemplateService().create({ name: 'A tpl', description: '', fields: [], outputFormat: 'x' })
const meetingTpl = getMeetingDocTemplateService().create({ name: 'A doc', description: '', systemPrompt: 'p' })
// 로그아웃 → 로컬 모드: A의 항목이 보이지 않는다
openDb('_local')
expect(getCustomInstructionService().getById(mine.id)).toBeNull()
expect(getDictationTemplateService().getById(dictation.id)).toBeNull()
expect(getMeetingDocTemplateService().getById(meetingTpl.id)).toBeNull()
// B 로그인
openDb(USER_B)
const remote = new FakeSyncRemote(USER_B)
const engine = new SyncEngine({ remote, userId: USER_B })
await engine.runFullSync()
engine.dispose()
expect(getCustomInstructionService().getById(mine.id)).toBeNull()
expect(remote.find('custom_instructions', mine.id)).toBeUndefined()
expect(remote.find('user_templates', dictation.id)).toBeUndefined()
expect(remote.find('user_templates', meetingTpl.id)).toBeUndefined()
expect(getCustomInstructionService().getAll().filter((i) => i.isBuiltin)).toHaveLength(5)
// A 다시 로그인: A의 항목이 돌아온다
openDb(USER)
expect(getCustomInstructionService().getById(mine.id)?.prompt).toBe('secret prompt of A')
expect(getDictationTemplateService().getById(dictation.id)).not.toBeNull()
})
it('로컬 모드에서 만든 명령은 처음 로그인한 계정 하나만 가져간다', () => {
openDb('_local')
getCustomInstructionService().initialize()
const local = getCustomInstructionService().create({ name: 'local', description: '', prompt: 'p' })
openDb(USER)
expect(getCustomInstructionService().getById(local.id)).not.toBeNull()
openDb(USER_B)
expect(getCustomInstructionService().getById(local.id)).toBeNull()
})
it('다른 계정 소유 템플릿(P0002)은 재시도 불가로 보관되고, 일반 P0002 는 여전히 재시도한다', () => {
const err = templateUpsertError({ code: 'P0002', message: 'template_not_found' })
expect(err.retryable).toBe(false)
expect(toSyncRemoteError({ code: 'P0002', message: 'history_not_found' }).retryable).toBe(true)
expect(templateUpsertError(new SyncRemoteError('fetch failed', 'network', true)).retryable).toBe(true)
})
})
describe('미룬 행과 커서', () => {
it('부모 회의가 늦게 온 메모는 커서가 지나가도 다음 pull 에서 반영된다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const meetingId = uuid()
const memoId = uuid()
// 메모가 먼저 서버에 보이고(회의 행은 아직 없음)
remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'late parent', timestamp_ms: 1 })
for (let i = 0; i < 120; i++) remote.now()
// 더 새 메모(다른 회의)로 memo 커서가 2분 앞으로 간다
const otherMeeting = uuid()
remote.mobileInsert('meetings', { id: otherMeeting, title: 'other', status: 'completed', started_at: remote.now() })
remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: otherMeeting, content: 'newer', timestamp_ms: 1 })
const engine = new SyncEngine({ remote, userId: USER })
const first = await engine.runFullSync()
expect(first.errors).toEqual([])
expect(testMemo(memoId)).toBeUndefined()
// 부모 회의가 도착
remote.mobileInsert('meetings', { id: meetingId, title: 'late', status: 'completed', started_at: remote.now() })
await engine.pull()
engine.dispose()
expect(testMemo(memoId)?.content).toBe('late parent')
})
it('회의 pull 이 실패하면 같은 실행에서 메모 커서를 넘기지 않는다', async () => {
openDb(USER)
const remote = new GatedRemote(USER)
const meetingId = uuid()
remote.mobileInsert('meetings', { id: meetingId, title: 'm', status: 'completed', started_at: remote.now() })
const memoId = uuid()
remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'child', timestamp_ms: 1 })
let failMeetings = true
remote.gate = (request) => {
if (request.table === 'meetings' && failMeetings) throw new SyncRemoteError('fetch failed', 'network', true)
}
const engine = new SyncEngine({ remote, userId: USER })
const failed = await engine.pull()
expect(failed.errors.some((e) => e.startsWith('meetings'))).toBe(true)
expect(remote.calls.filter((c) => c === 'fetch:meeting_memos')).toEqual([])
failMeetings = false
await engine.pull()
engine.dispose()
expect(testMemo(memoId)?.content).toBe('child')
})
})
function testMemo(id: string): { content: string } | undefined {
return dbs[dbs.length - 1].db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).get()
}
describe('push 전에 원격 삭제 반영', () => {
it('폰에서 지운 행의 오프라인 편집이 서버에 행을 되살리지 않는다', async () => {
const d = openDb(USER)
const remote = new FakeSyncRemote(USER)
const id = uuid()
remote.mobileInsert('history', { id, original_text: 'orig', duration: 1, mode: 'dictation', status: 'completed' })
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeDefined()
// 폰이 지우고, 데스크톱은 오프라인에서 편집해 대기 upsert 가 있다
remote.mobileDelete('history', id)
d.db.update(history).set({ title: 'edited offline', updatedAt: Date.now() }).where(eq(history.id, id)).run()
enqueueChange('history', id, 'upsert')
const result = await engine.flush()
engine.dispose()
expect(remote.find('history', id)).toBeUndefined()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined()
expect(pendingOps('history').has(id)).toBe(false)
expect(result.deleted).toBe(1)
})
it('최초 대조(backfill)는 서버에서 지워진 행을 다시 올리지 않는다', async () => {
const d = openDb(USER)
const remote = new FakeSyncRemote(USER)
const id = uuid()
remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
remote.mobileDelete('history', id)
const at = Date.now()
d.db.insert(history).values({ id, originalText: 'x', duration: 1, createdAt: at, updatedAt: at }).run()
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
engine.dispose()
expect(remote.find('history', id)).toBeUndefined()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined()
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
})
})
describe('명령 길이 제한', () => {
it('서버 제한을 넘는 명령은 만들거나 고칠 수 없다', () => {
openDb(USER)
const svc = getCustomInstructionService()
svc.initialize()
expect(() => svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/)
expect(() => svc.create({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1), description: '', prompt: 'p' })).toThrow(/too long/)
const ok = svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt) })
expect(() => svc.update(ok.id, { prompt: 'y'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/)
expect(svc.getById(ok.id)?.prompt).toHaveLength(INSTRUCTION_LIMITS.prompt)
})
it('이미 저장된 긴 명령은 잘라 올리지 않고 재시도 불가 오류로 보관된다', async () => {
openDb(USER)
const svc = getCustomInstructionService()
svc.initialize()
const longPrompt = 'z'.repeat(6000)
const id = uuid()
svc.applyRemote({ id, name: 'legacy', description: '', prompt: longPrompt, icon: 'x', isBuiltin: false, order: 9, createdAt: 1, updatedAt: 1 })
const instruction = svc.getById(id)
expect(instruction).not.toBeNull()
expect(instructionPushError(instruction!)?.retryable).toBe(false)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
engine.dispose()
expect(remote.find('custom_instructions', id)).toBeUndefined()
expect(svc.getById(id)?.prompt).toBe(longPrompt)
})
})
describe('엔티티 레지스트리', () => {
it('모든 엔티티가 정확히 한 번 등록되고 부모가 자식보다 먼저 push 된다', () => {
const entities = SYNC_REGISTRY.map((e) => e.entity)
expect([...entities].sort()).toEqual([...SYNC_ENTITIES].sort())
expect(new Set(entities).size).toBe(entities.length)
for (const spec of SYNC_ENTITY_SPECS) {
const parent = parentOf(spec.entity)
if (!parent) continue
expect(SYNC_ENTITIES).toContain(parent)
expect(entities.indexOf(parent)).toBeLessThan(entities.indexOf(spec.entity))
}
})
it('push·delete 순서와 Realtime 대상이 이전과 같다', () => {
expect(upsertOrder().map((e) => e.entity)).toEqual([
'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'custom_instructions',
'builtin_instructions', 'user_settings', 'user_templates', 'knowledge_documents', 'memo_tags',
'history_audio', 'meeting_audio',
])
expect(deleteOrder().map((e) => e.entity)).toEqual([
'memo_tags', 'knowledge_documents', 'user_templates', 'custom_instructions', 'meeting_documents',
'meeting_memos', 'meetings', 'dictionary', 'history',
])
expect([...realtimeTables()].sort()).toEqual(
[
'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'memo_tags',
'custom_instructions', 'user_templates', 'knowledge_documents', 'user_settings', 'sync_tombstones',
].sort()
)
})
})

View file

@ -0,0 +1,136 @@
// closeToTray=false 로 메인 창을 닫으면 창 없는 프로세스로 남지 않고 종료한다. 창이 사라진 뒤 트레이
// '표시'·두 번째 실행은 창을 다시 만든다.
import { beforeEach, describe, expect, it, vi } from 'vitest'
type Listener = (...args: unknown[]) => void
const state = vi.hoisted(() => ({
windows: [] as Array<{ listeners: Map<string, Listener>; destroyed: boolean }>,
quit: vi.fn(),
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
const quit = state.quit
class FakeBrowserWindow {
listeners = new Map<string, Listener>()
destroyed = false
webContents = {
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
openDevTools: vi.fn(),
insertCSS: vi.fn(async () => ''),
send: vi.fn(),
}
constructor() {
state.windows.push(this)
}
on(event: string, listener: Listener): this {
this.listeners.set(event, listener)
return this
}
once(event: string, listener: Listener): this {
return this.on(event, listener)
}
setSkipTaskbar = vi.fn()
setMenu = vi.fn()
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
center = vi.fn()
restore = vi.fn()
show = vi.fn()
focus = vi.fn()
hide = vi.fn()
setAlwaysOnTop = vi.fn()
flashFrame = vi.fn()
isMinimized = vi.fn(() => false)
isDestroyed(): boolean {
return this.destroyed
}
}
return {
app: { quit, getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {
getPrimaryDisplay: vi.fn(() => ({ workAreaSize: { width: 1920, height: 1080 }, workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })),
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
},
ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
clipboard: { writeText: vi.fn() },
shell: { openExternal: vi.fn(async () => undefined) },
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
Tray: vi.fn(),
dialog: { showErrorBox: vi.fn() },
}
})
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
vi.mock('../../../src/main/lifecycle', () => {
let quitting = false
return {
getIsQuitting: () => quitting,
setIsQuitting: (value: boolean) => {
quitting = value
},
}
})
const config: Record<string, unknown> = { closeToTray: false, theme: 'dark', language: 'ko' }
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => config[key],
configSet: (key: string, value: unknown) => {
config[key] = value
},
}))
beforeEach(async () => {
state.windows.length = 0
state.quit.mockClear()
const lifecycle = await import('../../../src/main/lifecycle')
lifecycle.setIsQuitting(false)
})
describe('main window close', () => {
it('closeToTray=false 이면 창 닫기가 앱 종료로 이어진다', async () => {
config.closeToTray = false
const { createMainWindow } = await import('../../../src/main/windows/WindowManager')
createMainWindow()
const win = state.windows[0]
const event = { preventDefault: vi.fn() }
win.listeners.get('close')?.(event)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(state.quit).toHaveBeenCalledTimes(1)
})
it('closeToTray=true 이면 숨기기만 하고 종료하지 않는다', async () => {
vi.resetModules()
config.closeToTray = true
const { createMainWindow } = await import('../../../src/main/windows/WindowManager')
createMainWindow()
const win = state.windows[0]
const event = { preventDefault: vi.fn() }
win.listeners.get('close')?.(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(state.quit).not.toHaveBeenCalled()
})
it('창이 파괴된 뒤 showMainWindow 는 창을 다시 만든다(부트스트랩 전에는 만들지 않는다)', async () => {
vi.resetModules()
const { createMainWindow, showMainWindow, getMainWindow } = await import('../../../src/main/windows/WindowManager')
expect(showMainWindow()).toBeNull()
expect(state.windows).toHaveLength(0)
createMainWindow()
const first = state.windows[0]
first.destroyed = true
first.listeners.get('closed')?.()
expect(getMainWindow()).toBeNull()
expect(showMainWindow()).not.toBeNull()
expect(state.windows).toHaveLength(2)
})
})

View file

@ -0,0 +1,100 @@
// 내비게이션·외부 URL 가드 회귀 테스트
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain, shell } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
hardenWebContents,
isTrustedIpcSender,
openExternalSafe,
} from '../../../src/main/windows/web-contents-hardening'
type Handler = (...args: unknown[]) => unknown
function fakeWindow() {
const listeners = new Map<string, Handler>()
let openHandler: ((details: { url: string }) => { action: string }) | null = null
const webContents = {
on: vi.fn((event: string, listener: Handler) => {
listeners.set(event, listener)
}),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
openHandler = handler
}),
}
return {
win: { webContents } as unknown as Parameters<typeof hardenWebContents>[0],
fire: (event: string, url: string) => {
const preventDefault = vi.fn()
listeners.get(event)?.({ preventDefault }, url)
return preventDefault
},
open: (url: string) => openHandler?.({ url }),
listeners,
}
}
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear()
})
describe('hardenWebContents', () => {
it('앱 창이 원격 페이지로 이동하지 못하게 막고 https 는 브라우저로 넘긴다', async () => {
const w = fakeWindow()
hardenWebContents(w.win)
const prevented = w.fire('will-navigate', 'https://evil.example/phish')
expect(prevented).toHaveBeenCalled()
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://evil.example/phish'))
})
it('앱 자신의 페이지(file://)로의 이동은 허용한다', () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.fire('will-navigate', 'file:///C:/app/out/renderer/index.html#/x')).not.toHaveBeenCalled()
})
it('새 창은 항상 거부하고 file:/UNC/사용자 정의 scheme 은 OS로 넘기지 않는다', async () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.open('file:///C:/Windows/System32/calc.exe')).toEqual({ action: 'deny' })
expect(w.open('search-ms:query=x')).toEqual({ action: 'deny' })
expect(w.fire('will-navigate', 'ms-msdt:/id PCWDiagnostic')).toHaveBeenCalled()
await new Promise((r) => setTimeout(r, 10))
expect(shell.openExternal).not.toHaveBeenCalled()
expect(w.listeners.has('will-attach-webview')).toBe(true)
})
})
describe('openExternalSafe / isTrustedIpcSender', () => {
it('허용 scheme 만 연다', async () => {
expect(await openExternalSafe('file:///etc/passwd')).toBe(false)
expect(await openExternalSafe('https://d3ro.chanpaca.net/')).toBe(true)
expect(shell.openExternal).toHaveBeenCalledTimes(1)
})
it('원격 페이지가 보낸 IPC 는 신뢰하지 않는다', () => {
expect(isTrustedIpcSender({ senderFrame: { url: 'file:///C:/app/index.html' } } as never)).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: 'https://evil.example/' } } as never)).toBe(false)
expect(isTrustedIpcSender({ senderFrame: null } as never)).toBe(false)
})
})
describe('SYSTEM.OPEN_EXTERNAL IPC', () => {
it('file:·원격 발신자 요청을 거부하고 https 는 연다', async () => {
const handlers = new Map<string, Handler>()
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => {
handlers.set(channel, handler)
})
const { registerWindowHandlers } = await import('../../../src/main/ipc/window-handlers')
registerWindowHandlers()
const handler = handlers.get(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL)!
const appEvent = { senderFrame: { url: 'file:///C:/app/index.html' } }
const remoteEvent = { senderFrame: { url: 'https://evil.example/' } }
await expect(handler(appEvent, { url: 'file:///C:/Windows/System32/calc.exe' })).resolves.toMatchObject({ success: false })
await expect(handler(remoteEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: false })
expect(shell.openExternal).not.toHaveBeenCalled()
await expect(handler(appEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: true })
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com')
})
})