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,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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue