fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation
This commit is contained in:
parent
1b8fe445f3
commit
9aa7302944
30 changed files with 2614 additions and 386 deletions
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue