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,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')
})
})