457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { BrowserUrlPolicy } from './browser-url-policy'
|
|
import {
|
|
ElectronBrowserSession,
|
|
type BrowserPartitionSession,
|
|
type BrowserWebContents,
|
|
type BrowserWindowHandle,
|
|
type FilteringProxyLike
|
|
} from './electron-browser-session'
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((resolvePromise) => {
|
|
resolve = resolvePromise
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function createHarness() {
|
|
const debuggerEvents = new EventEmitter()
|
|
const contentEvents = new EventEmitter()
|
|
const partitionEvents = new EventEmitter()
|
|
const windowEvents = new EventEmitter()
|
|
let currentUrl = ''
|
|
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
|
const sendCommand = vi.fn(async () => ({}))
|
|
const capturedImage = {
|
|
getSize: () => ({ width: 1_280, height: 800 }),
|
|
resize: vi.fn(),
|
|
toJPEG: () => Buffer.from([0xff, 0xd8, 0xff, 0xd9])
|
|
}
|
|
capturedImage.resize.mockReturnValue(capturedImage)
|
|
const webContents: BrowserWebContents = {
|
|
debugger: {
|
|
attach: vi.fn(),
|
|
detach: vi.fn(),
|
|
isAttached: vi.fn(() => true),
|
|
sendCommand,
|
|
on: (event, listener) =>
|
|
debuggerEvents.on(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
off: (event, listener) =>
|
|
debuggerEvents.off(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
)
|
|
},
|
|
on: (event, listener) =>
|
|
contentEvents.on(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
off: (event, listener) =>
|
|
contentEvents.off(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
setWindowOpenHandler: vi.fn((handler) => {
|
|
openHandler = handler
|
|
}),
|
|
capturePage: vi.fn(async () => capturedImage),
|
|
getURL: vi.fn(() => currentUrl),
|
|
stop: vi.fn(),
|
|
destroy: vi.fn(),
|
|
isDestroyed: vi.fn(() => false)
|
|
}
|
|
const window: BrowserWindowHandle = {
|
|
webContents,
|
|
loadURL: vi.fn(async (url: string) => {
|
|
currentUrl = url
|
|
}),
|
|
show: vi.fn(),
|
|
minimize: vi.fn(),
|
|
restore: vi.fn(),
|
|
isMinimized: vi.fn(() => false),
|
|
focus: vi.fn(),
|
|
on: (event, listener) =>
|
|
windowEvents.on(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
off: (event, listener) =>
|
|
windowEvents.off(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
destroy: vi.fn(),
|
|
isDestroyed: vi.fn(() => false)
|
|
}
|
|
let permissionCheck: ((...values: unknown[]) => boolean) | undefined
|
|
let permissionRequest:
|
|
| ((
|
|
contents: unknown,
|
|
permission: string,
|
|
callback: (granted: boolean) => void,
|
|
details: unknown
|
|
) => void)
|
|
| undefined
|
|
let displayMedia:
|
|
| ((
|
|
request: unknown,
|
|
callback: (streams: Record<string, never>) => void
|
|
) => void)
|
|
| undefined
|
|
const partition: BrowserPartitionSession = {
|
|
setPermissionCheckHandler: vi.fn((handler) => {
|
|
permissionCheck = handler
|
|
}),
|
|
setPermissionRequestHandler: vi.fn((handler) => {
|
|
permissionRequest = handler
|
|
}),
|
|
setDisplayMediaRequestHandler: vi.fn((handler) => {
|
|
displayMedia = handler
|
|
}),
|
|
setProxy: vi.fn(async () => undefined),
|
|
setUserAgent: vi.fn(),
|
|
on: (event, listener) =>
|
|
partitionEvents.on(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
off: (event, listener) =>
|
|
partitionEvents.off(
|
|
event,
|
|
listener as (...argumentsValue: unknown[]) => void
|
|
),
|
|
clearData: vi.fn(async () => undefined),
|
|
closeAllConnections: vi.fn(async () => undefined)
|
|
}
|
|
const proxy: FilteringProxyLike = {
|
|
start: vi.fn(async () => 'http://127.0.0.1:12345'),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const policy = new BrowserUrlPolicy(async () => [
|
|
{ address: '93.184.216.34', family: 4 }
|
|
])
|
|
return {
|
|
contentEvents,
|
|
debuggerEvents,
|
|
partitionEvents,
|
|
windowEvents,
|
|
partition,
|
|
proxy,
|
|
policy,
|
|
sendCommand,
|
|
webContents,
|
|
window,
|
|
setCurrentUrl(value: string) {
|
|
currentUrl = value
|
|
},
|
|
getOpenHandler: () => openHandler,
|
|
getPermissionCheck: () => permissionCheck,
|
|
getPermissionRequest: () => permissionRequest,
|
|
getDisplayMedia: () => displayMedia
|
|
}
|
|
}
|
|
|
|
describe('ElectronBrowserSession', () => {
|
|
it('creates an isolated sandboxed partition and denies privileged capabilities', async () => {
|
|
const harness = createHarness()
|
|
const createWindow = vi.fn(async (options: Record<string, unknown>) => {
|
|
const preferences = options.webPreferences as Record<string, unknown>
|
|
expect(preferences).toMatchObject({
|
|
sandbox: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
webSecurity: true,
|
|
allowRunningInsecureContent: false,
|
|
devTools: false
|
|
})
|
|
return harness.window
|
|
})
|
|
const session = await ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
createPartition: vi.fn(async () => harness.partition),
|
|
createWindow,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
|
|
expect(session.partition).toMatch(/^browser-/u)
|
|
expect(harness.partition.setProxy).toHaveBeenCalledWith({
|
|
mode: 'fixed_servers',
|
|
proxyRules: 'http://127.0.0.1:12345',
|
|
proxyBypassRules: '<-loopback>'
|
|
})
|
|
expect(harness.partition.setUserAgent).toHaveBeenCalledWith(
|
|
expect.stringMatching(/ Chrome\/.+ Safari\/537\.36$/u),
|
|
'zh-CN,zh,en'
|
|
)
|
|
expect(
|
|
vi.mocked(harness.partition.setUserAgent!).mock.calls[0]?.[0]
|
|
).not.toContain('Electron')
|
|
expect(harness.getPermissionCheck()?.()).toBe(false)
|
|
const permissionCallback = vi.fn()
|
|
harness.getPermissionRequest()?.({}, 'geolocation', permissionCallback, {})
|
|
expect(permissionCallback).toHaveBeenCalledWith(false)
|
|
const mediaCallback = vi.fn()
|
|
harness.getDisplayMedia()?.({}, mediaCallback)
|
|
expect(mediaCallback).toHaveBeenCalledWith({})
|
|
expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({
|
|
action: 'deny'
|
|
})
|
|
expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank')
|
|
expect(
|
|
vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0]
|
|
).toBeLessThan(
|
|
vi.mocked(harness.webContents.debugger.attach).mock
|
|
.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
|
)
|
|
expect(harness.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
|
|
expect(harness.sendCommand).toHaveBeenCalledWith('Page.enable')
|
|
expect(harness.sendCommand).toHaveBeenCalledWith(
|
|
'Page.setInterceptFileChooserDialog',
|
|
{ enabled: true }
|
|
)
|
|
await expect(
|
|
session.captureScreenshot(new AbortController().signal)
|
|
).resolves.toEqual({
|
|
type: 'image',
|
|
mimeType: 'image/jpeg',
|
|
data: '/9j/2Q=='
|
|
})
|
|
|
|
const downloadEvent = { preventDefault: vi.fn() }
|
|
const item = { cancel: vi.fn() }
|
|
harness.partitionEvents.emit('will-download', downloadEvent, item)
|
|
expect(downloadEvent.preventDefault).toHaveBeenCalled()
|
|
expect(item.cancel).toHaveBeenCalled()
|
|
harness.debuggerEvents.emit(
|
|
'message',
|
|
{},
|
|
'Page.fileChooserOpened',
|
|
{}
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(harness.sendCommand).toHaveBeenCalledWith(
|
|
'Page.handleFileChooser',
|
|
{ action: 'cancel' }
|
|
)
|
|
)
|
|
await session.dispose()
|
|
})
|
|
|
|
it('allows HTTP(S) top-level navigation and cross-origin redirects', async () => {
|
|
const harness = createHarness()
|
|
const session = await ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
createPartition: async () => harness.partition,
|
|
createWindow: async () => harness.window,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
const target = await harness.policy.validate(
|
|
'https://example.com/start',
|
|
new AbortController().signal
|
|
)
|
|
session.approveNavigation(target)
|
|
harness.setCurrentUrl('https://example.com/page')
|
|
expect(session.getCurrentOrigin()).toBe('https://example.com')
|
|
|
|
const sameOriginEvent = { preventDefault: vi.fn() }
|
|
harness.contentEvents.emit(
|
|
'will-navigate',
|
|
sameOriginEvent,
|
|
'https://example.com/next'
|
|
)
|
|
expect(sameOriginEvent.preventDefault).not.toHaveBeenCalled()
|
|
const foreignEvent = { preventDefault: vi.fn() }
|
|
harness.contentEvents.emit(
|
|
'will-redirect',
|
|
foreignEvent,
|
|
'https://attacker.example/'
|
|
)
|
|
expect(foreignEvent.preventDefault).not.toHaveBeenCalled()
|
|
|
|
harness.setCurrentUrl('https://attacker.example/')
|
|
harness.contentEvents.emit(
|
|
'did-navigate',
|
|
{},
|
|
'https://attacker.example/'
|
|
)
|
|
expect(harness.webContents.stop).not.toHaveBeenCalled()
|
|
expect(session.getCurrentOrigin()).toBe('https://attacker.example')
|
|
await expect(
|
|
session.validateRedirect(
|
|
'http://10.0.0.25/admin',
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toBeUndefined()
|
|
expect(session.getApprovedOrigin()).toBe('http://10.0.0.25')
|
|
await session.dispose()
|
|
})
|
|
|
|
it('restores the browser for interaction and minimizes it on close', async () => {
|
|
const harness = createHarness()
|
|
const parentWindow = {
|
|
setEnabled: vi.fn(),
|
|
focus: vi.fn(),
|
|
isDestroyed: vi.fn(() => false)
|
|
}
|
|
let createdWindowOptions: Record<string, unknown> | undefined
|
|
const createWindow = vi.fn(
|
|
async (options: Record<string, unknown>) => {
|
|
createdWindowOptions = options
|
|
return harness.window
|
|
}
|
|
)
|
|
const session = await ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
parentWindow,
|
|
createPartition: async () => harness.partition,
|
|
createWindow,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
|
|
expect(createWindow).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
parent: parentWindow,
|
|
show: false,
|
|
title: 'GoodBuddy 浏览器交互'
|
|
})
|
|
)
|
|
expect(createdWindowOptions?.modal).toBeUndefined()
|
|
const interaction = session.openInteraction()
|
|
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
|
|
expect(harness.window.show).toHaveBeenCalledOnce()
|
|
expect(harness.window.focus).toHaveBeenCalledOnce()
|
|
const closeEvent = { preventDefault: vi.fn() }
|
|
harness.windowEvents.emit('close', closeEvent)
|
|
|
|
await expect(interaction).resolves.toEqual({
|
|
type: 'image',
|
|
mimeType: 'image/jpeg',
|
|
data: '/9j/2Q=='
|
|
})
|
|
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
|
|
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
|
|
expect(harness.window.minimize).toHaveBeenCalledOnce()
|
|
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
|
|
expect(parentWindow.focus).toHaveBeenCalledOnce()
|
|
expect(
|
|
vi.mocked(harness.webContents.capturePage!).mock
|
|
.invocationCallOrder[0]
|
|
).toBeLessThan(
|
|
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
|
|
Number.POSITIVE_INFINITY
|
|
)
|
|
const repeatedCloseEvent = { preventDefault: vi.fn() }
|
|
harness.windowEvents.emit('close', repeatedCloseEvent)
|
|
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
|
|
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
|
|
expect(harness.window.destroy).not.toHaveBeenCalled()
|
|
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
|
|
const reopenedInteraction = session.openInteraction()
|
|
expect(harness.window.restore).toHaveBeenCalledOnce()
|
|
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
|
|
await reopenedInteraction
|
|
await session.dispose()
|
|
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
|
const harness = createHarness()
|
|
const session = await ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
createPartition: async () => harness.partition,
|
|
createWindow: async () => harness.window,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
await session.dispose()
|
|
await session.dispose()
|
|
|
|
expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce()
|
|
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
|
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
|
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
|
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
|
expect(harness.contentEvents.listenerCount('will-navigate')).toBe(0)
|
|
expect(harness.debuggerEvents.listenerCount('message')).toBe(0)
|
|
})
|
|
|
|
it('cleans up partially created resources when debugger setup fails', async () => {
|
|
const harness = createHarness()
|
|
harness.sendCommand.mockRejectedValueOnce(new Error('debugger failed'))
|
|
await expect(
|
|
ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
createPartition: async () => harness.partition,
|
|
createWindow: async () => harness.window,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
).rejects.toThrow('无法创建安全浏览器会话')
|
|
expect(harness.window.destroy).toHaveBeenCalled()
|
|
expect(harness.partition.clearData).toHaveBeenCalled()
|
|
expect(harness.proxy.dispose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('reclaims a partition that resolves after setup times out', async () => {
|
|
const harness = createHarness()
|
|
const partitionGate = deferred<BrowserPartitionSession>()
|
|
const creation = ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
setupTimeoutMs: 5,
|
|
createPartition: () => partitionGate.promise,
|
|
createWindow: async () => harness.window,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
|
|
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
|
|
partitionGate.resolve(harness.partition)
|
|
await vi.waitFor(() =>
|
|
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
|
)
|
|
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('destroys a hidden window that resolves after setup times out', async () => {
|
|
const harness = createHarness()
|
|
const windowGate = deferred<BrowserWindowHandle>()
|
|
const creation = ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
setupTimeoutMs: 5,
|
|
createPartition: async () => harness.partition,
|
|
createWindow: () => windowGate.promise,
|
|
createProxy: () => harness.proxy
|
|
})
|
|
|
|
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
|
|
windowGate.resolve(harness.window)
|
|
await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce())
|
|
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
|
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('disposes a proxy whose start resolves after setup times out', async () => {
|
|
const harness = createHarness()
|
|
const proxyGate = deferred<string>()
|
|
const proxy: FilteringProxyLike = {
|
|
start: vi.fn(() => proxyGate.promise),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const creation = ElectronBrowserSession.create({
|
|
policy: harness.policy,
|
|
setupTimeoutMs: 5,
|
|
createPartition: async () => harness.partition,
|
|
createWindow: async () => harness.window,
|
|
createProxy: () => proxy
|
|
})
|
|
|
|
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
|
|
proxyGate.resolve('http://127.0.0.1:12345')
|
|
await vi.waitFor(() => expect(proxy.dispose).toHaveBeenCalledOnce())
|
|
})
|
|
})
|