mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 216 low-value browser network and session tests (#38681)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -211,7 +211,6 @@
|
||||
1 test-no-real-time-wait:src/main/browser/guest-lifecycle/popups/browser-popup-native-compatibility.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/identity/browser-session-ua-wire-identity-cross-context.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/identity/browser-session-ua-wire-identity.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/network/execution-routes/paired-runtime-browser-network-route.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/network/tunnels/execution-route-socket-duplex.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/network/tunnels/remote-browser-socks-server.test.ts
|
||||
1 test-no-real-time-wait:src/main/claude-accounts/claude-login-session.test.ts
|
||||
@@ -663,7 +662,6 @@
|
||||
2 test-no-wall-clock:src/main/antigravity/posix-hook-invocation.test.ts
|
||||
2 test-no-wall-clock:src/main/browser/cookies/browser-cookie-import-partition-reland.electron.test.ts
|
||||
2 test-no-wall-clock:src/main/browser/network/execution-routes/wsl-browser-network-execution-route.test.ts
|
||||
2 test-no-wall-clock:src/main/browser/sessions/browser-session-registry.test.ts
|
||||
1 test-no-wall-clock:src/main/claude-accounts/claude-account-folder.test.ts
|
||||
1 test-no-wall-clock:src/main/claude-usage/persisted-source-authority.test.ts
|
||||
1 test-no-wall-clock:src/main/claude/claude-background-wake-retention.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1224,7 +1224,6 @@
|
||||
"src/main/browser/sessions/browser-session-proxy.test.ts": 857,
|
||||
"src/main/browser/sessions/browser-session-registry-identity.persistence.test.ts": 603,
|
||||
"src/main/browser/sessions/browser-session-registry-startup-metadata.test.ts": 478,
|
||||
"src/main/browser/sessions/browser-session-registry.persistence.test.ts": 1132,
|
||||
"src/main/browser/sessions/browser-session-registry.test.ts": 883,
|
||||
"src/main/browser/identity/browser-session-ua-wire-identity-cross-context.electron.test.ts": 7167,
|
||||
"src/main/browser/identity/browser-session-ua-wire-identity.electron.test.ts": 8280,
|
||||
|
||||
@@ -87,69 +87,6 @@ describe('BrowserClientNetworkRouteRegistry', () => {
|
||||
expect(route.close).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('rejects native and WSL routes for a different authority runtime', async () => {
|
||||
const routeFactory = vi.fn(() => createRoute())
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: routeFactory
|
||||
})
|
||||
const key = browserNetworkExecutionHostKey({
|
||||
kind: 'native',
|
||||
runtimeId: 'runtime-b',
|
||||
revision: 1
|
||||
})
|
||||
|
||||
await expect(registry.retain(key, signal())).rejects.toThrow(
|
||||
'browser_client_network_route_authority_mismatch'
|
||||
)
|
||||
const wslKey = browserNetworkExecutionHostKey({
|
||||
kind: 'wsl',
|
||||
runtimeId: 'runtime-b',
|
||||
revision: 1,
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
await expect(registry.retain(wslKey, signal())).rejects.toThrow(
|
||||
'browser_client_network_route_authority_mismatch'
|
||||
)
|
||||
expect(routeFactory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes every retained route and rejects later admission', async () => {
|
||||
const firstRoute = createRoute()
|
||||
const secondRoute = createRoute()
|
||||
const routeFactory = vi.fn().mockReturnValueOnce(firstRoute).mockReturnValueOnce(secondRoute)
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: routeFactory
|
||||
})
|
||||
await registry.retain(
|
||||
browserNetworkExecutionHostKey({ kind: 'native', runtimeId: 'runtime-a', revision: 1 }),
|
||||
signal()
|
||||
)
|
||||
await registry.retain(
|
||||
browserNetworkExecutionHostKey({
|
||||
kind: 'ssh',
|
||||
targetId: 'ssh-a',
|
||||
providerEpoch: 'provider-a',
|
||||
connectionGeneration: 3
|
||||
}),
|
||||
signal()
|
||||
)
|
||||
|
||||
await registry.close()
|
||||
|
||||
expect(firstRoute.close).toHaveBeenCalledOnce()
|
||||
expect(secondRoute.close).toHaveBeenCalledOnce()
|
||||
await expect(
|
||||
registry.retain(
|
||||
browserNetworkExecutionHostKey({ kind: 'native', runtimeId: 'runtime-a', revision: 2 }),
|
||||
signal()
|
||||
)
|
||||
).rejects.toThrow('browser_client_network_route_registry_closed')
|
||||
})
|
||||
|
||||
it('retires old authority routes without destroying them before exact page cleanup', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
@@ -181,92 +118,6 @@ describe('BrowserClientNetworkRouteRegistry', () => {
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('force-closes retained retired routes during final shutdown', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: () => route
|
||||
})
|
||||
await registry.retain(
|
||||
browserNetworkExecutionHostKey({ kind: 'native', runtimeId: 'runtime-a', revision: 1 }),
|
||||
signal()
|
||||
)
|
||||
|
||||
void registry.retire()
|
||||
await registry.close()
|
||||
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('suspends every retained transport and restores the same listener address', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: () => route
|
||||
})
|
||||
const key = browserNetworkExecutionHostKey({
|
||||
kind: 'native',
|
||||
runtimeId: 'runtime-a',
|
||||
revision: 1
|
||||
})
|
||||
await registry.retain(key, signal())
|
||||
|
||||
registry.suspend()
|
||||
await expect(registry.retain(key, signal())).rejects.toThrow(
|
||||
'browser_client_network_route_registry_suspended'
|
||||
)
|
||||
await registry.reconnect()
|
||||
|
||||
expect(route.suspend).toHaveBeenCalledOnce()
|
||||
expect(route.reconnect).toHaveBeenCalledOnce()
|
||||
await expect(registry.retain(key, signal())).resolves.toMatchObject({
|
||||
proxyEndpoint: { host: '127.0.0.1', port: 43123 }
|
||||
})
|
||||
await registry.close()
|
||||
})
|
||||
|
||||
it('retries one flaky route without closing healthy retained routes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const flaky = createRoute()
|
||||
flaky.reconnect
|
||||
.mockRejectedValueOnce(new Error('transient tunnel failure'))
|
||||
.mockResolvedValue({ host: '127.0.0.1', port: 43123 })
|
||||
const healthy = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
reconnectGraceMs: 1_000,
|
||||
reconnectRetryDelayMs: 10,
|
||||
createRoute: vi.fn().mockReturnValueOnce(flaky).mockReturnValueOnce(healthy)
|
||||
})
|
||||
await registry.retain(
|
||||
browserNetworkExecutionHostKey({ kind: 'native', runtimeId: 'runtime-a', revision: 1 }),
|
||||
signal()
|
||||
)
|
||||
await registry.retain(
|
||||
browserNetworkExecutionHostKey({
|
||||
kind: 'ssh',
|
||||
targetId: 'ssh-a',
|
||||
providerEpoch: 'provider-a',
|
||||
connectionGeneration: 1
|
||||
}),
|
||||
signal()
|
||||
)
|
||||
|
||||
registry.suspend()
|
||||
const reconnecting = registry.reconnect()
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(reconnecting).resolves.toBeUndefined()
|
||||
|
||||
expect(flaky.reconnect).toHaveBeenCalledTimes(2)
|
||||
expect(healthy.reconnect).toHaveBeenCalledOnce()
|
||||
expect(flaky.close).not.toHaveBeenCalled()
|
||||
expect(healthy.close).not.toHaveBeenCalled()
|
||||
await registry.close()
|
||||
})
|
||||
|
||||
it('aborts a stale route recovery without retaining its retry timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
let rejectReconnect = (_error: Error): void => {}
|
||||
@@ -398,22 +249,6 @@ describe('BrowserClientNetworkRouteRegistry', () => {
|
||||
expect(retired).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a retain whose signal aborted before admission', async () => {
|
||||
const routeFactory = vi.fn(() => createRoute())
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: routeFactory
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(registry.retain(nativeKey(1), controller.signal)).rejects.toThrow(
|
||||
'browser_client_network_route_aborted'
|
||||
)
|
||||
expect(routeFactory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a retain the moment its signal aborts mid-startup', async () => {
|
||||
let resolveStart = (_address: { host: string; port: number }): void => {}
|
||||
const route = createRoute(
|
||||
@@ -461,111 +296,6 @@ describe('BrowserClientNetworkRouteRegistry', () => {
|
||||
expect(route.start).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a fully released key clean so final cleanup closes it only once', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
const retained = await registry.retain(nativeKey(1), signal())
|
||||
|
||||
await retained.release()
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
|
||||
await registry.close()
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('never re-closes a route the registry already force-closed', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
const key = nativeKey(1)
|
||||
const first = await registry.retain(key, signal())
|
||||
const second = await registry.retain(key, signal())
|
||||
|
||||
await registry.close()
|
||||
await first.release()
|
||||
await second.release()
|
||||
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores a repeated release from the same page handle', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
const key = nativeKey(1)
|
||||
const first = await registry.retain(key, signal())
|
||||
const second = await registry.retain(key, signal())
|
||||
|
||||
await first.release()
|
||||
await first.release()
|
||||
expect(route.close).not.toHaveBeenCalled()
|
||||
|
||||
await second.release()
|
||||
expect(route.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses a retain whose route came back on a different port', async () => {
|
||||
const route = createRoute()
|
||||
route.reconnect.mockResolvedValue({ host: '127.0.0.1', port: 43999 })
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
const key = nativeKey(1)
|
||||
await registry.retain(key, signal())
|
||||
|
||||
await expect(registry.retain(key, signal())).rejects.toThrow(
|
||||
'browser_client_network_route_address_changed'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a reconnect that moved a retained route to a different port', async () => {
|
||||
const route = createRoute()
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
await registry.retain(nativeKey(1), signal())
|
||||
registry.suspend()
|
||||
route.reconnect.mockResolvedValue({ host: '127.0.0.1', port: 43999 })
|
||||
|
||||
await expect(registry.reconnect()).rejects.toThrow(
|
||||
'browser_client_network_route_address_changed'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails retirement when a route refuses to suspend', async () => {
|
||||
const route = createRoute()
|
||||
route.suspend.mockImplementation(() => {
|
||||
throw new Error('suspend refused')
|
||||
})
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
await registry.retain(nativeKey(1), signal())
|
||||
|
||||
expect(() => registry.retire()).toThrow('Browser client network route suspension failed')
|
||||
|
||||
await expect(registry.retire()).rejects.toThrow(
|
||||
'Browser client network route suspension failed'
|
||||
)
|
||||
expect(route.suspend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a failed final cleanup to an awaiting retirement', async () => {
|
||||
const route = createRoute()
|
||||
route.close.mockRejectedValue(new Error('route cleanup outcome unknown'))
|
||||
@@ -585,40 +315,6 @@ describe('BrowserClientNetworkRouteRegistry', () => {
|
||||
|
||||
expect(retirementFailure).toBe('Browser client network route cleanup failed')
|
||||
})
|
||||
|
||||
it('refuses to report retirement for a registry whose close already failed', async () => {
|
||||
const route = createRoute()
|
||||
route.close.mockRejectedValue(new Error('route cleanup outcome unknown'))
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
await registry.retain(nativeKey(1), signal())
|
||||
|
||||
await expect(registry.close()).rejects.toThrow('Browser client network route cleanup failed')
|
||||
|
||||
await expect(registry.retire()).rejects.toThrow('Browser client network route cleanup failed')
|
||||
})
|
||||
|
||||
it('surfaces both the retain failure and its cleanup failure', async () => {
|
||||
const route = createRoute()
|
||||
route.start.mockRejectedValue(new Error('tunnel start failed'))
|
||||
route.close.mockRejectedValue(new Error('route cleanup outcome unknown'))
|
||||
const registry = new BrowserClientNetworkRouteRegistry({
|
||||
authority,
|
||||
authorityStorageKey,
|
||||
createRoute: vi.fn(() => route)
|
||||
})
|
||||
|
||||
await expect(registry.retain(nativeKey(1), signal())).rejects.toMatchObject({
|
||||
message: 'tunnel start failed',
|
||||
errors: [
|
||||
expect.objectContaining({ message: 'tunnel start failed' }),
|
||||
expect.objectContaining({ message: 'route cleanup outcome unknown' })
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function nativeKey(revision: number): string {
|
||||
|
||||
-303
@@ -25,7 +25,6 @@ vi.mock('../../../../shared/remote-runtime/remote-runtime-client', () => ({
|
||||
|
||||
import { PairedRuntimeBrowserNetworkRoute } from './paired-runtime-browser-network-route'
|
||||
import { BrowserNetworkTunnelOutboundMemoryBudgetRegistry } from '../tunnels/browser-network-tunnel-outbound-memory-budget'
|
||||
import { RemoteBrowserSocksServer } from '../tunnels/remote-browser-socks-server'
|
||||
|
||||
const pairing = {
|
||||
v: 2,
|
||||
@@ -119,92 +118,6 @@ describe('PairedRuntimeBrowserNetworkRoute', () => {
|
||||
expect(closeSubscription).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('surfaces listener teardown failures', async () => {
|
||||
vi.spyOn(RemoteBrowserSocksServer.prototype, 'close').mockRejectedValueOnce(
|
||||
new Error('listener close failed')
|
||||
)
|
||||
const route = createRoute()
|
||||
|
||||
await expect(route.close()).rejects.toThrow('listener close failed')
|
||||
})
|
||||
|
||||
it('tears down the local route when the runtime closes the tunnel', async () => {
|
||||
let callbacks: RemoteRuntimeSubscriptionCallbacks | undefined
|
||||
const closeSubscription = vi.fn()
|
||||
subscribeRemoteRuntimeRequestMock.mockImplementationOnce(
|
||||
async (...args: unknown[]): Promise<RemoteRuntimeSubscription> => {
|
||||
callbacks = args[4] as RemoteRuntimeSubscriptionCallbacks
|
||||
return {
|
||||
requestId: 'runtime-closed-subscription',
|
||||
close: closeSubscription,
|
||||
sendBinary: () => true
|
||||
}
|
||||
}
|
||||
)
|
||||
const onError = vi.fn()
|
||||
const route = createRoute({ onError })
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(callbacks).toBeDefined())
|
||||
callbacks!.onResponse({
|
||||
id: 'runtime-closed-subscription',
|
||||
ok: true,
|
||||
result: { type: 'ready', tunnelGeneration: 7 },
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
await starting
|
||||
|
||||
callbacks!.onResponse({
|
||||
id: 'runtime-closed-subscription',
|
||||
ok: true,
|
||||
result: { type: 'closed', tunnelGeneration: 7 },
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(closeSubscription).toHaveBeenCalledOnce())
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Browser network route closed by the runtime' })
|
||||
)
|
||||
})
|
||||
|
||||
it('finishes failure cleanup when reporting and listener teardown both throw', async () => {
|
||||
let callbacks: RemoteRuntimeSubscriptionCallbacks | undefined
|
||||
const closeSubscription = vi.fn()
|
||||
subscribeRemoteRuntimeRequestMock.mockImplementationOnce(
|
||||
async (...args: unknown[]): Promise<RemoteRuntimeSubscription> => {
|
||||
callbacks = args[4] as RemoteRuntimeSubscriptionCallbacks
|
||||
return {
|
||||
requestId: 'cleanup-failure-subscription',
|
||||
close: closeSubscription,
|
||||
sendBinary: () => true
|
||||
}
|
||||
}
|
||||
)
|
||||
const closeSocks = vi
|
||||
.spyOn(RemoteBrowserSocksServer.prototype, 'close')
|
||||
.mockRejectedValueOnce(new Error('listener close failed'))
|
||||
const route = createRoute({
|
||||
onError: () => {
|
||||
throw new Error('reporting failed')
|
||||
}
|
||||
})
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(callbacks).toBeDefined())
|
||||
callbacks!.onResponse({
|
||||
id: 'cleanup-failure-subscription',
|
||||
ok: true,
|
||||
result: { type: 'ready', tunnelGeneration: 7 },
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
await starting
|
||||
|
||||
callbacks!.onClose?.()
|
||||
|
||||
await vi.waitFor(() => expect(closeSubscription).toHaveBeenCalledOnce())
|
||||
expect(closeSocks).not.toHaveBeenCalled()
|
||||
await expect(route.close()).rejects.toThrow('listener close failed')
|
||||
expect(closeSocks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('binds and releases the exact browser-host outbound memory lease', async () => {
|
||||
let callbacks: RemoteRuntimeSubscriptionCallbacks | undefined
|
||||
let subscriptionOptions: Record<string, unknown> | undefined
|
||||
@@ -235,14 +148,6 @@ describe('PairedRuntimeBrowserNetworkRoute', () => {
|
||||
expect(registry.evidence()).toMatchObject({ hosts: 0, leases: 0 })
|
||||
})
|
||||
|
||||
it('opens no subscription when browser-host memory admission is exhausted', async () => {
|
||||
const registry = new BrowserNetworkTunnelOutboundMemoryBudgetRegistry({ processMaxLeases: 0 })
|
||||
const route = createRoute({ outboundMemoryBudgetRegistry: registry })
|
||||
|
||||
await expect(route.start()).rejects.toThrow('outbound memory admission failed')
|
||||
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps one listener fail-closed and accepts an increasing replacement generation', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
@@ -274,111 +179,6 @@ describe('PairedRuntimeBrowserNetworkRoute', () => {
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('suspends the tunnel immediately while preserving the SOCKS listener', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
const address = await starting
|
||||
|
||||
route.suspend()
|
||||
expect(attempts[0]!.close).toHaveBeenCalledOnce()
|
||||
const offline = await connectSocks(address.host, address.port)
|
||||
await greetSocks(offline)
|
||||
offline.write(domainConnectRequest('offline.internal', 443))
|
||||
expect(Array.from(await readExact(offline, 10))).toEqual([5, 1, 0, 1, 0, 0, 0, 0, 0, 0])
|
||||
offline.destroy()
|
||||
|
||||
const reconnecting = route.reconnect()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(2))
|
||||
ready(attempts[1]!, 8)
|
||||
await expect(reconnecting).resolves.toEqual(address)
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('reattaches with the exact existing v1 payload and capability pair', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute({
|
||||
subscription: { clientCapabilities: ['existing.optional.v1'] }
|
||||
})
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
await starting
|
||||
attempts[0]!.callbacks.onClose?.()
|
||||
|
||||
const reconnecting = route.reconnect()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(2))
|
||||
ready(attempts[1]!, 8)
|
||||
await reconnecting
|
||||
|
||||
for (const attempt of attempts) {
|
||||
expect(attempt.method).toBe('network.browserTunnel')
|
||||
expect(attempt.params).toEqual({
|
||||
...lease,
|
||||
executionHost: { kind: 'native', runtimeId: 'runtime-a', revision: 1 }
|
||||
})
|
||||
expect(attempt.options).toMatchObject({
|
||||
perMessageDeflate: false,
|
||||
clientCapabilities: [
|
||||
'existing.optional.v1',
|
||||
'browser.clientHost.v1',
|
||||
'network.browserTunnel.v1'
|
||||
]
|
||||
})
|
||||
}
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('keeps page-command negotiation off the independent tunnel attach', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const commandLease = { ...lease, pageCommandProtocolVersion: 1 }
|
||||
const route = new PairedRuntimeBrowserNetworkRoute({
|
||||
connection: pairedRuntimeConnectionFromOffer(pairing),
|
||||
lease: commandLease,
|
||||
executionHostRevision: 1
|
||||
})
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
await starting
|
||||
|
||||
expect(attempts[0]!.params).toEqual({
|
||||
...lease,
|
||||
executionHost: { kind: 'native', runtimeId: 'runtime-a', revision: 1 }
|
||||
})
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('negotiates execution-host routing only for an SSH descriptor', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const executionHost = {
|
||||
kind: 'ssh' as const,
|
||||
targetId: 'target-a',
|
||||
providerEpoch: 'provider-epoch-a',
|
||||
connectionGeneration: 2
|
||||
}
|
||||
const route = createRoute({ executionHost })
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
await starting
|
||||
|
||||
expect(attempts[0]).toMatchObject({
|
||||
method: 'network.browserTunnel',
|
||||
params: { ...lease, executionHost },
|
||||
options: {
|
||||
clientCapabilities: [
|
||||
'browser.clientHost.v1',
|
||||
'network.browserTunnel.v1',
|
||||
'network.browserTunnel.executionHosts.v1'
|
||||
]
|
||||
}
|
||||
})
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('ignores late callbacks from a superseded transport', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
@@ -413,69 +213,6 @@ describe('PairedRuntimeBrowserNetworkRoute', () => {
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('reconnects after a current tunnel protocol failure closes the client', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
const address = await starting
|
||||
|
||||
attempts[0]!.callbacks.onBinary?.(new Uint8Array([1, 2, 3]))
|
||||
await vi.waitFor(() => expect(attempts[0]!.close).toHaveBeenCalledOnce())
|
||||
const reconnecting = route.reconnect()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(2))
|
||||
ready(attempts[1]!, 8)
|
||||
await reconnecting
|
||||
|
||||
const current = await connectSocks(address.host, address.port)
|
||||
await greetSocks(current)
|
||||
current.write(domainConnectRequest('recovered.internal', 443))
|
||||
await vi.waitFor(() => expect(openFrames(attempts[1]!)).toHaveLength(1))
|
||||
attempts[1]!.callbacks.onBinary?.(
|
||||
encodeBrowserNetworkTunnelFrame({
|
||||
opcode: BrowserNetworkTunnelOpcode.Opened,
|
||||
tunnelGeneration: 8,
|
||||
streamId: 1,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
expect(Array.from(await readExact(current, 10))).toEqual([5, 0, 0, 1, 0, 0, 0, 0, 0, 0])
|
||||
|
||||
current.destroy()
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('rebuilds a retained route after its tunnel transport dies unprompted', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
const address = await starting
|
||||
|
||||
attempts[0]!.callbacks.onClose?.()
|
||||
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(2))
|
||||
ready(attempts[1]!, 8)
|
||||
const recovered = await connectSocks(address.host, address.port)
|
||||
await greetSocks(recovered)
|
||||
recovered.write(domainConnectRequest('recovered.internal', 443))
|
||||
await vi.waitFor(() => expect(openFrames(attempts[1]!)).toHaveLength(1))
|
||||
attempts[1]!.callbacks.onBinary?.(
|
||||
encodeBrowserNetworkTunnelFrame({
|
||||
opcode: BrowserNetworkTunnelOpcode.Opened,
|
||||
tunnelGeneration: 8,
|
||||
streamId: 1,
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
)
|
||||
expect(Array.from(await readExact(recovered, 10))).toEqual([5, 0, 0, 1, 0, 0, 0, 0, 0, 0])
|
||||
|
||||
recovered.destroy()
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('recovers again after a second transport death', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
@@ -498,46 +235,6 @@ describe('PairedRuntimeBrowserNetworkRoute', () => {
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('retries a failing rebuild and reports the terminal failure to the route owner', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const onUnavailable = vi.fn()
|
||||
const route = createRoute({ onUnavailable, recoveryGraceMs: 200, recoveryRetryDelayMs: 10 })
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
await starting
|
||||
|
||||
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('runtime unreachable'))
|
||||
attempts[0]!.callbacks.onClose?.()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onUnavailable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('could not be rebuilt') })
|
||||
)
|
||||
)
|
||||
expect(subscribeRemoteRuntimeRequestMock.mock.calls.length).toBeGreaterThan(2)
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('abandons transport recovery once the route is suspended', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute({ recoveryGraceMs: 5_000 })
|
||||
const starting = route.start()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(1))
|
||||
ready(attempts[0]!, 7)
|
||||
await starting
|
||||
|
||||
attempts[0]!.callbacks.onClose?.()
|
||||
await vi.waitFor(() => expect(attempts).toHaveLength(2))
|
||||
attempts[1]!.callbacks.onClose?.()
|
||||
route.suspend()
|
||||
|
||||
const settled = attempts.length
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(attempts).toHaveLength(settled)
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('rejects a non-increasing replacement generation and remains retryable', async () => {
|
||||
const attempts = mockSubscriptionAttempts()
|
||||
const route = createRoute()
|
||||
|
||||
+2
-150
@@ -1,10 +1,8 @@
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { connect, createServer, type AddressInfo, type Socket } from 'node:net'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { connect, createServer, type Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnection } from '../../../ssh/connection/ssh-connection'
|
||||
import type { SshProviderEpoch } from '../../../../shared/ssh-types'
|
||||
import { resolveSshBrowserNetworkExecutionRoute } from './ssh-browser-network-execution-route'
|
||||
import { setNetworkProxySettingsSource } from './browser-network-proxy-settings-source'
|
||||
import type { BrowserNetworkTunnelSocket } from '../tunnels/browser-network-tunnel-stream-state'
|
||||
@@ -48,75 +46,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('SSH browser network execution route', () => {
|
||||
it('rejects an unavailable or stale authority before opening a destination', async () => {
|
||||
const connection = fakeConnection({ forwardOut: vi.fn() })
|
||||
const registerAuthorityAbort = vi.fn()
|
||||
|
||||
await expect(
|
||||
resolveSshBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 1 },
|
||||
{
|
||||
connectionManager: { getConnection: () => connection },
|
||||
isCurrentAuthority: () => false,
|
||||
registerAuthorityAbort
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('browser_tunnel_execution_host_unavailable')
|
||||
expect(registerAuthorityAbort).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the exact domain and port to ssh2 forwardOut and fences rotation', async () => {
|
||||
const channel = new PassThrough() as PassThrough & { close: ReturnType<typeof vi.fn> }
|
||||
channel.close = vi.fn(() => channel.destroy())
|
||||
const forwardOut = vi.fn(
|
||||
(
|
||||
_sourceHost: string,
|
||||
_sourcePort: number,
|
||||
_host: string,
|
||||
_port: number,
|
||||
callback: (error: Error | undefined, channel: PassThrough) => void
|
||||
) => callback(undefined, channel)
|
||||
)
|
||||
const connection = fakeConnection({ forwardOut })
|
||||
let current = true
|
||||
let authorityAbort: AbortController | undefined
|
||||
const removeAuthorityAbort = vi.fn()
|
||||
const route = await resolveSshBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 1 },
|
||||
{
|
||||
connectionManager: { getConnection: () => connection },
|
||||
isCurrentAuthority: (authority) =>
|
||||
current &&
|
||||
authority.providerEpoch === ('provider-epoch-a' as SshProviderEpoch) &&
|
||||
authority.connectionGeneration === 2,
|
||||
registerAuthorityAbort: (_authority, controller) => {
|
||||
authorityAbort = controller
|
||||
return removeAuthorityAbort
|
||||
},
|
||||
readHostProxyEnvironment: noHostProxy
|
||||
}
|
||||
)
|
||||
|
||||
const socket = route.connect({ host: 'split-horizon.internal', port: 8443 })
|
||||
await once(socket as unknown as EventEmitter, 'connect')
|
||||
expect(forwardOut).toHaveBeenCalledWith(
|
||||
'127.0.0.1',
|
||||
0,
|
||||
'split-horizon.internal',
|
||||
8443,
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
current = false
|
||||
authorityAbort?.abort()
|
||||
await route.whenInvalidated
|
||||
expect(route.isValid()).toBe(false)
|
||||
await route.close()
|
||||
await route.close()
|
||||
expect(removeAuthorityAbort).toHaveBeenCalledOnce()
|
||||
expect(socket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not adopt a replacement ssh2 client under the captured authority', async () => {
|
||||
const firstForwardOut = vi.fn()
|
||||
const secondForwardOut = vi.fn()
|
||||
@@ -177,83 +106,6 @@ describe('SSH browser network execution route', () => {
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('owns one system dynamic forward for the whole execution route', async () => {
|
||||
const connection = fakeConnection(null)
|
||||
const process = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null
|
||||
signalCode: NodeJS.Signals | null
|
||||
}
|
||||
process.exitCode = null
|
||||
process.signalCode = null
|
||||
const dispose = vi.fn()
|
||||
const close = vi.fn(async () => {})
|
||||
const startDynamicForward = vi.fn(async () => ({
|
||||
localPort: 45678,
|
||||
process: process as unknown as ChildProcess,
|
||||
stderrTail: () => '',
|
||||
dispose,
|
||||
close
|
||||
}))
|
||||
const route = await resolveSshBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 1 },
|
||||
{
|
||||
connectionManager: { getConnection: () => connection },
|
||||
isCurrentAuthority: () => true,
|
||||
registerAuthorityAbort: () => () => {},
|
||||
startDynamicForward,
|
||||
readHostProxyEnvironment: noHostProxy
|
||||
}
|
||||
)
|
||||
|
||||
expect(startDynamicForward).toHaveBeenCalledOnce()
|
||||
process.emit('error', new Error('dynamic forward failed'))
|
||||
await route.whenInvalidated
|
||||
expect(route.isValid()).toBe(false)
|
||||
await route.close()
|
||||
await route.close()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails a stale system SSH socket loudly instead of closing it silently', async () => {
|
||||
const listener = createServer()
|
||||
servers.push(listener)
|
||||
await new Promise<void>((resolve) => listener.listen(0, '127.0.0.1', resolve))
|
||||
const connection = fakeConnection(null)
|
||||
const forwardProcess = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null
|
||||
signalCode: NodeJS.Signals | null
|
||||
}
|
||||
forwardProcess.exitCode = null
|
||||
forwardProcess.signalCode = null
|
||||
const route = await resolveSshBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 1 },
|
||||
{
|
||||
connectionManager: { getConnection: () => connection },
|
||||
isCurrentAuthority: () => true,
|
||||
registerAuthorityAbort: () => () => {},
|
||||
readHostProxyEnvironment: noHostProxy,
|
||||
startDynamicForward: async () => ({
|
||||
localPort: (listener.address() as AddressInfo).port,
|
||||
process: forwardProcess as unknown as ChildProcess,
|
||||
stderrTail: () => '',
|
||||
dispose: () => {},
|
||||
close: async () => {}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
forwardProcess.exitCode = 0
|
||||
const socket = route.connect({ host: 'stale.internal', port: 443 })
|
||||
const events: string[] = []
|
||||
socket.on('error', (error) => events.push(`error:${error.message}`))
|
||||
socket.on('close', () => events.push('close'))
|
||||
|
||||
await vi.waitFor(() => expect(events).toContain('close'))
|
||||
expect(events[0]).toBe('error:browser_tunnel_execution_host_stale')
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('aborts system SSH startup when route authorization is revoked', async () => {
|
||||
const connection = fakeConnection(null)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -234,17 +234,6 @@ describe('WSL browser network execution route', () => {
|
||||
expect(child.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects stale runtime identity before launching WSL', async () => {
|
||||
const launchRelay = vi.fn()
|
||||
await expect(
|
||||
resolveWslBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 8 },
|
||||
{ launchRelay }
|
||||
)
|
||||
).rejects.toThrow('browser_tunnel_execution_host_mismatch')
|
||||
expect(launchRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates the route when the exact distro process exits', async () => {
|
||||
const child = createRelayProcess(() => new FakeDestination())
|
||||
const route = await resolveWslBrowserNetworkExecutionRoute(
|
||||
@@ -261,26 +250,6 @@ describe('WSL browser network execution route', () => {
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('delivers a response larger than the relay receive window', async () => {
|
||||
const payload = patternedPayload(4 * BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES)
|
||||
const destination = new PacedDestination(payload)
|
||||
const route = await resolveWslBrowserNetworkExecutionRoute(
|
||||
{ executionHost, runtimeId: 'runtime-a', runtimeRevision: 7 },
|
||||
{ launchRelay: async () => createRelayProcess(() => destination.accept()) }
|
||||
)
|
||||
|
||||
const socket = route.connect({ host: 'large.internal', port: 443 })
|
||||
socket.on('error', () => {})
|
||||
const received: Buffer[] = []
|
||||
socket.on('data', (bytes) => received.push(Buffer.from(bytes)))
|
||||
|
||||
await vi.waitFor(() => expect(Buffer.concat(received).byteLength).toBe(payload.byteLength), {
|
||||
timeout: 10_000
|
||||
})
|
||||
expect(Buffer.concat(received).equals(payload)).toBe(true)
|
||||
await route.close()
|
||||
})
|
||||
|
||||
it('holds the WSL destination behind a stalled paired-client reader, then completes', async () => {
|
||||
const payload = patternedPayload(8 * BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES)
|
||||
const destination = new PacedDestination(payload)
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
encodeBrowserNetworkTunnelWindowUpdate,
|
||||
type BrowserNetworkTunnelFrame
|
||||
} from '../../../../shared/browser-network-tunnel-protocol'
|
||||
import {
|
||||
BROWSER_NETWORK_TUNNEL_CONNECT_TIMEOUT_MS,
|
||||
BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES
|
||||
} from './browser-network-tunnel-stream-state'
|
||||
import { BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES } from './browser-network-tunnel-stream-state'
|
||||
import { BrowserNetworkTunnelClient } from './browser-network-tunnel-client'
|
||||
|
||||
function frame(
|
||||
@@ -130,101 +127,6 @@ describe('BrowserNetworkTunnelClient', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('destroys every stream when the route closes or rejects a frame', async () => {
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
sendBinary: () => true
|
||||
})
|
||||
const firstOpening = client.open({ host: 'one.internal', port: 80 })
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Opened, new Uint8Array(), 7, 1))
|
||||
const first = await firstOpening
|
||||
first.on('error', () => {})
|
||||
const secondOpening = client.open({ host: 'two.internal', port: 80 })
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Opened, new Uint8Array(), 7, 2))
|
||||
const second = await secondOpening
|
||||
second.on('error', () => {})
|
||||
|
||||
client.handleBinary(new Uint8Array([1, 2, 3]))
|
||||
|
||||
expect(first.destroyed).toBe(true)
|
||||
expect(second.destroyed).toBe(true)
|
||||
await expect(client.open({ host: 'three.internal', port: 80 })).rejects.toThrow(
|
||||
'Browser tunnel is closed'
|
||||
)
|
||||
})
|
||||
|
||||
it('retires a remotely failed stream without echoing an error or closing the route', async () => {
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
const opening = client.open({ host: 'refused.internal', port: 443 })
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Opened))
|
||||
const socket = await opening
|
||||
const socketError = once(socket, 'error')
|
||||
sent.length = 0
|
||||
|
||||
client.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.Error, new TextEncoder().encode('destination_error'))
|
||||
)
|
||||
|
||||
await expect(socketError).resolves.toEqual([
|
||||
expect.objectContaining({ message: 'Browser tunnel destination failed: destination_error' })
|
||||
])
|
||||
expect(sent).toEqual([])
|
||||
const nextOpening = client.open({ host: 'healthy.internal', port: 443 })
|
||||
expect(decodeBrowserNetworkTunnelFrame(sent[0]!)?.opcode).toBe(BrowserNetworkTunnelOpcode.Open)
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Opened, new Uint8Array(), 7, 2))
|
||||
const nextSocket = await nextOpening
|
||||
nextSocket.on('error', () => {})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('rejects a destination failure before opening without an unhandled socket error', async () => {
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
sendBinary: () => true
|
||||
})
|
||||
const opening = client.open({ host: 'refused.internal', port: 443 })
|
||||
|
||||
client.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.Error, new TextEncoder().encode('destination_error'))
|
||||
)
|
||||
|
||||
await expect(opening).rejects.toThrow('Browser tunnel destination failed: destination_error')
|
||||
// Allow a deferred socket 'error' to surface before the test ends.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('fails a never-connected stream on timeout and notifies the remote', async () => {
|
||||
vi.useFakeTimers()
|
||||
let opening: Promise<unknown>
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
try {
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
opening = client.open({ host: 'unreachable.internal', port: 443 })
|
||||
sent.length = 0
|
||||
vi.advanceTimersByTime(BROWSER_NETWORK_TUNNEL_CONNECT_TIMEOUT_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
await expect(opening).rejects.toThrow('Browser tunnel destination connect timed out')
|
||||
expect(decodeBrowserNetworkTunnelFrame(sent[0]!)?.opcode).toBe(BrowserNetworkTunnelOpcode.Error)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
})
|
||||
|
||||
it('ignores a retired stream frame but rejects a never-allocated stream ID', async () => {
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
@@ -285,35 +187,6 @@ describe('BrowserNetworkTunnelClient', () => {
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('withholds destination credit until a readable consumer asks for data', async () => {
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
tunnelGeneration: 7,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
const opening = client.open({ host: 'localhost', port: 8080 })
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Opened))
|
||||
const socket = await opening
|
||||
socket.on('error', () => {})
|
||||
sent.length = 0
|
||||
|
||||
client.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, new Uint8Array([7, 8, 9])))
|
||||
expect(sent).toEqual([])
|
||||
|
||||
const received = once(socket, 'data')
|
||||
await expect(received).resolves.toEqual([Buffer.from([7, 8, 9])])
|
||||
expect(sent).toEqual([])
|
||||
socket.settleRead(3)
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1))
|
||||
expect(decodeBrowserNetworkTunnelFrame(sent[0]!)?.opcode).toBe(
|
||||
BrowserNetworkTunnelOpcode.WindowUpdate
|
||||
)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('replenishes only bytes settled after the readable consumer takes them', async () => {
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const client = new BrowserNetworkTunnelClient({
|
||||
|
||||
@@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite
|
||||
import {
|
||||
BrowserNetworkTunnelOpcode,
|
||||
decodeBrowserNetworkTunnelFrame,
|
||||
decodeBrowserNetworkTunnelWindowUpdate,
|
||||
encodeBrowserNetworkTunnelFrame
|
||||
decodeBrowserNetworkTunnelWindowUpdate
|
||||
} from '../../../../shared/browser-network-tunnel-protocol'
|
||||
import { BrowserNetworkTunnelClient } from './browser-network-tunnel-client'
|
||||
import type { BrowserNetworkTunnelDuplex } from './browser-network-tunnel-duplex'
|
||||
@@ -67,42 +66,6 @@ describe('browser network tunnel conformance', () => {
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('ends the client stream when the destination closes without a half-close', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const { duplex, destination } = await openStream(tunnel)
|
||||
const consumer = consumeDuplex(duplex)
|
||||
destination.emit('data', new Uint8Array([1, 2, 3]))
|
||||
await settle()
|
||||
|
||||
destination.emit('close')
|
||||
await whenClosed(duplex)
|
||||
|
||||
expect(opcodesFor(tunnel, 'sessionToClient', 1).slice(-1)).toEqual(['Close'])
|
||||
expect(consumer.ended).toBe(true)
|
||||
expect(consumer.bytes()).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(tunnel.closures).toEqual([])
|
||||
expect(tunnel.onSessionClose).not.toHaveBeenCalled()
|
||||
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('refuses a stream whose destination connect throws without fencing the tunnel', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const healthy = await openStream(tunnel, 'healthy.internal')
|
||||
|
||||
tunnel.refuseNextConnect = true
|
||||
await expect(tunnel.client.open({ host: 'refused.internal', port: 443 })).rejects.toThrow(
|
||||
'destination_connect_failed'
|
||||
)
|
||||
|
||||
expect(tunnel.closures).toEqual([])
|
||||
expect(tunnel.onSessionClose).not.toHaveBeenCalled()
|
||||
expect(healthy.duplex.destroyed).toBe(false)
|
||||
await expectStreamStillCarriesBytes(healthy)
|
||||
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('fails only the pending stream when the destination never connects', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const healthy = await openStream(tunnel, 'healthy.internal')
|
||||
@@ -120,20 +83,6 @@ describe('browser network tunnel conformance', () => {
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('reports the session connect timeout as a stream-scoped error', async () => {
|
||||
// Use raw frames because the client's earlier timeout would mask the session timeout.
|
||||
const peer = createRawFramePeerSession()
|
||||
peer.session.handleBinary(openFrame(1, 'silent.internal', 443))
|
||||
|
||||
vi.advanceTimersByTime(BROWSER_NETWORK_TUNNEL_CONNECT_TIMEOUT_MS)
|
||||
|
||||
expect(peer.errorsFor(1)).toEqual(['destination_connect_timeout'])
|
||||
expect(peer.onClose).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
peer.session.close()
|
||||
expect(peer.aggregate.outstanding).toBe(0)
|
||||
})
|
||||
|
||||
it('fails only the erroring stream when the destination breaks mid-transfer', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const healthy = await openStream(tunnel, 'healthy.internal')
|
||||
@@ -249,24 +198,6 @@ describe('browser network tunnel conformance', () => {
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('answers a heartbeat from either peer without disturbing streams', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const healthy = await openStream(tunnel, 'healthy.internal')
|
||||
const nonce = new Uint8Array([4, 2])
|
||||
|
||||
tunnel.client.handleBinary(pingFrame(nonce))
|
||||
tunnel.session.handleBinary(pingFrame(nonce))
|
||||
await settle()
|
||||
|
||||
expect(heartbeatFrames(tunnel, 'clientToSession')).toEqual([['Pong', nonce]])
|
||||
expect(heartbeatFrames(tunnel, 'sessionToClient')).toEqual([['Pong', nonce]])
|
||||
expect(tunnel.closures).toEqual([])
|
||||
expect(tunnel.onSessionClose).not.toHaveBeenCalled()
|
||||
await expectStreamStillCarriesBytes(healthy)
|
||||
|
||||
await teardown(tunnel)
|
||||
})
|
||||
|
||||
it('leaves no timer or budget claim behind when both peers tear down mid-transfer', async () => {
|
||||
const tunnel = createConformanceTunnel()
|
||||
const idle = await openStream(tunnel, 'idle.internal')
|
||||
@@ -442,37 +373,6 @@ function createConformanceTunnel(): ConformanceTunnel {
|
||||
return tunnel
|
||||
}
|
||||
|
||||
function createRawFramePeerSession() {
|
||||
const aggregate = createAggregateBudgetProbe()
|
||||
const frames: RecordedFrame[] = []
|
||||
const onClose = vi.fn<() => void>()
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: TUNNEL_GENERATION,
|
||||
connect: () => new FakeDestination(),
|
||||
sendBinary: (bytes) => {
|
||||
const frame = decodeBrowserNetworkTunnelFrame(bytes)
|
||||
if (frame) {
|
||||
frames.push({ direction: 'sessionToClient', ...frame })
|
||||
}
|
||||
return true
|
||||
},
|
||||
onClose,
|
||||
claimAggregateRetainedBytes: aggregate.claim
|
||||
})
|
||||
return {
|
||||
session,
|
||||
onClose,
|
||||
aggregate,
|
||||
errorsFor: (streamId: number) =>
|
||||
frames
|
||||
.filter(
|
||||
(frame) =>
|
||||
frame.streamId === streamId && frame.opcode === BrowserNetworkTunnelOpcode.Error
|
||||
)
|
||||
.map((frame) => new TextDecoder().decode(frame.payload))
|
||||
}
|
||||
}
|
||||
|
||||
function record(tunnel: ConformanceTunnel, direction: FrameDirection, bytes: Uint8Array): void {
|
||||
const frame = decodeBrowserNetworkTunnelFrame(bytes)
|
||||
if (!frame) {
|
||||
@@ -593,44 +493,6 @@ function windowUpdatesFor(
|
||||
.map((frame) => decodeBrowserNetworkTunnelWindowUpdate(frame.payload)!)
|
||||
}
|
||||
|
||||
function heartbeatFrames(
|
||||
tunnel: ConformanceTunnel,
|
||||
direction: FrameDirection
|
||||
): [string, Uint8Array][] {
|
||||
return tunnel.frames
|
||||
.filter(
|
||||
(frame) =>
|
||||
frame.direction === direction &&
|
||||
(frame.opcode === BrowserNetworkTunnelOpcode.Ping ||
|
||||
frame.opcode === BrowserNetworkTunnelOpcode.Pong)
|
||||
)
|
||||
.map((frame) => [BrowserNetworkTunnelOpcode[frame.opcode]!, new Uint8Array(frame.payload)])
|
||||
}
|
||||
|
||||
function pingFrame(payload: Uint8Array): Uint8Array {
|
||||
return encodeBrowserNetworkTunnelFrame({
|
||||
opcode: BrowserNetworkTunnelOpcode.Ping,
|
||||
tunnelGeneration: TUNNEL_GENERATION,
|
||||
streamId: 0,
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
function openFrame(streamId: number, host: string, port: number): Uint8Array {
|
||||
const name = new TextEncoder().encode(host)
|
||||
const payload = new Uint8Array(4 + name.byteLength)
|
||||
const view = new DataView(payload.buffer)
|
||||
view.setUint16(0, port, false)
|
||||
view.setUint16(2, name.byteLength, false)
|
||||
payload.set(name, 4)
|
||||
return encodeBrowserNetworkTunnelFrame({
|
||||
opcode: BrowserNetworkTunnelOpcode.Open,
|
||||
tunnelGeneration: TUNNEL_GENERATION,
|
||||
streamId,
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
function concatBytes(chunks: readonly Uint8Array<ArrayBufferLike>[]): Uint8Array {
|
||||
return new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))))
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
resolveNativeBrowserNetworkExecutionRoute
|
||||
} from '../execution-routes/browser-network-execution-route'
|
||||
import { setNetworkProxySettingsSource } from '../execution-routes/browser-network-proxy-settings-source'
|
||||
import {
|
||||
matchesProxyBypassRules,
|
||||
selectBrowserNetworkTunnelProxy
|
||||
} from './browser-network-tunnel-proxy-selection'
|
||||
import type { BrowserNetworkTunnelSocket } from './browser-network-tunnel-stream-state'
|
||||
|
||||
const closers: (() => Promise<void>)[] = []
|
||||
@@ -101,18 +97,6 @@ describe('native browser tunnel upstream proxy (#29154)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sends a credential that is not valid percent-encoding as written', async () => {
|
||||
const proxy = await startConnectProxy(await startEchoServer())
|
||||
const socket = connectNativeBrowserNetworkTarget(
|
||||
{ host: 'site.invalid', port: 443 },
|
||||
{ HTTPS_PROXY: `http://user:p%ss@127.0.0.1:${proxy.port}` }
|
||||
)
|
||||
await expect(roundTrip(socket, 'd')).resolves.toBe('echo:d')
|
||||
expect(proxy.requests[0]?.authorization).toBe(
|
||||
`Basic ${Buffer.from('user:p%ss').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers Orca's proxy setting and its bypass list over the environment", async () => {
|
||||
const proxy = await startConnectProxy(await startEchoServer())
|
||||
setNetworkProxySettingsSource(() => ({
|
||||
@@ -127,23 +111,6 @@ describe('native browser tunnel upstream proxy (#29154)', () => {
|
||||
expect(proxy.requests.map((request) => request.authority)).toEqual(['site.invalid:80'])
|
||||
})
|
||||
|
||||
it('dials NO_PROXY and loopback destinations directly', async () => {
|
||||
const echoPort = await startEchoServer()
|
||||
const proxy = await startConnectProxy(echoPort)
|
||||
const env = { HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`, NO_PROXY: 'example.test' }
|
||||
const socket = connectNativeBrowserNetworkTarget({ host: '127.0.0.1', port: echoPort }, env)
|
||||
await expect(roundTrip(socket, 'b')).resolves.toBe('echo:b')
|
||||
expect(proxy.requests).toEqual([])
|
||||
expect(
|
||||
selectBrowserNetworkTunnelProxy({ host: 'a.example.test', port: 443 }, null, env)
|
||||
).toEqual({ kind: 'direct' })
|
||||
for (const host of ['localhost.', 'app.localhost.', '::1', '0:0:0:0:0:0:0:1', '127.1.2.3']) {
|
||||
expect(selectBrowserNetworkTunnelProxy({ host, port: 3000 }, null, env)).toEqual({
|
||||
kind: 'direct'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a proxy refusal as a connect failure instead of falling back to direct', async () => {
|
||||
const proxy = await startConnectProxy(await startEchoServer(), { reject: 403 })
|
||||
const socket = connectNativeBrowserNetworkTarget(
|
||||
@@ -152,69 +119,4 @@ describe('native browser tunnel upstream proxy (#29154)', () => {
|
||||
)
|
||||
await expect(roundTrip(socket, 'c')).rejects.toThrow('http_proxy_connect_rejected:403')
|
||||
})
|
||||
|
||||
it('refuses a proxy kind it cannot speak rather than bypassing it', () => {
|
||||
expect(() =>
|
||||
connectNativeBrowserNetworkTarget(
|
||||
{ host: 'site.invalid', port: 443 },
|
||||
{ ALL_PROXY: 'socks4://127.0.0.1:1080' }
|
||||
)
|
||||
).toThrow('browser_tunnel_proxy_unsupported')
|
||||
expect(
|
||||
selectBrowserNetworkTunnelProxy({ host: 'site.invalid', port: 443 }, null, {
|
||||
ALL_PROXY: 'socks5://127.0.0.1:1080'
|
||||
})
|
||||
).toMatchObject({ kind: 'socks5' })
|
||||
})
|
||||
|
||||
it('dials directly with no proxy configured', () => {
|
||||
expect(selectBrowserNetworkTunnelProxy({ host: 'site.invalid', port: 443 }, null, {})).toEqual({
|
||||
kind: 'direct'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxy bypass rules', () => {
|
||||
it.each([
|
||||
['*', 'any.host', 443, true, true],
|
||||
['<local>', 'intranet', 80, true, true],
|
||||
['<local>', 'intranet.corp', 80, false, false],
|
||||
['.corp.test', 'a.corp.test', 443, true, true],
|
||||
['.corp.test', 'corp.test', 443, true, false],
|
||||
['*.corp.test', 'corp.test', 443, true, false],
|
||||
['*corp.test', 'corp.test', 443, true, true],
|
||||
['corp.test', 'deep.a.corp.test', 443, true, false],
|
||||
['corp.test', 'corp.test', 443, true, true],
|
||||
['corp.test', 'notcorp.test', 443, false, false],
|
||||
['corp.test:8443', 'corp.test', 443, false, false],
|
||||
['corp.test:8443', 'corp.test', 8443, true, true],
|
||||
['10.0.0.0/8', '10.20.30.40', 443, true, true],
|
||||
['10.0.0.0/8', '11.0.0.1', 443, false, false],
|
||||
['[fd00::1]', 'fd00::1', 443, true, true],
|
||||
['[fd00::1]', 'fd00:0:0:0:0:0:0:1', 443, true, true],
|
||||
['fd00:0::1', '[FD00::1]', 443, true, true],
|
||||
['https://corp.test', 'corp.test', 443, true, true],
|
||||
['https://corp.test', 'corp.test', 80, false, false],
|
||||
['ftp://corp.test', 'corp.test', 21, false, false],
|
||||
['fc00::/7', 'fd00::123', 443, true, true],
|
||||
['fc00::/7', '2001:db8::1', 443, false, false],
|
||||
['192.168.*', '192.168.4.20', 443, true, true],
|
||||
['api.*.corp.test', 'api.eu.corp.test', 443, true, true],
|
||||
['api.*.corp.test', 'web.eu.corp.test', 443, false, false],
|
||||
['corp.test', 'corp.test.', 443, true, true],
|
||||
['', '169.254.169.254', 80, true, true],
|
||||
['<-loopback>', 'localhost', 3000, false, false],
|
||||
['<-loopback>;<local>', 'localhost', 3000, true, true],
|
||||
['<local>;<-loopback>', 'localhost', 3000, false, false],
|
||||
['<local>;<-loopback>', 'intranet', 80, true, true]
|
||||
])('%s against %s:%d (NO_PROXY %s, Chromium %s)', (rule, host, port, noProxy, chromium) => {
|
||||
expect(matchesProxyBypassRules({ host, port }, rule, 'no-proxy')).toBe(noProxy)
|
||||
expect(matchesProxyBypassRules({ host, port }, rule, 'chromium')).toBe(chromium)
|
||||
})
|
||||
|
||||
it('proxies loopback destinations when the bypass list subtracts them', () => {
|
||||
const settings = { httpProxyUrl: 'http://proxy.test:8080', httpProxyBypassRules: '<-loopback>' }
|
||||
const target = { host: '127.0.0.1', port: 3000 }
|
||||
expect(selectBrowserNetworkTunnelProxy(target, settings, {})).toMatchObject({ kind: 'http' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
encodeBrowserNetworkTunnelOpen,
|
||||
encodeBrowserNetworkTunnelWindowUpdate
|
||||
} from '../../../../shared/browser-network-tunnel-protocol'
|
||||
import {
|
||||
BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES,
|
||||
type BrowserNetworkTunnelSocket
|
||||
} from './browser-network-tunnel-stream-state'
|
||||
import type { BrowserNetworkTunnelSocket } from './browser-network-tunnel-stream-state'
|
||||
import { BrowserNetworkTunnelSession } from './browser-network-tunnel-session'
|
||||
|
||||
class FakeSocket extends EventEmitter implements BrowserNetworkTunnelSocket {
|
||||
@@ -65,39 +62,6 @@ function frame(
|
||||
}
|
||||
|
||||
describe('BrowserNetworkTunnelSession', () => {
|
||||
it('opens the exact remote DNS target and grants bounded receive credit', () => {
|
||||
const socket = new FakeSocket()
|
||||
const connect = vi.fn(() => socket)
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'split-horizon.internal', port: 443 })
|
||||
)
|
||||
)
|
||||
socket.emit('connect')
|
||||
|
||||
expect(connect).toHaveBeenCalledWith({ host: 'split-horizon.internal', port: 443 })
|
||||
expect(socket.paused).toBe(true)
|
||||
expect(sent.map(decodeBrowserNetworkTunnelFrame)).toEqual([
|
||||
expect.objectContaining({ opcode: BrowserNetworkTunnelOpcode.Opened, streamId: 3 }),
|
||||
expect.objectContaining({ opcode: BrowserNetworkTunnelOpcode.WindowUpdate, streamId: 3 })
|
||||
])
|
||||
const credit = decodeBrowserNetworkTunnelFrame(sent[1]!)
|
||||
expect(credit && decodeBrowserNetworkTunnelWindowUpdate(credit.payload)).toBe(
|
||||
BROWSER_NETWORK_TUNNEL_INITIAL_WINDOW_BYTES
|
||||
)
|
||||
})
|
||||
|
||||
it('replenishes client credit only after the destination write settles', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
@@ -129,149 +93,6 @@ describe('BrowserNetworkTunnelSession', () => {
|
||||
expect(update && decodeBrowserNetworkTunnelWindowUpdate(update.payload)).toBe(3)
|
||||
})
|
||||
|
||||
it('does not read destination bytes before the client grants credit', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => socket,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 8080 })
|
||||
)
|
||||
)
|
||||
socket.emit('connect')
|
||||
sent.length = 0
|
||||
|
||||
session.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.WindowUpdate, encodeBrowserNetworkTunnelWindowUpdate(3))
|
||||
)
|
||||
socket.emit('data', new Uint8Array([4, 5, 6, 7]))
|
||||
|
||||
const first = decodeBrowserNetworkTunnelFrame(sent[0]!)
|
||||
expect(first?.opcode).toBe(BrowserNetworkTunnelOpcode.Data)
|
||||
expect(first?.payload).toEqual(new Uint8Array([4, 5, 6]))
|
||||
expect(socket.paused).toBe(true)
|
||||
|
||||
session.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.WindowUpdate, encodeBrowserNetworkTunnelWindowUpdate(1))
|
||||
)
|
||||
const second = decodeBrowserNetworkTunnelFrame(sent[1]!)
|
||||
expect(second?.payload).toEqual(new Uint8Array([7]))
|
||||
})
|
||||
|
||||
it('fences stale generations and closes every stream on disposal', () => {
|
||||
const socket = new FakeSocket()
|
||||
const connect = vi.fn(() => socket)
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'stale.internal', port: 80 }),
|
||||
3,
|
||||
6
|
||||
)
|
||||
)
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'current.internal', port: 80 })
|
||||
)
|
||||
)
|
||||
session.close()
|
||||
|
||||
expect(socket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects stream reuse without replacing the original destination', () => {
|
||||
const firstSocket = new FakeSocket()
|
||||
const secondSocket = new FakeSocket()
|
||||
const connect = vi.fn().mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket)
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
const open = (host: string): void =>
|
||||
session.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.Open, encodeBrowserNetworkTunnelOpen({ host, port: 80 }))
|
||||
)
|
||||
|
||||
open('first.internal')
|
||||
open('second.internal')
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(1)
|
||||
expect(firstSocket.destroyed).toBe(true)
|
||||
expect(secondSocket.destroyed).toBe(false)
|
||||
const error = decodeBrowserNetworkTunnelFrame(sent.at(-1)!)
|
||||
expect(error?.opcode).toBe(BrowserNetworkTunnelOpcode.Error)
|
||||
expect(error ? new TextDecoder().decode(error.payload) : '').toBe('stream_id_reused')
|
||||
})
|
||||
|
||||
it('rejects current-generation frames for never-reserved and reused stream IDs', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const connect = vi.fn(() => socket)
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
const open = frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
|
||||
session.handleBinary(open)
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Close))
|
||||
session.handleBinary(open)
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(1)
|
||||
expect(socket.destroyed).toBe(true)
|
||||
expect(decodeBrowserNetworkTunnelFrame(sent.at(-1)!)?.opcode).toBe(
|
||||
BrowserNetworkTunnelOpcode.Error
|
||||
)
|
||||
|
||||
const otherSocket = new FakeSocket()
|
||||
const unknownSession = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => otherSocket,
|
||||
sendBinary: () => true
|
||||
})
|
||||
unknownSession.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
unknownSession.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, new Uint8Array([1]), 4))
|
||||
expect(otherSocket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps concurrent streams alive when late data arrives for a retired stream', () => {
|
||||
const firstSocket = new FakeSocket()
|
||||
const secondSocket = new FakeSocket()
|
||||
@@ -304,88 +125,6 @@ describe('BrowserNetworkTunnelSession', () => {
|
||||
session.close()
|
||||
})
|
||||
|
||||
it('accepts unused out-of-order stream IDs without permitting reuse', () => {
|
||||
const firstSocket = new FakeSocket()
|
||||
const secondSocket = new FakeSocket()
|
||||
const connect = vi.fn().mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket)
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: () => true
|
||||
})
|
||||
const open = (streamId: number): void =>
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 }),
|
||||
streamId
|
||||
)
|
||||
)
|
||||
|
||||
open(5)
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Close, new Uint8Array(), 5))
|
||||
open(3)
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(secondSocket.destroyed).toBe(false)
|
||||
})
|
||||
|
||||
it('fences invalid half-close transitions and emits destination EOF once', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => socket,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
socket.emit('connect')
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.HalfClose))
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, new Uint8Array([1])))
|
||||
expect(socket.ended).toBe(true)
|
||||
expect(socket.destroyed).toBe(true)
|
||||
|
||||
const responseSocket = new FakeSocket()
|
||||
const responseFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const responseSession = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => responseSocket,
|
||||
sendBinary: (bytes) => {
|
||||
responseFrames.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
responseSession.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
responseSocket.emit('connect')
|
||||
responseFrames.length = 0
|
||||
responseSession.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.WindowUpdate, encodeBrowserNetworkTunnelWindowUpdate(1))
|
||||
)
|
||||
responseSocket.emit('data', new Uint8Array([1]))
|
||||
responseSocket.emit('end')
|
||||
responseSession.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.WindowUpdate, encodeBrowserNetworkTunnelWindowUpdate(1))
|
||||
)
|
||||
expect(
|
||||
responseFrames
|
||||
.map(decodeBrowserNetworkTunnelFrame)
|
||||
.filter((item) => item?.opcode === BrowserNetworkTunnelOpcode.HalfClose)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('flushes credited destination bytes before close', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
@@ -425,120 +164,6 @@ describe('BrowserNetworkTunnelSession', () => {
|
||||
expect(socket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on pre-connect credit and synchronous transport failure', () => {
|
||||
const socket = new FakeSocket()
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => socket,
|
||||
sendBinary: () => true
|
||||
})
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
session.handleBinary(
|
||||
frame(BrowserNetworkTunnelOpcode.WindowUpdate, encodeBrowserNetworkTunnelWindowUpdate(1))
|
||||
)
|
||||
expect(socket.destroyed).toBe(true)
|
||||
|
||||
const throwingSocket = new FakeSocket()
|
||||
const throwingSession = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => throwingSocket,
|
||||
sendBinary: () => {
|
||||
throw new Error('transport closed')
|
||||
}
|
||||
})
|
||||
throwingSession.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
expect(() => throwingSocket.emit('connect')).not.toThrow()
|
||||
expect(throwingSocket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('closes a stream that exceeds its receive window', () => {
|
||||
const socket = new FakeSocket()
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => socket,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
}
|
||||
})
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 80 })
|
||||
)
|
||||
)
|
||||
socket.emit('connect')
|
||||
sent.length = 0
|
||||
const chunk = new Uint8Array(64 * 1024)
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, chunk))
|
||||
}
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, new Uint8Array([1])))
|
||||
|
||||
expect(socket.destroyed).toBe(true)
|
||||
expect(decodeBrowserNetworkTunnelFrame(sent.at(-1)!)?.opcode).toBe(
|
||||
BrowserNetworkTunnelOpcode.Error
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the destination open rate with an injectable monotonic clock', () => {
|
||||
let now = 1_000
|
||||
const sockets: FakeSocket[] = []
|
||||
const connect = vi.fn(() => {
|
||||
const socket = new FakeSocket()
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
})
|
||||
const sent: Uint8Array<ArrayBufferLike>[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect,
|
||||
sendBinary: (bytes) => {
|
||||
sent.push(bytes)
|
||||
return true
|
||||
},
|
||||
now: () => now
|
||||
})
|
||||
const openAndClose = (streamId: number): void => {
|
||||
const socketCount = sockets.length
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'burst.internal', port: 443 }),
|
||||
streamId
|
||||
)
|
||||
)
|
||||
if (sockets.length > socketCount) {
|
||||
sockets.at(-1)!.emit('connect')
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Close, new Uint8Array(), streamId))
|
||||
}
|
||||
}
|
||||
|
||||
for (let streamId = 1; streamId <= 1024; streamId += 1) {
|
||||
openAndClose(streamId)
|
||||
}
|
||||
openAndClose(1025)
|
||||
expect(connect).toHaveBeenCalledTimes(1024)
|
||||
expect(new TextDecoder().decode(decodeBrowserNetworkTunnelFrame(sent.at(-1)!)?.payload)).toBe(
|
||||
'open_rate_exceeded'
|
||||
)
|
||||
|
||||
now += 10_001
|
||||
openAndClose(1026)
|
||||
expect(connect).toHaveBeenCalledTimes(1025)
|
||||
})
|
||||
|
||||
it('caps aggregate retained destination bytes and releases exact stream accounting', () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
@@ -620,29 +245,6 @@ describe('BrowserNetworkTunnelSession', () => {
|
||||
expect(sockets.slice(1, 33).some((socket) => socket.destroyed)).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds unsettled destination write chunks independently of byte credit', () => {
|
||||
const socket = new FakeSocket()
|
||||
const session = new BrowserNetworkTunnelSession({
|
||||
tunnelGeneration: 7,
|
||||
connect: () => socket,
|
||||
sendBinary: () => true
|
||||
})
|
||||
session.handleBinary(
|
||||
frame(
|
||||
BrowserNetworkTunnelOpcode.Open,
|
||||
encodeBrowserNetworkTunnelOpen({ host: 'tiny-writes.internal', port: 443 })
|
||||
)
|
||||
)
|
||||
socket.emit('connect')
|
||||
|
||||
for (let index = 0; index < 257; index += 1) {
|
||||
session.handleBinary(frame(BrowserNetworkTunnelOpcode.Data, new Uint8Array([index & 0xff])))
|
||||
}
|
||||
|
||||
expect(socket.writes).toHaveLength(256)
|
||||
expect(socket.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('releases queued bytes once when an accepted send closes reentrantly', () => {
|
||||
const socket = new FakeSocket()
|
||||
let closeOnData = false
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
resolveBrowserRouteGuestPopupOpener
|
||||
} from './browser-route-guest-popup-ownership'
|
||||
import { BrowserRouteWebContentsRegistry } from './browser-route-webcontents-registry'
|
||||
|
||||
const GESTURE_CLICK_AT = 1_700_000_000_000
|
||||
const partition = `persist:orca-browser-v1-${'a'.repeat(64)}`
|
||||
const otherPartition = `persist:orca-browser-v1-${'b'.repeat(64)}`
|
||||
const page = {
|
||||
partition,
|
||||
browserPageId: 'page-a',
|
||||
@@ -30,174 +27,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('route guest OAuth popups', () => {
|
||||
it('denies window.open without a recognized user gesture', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
expect(popupMocks.openPopupWithOriginBar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows one popup per gesture, bound to the opener partition, and never replays it', () => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
|
||||
const allowed = guest.openWindow('https://accounts.example.com/oauth')
|
||||
|
||||
expect(allowed.action).toBe('allow')
|
||||
expect(allowed.overrideBrowserWindowOptions?.webPreferences?.partition).toBe(partition)
|
||||
expect(allowed.overrideBrowserWindowOptions?.webPreferences?.sandbox).toBe(true)
|
||||
const opened = allowed.createWindow?.(popupOptions(createPopupContents()))
|
||||
expect(opened).not.toBeUndefined()
|
||||
expect(popups()).toHaveLength(1)
|
||||
expect(popups()[0]?.loaded).toBe(true)
|
||||
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
// Pin elapsed times independently of the gesture-window constant to catch widening.
|
||||
it('still honors a gesture 1s after the click', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(GESTURE_CLICK_AT)
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
|
||||
vi.setSystemTime(GESTURE_CLICK_AT + 1_000)
|
||||
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth').action).toBe('allow')
|
||||
})
|
||||
|
||||
it('denies a popup more than 1s after the click that would have authorized it', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(GESTURE_CLICK_AT)
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
|
||||
vi.setSystemTime(GESTURE_CLICK_AT + 1_001)
|
||||
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
expect(popupMocks.openPopupWithOriginBar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects clock rollback instead of treating a future input as a current gesture', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(GESTURE_CLICK_AT)
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
vi.setSystemTime(GESTURE_CLICK_AT - 1)
|
||||
expect(guest.openWindow('about:blank')).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('stops opening popups at the fourth one even while gestures keep arriving', () => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
guest.emitInput('mouseDown')
|
||||
const allowed = guest.openWindow(`https://accounts.example.com/oauth/${index}`)
|
||||
expect(allowed.action).toBe('allow')
|
||||
allowed.createWindow?.(popupOptions(createPopupContents()))
|
||||
}
|
||||
expect(popups()).toHaveLength(4)
|
||||
|
||||
guest.emitInput('mouseDown')
|
||||
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth/overflow')).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(popups()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('tells the owning page every popup it refused, and stays quiet once the page is gone', () => {
|
||||
const { guest, blockedPopups } = attachRegisteredGuest()
|
||||
|
||||
guest.openWindow('https://accounts.example.com/oauth')
|
||||
guest.emitInput('mouseDown')
|
||||
guest.openWindow('file:///etc/hosts')
|
||||
|
||||
expect(blockedPopups()).toEqual([
|
||||
{ openerWebContentsId: page.webContentsId, url: 'https://accounts.example.com/oauth' },
|
||||
{ openerWebContentsId: page.webContentsId, url: 'file:///etc/hosts' }
|
||||
])
|
||||
|
||||
guest.destroy()
|
||||
guest.openWindow('https://accounts.example.com/oauth')
|
||||
|
||||
expect(blockedPopups()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies the WebRTC policy before the popup can load and fails closed when it cannot', () => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const allowed = guest.openWindow('https://accounts.example.com/oauth')
|
||||
const contents = createPopupContents()
|
||||
|
||||
allowed.createWindow?.(popupOptions(contents))
|
||||
|
||||
expect(contents.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
expect(popups()[0]?.order).toEqual(['webrtc-policy', 'load'])
|
||||
|
||||
guest.emitInput('mouseDown')
|
||||
const second = guest.openWindow('https://accounts.example.com/oauth')
|
||||
second.createWindow?.(popupOptions(createPopupContents({ webRtcPolicyThrows: true })))
|
||||
|
||||
expect(popups()[1]?.loaded).toBe(false)
|
||||
expect(popups()[1]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when the popup contents land outside the opener partition', () => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const allowed = guest.openWindow('https://accounts.example.com/oauth')
|
||||
|
||||
allowed.createWindow?.(popupOptions(createPopupContents({ partition: otherPartition })))
|
||||
|
||||
expect(popups()[0]?.loaded).toBe(false)
|
||||
expect(popups()[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses non-web destinations even with a real gesture', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('file:///etc/hosts')).toEqual({ action: 'deny' })
|
||||
for (const url of [
|
||||
'about:blank?state=x',
|
||||
'about:srcdoc',
|
||||
'data:text/html,login',
|
||||
'javascript:alert(1)'
|
||||
]) {
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow(url)).toEqual({ action: 'deny' })
|
||||
}
|
||||
expect(popupMocks.openPopupWithOriginBar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['about:blank', 'about:blank#state'])(
|
||||
'admits a gesture-backed blank bootstrap: %s',
|
||||
(url) => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
expect(guest.openWindow(url)).toEqual({ action: 'deny' })
|
||||
guest.emitInput('mouseDown')
|
||||
const allowed = guest.openWindow(url)
|
||||
expect(allowed.action).toBe('allow')
|
||||
allowed.createWindow?.(popupOptions(createPopupContents()))
|
||||
expect(popups()[0]?.loaded).toBe(true)
|
||||
expect(guest.openWindow(url)).toEqual({ action: 'deny' })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not treat the quarantine blank-page exemption as popup authority', () => {
|
||||
const { registry, guest } = attachGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('about:blank#state')).toEqual({ action: 'deny' })
|
||||
registry.registerGuest(page)
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('about:blank')).toEqual({ action: 'deny' })
|
||||
registry.grantNavigation(page)
|
||||
const claim = registry.claimGuestLifecycle(page)!
|
||||
registry.revokeNavigation(claim)
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('about:blank')).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('spends a gesture on a denial and rechecks authority before adopting a child', () => {
|
||||
const { registry, guest, popups } = attachRegisteredGuest()
|
||||
@@ -212,21 +42,6 @@ describe('route guest OAuth popups', () => {
|
||||
expect(popups()[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('holds popups shut until the page is registered and navigation is granted', () => {
|
||||
const { registry, guest } = attachGuest()
|
||||
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
|
||||
registry.registerGuest(page)
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
|
||||
registry.grantNavigation(page)
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth').action).toBe('allow')
|
||||
})
|
||||
|
||||
it('closes live popups when the opener page is fenced and reopens them once it is regranted', () => {
|
||||
const { registry, guest, popups } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
@@ -246,43 +61,6 @@ describe('route guest OAuth popups', () => {
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth').action).toBe('allow')
|
||||
})
|
||||
|
||||
it('closes popups and stops accepting new ones when the opener guest is destroyed', () => {
|
||||
const { guest, popups } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
guest
|
||||
.openWindow('https://accounts.example.com/oauth')
|
||||
.createWindow?.(popupOptions(createPopupContents()))
|
||||
|
||||
guest.destroy()
|
||||
|
||||
expect(popups()[0]?.closed).toBe(true)
|
||||
guest.emitInput('mouseDown')
|
||||
expect(guest.openWindow('https://accounts.example.com/oauth')).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('owns popups by their opener page and drops ownership when the popup is destroyed', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const contents = createPopupContents()
|
||||
guest.openWindow('https://accounts.example.com/oauth').createWindow?.(popupOptions(contents))
|
||||
|
||||
expect(resolveBrowserRouteGuestPopupOpener(contents.id)).toBe(page.webContentsId)
|
||||
|
||||
contents.emitDestroyed()
|
||||
|
||||
expect(resolveBrowserRouteGuestPopupOpener(contents.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('claims no ownership for a popup that failed the partition fence', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const contents = createPopupContents({ partition: otherPartition })
|
||||
|
||||
guest.openWindow('https://accounts.example.com/oauth').createWindow?.(popupOptions(contents))
|
||||
|
||||
expect(resolveBrowserRouteGuestPopupOpener(contents.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops popup ownership when the opener guest goes away', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
@@ -303,27 +81,6 @@ describe('route guest OAuth popups', () => {
|
||||
expect(resolveBrowserRouteGuestPopupOpener(descendant.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('gives popup descendants the same gesture-gated handler', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const contents = createPopupContents()
|
||||
guest.openWindow('https://accounts.example.com/oauth').createWindow?.(popupOptions(contents))
|
||||
|
||||
expect(contents.openWindow('https://accounts.example.com/step-2')).toEqual({ action: 'deny' })
|
||||
contents.emitInput('mouseDown')
|
||||
expect(contents.openWindow('https://accounts.example.com/step-2').action).toBe('allow')
|
||||
})
|
||||
|
||||
it('lets a popup page leave when it asks to confirm', () => {
|
||||
const { guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
const contents = createPopupContents()
|
||||
guest.openWindow('https://accounts.example.com/oauth').createWindow?.(popupOptions(contents))
|
||||
|
||||
// preventDefault() permits navigation for will-prevent-unload.
|
||||
expect(contents.navigate('https://accounts.example.com/next', 'will-prevent-unload')).toBe(true)
|
||||
})
|
||||
|
||||
it('guards blank descendants and redirects after the root loses authority', () => {
|
||||
const { registry, guest } = attachRegisteredGuest()
|
||||
guest.emitInput('mouseDown')
|
||||
@@ -339,39 +96,6 @@ describe('route guest OAuth popups', () => {
|
||||
contents.emitInput('mouseDown')
|
||||
expect(contents.openWindow('about:blank')).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('loads a Cmd/Ctrl+Shift-clicked GET itself but never replays a POST that has no page', () => {
|
||||
const { guest, blockedPopups } = attachRegisteredGuest()
|
||||
popupMocks.openPopupWithOriginBar.mockImplementation(() => ({
|
||||
contentWebContents: createPopupContents(),
|
||||
close: vi.fn(),
|
||||
onClosed: vi.fn()
|
||||
}))
|
||||
guest.emitInput('mouseDown')
|
||||
guest.openWindow('https://accounts.example.com/docs').createWindow?.({})
|
||||
expect(popupMocks.openPopupWithOriginBar).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ loadInitialUrl: true }),
|
||||
'https://accounts.example.com/docs',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(blockedPopups()).toEqual([])
|
||||
|
||||
guest.emitInput('mouseDown')
|
||||
guest
|
||||
.openWindow('https://accounts.example.com/submit', {
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
data: []
|
||||
})
|
||||
.createWindow?.({})
|
||||
expect(popupMocks.openPopupWithOriginBar).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ loadInitialUrl: false }),
|
||||
'https://accounts.example.com/submit',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(blockedPopups()).toEqual([
|
||||
{ openerWebContentsId: page.webContentsId, url: 'https://accounts.example.com/submit' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function popupOptions(
|
||||
|
||||
@@ -182,103 +182,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('holds navigation and popups until exact registration and an explicit grant', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
|
||||
expect(registry.attachGuest(guest.guest)).toBe(true)
|
||||
const beforeRegistration = navigationEvent()
|
||||
guest.emit('will-navigate', beforeRegistration, 'https://example.com/')
|
||||
expect(beforeRegistration.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.openWindow()).toEqual({ action: 'deny' })
|
||||
|
||||
expect(registry.registerGuest({ ...page, rendererWebContentsId: 12 })).toBe(false)
|
||||
expect(registry.grantNavigation(page)).toBe(false)
|
||||
expect(registry.registerGuest(page)).toBe(true)
|
||||
|
||||
const beforeGrant = navigationEvent()
|
||||
guest.emit('will-navigate', beforeGrant, 'https://example.com/')
|
||||
expect(beforeGrant.preventDefault).toHaveBeenCalledOnce()
|
||||
|
||||
expect(registry.grantNavigation(page)).toBe(true)
|
||||
const afterGrant = navigationEvent()
|
||||
guest.emit('will-navigate', afterGrant, 'https://example.com/')
|
||||
expect(afterGrant.preventDefault).not.toHaveBeenCalled()
|
||||
guest.setUrl('https://example.com/')
|
||||
const laterNavigation = navigationEvent()
|
||||
guest.emit('will-navigate', laterNavigation, 'https://example.org/')
|
||||
expect(laterNavigation.preventDefault).not.toHaveBeenCalled()
|
||||
expect(guest.openWindow()).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('revokes only the exact guest lifecycle claim without destroying it', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
const claim = requireLifecycleClaim(registry)
|
||||
|
||||
expect(
|
||||
registry.revokeNavigation({
|
||||
...claim,
|
||||
registration: { ...page, pageHostGeneration: page.pageHostGeneration + 1 }
|
||||
})
|
||||
).toBe(false)
|
||||
const afterStaleFence = navigationEvent()
|
||||
guest.emit('will-navigate', afterStaleFence, 'https://example.com/')
|
||||
expect(afterStaleFence.preventDefault).not.toHaveBeenCalled()
|
||||
|
||||
expect(registry.revokeNavigation(claim)).toBe(true)
|
||||
const afterExactFence = navigationEvent()
|
||||
guest.emit('will-navigate', afterExactFence, 'https://example.com/')
|
||||
expect(afterExactFence.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.guest.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads only a normalized web URL on the exact registered guest', async () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
const claim = requireLifecycleClaim(registry)
|
||||
|
||||
await expect(registry.navigateGuest(claim, 'example.com/path')).resolves.toBe(true)
|
||||
expect(guest.guest.loadURL).toHaveBeenCalledWith('https://example.com/path')
|
||||
await expect(
|
||||
registry.navigateGuest(
|
||||
{ ...claim, registration: { ...page, pageHostGeneration: 8 } },
|
||||
'https://a.test'
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
await expect(registry.navigateGuest(claim, 'file:///etc/passwd')).resolves.toBe(false)
|
||||
expect(guest.guest.loadURL).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retires an exact unregistered quarantined guest without touching a replacement', async () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
|
||||
expect(registry.claimGuestLifecycle({ ...page, webContentsId: 42 })).toBeNull()
|
||||
expect(guest.guest.close).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
registry.beginGuestRetirement(requireLifecycleClaim(registry))
|
||||
).resolves.toBeUndefined()
|
||||
expect(guest.guest.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not join a different generation already retiring on the same guest', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
|
||||
expect(registry.beginGuestRetirement(requireLifecycleClaim(registry))).not.toBeNull()
|
||||
expect(registry.claimGuestLifecycle({ ...page, pageHostGeneration: 8 })).toBeNull()
|
||||
})
|
||||
|
||||
it('settles exact guest retirement only after its destroyed acknowledgement', async () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
@@ -295,36 +198,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
await expect(retired).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a guest whose renderer does not own the prepared authority', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, rendererWebContentsId: 12 })
|
||||
const siblingClaim = { ...page, rendererWebContentsId: 12 }
|
||||
|
||||
expect(registry.attachGuest(guest.guest)).toBe(true)
|
||||
expect(registry.registerGuest(siblingClaim)).toBe(false)
|
||||
expect(registry.grantNavigation(siblingClaim)).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks non-proxied WebRTC before admitting a route guest', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
|
||||
expect(registry.attachGuest(guest.guest)).toBe(true)
|
||||
expect(guest.guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
it('fails closed when WebRTC route policy cannot be applied', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({
|
||||
session: routeSession,
|
||||
webRtcPolicyError: new Error('policy unavailable')
|
||||
})
|
||||
|
||||
expect(registry.attachGuest(guest.guest)).toBe(false)
|
||||
expect(guest.guest.close).toHaveBeenCalledOnce()
|
||||
expect(registry.registerGuest(page)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps policy-rejected guests quarantined through delayed or failed close', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
for (const rejected of [
|
||||
@@ -347,68 +220,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('allows blank navigation while quarantined but blocks invalid schemes after a grant', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
|
||||
const blank = navigationEvent()
|
||||
guest.emit('will-navigate', blank, 'about:blank')
|
||||
expect(blank.preventDefault).not.toHaveBeenCalled()
|
||||
|
||||
expect(registry.registerGuest(page)).toBe(true)
|
||||
expect(registry.grantNavigation(page)).toBe(true)
|
||||
const localFile = navigationEvent()
|
||||
guest.emit('will-navigate', localFile, 'file:///etc/passwd')
|
||||
expect(localFile.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('destroys invalid route guests but ignores unrelated browser sessions', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const unrelated = createGuest({
|
||||
session: { marker: 'profile-session' } as unknown as Session
|
||||
})
|
||||
expect(registry.attachGuest(unrelated.guest)).toBe(false)
|
||||
expect(unrelated.guest.close).not.toHaveBeenCalled()
|
||||
|
||||
for (const invalid of [
|
||||
createGuest({ session: routeSession, type: 'window' }),
|
||||
createGuest({ session: routeSession, url: 'https://example.com/' }),
|
||||
createGuest({ session: routeSession, rendererWebContentsId: 0 })
|
||||
]) {
|
||||
expect(registry.attachGuest(invalid.guest)).toBe(false)
|
||||
expect(invalid.guest.close).toHaveBeenCalledOnce()
|
||||
}
|
||||
const alreadyDestroyed = createGuest({ session: routeSession, destroyed: true })
|
||||
expect(registry.attachGuest(alreadyDestroyed.guest)).toBe(false)
|
||||
expect(alreadyDestroyed.guest.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds unregistered route guests and destroys excess attachments', () => {
|
||||
const { registry, routeSession } = createHarness({ maxGuests: 1 })
|
||||
const first = createGuest({ session: routeSession })
|
||||
const second = createGuest({ id: 42, session: routeSession })
|
||||
|
||||
expect(registry.attachGuest(first.guest)).toBe(true)
|
||||
expect(registry.attachGuest(second.guest)).toBe(false)
|
||||
expect(second.guest.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps delayed or failed admission closure navigation- and popup-denied', () => {
|
||||
const { registry, routeSession } = createHarness({ maxGuests: 1 })
|
||||
registry.attachGuest(createGuest({ session: routeSession }).guest)
|
||||
for (const rejected of [
|
||||
createGuest({ id: 42, session: routeSession, closeDestroys: false }),
|
||||
createGuest({ id: 43, session: routeSession, closeError: new Error('close failed') })
|
||||
]) {
|
||||
expect(registry.attachGuest(rejected.guest)).toBe(false)
|
||||
const navigation = navigationEvent()
|
||||
rejected.emit('will-navigate', navigation, 'https://example.com/')
|
||||
expect(navigation.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(rejected.openWindow()).toEqual({ action: 'deny' })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects page conflicts and stale destroyed callbacks cannot retire a replacement', () => {
|
||||
const { registry, replaceAuthority, routeSession } = createHarness()
|
||||
const first = createGuest({ session: routeSession })
|
||||
@@ -429,43 +240,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
expect(first.guest.off).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
||||
})
|
||||
|
||||
it('revokes navigation immediately when the logical page is no longer prepared', () => {
|
||||
const { registry, routeSession, setPrepared } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
setPrepared(false)
|
||||
|
||||
const afterRelease = navigationEvent()
|
||||
guest.emit('will-redirect', afterRelease, 'https://example.com/', false, true)
|
||||
expect(afterRelease.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('quarantines a late attachment after its logical page authority is released', () => {
|
||||
const { registry, routeSession, setPrepared } = createHarness()
|
||||
setPrepared(false)
|
||||
const guest = createGuest({ session: routeSession })
|
||||
|
||||
expect(registry.attachGuest(guest.guest)).toBe(true)
|
||||
expect(registry.registerGuest(page)).toBe(false)
|
||||
const navigation = navigationEvent()
|
||||
guest.emit('will-navigate', navigation, 'https://example.com/')
|
||||
expect(navigation.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.openWindow()).toEqual({ action: 'deny' })
|
||||
})
|
||||
|
||||
it('does not retain mutable registration input as page authority', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
const registration = { ...page }
|
||||
registry.attachGuest(guest.guest)
|
||||
|
||||
expect(registry.registerGuest(registration)).toBe(true)
|
||||
registration.browserPageId = 'mutated-page'
|
||||
expect(registry.grantNavigation(page)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not revive a stale guest when the same logical page tuple is prepared again', () => {
|
||||
const { registry, replaceAuthority, routeSession } = createHarness()
|
||||
const stale = createGuest({ session: routeSession })
|
||||
@@ -526,110 +300,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
expect(duplicateRetired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores retirement for a different opaque authority', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
|
||||
expect(
|
||||
registry.retirePageAuthority({
|
||||
partition,
|
||||
browserPageId: page.browserPageId,
|
||||
pageHostGeneration: page.pageHostGeneration,
|
||||
rendererWebContentsId: page.rendererWebContentsId,
|
||||
pageAuthority: Symbol('wrong-authority'),
|
||||
onRetired: vi.fn()
|
||||
})
|
||||
).toBe(true)
|
||||
expect(guest.guest.close).not.toHaveBeenCalled()
|
||||
const navigation = navigationEvent()
|
||||
guest.emit('will-navigate', navigation, 'https://example.com/')
|
||||
expect(navigation.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not settle retirement when destroyed inspection is unavailable', () => {
|
||||
const { getPageAuthority, registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
const retired = vi.fn()
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
vi.mocked(guest.guest.isDestroyed).mockImplementation(() => {
|
||||
throw new Error('inspection unavailable')
|
||||
})
|
||||
|
||||
expect(
|
||||
registry.retirePageAuthority({
|
||||
partition,
|
||||
browserPageId: page.browserPageId,
|
||||
pageHostGeneration: page.pageHostGeneration,
|
||||
rendererWebContentsId: page.rendererWebContentsId,
|
||||
pageAuthority: getPageAuthority(),
|
||||
onRetired: retired
|
||||
})
|
||||
).toBe(false)
|
||||
expect(retired).not.toHaveBeenCalled()
|
||||
const navigation = navigationEvent()
|
||||
guest.emit('will-navigate', navigation, 'https://example.com/')
|
||||
expect(navigation.preventDefault).toHaveBeenCalledOnce()
|
||||
|
||||
guest.destroy()
|
||||
expect(retired).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retires the exact page authority when its guest process exits', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
|
||||
guest.emit('render-process-gone', {}, { reason: 'crashed' })
|
||||
|
||||
expect(guest.guest.close).toHaveBeenCalledOnce()
|
||||
expect(registry.grantNavigation(page)).toBe(false)
|
||||
const navigation = navigationEvent()
|
||||
guest.emit('will-navigate', navigation, 'https://example.com/')
|
||||
expect(navigation.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports one exact availability loss for a crashed guest generation', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
const unavailable = vi.fn()
|
||||
registry.watchPageAvailability(page.browserPageId, unavailable)
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
|
||||
guest.emit('render-process-gone', {}, { reason: 'crashed' })
|
||||
guest.destroy()
|
||||
|
||||
expect(unavailable).toHaveBeenCalledOnce()
|
||||
expect(unavailable).toHaveBeenCalledWith(page)
|
||||
})
|
||||
|
||||
it('does not report explicit page retirement as an availability loss', () => {
|
||||
const { getPageAuthority, registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
const unavailable = vi.fn()
|
||||
registry.watchPageAvailability(page.browserPageId, unavailable)
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
|
||||
registry.retirePageAuthority({
|
||||
...page,
|
||||
pageAuthority: getPageAuthority(),
|
||||
onRetired: vi.fn()
|
||||
})
|
||||
guest.destroy()
|
||||
|
||||
expect(unavailable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retires every page owned by a crashed host renderer', () => {
|
||||
const { registry, retirePreparedPagesOwnedByRenderer, routeSession } = createHarness()
|
||||
const first = createGuest({ session: routeSession })
|
||||
@@ -650,59 +320,6 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
expect(retirePreparedPagesOwnedByRenderer).toHaveBeenCalledWith(page.rendererWebContentsId)
|
||||
})
|
||||
|
||||
it('reports registered pages owned by a crashed host renderer', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
const unavailable = vi.fn()
|
||||
registry.watchPageAvailability(page.browserPageId, unavailable)
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
|
||||
registry.retireRenderer(page.rendererWebContentsId)
|
||||
|
||||
expect(unavailable).toHaveBeenCalledOnce()
|
||||
expect(unavailable).toHaveBeenCalledWith(page)
|
||||
})
|
||||
|
||||
it('keeps renderer retirement fail-closed when logical-owner cleanup throws', () => {
|
||||
const { registry, retirePreparedPagesOwnedByRenderer, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession, closeDestroys: false })
|
||||
registry.attachGuest(guest.guest)
|
||||
retirePreparedPagesOwnedByRenderer.mockImplementation(() => {
|
||||
throw new Error('owner cleanup unavailable')
|
||||
})
|
||||
|
||||
expect(() => registry.retireRenderer(page.rendererWebContentsId)).not.toThrow()
|
||||
expect(guest.guest.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed when Electron guest inspection races destruction', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
vi.mocked(guest.guest.getURL).mockImplementation(() => {
|
||||
throw new Error('guest destroyed')
|
||||
})
|
||||
|
||||
expect(() => registry.attachGuest(guest.guest)).not.toThrow()
|
||||
expect(guest.guest.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('turns registration-time inspection failures into navigation denial', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
registry.registerGuest(page)
|
||||
registry.grantNavigation(page)
|
||||
vi.mocked(guest.guest.getType).mockImplementation(() => {
|
||||
throw new Error('guest swapped')
|
||||
})
|
||||
|
||||
const navigation = navigationEvent()
|
||||
expect(() => guest.emit('will-navigate', navigation, 'https://example.com/')).not.toThrow()
|
||||
expect(navigation.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a destroyed claim distinct from a reused WebContents identity', async () => {
|
||||
const { registry, replaceAuthority, routeSession } = createHarness()
|
||||
const first = createGuest({ session: routeSession })
|
||||
@@ -727,16 +344,4 @@ describe('BrowserRouteWebContentsRegistry', () => {
|
||||
await expect(registry.beginGuestRetirement(replacementClaim)).resolves.toBeUndefined()
|
||||
expect(replacement.guest.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects registration without throwing when blank-document inspection fails', () => {
|
||||
const { registry, routeSession } = createHarness()
|
||||
const guest = createGuest({ session: routeSession })
|
||||
registry.attachGuest(guest.guest)
|
||||
vi.mocked(guest.guest.getURL).mockImplementation(() => {
|
||||
throw new Error('guest unavailable')
|
||||
})
|
||||
|
||||
expect(() => registry.registerGuest(page)).not.toThrow()
|
||||
expect(registry.registerGuest(page)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -213,16 +213,6 @@ describe('LocalSshBrowserRoute', () => {
|
||||
accepted.destroy()
|
||||
})
|
||||
|
||||
it('retains one shared route per target', async () => {
|
||||
const dependencies = {
|
||||
resolveExecutionRoute: async () => fakeExecutionRoute('route-shared'),
|
||||
getAuthority: () => ({ providerEpoch: 'epoch-1', connectionGeneration: 1 })
|
||||
}
|
||||
const first = await retainLocalSshBrowserRoute('target-shared', dependencies)
|
||||
const second = await retainLocalSshBrowserRoute('target-shared', dependencies)
|
||||
expect(second.port).toBe(first.port)
|
||||
})
|
||||
|
||||
it('drops the shared route on explicit close so a re-add starts fresh', async () => {
|
||||
const dependencies = {
|
||||
resolveExecutionRoute: async () => fakeExecutionRoute('route-shared'),
|
||||
@@ -236,60 +226,4 @@ describe('LocalSshBrowserRoute', () => {
|
||||
socket.destroy()
|
||||
expect(first.port).not.toBe(0)
|
||||
})
|
||||
|
||||
it('classifies forwarding probes by the wire reason code, never by scary wording', async () => {
|
||||
let failure: (Error & { reason?: number }) | null = null
|
||||
const route = new LocalSshBrowserRoute('target-probe', {
|
||||
resolveExecutionRoute: async () => {
|
||||
const executionRoute = fakeExecutionRoute('route-probe')
|
||||
const originalConnect = executionRoute.connect
|
||||
executionRoute.connect = (target) => {
|
||||
if (failure) {
|
||||
const socket = new FakeTunnelSocket('never-connect')
|
||||
const error = failure
|
||||
queueMicrotask(() => {
|
||||
socket.emit('error', error)
|
||||
})
|
||||
return socket
|
||||
}
|
||||
return originalConnect(target)
|
||||
}
|
||||
return executionRoute
|
||||
},
|
||||
getAuthority: () => ({ providerEpoch: 'epoch-1', connectionGeneration: 1 })
|
||||
})
|
||||
routes.push(route)
|
||||
await route.listen()
|
||||
|
||||
const withReason = (message: string, reason?: number): Error & { reason?: number } => {
|
||||
const error = new Error(message) as Error & { reason?: number }
|
||||
if (reason !== undefined) {
|
||||
error.reason = reason
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
failure = withReason('(SSH) Channel open failure: open failed', 1)
|
||||
expect(await route.probeForwarding(500)).toBe('forwarding-blocked')
|
||||
// CONNECT_FAILED means forwarding was permitted even if the destination firewall says "denied".
|
||||
failure = withReason('(SSH) Channel open failure: connection denied by firewall', 2)
|
||||
expect(await route.probeForwarding(500)).toBe('ok')
|
||||
failure = withReason('administratively prohibited: open failed')
|
||||
expect(await route.probeForwarding(500)).toBe('forwarding-blocked')
|
||||
failure = withReason('open forbidden: access denied, not allowed')
|
||||
expect(await route.probeForwarding(500)).toBe('ok')
|
||||
failure = null
|
||||
expect(await route.probeForwarding(500)).toBe('ok')
|
||||
})
|
||||
|
||||
it('reports ssh-unavailable when the probe cannot resolve a route', async () => {
|
||||
const route = new LocalSshBrowserRoute('target-down', {
|
||||
resolveExecutionRoute: async () => {
|
||||
throw new Error('browser_tunnel_execution_host_unavailable')
|
||||
},
|
||||
getAuthority: () => ({ providerEpoch: 'epoch-1', connectionGeneration: 1 })
|
||||
})
|
||||
routes.push(route)
|
||||
expect(await route.probeForwarding(500)).toBe('ssh-unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -52,69 +52,6 @@ describe('BrowserRoutePartitionBindingStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads pre-scope v1 metadata as unscoped bindings', () => {
|
||||
const filePath = createPath()
|
||||
writeFileSync(filePath, JSON.stringify({ version: 1, bindings: { [partition]: fingerprint } }))
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath })
|
||||
|
||||
expect(store.get(partition)).toBe(fingerprint)
|
||||
expect(store.listBindings().get(partition)).toEqual({
|
||||
fingerprint,
|
||||
storageScope: null,
|
||||
lastUsedAt: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('removes bindings and rejects a non-hex storage scope', () => {
|
||||
const filePath = createPath()
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath })
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
|
||||
expect(() => store.set(thirdPartition, fingerprint, 'z')).toThrow(
|
||||
'browser_route_partition_binding_invalid'
|
||||
)
|
||||
expect(store.remove([partition])).toBe(1)
|
||||
expect(store.remove([partition])).toBe(0)
|
||||
expect(new BrowserRoutePartitionBindingStore({ filePath }).get(partition)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects replacement and invalid binding shapes', () => {
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: createPath() })
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
|
||||
expect(() => store.set(partition, 'b'.repeat(64), 'e'.repeat(64))).toThrow(
|
||||
'browser_route_partition_binding_conflict'
|
||||
)
|
||||
expect(() => store.set('persist:unowned', fingerprint, 'e'.repeat(64))).toThrow(
|
||||
'browser_route_partition_binding_invalid'
|
||||
)
|
||||
expect(() => store.set(partition, 'short', 'e'.repeat(64))).toThrow(
|
||||
'browser_route_partition_binding_invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on corrupt or malformed persisted metadata', () => {
|
||||
const filePath = createPath()
|
||||
writeFileSync(filePath, '{broken')
|
||||
expect(() => new BrowserRoutePartitionBindingStore({ filePath }).get(partition)).toThrow(
|
||||
'browser_route_partition_binding_store_invalid'
|
||||
)
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({ version: 3, bindings: {} }))
|
||||
expect(() => new BrowserRoutePartitionBindingStore({ filePath }).get(partition)).toThrow(
|
||||
'browser_route_partition_binding_store_invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an oversized binding file before parsing it', () => {
|
||||
const filePath = createPath()
|
||||
writeFileSync(filePath, JSON.stringify({ version: 1, bindings: {}, padding: 'x'.repeat(256) }))
|
||||
|
||||
expect(() =>
|
||||
new BrowserRoutePartitionBindingStore({ filePath, maxFileBytes: 128 }).get(partition)
|
||||
).toThrow('browser_route_partition_binding_store_invalid')
|
||||
})
|
||||
|
||||
it('fails closed when Chromium data exists without matching binding metadata', () => {
|
||||
const paths = createStorePaths()
|
||||
mkdirSync(join(paths.partitionDataRoot, partition.slice('persist:'.length)), {
|
||||
@@ -128,46 +65,6 @@ describe('BrowserRoutePartitionBindingStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts existing Chromium data only with matching durable metadata', () => {
|
||||
const paths = createStorePaths()
|
||||
const store = new BrowserRoutePartitionBindingStore(paths)
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
mkdirSync(join(paths.partitionDataRoot, partition.slice('persist:'.length)), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
expect(store.get(partition)).toBe(fingerprint)
|
||||
})
|
||||
|
||||
it('bounds distinct persisted bindings when liveness is unknown', () => {
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: createPath(), maxBindings: 1 })
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
|
||||
expect(() => store.set(otherPartition, 'b'.repeat(64), 'e'.repeat(64))).toThrow(
|
||||
'browser_route_partition_binding_capacity'
|
||||
)
|
||||
})
|
||||
|
||||
it('evicts the least recently used releasable binding instead of failing at capacity', () => {
|
||||
const store = new BrowserRoutePartitionBindingStore({
|
||||
filePath: createPath(),
|
||||
maxBindings: 2,
|
||||
isPartitionRetained: () => false
|
||||
})
|
||||
vi.setSystemTime(1_000)
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
vi.setSystemTime(2_000)
|
||||
store.set(otherPartition, 'b'.repeat(64), 'e'.repeat(64))
|
||||
|
||||
vi.setSystemTime(3_000)
|
||||
const evicted = store.set(thirdPartition, 'c'.repeat(64), 'e'.repeat(64))
|
||||
|
||||
expect(evicted).toEqual([partition])
|
||||
expect(store.get(partition)).toBeNull()
|
||||
expect(store.get(otherPartition)).toBe('b'.repeat(64))
|
||||
expect(store.get(thirdPartition)).toBe('c'.repeat(64))
|
||||
})
|
||||
|
||||
it('never evicts a retained partition, and fails only when every binding is retained', () => {
|
||||
const filePath = createPath()
|
||||
const retained = new Set<string>()
|
||||
@@ -212,22 +109,6 @@ describe('BrowserRoutePartitionBindingStore', () => {
|
||||
expect(store.get(partition)).toBe(fingerprint)
|
||||
})
|
||||
|
||||
it('resolves an existing partition by fingerprint and re-points it on rebind', () => {
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: createPath() })
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
|
||||
expect(store.findPartitionByFingerprint(fingerprint)).toBe(partition)
|
||||
expect(store.findPartitionByFingerprint('b'.repeat(64))).toBeNull()
|
||||
|
||||
store.rebind(partition, 'b'.repeat(64), 'e'.repeat(64))
|
||||
|
||||
expect(store.findPartitionByFingerprint('b'.repeat(64))).toBe(partition)
|
||||
expect(store.get(partition)).toBe('b'.repeat(64))
|
||||
expect(() => store.rebind(otherPartition, 'c'.repeat(64), 'e'.repeat(64))).toThrow(
|
||||
'browser_route_partition_binding_invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('answers to an alias beside the fingerprint older builds read', () => {
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: createPath() })
|
||||
store.set(partition, fingerprint, 'e'.repeat(64))
|
||||
@@ -242,33 +123,4 @@ describe('BrowserRoutePartitionBindingStore', () => {
|
||||
store.set(partition, fingerprint, 'f'.repeat(64))
|
||||
expect(store.findPartitionByFingerprint('b'.repeat(64))).toBe(partition)
|
||||
})
|
||||
|
||||
it('keeps aliases bounded and drops malformed ones without rejecting the file', () => {
|
||||
const filePath = createPath()
|
||||
writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
bindings: {
|
||||
[partition]: {
|
||||
fingerprint,
|
||||
storageScope: 'e'.repeat(64),
|
||||
lastUsedAt: 1,
|
||||
aliasFingerprints: ['not-a-fingerprint', 7, 'b'.repeat(64)]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath })
|
||||
expect(store.listBindings().get(partition)?.aliasFingerprints).toEqual(['b'.repeat(64)])
|
||||
|
||||
store.rebind(partition, fingerprint, 'e'.repeat(64), 'c'.repeat(64))
|
||||
store.rebind(partition, fingerprint, 'e'.repeat(64), 'd'.repeat(64))
|
||||
|
||||
expect(store.listBindings().get(partition)?.aliasFingerprints).toEqual([
|
||||
'c'.repeat(64),
|
||||
'd'.repeat(64)
|
||||
])
|
||||
expect(store.findPartitionByFingerprint('b'.repeat(64))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -136,45 +136,6 @@ function deferred(): {
|
||||
}
|
||||
|
||||
describe('BrowserRouteSessionRegistry', () => {
|
||||
it('prepares authority-scoped remote metadata without needing a desktop profile record', async () => {
|
||||
const { dependencies, registry } = createHarness()
|
||||
const profileMetadata = {
|
||||
id: identity.browserProfileId,
|
||||
label: 'Remote profile',
|
||||
scope: 'isolated'
|
||||
}
|
||||
await prepare(registry, { profileMetadata })
|
||||
expect(dependencies.validateProfile).toHaveBeenCalledWith(
|
||||
identity.browserProfileId,
|
||||
profileMetadata
|
||||
)
|
||||
expect(dependencies.setupPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileMetadata,
|
||||
browserProfileId: identity.browserProfileId,
|
||||
partition: 'persist:route-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ id: 'other-profile', label: 'Remote', scope: 'isolated' },
|
||||
{
|
||||
id: identity.browserProfileId,
|
||||
label: 'Remote',
|
||||
scope: 'isolated',
|
||||
partition: 'persist:local-default'
|
||||
}
|
||||
])(
|
||||
'rejects inconsistent or arbitrary partition metadata before touching storage',
|
||||
async (profileMetadata) => {
|
||||
const { registry, dependencies } = createHarness()
|
||||
await expect(prepare(registry, { profileMetadata })).rejects.toThrow()
|
||||
expect(dependencies.getSession).not.toHaveBeenCalled()
|
||||
expect(dependencies.bindingStore.set).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('rekeys one prepared authority and makes the old release handle inert', async () => {
|
||||
const { dependencies, registry } = createHarness()
|
||||
const previousHandle = await prepare(registry)
|
||||
@@ -204,126 +165,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applies and verifies the exact fail-closed proxy before allowlisting', async () => {
|
||||
const { dependencies, order, registry, session } = createHarness()
|
||||
|
||||
const handle = await prepare(registry)
|
||||
|
||||
expect(handle.partition).toBe('persist:route-a')
|
||||
expect(order).toEqual([
|
||||
'validate-profile',
|
||||
'persist-binding',
|
||||
'get-session',
|
||||
'set-proxy',
|
||||
'setup-policies',
|
||||
'close-connections',
|
||||
'resolve-proxy'
|
||||
])
|
||||
expect(session.setProxy).toHaveBeenCalledWith({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'socks5://127.0.0.1:43123',
|
||||
proxyBypassRules: '<-loopback>'
|
||||
})
|
||||
expect(session.resolveProxy).toHaveBeenCalledWith('http://browser-route-probe.invalid/')
|
||||
expect(registry.isAllowedPartition(handle.partition)).toBe(true)
|
||||
|
||||
handle.release()
|
||||
expect(registry.isAllowedPartition(handle.partition)).toBe(false)
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('indexes only exact live page generations by their Electron session', async () => {
|
||||
const { registry, session } = createHarness()
|
||||
const handle = await prepare(registry)
|
||||
|
||||
expect(registry.getPartitionForSession(session)).toBe(handle.partition)
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 2,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
handle.release()
|
||||
expect(registry.getPartitionForSession(session)).toBe(handle.partition)
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('binds prepared page authority to the exact owning renderer', async () => {
|
||||
const { registry } = createHarness()
|
||||
const handle = await prepare(registry)
|
||||
const page = {
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1
|
||||
}
|
||||
|
||||
expect(registry.getPreparedPageAuthority({ ...page, rendererWebContentsId: 11 })).not.toBeNull()
|
||||
expect(registry.getPreparedPageAuthority({ ...page, rendererWebContentsId: 12 })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not move a live page generation to another renderer', async () => {
|
||||
const { registry } = createHarness()
|
||||
const owned = await prepare(registry)
|
||||
|
||||
await expect(prepare(registry, { rendererWebContentsId: 12 })).rejects.toThrow(
|
||||
'browser_route_partition_page_owner_conflict'
|
||||
)
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: owned.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('retires prepared but unregistered pages when their owning renderer exits', async () => {
|
||||
const { registry } = createHarness()
|
||||
const owned = await prepare(registry)
|
||||
await prepare(registry, {
|
||||
browserPageId: 'page-b',
|
||||
pageHostGeneration: 2,
|
||||
rendererWebContentsId: 12
|
||||
})
|
||||
|
||||
expect(registry.retirePreparedPagesOwnedByRenderer(11)).toBe(1)
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: owned.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
registry.getPreparedPageAuthority({
|
||||
partition: owned.partition,
|
||||
browserPageId: 'page-b',
|
||||
pageHostGeneration: 2,
|
||||
rendererWebContentsId: 12
|
||||
})
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('continues owner retirement across partitions when policy cleanup throws', async () => {
|
||||
const { registry } = createHarness({ cleanupError: new Error('cleanup unavailable') })
|
||||
const first = await prepare(registry)
|
||||
@@ -342,23 +183,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an in-flight prepare after its renderer owner exits', async () => {
|
||||
const probeStarted = deferred()
|
||||
const resolveProxyGate = deferred()
|
||||
const { dependencies, registry } = createHarness({
|
||||
proxyProbeStarted: probeStarted.resolve,
|
||||
resolveProxyGate: resolveProxyGate.promise
|
||||
})
|
||||
const preparing = prepare(registry)
|
||||
await probeStarted.promise
|
||||
|
||||
expect(registry.retirePreparedPagesOwnedByRenderer(11)).toBe(0)
|
||||
resolveProxyGate.resolve()
|
||||
await expect(preparing).rejects.toThrow('browser_route_partition_renderer_retired')
|
||||
expect(registry.isAllowedPartition('persist:route-a')).toBe(false)
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('admits a live sibling waiter after fencing a stale pending owner', async () => {
|
||||
const probeStarted = deferred()
|
||||
const resolveProxyGate = deferred()
|
||||
@@ -389,25 +213,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
expect(dependencies.clearPolicies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds callers waiting on one pending partition setup', async () => {
|
||||
const probeStarted = deferred()
|
||||
const resolveProxyGate = deferred()
|
||||
const { registry } = createHarness({
|
||||
maxPagesPerPartition: 1,
|
||||
proxyProbeStarted: probeStarted.resolve,
|
||||
resolveProxyGate: resolveProxyGate.promise
|
||||
})
|
||||
const first = prepare(registry)
|
||||
await probeStarted.promise
|
||||
|
||||
await expect(prepare(registry, { browserPageId: 'page-b' })).rejects.toThrow(
|
||||
'browser_route_partition_pending_capacity'
|
||||
)
|
||||
resolveProxyGate.resolve()
|
||||
const handle = await first
|
||||
handle.release()
|
||||
})
|
||||
|
||||
it('does not resolve a live-partition prepare after owner retirement', async () => {
|
||||
const { registry } = createHarness()
|
||||
const first = await prepare(registry)
|
||||
@@ -422,31 +227,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
expect(registry.isAllowedPartition(first.partition)).toBe(false)
|
||||
})
|
||||
|
||||
it('changes opaque page authority when the same logical tuple is prepared again', async () => {
|
||||
const { registry } = createHarness()
|
||||
const first = await prepare(registry)
|
||||
const firstAuthority = registry.getPreparedPageAuthority({
|
||||
partition: first.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
first.release()
|
||||
|
||||
const replacement = await prepare(registry)
|
||||
const replacementAuthority = registry.getPreparedPageAuthority({
|
||||
partition: replacement.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
|
||||
expect(firstAuthority).not.toBeNull()
|
||||
expect(replacementAuthority).not.toBeNull()
|
||||
expect(replacementAuthority).not.toBe(firstAuthority)
|
||||
replacement.release()
|
||||
})
|
||||
|
||||
it('keeps route policy installed until delayed exact-page retirement settles', async () => {
|
||||
const { dependencies, registry, retirements } = createHarness({
|
||||
maxPagesPerPartition: 1,
|
||||
@@ -493,72 +273,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed when exact-page retirement cannot be started', async () => {
|
||||
const { dependencies, registry } = createHarness({
|
||||
retirementError: new Error('retirement unavailable')
|
||||
})
|
||||
const handle = await prepare(registry)
|
||||
|
||||
expect(() => handle.release()).not.toThrow()
|
||||
expect(registry.isAllowedPartition(handle.partition)).toBe(false)
|
||||
expect(dependencies.clearPolicies).not.toHaveBeenCalled()
|
||||
await expect(prepare(registry)).rejects.toThrow('browser_route_partition_page_retiring')
|
||||
})
|
||||
|
||||
it('lets an exact lifecycle event begin the same fenced page retirement', async () => {
|
||||
const { registry, retirements } = createHarness({ retirementSettled: false })
|
||||
const handle = await prepare(registry)
|
||||
const pageAuthority = registry.getPreparedPageAuthority({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11
|
||||
})
|
||||
|
||||
expect(
|
||||
registry.retirePreparedPage({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11,
|
||||
pageAuthority: pageAuthority ?? Symbol('missing')
|
||||
})
|
||||
).toBe(true)
|
||||
expect(registry.isAllowedPartition(handle.partition)).toBe(false)
|
||||
expect(retirements).toHaveLength(1)
|
||||
expect(
|
||||
registry.retirePreparedPage({
|
||||
partition: handle.partition,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 1,
|
||||
rendererWebContentsId: 11,
|
||||
pageAuthority: pageAuthority ?? Symbol('missing')
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never allowlists a partition whose proxy resolves direct or elsewhere', async () => {
|
||||
const { dependencies, registry } = createHarness({ resolvedProxy: 'DIRECT' })
|
||||
|
||||
await expect(prepare(registry)).rejects.toThrow(
|
||||
'browser_route_partition_proxy_verification_failed'
|
||||
)
|
||||
expect(registry.isAllowedPartition('persist:route-a')).toBe(false)
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears partially installed policies when async policy setup fails', async () => {
|
||||
const { dependencies, registry, session } = createHarness({
|
||||
setupError: new Error('policy setup failed')
|
||||
})
|
||||
|
||||
await expect(prepare(registry)).rejects.toThrow('policy setup failed')
|
||||
expect(session.setProxy).toHaveBeenCalledTimes(1)
|
||||
expect(session.closeAllConnections).toHaveBeenCalledTimes(1)
|
||||
expect(dependencies.clearPolicies).toHaveBeenCalledTimes(1)
|
||||
expect(registry.isAllowedPartition('persist:route-a')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps retries joined until failed policy setup settles proxy cleanup (reject: %s)',
|
||||
async (rejectProxy) => {
|
||||
@@ -597,43 +311,6 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a binding collision before opening an Electron session', async () => {
|
||||
const { bindings, dependencies, registry } = createHarness()
|
||||
bindings.set('persist:route-a', 'c'.repeat(64))
|
||||
|
||||
await expect(prepare(registry)).rejects.toThrow('browser_route_partition_binding_conflict')
|
||||
expect(dependencies.getSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing browser profile before consuming durable binding capacity', async () => {
|
||||
const { dependencies, registry } = createHarness({
|
||||
profileError: new Error('browser_route_partition_profile_unavailable')
|
||||
})
|
||||
|
||||
await expect(prepare(registry)).rejects.toThrow('browser_route_partition_profile_unavailable')
|
||||
expect(dependencies.bindingStore.set).not.toHaveBeenCalled()
|
||||
expect(dependencies.getSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid listener and page identities before binding persistence', async () => {
|
||||
const { dependencies, registry } = createHarness()
|
||||
|
||||
await expect(
|
||||
prepare(registry, { proxyEndpoint: { host: '0.0.0.0', port: 43123 } })
|
||||
).rejects.toThrow('browser_route_partition_proxy_invalid')
|
||||
await expect(prepare(registry, { browserPageId: '' })).rejects.toThrow(
|
||||
'browser_route_partition_page_invalid'
|
||||
)
|
||||
await expect(prepare(registry, { pageHostGeneration: 0 })).rejects.toThrow(
|
||||
'browser_route_partition_page_invalid'
|
||||
)
|
||||
await expect(prepare(registry, { rendererWebContentsId: 0 })).rejects.toThrow(
|
||||
'browser_route_partition_page_invalid'
|
||||
)
|
||||
expect(dependencies.bindingStore.set).not.toHaveBeenCalled()
|
||||
expect(dependencies.getSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares one prepared partition and fences stale page-handle cleanup', async () => {
|
||||
const { dependencies, registry, session } = createHarness()
|
||||
const first = await prepare(registry)
|
||||
@@ -664,27 +341,4 @@ describe('BrowserRouteSessionRegistry', () => {
|
||||
first.release()
|
||||
second.release()
|
||||
})
|
||||
|
||||
it('bounds distinct live and pending partitions', async () => {
|
||||
const { registry } = createHarness({ maxLivePartitions: 1 })
|
||||
const first = await prepare(registry)
|
||||
|
||||
await expect(
|
||||
prepare(registry, {
|
||||
browserPageId: 'page-b',
|
||||
identity: { ...identity, executionHostIdentity: 'execution-host-b' }
|
||||
})
|
||||
).rejects.toThrow('browser_route_partition_capacity')
|
||||
first.release()
|
||||
})
|
||||
|
||||
it('bounds distinct page generations retained by one partition', async () => {
|
||||
const { registry } = createHarness({ maxPagesPerPartition: 1 })
|
||||
const first = await prepare(registry)
|
||||
|
||||
await expect(prepare(registry, { browserPageId: 'page-b' })).rejects.toThrow(
|
||||
'browser_route_partition_page_capacity'
|
||||
)
|
||||
first.release()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,66 +137,12 @@ function profileFor(partition: string): BrowserSessionProfile {
|
||||
}
|
||||
}
|
||||
|
||||
function fireWillDownload(partition: string): { cancelled: boolean } {
|
||||
const sess = sessionsByPartition.get(partition)
|
||||
if (!sess || sess.listeners.length !== 1) {
|
||||
throw new Error(`expected exactly one will-download listener on ${partition}`)
|
||||
}
|
||||
let cancelled = false
|
||||
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
|
||||
return { cancelled }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionsByPartition.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('partition download policy', () => {
|
||||
it('routes a download on a partition that did not ask for the deny', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guestWebContentsId: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tells the reader about the refusal, and only on the partition that refused', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
fireWillDownload('persist:browsing-1')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
|
||||
|
||||
fireWillDownload('orca-doc-preview')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps each partition on its own decision', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partition permission policy', () => {
|
||||
it('denies clipboard reads only to pages an SSH host has driven (#29145)', async () => {
|
||||
mocks.isRemoteCliDrivenGuest.mockImplementation((id?: number) => id === 7)
|
||||
@@ -245,44 +191,6 @@ describe('partition permission policy', () => {
|
||||
expect(checkHandler(guest(8), 'clipboard-read', 'https://a.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('answers non-notification requests without inventorying frames', async () => {
|
||||
const install = await loadInstaller()
|
||||
const partition = 'persist:permission-frame-inventory'
|
||||
await install(profileFor(partition), { sitePermissions: 'session' })
|
||||
const { sessionBrowserSitePermissions } =
|
||||
await import('../site-settings/browser-site-permission-prompt')
|
||||
const memory = sessionBrowserSitePermissions(partition)
|
||||
const requestHandler =
|
||||
sessionsByPartition.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]
|
||||
if (typeof requestHandler !== 'function') {
|
||||
throw new Error('Expected the installed permission request handler')
|
||||
}
|
||||
const enumerateFrames = vi.fn(() => [{ url: 'about:srcdoc', origin: 'https://other.test' }])
|
||||
const tab = {
|
||||
id: 12,
|
||||
getURL: () => 'https://a.test/page',
|
||||
mainFrame: {
|
||||
get framesInSubtree() {
|
||||
return enumerateFrames()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const decision of ['allow', 'block'] as const) {
|
||||
memory.set('https://a.test', ['location', 'clipboard', 'midi'], decision)
|
||||
for (const permission of ['geolocation', 'clipboard-read', 'midi']) {
|
||||
const answer = await new Promise<boolean>((resolve) => {
|
||||
requestHandler(tab, permission, resolve, {
|
||||
requestingUrl: 'about:srcdoc',
|
||||
isMainFrame: false
|
||||
})
|
||||
})
|
||||
expect(answer).toBe(decision === 'allow')
|
||||
}
|
||||
}
|
||||
expect(enumerateFrames).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers a notification from a frame with an inherited origin as the frame's real site", async () => {
|
||||
const install = await loadInstaller()
|
||||
const store = await import('../site-settings/browser-site-permission-store')
|
||||
@@ -499,119 +407,4 @@ describe('partition permission policy', () => {
|
||||
expect.objectContaining({ guestWebContentsId: 11, permission: 'fileSystem' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a blocklisted system folder at once instead of leaving the page waiting', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-6'))
|
||||
install(profileFor('orca-doc-preview'), { permissions: 'deny' })
|
||||
const answer = (partition: string) => {
|
||||
const sess = sessionsByPartition.get(partition)
|
||||
const listener = sess?.on.mock.calls.find(
|
||||
([event]) => event === 'file-system-access-restricted'
|
||||
)?.[1]
|
||||
const actions: string[] = []
|
||||
listener?.(
|
||||
{},
|
||||
{
|
||||
origin: 'https://app.test',
|
||||
isDirectory: true,
|
||||
path: '/Users/me',
|
||||
webContents: { id: 12 }
|
||||
},
|
||||
(action: string) => actions.push(action)
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
expect(answer('orca-doc-preview')).toEqual(['deny'])
|
||||
expect(mocks.notifyPermissionDenied).not.toHaveBeenCalled()
|
||||
expect(answer('persist:browsing-6')).toEqual(['deny'])
|
||||
expect(mocks.notifyPermissionDenied).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 12,
|
||||
permission: 'fileSystem',
|
||||
rawUrl: 'https://app.test'
|
||||
})
|
||||
})
|
||||
|
||||
it('hands a link to another app to the open-app prompt instead of denying it', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-4'))
|
||||
const sess = sessionsByPartition.get('persist:browsing-4')
|
||||
if (!sess) {
|
||||
throw new Error('Expected the browsing session')
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session.
|
||||
const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as (
|
||||
webContents: { id: number; getURL: () => string },
|
||||
permission: string,
|
||||
callback: (allowed: boolean) => void,
|
||||
details: { externalURL?: string; requestingUrl?: string }
|
||||
) => void
|
||||
const guest = { id: 9, getURL: () => 'https://acme.okta.com/signin' }
|
||||
const details = {
|
||||
externalURL: 'com-okta-authenticator:/deviceChallenge',
|
||||
requestingUrl: 'https://login.acme.okta.com/frame'
|
||||
}
|
||||
const decisions: boolean[] = []
|
||||
|
||||
mocks.requestExternalAppLaunch.mockReturnValueOnce(true)
|
||||
requestHandler(guest, 'openExternal', (allowed) => decisions.push(allowed), details)
|
||||
const [launchGuest, launchUrl, requestingUrl] = mocks.requestExternalAppLaunch.mock.calls[0]
|
||||
expect([launchGuest, launchUrl]).toEqual([guest, 'com-okta-authenticator:/deviceChallenge'])
|
||||
expect(requestingUrl()).toBe('https://login.acme.okta.com/frame')
|
||||
expect(mocks.notifyPermissionDenied).not.toHaveBeenCalled()
|
||||
|
||||
mocks.requestExternalAppLaunch.mockReturnValueOnce(false)
|
||||
requestHandler(guest, 'openExternal', (allowed) => decisions.push(allowed), details)
|
||||
expect(mocks.notifyPermissionDenied).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guestWebContentsId: 9, permission: 'openExternal' })
|
||||
)
|
||||
// Orca launches approved apps itself, so Chromium's launch remains denied.
|
||||
expect(decisions).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('cancels a download from a page an SSH host drives (#29145)', async () => {
|
||||
mocks.isRemoteCliDrivenGuest.mockImplementation((id?: number) => id === 42)
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-3'))
|
||||
|
||||
expect(fireWillDownload('persist:browsing-3')).toEqual({ cancelled: true })
|
||||
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every request and check on a strict partition without WebAuthn handlers', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { permissions: 'deny' })
|
||||
const sess = sessionsByPartition.get('orca-doc-preview')
|
||||
if (!sess) {
|
||||
throw new Error('Expected the preview session')
|
||||
}
|
||||
const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as (
|
||||
webContents: Electron.WebContents,
|
||||
permission: string,
|
||||
callback: (allowed: boolean) => void
|
||||
) => void
|
||||
const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as (
|
||||
webContents: Electron.WebContents,
|
||||
permission: string
|
||||
) => boolean
|
||||
const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as (
|
||||
request: Electron.DisplayMediaRequestHandlerHandlerRequest,
|
||||
callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void
|
||||
) => void
|
||||
|
||||
for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) {
|
||||
let decision: boolean | null = null
|
||||
requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed))
|
||||
expect(decision).toBe(false)
|
||||
expect(checkHandler({} as Electron.WebContents, permission)).toBe(false)
|
||||
}
|
||||
expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
|
||||
expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess)
|
||||
let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null
|
||||
displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => {
|
||||
displayMediaDecision = decision
|
||||
})
|
||||
expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
invalidateBrowserSessionProxyApplication,
|
||||
setBrowserNetworkProxySettingsResolver
|
||||
} from './browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../../network/electron-proxy-credentials'
|
||||
import {
|
||||
applyProxySettingsToSession,
|
||||
releaseProxySessionApplication
|
||||
@@ -68,110 +67,6 @@ describe('browser session proxy', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('pins the configured proxy onto every browser partition', async () => {
|
||||
await applyBrowserSessionProxies(PROFILES, {
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
})
|
||||
|
||||
expect(fromPartitionMock).toHaveBeenCalledWith('persist:orca-browser')
|
||||
expect(fromPartitionMock).toHaveBeenCalledWith('persist:orca-browser-session-iso')
|
||||
for (const partition of PROFILES.map((p) => p.partition)) {
|
||||
expect(sessionsByPartition.get(partition)?.setProxy).toHaveBeenCalledWith({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'socks5://127.0.0.1:1080'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('carries bypass rules through to each partition', async () => {
|
||||
await applyBrowserSessionProxies(PROFILES, {
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: 'localhost, *.internal'
|
||||
})
|
||||
|
||||
expect(sessionsByPartition.get('persist:orca-browser')?.setProxy).toHaveBeenCalledWith({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'socks5://127.0.0.1:1080',
|
||||
proxyBypassRules: 'localhost;*.internal'
|
||||
})
|
||||
})
|
||||
|
||||
it('authenticates a credentialed proxy without exposing credentials in partition rules', async () => {
|
||||
await applyBrowserSessionProxies(PROFILES.slice(0, 1), {
|
||||
httpProxyUrl: 'http://browser-user:browser-pass@proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
})
|
||||
const sess = sessionsByPartition.get('persist:orca-browser')
|
||||
expect(sess?.setProxy).toHaveBeenCalledWith({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'http://proxy.example:8080'
|
||||
})
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
const callback = vi.fn()
|
||||
handleElectronProxyLogin(
|
||||
{ preventDefault } as never,
|
||||
{ session: sess } as never,
|
||||
{} as never,
|
||||
{
|
||||
isProxy: true,
|
||||
scheme: 'basic',
|
||||
host: 'proxy.example',
|
||||
port: 8080,
|
||||
realm: 'proxy'
|
||||
},
|
||||
callback
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(callback).toHaveBeenCalledWith('browser-user', 'browser-pass')
|
||||
})
|
||||
|
||||
it('keeps sweeping but reports failure so the failed partition stays gated', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fromPartitionMock.mockImplementationOnce(() => {
|
||||
throw new Error('partition unavailable')
|
||||
})
|
||||
|
||||
await expect(
|
||||
applyBrowserSessionProxies(PROFILES, {
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
})
|
||||
).rejects.toThrow('persist:orca-browser')
|
||||
|
||||
expect(
|
||||
sessionsByPartition.get('persist:orca-browser-session-iso')?.setProxy
|
||||
).toHaveBeenCalledWith({ mode: 'fixed_servers', proxyRules: 'socks5://127.0.0.1:1080' })
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[proxy] Failed to apply proxy to browser partition',
|
||||
'persist:orca-browser'
|
||||
)
|
||||
})
|
||||
|
||||
it('starts every partition write without waiting for earlier partitions', async () => {
|
||||
let finishFirstWrite: (() => void) | undefined
|
||||
const firstSession = fromPartition('persist:orca-browser')
|
||||
firstSession.setProxy.mockImplementation(
|
||||
() => new Promise<void>((resolve) => (finishFirstWrite = resolve))
|
||||
)
|
||||
sessionsByPartition.set('persist:orca-browser', firstSession)
|
||||
|
||||
const sweep = applyBrowserSessionProxies(PROFILES, {
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sessionsByPartition.get('persist:orca-browser-session-iso')?.setProxy
|
||||
).toHaveBeenCalledOnce()
|
||||
)
|
||||
finishFirstWrite?.()
|
||||
await sweep
|
||||
})
|
||||
|
||||
it('settles an in-flight new partition on the latest swept policy', async () => {
|
||||
let proxyUrl = 'http://old.example:8080'
|
||||
let finishFirstWrite: (() => void) | undefined
|
||||
@@ -218,29 +113,6 @@ describe('browser session proxy', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reads settings through the injected resolver when none are passed', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const sess = fromPartitionMock('persist:orca-browser')
|
||||
|
||||
await applyProxyToBrowserSession(sess as never)
|
||||
|
||||
expect(sess.setProxy).toHaveBeenCalledWith({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'socks5://127.0.0.1:1080'
|
||||
})
|
||||
})
|
||||
|
||||
it('makes no proxy write when no resolver is registered', async () => {
|
||||
const sess = fromPartitionMock('persist:orca-browser')
|
||||
|
||||
await applyProxyToBrowserSession(sess as never)
|
||||
|
||||
expect(sess.setProxy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('startup readiness', () => {
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
@@ -292,24 +164,6 @@ describe('browser session proxy', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries only a failed installation after its bounded attempts are exhausted', async () => {
|
||||
const failed = fromPartition('persist:orca-browser')
|
||||
const healthy = fromPartition('persist:orca-browser-session-iso')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
failed.resolveProxy.mockRejectedValueOnce(new Error('native proxy failure'))
|
||||
}
|
||||
const initialFailure = expect(applyProxySettingsToSession(failed, {})).rejects.toThrow(
|
||||
'native proxy failure'
|
||||
)
|
||||
await applyProxySettingsToSession(healthy, {})
|
||||
await initialFailure
|
||||
|
||||
await awaitBrowserSessionProxies(PROFILES)
|
||||
|
||||
expect(failed.resolveProxy).toHaveBeenCalledTimes(4)
|
||||
expect(healthy.resolveProxy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('awaits every installation and preserves failed-partition warnings and retry budget', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const failed = fromPartition('persist:orca-browser')
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLEAN_USER_AGENT,
|
||||
createFsState,
|
||||
IDENTITY_RECORD_PATH,
|
||||
installModuleMocks,
|
||||
META_PATH,
|
||||
seedMeta
|
||||
} from './browser-session-registry-persistence-test-fixture'
|
||||
|
||||
@@ -14,84 +12,6 @@ describe('BrowserSessionRegistry retired identity data', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => {
|
||||
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
|
||||
const brokenUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36'
|
||||
const validUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: { browserFamily: 'arc', importedAt: 1 },
|
||||
userAgent: brokenUa,
|
||||
userAgentByPartition: {
|
||||
'persist:orca-browser': brokenUa,
|
||||
[importedPartition]: validUa
|
||||
},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {},
|
||||
profiles: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
scope: 'imported',
|
||||
partition: importedPartition,
|
||||
label: 'Imported',
|
||||
source: { browserFamily: 'chrome', importedAt: 1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) =>
|
||||
r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0])
|
||||
)
|
||||
expect(appliedUas).not.toContain(brokenUa)
|
||||
expect(appliedUas).not.toContain(validUa)
|
||||
expect(appliedUas.length).toBeGreaterThan(0)
|
||||
expect(appliedUas.every((ua) => ua === CLEAN_USER_AGENT)).toBe(true)
|
||||
expect(installBrowserSessionUserAgentPolicyMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags the retired per-profile choice without rewriting its persisted bytes', async () => {
|
||||
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
profiles: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
scope: 'imported',
|
||||
partition: importedPartition,
|
||||
label: 'Imported',
|
||||
source: { browserFamily: 'comet', importedAt: 1 },
|
||||
userAgentMode: 'native'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(JSON.parse(fsState.files.get(IDENTITY_RECORD_PATH) ?? '{}')).toEqual({
|
||||
version: 1,
|
||||
mode: 'clean',
|
||||
explicitSelection: false,
|
||||
migrationNoticePending: true
|
||||
})
|
||||
)
|
||||
// Preserve retired choices for rollback.
|
||||
expect(JSON.parse(fsState.files.get(META_PATH) ?? '{}').profiles[0].userAgentMode).toBe(
|
||||
'native'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not re-arm the retired-choice notice on the launch after an explicit choice', async () => {
|
||||
const profileId = '11111111-1111-4111-8111-111111111111'
|
||||
const fsState = createFsState()
|
||||
@@ -128,82 +48,4 @@ describe('BrowserSessionRegistry retired identity data', () => {
|
||||
migrationNoticePending: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ scenario: 'malformed members', malformed: [null, 42, 'broken'], failWrite: false },
|
||||
{ scenario: 'a read-only notice', malformed: [], failWrite: true }
|
||||
])('hydrates the valid profile despite $scenario', async ({ malformed, failWrite }) => {
|
||||
const profileId = '11111111-1111-4111-8111-111111111111'
|
||||
const partition = `persist:orca-browser-session-${profileId}`
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
profiles: [
|
||||
...malformed,
|
||||
{
|
||||
id: profileId,
|
||||
scope: 'isolated',
|
||||
partition,
|
||||
label: 'Existing',
|
||||
source: null,
|
||||
userAgentMode: 'native'
|
||||
}
|
||||
]
|
||||
})
|
||||
installModuleMocks(fsState, new Set(), failWrite)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
expect(() => browserSessionRegistry.initializeBrowserSessionsFromPersistedState()).not.toThrow()
|
||||
expect(browserSessionRegistry.getProfile(profileId)?.partition).toBe(partition)
|
||||
if (failWrite) {
|
||||
await vi.waitFor(() => expect(errors).toHaveBeenCalled())
|
||||
expect(errors.mock.calls[0]?.[1]).toMatchObject({ message: 'read-only userData' })
|
||||
}
|
||||
await vi.waitFor(() => expect(fsState.files.has(IDENTITY_RECORD_PATH)).toBe(!failWrite))
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.profiles).toHaveLength(malformed.length + 1)
|
||||
expect(written.profiles.at(-1).userAgentMode).toBe('native')
|
||||
})
|
||||
|
||||
it('hydrates a retired native profile under the process identity', async () => {
|
||||
const importedPartition = 'persist:orca-browser-session-12121212-1212-4121-8121-121212121212'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {},
|
||||
profiles: [
|
||||
{
|
||||
id: '12121212-1212-4121-8121-121212121212',
|
||||
scope: 'isolated',
|
||||
partition: importedPartition,
|
||||
label: 'Google',
|
||||
source: null,
|
||||
userAgentMode: 'native'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
const importedSessions = sessionFromPartitionMock.mock.results
|
||||
.filter((_, index) => sessionFromPartitionMock.mock.calls[index]?.[0] === importedPartition)
|
||||
.map((result) => result.value)
|
||||
expect(importedSessions.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
importedSessions.every((sess) => sess.setUserAgent.mock.calls[0]?.[0] === CLEAN_USER_AGENT)
|
||||
).toBe(true)
|
||||
expect(
|
||||
installBrowserSessionUserAgentPolicyMock.mock.calls.some(
|
||||
([sess]) => sess.partition === importedPartition
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLEAN_USER_AGENT,
|
||||
createFsState,
|
||||
installModuleMocks,
|
||||
META_PATH,
|
||||
seedMeta,
|
||||
seedSitePermissions
|
||||
} from './browser-session-registry-persistence-test-fixture'
|
||||
|
||||
describe('BrowserSessionRegistry persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('expires a legacy pendingCookieDbPath without copying it over the live jar', async () => {
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
pendingCookieDbPath: '/staged/legacy',
|
||||
profiles: []
|
||||
})
|
||||
fsState.present.add('/staged/legacy')
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.applyPendingCookieImport()
|
||||
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.pendingCookieDbPath).toBeNull()
|
||||
expect(written.pendingCookieImports).toEqual({})
|
||||
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the existing Network database when expiring an unmarked legacy image', async () => {
|
||||
const stagedPath = '/staged/network-import'
|
||||
const networkPath = '/user-data/Partitions/orca-browser/Network/Cookies'
|
||||
const legacyPath = '/user-data/Partitions/orca-browser/Cookies'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
pendingCookieDbPath: stagedPath,
|
||||
profiles: []
|
||||
})
|
||||
fsState.files.set(stagedPath, 'imported cookies')
|
||||
fsState.files.set(networkPath, 'old cookies')
|
||||
fsState.present.add(stagedPath)
|
||||
fsState.present.add(networkPath)
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.applyPendingCookieImport()
|
||||
|
||||
expect(fsState.files.get(networkPath)).toBe('old cookies')
|
||||
expect(fsState.present.has(legacyPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('persists new browser session profiles under the active Orca profile directory', async () => {
|
||||
const fsState = createFsState()
|
||||
const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json'
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId: 'local-work',
|
||||
profileDirectory: '/user-data/profiles/local-work'
|
||||
})
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Work Browser')
|
||||
|
||||
expect(profile).not.toBeNull()
|
||||
expect(fsState.files.has(profileMetaPath)).toBe(true)
|
||||
expect(fsState.files.has(META_PATH)).toBe(false)
|
||||
expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({
|
||||
id: profile!.id,
|
||||
partition: profile!.partition,
|
||||
label: 'Work Browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the process identity and request exceptions to new profiles', async () => {
|
||||
const fsState = createFsState()
|
||||
const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
await browserSessionRegistry.createProfile('isolated', 'Default identity')
|
||||
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT)
|
||||
expect(installBrowserSessionUserAgentPolicyMock).toHaveBeenCalledWith(
|
||||
profileSession,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('clears only the requested partition and unlinks its staged database files', async () => {
|
||||
const otherPartition = 'persist:orca-browser-session-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: '/staged/default',
|
||||
pendingCookieImports: {
|
||||
'persist:orca-browser': '/staged/default',
|
||||
[otherPartition]: '/staged/other'
|
||||
},
|
||||
profiles: []
|
||||
})
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
fsState.files.set(`/staged/other${suffix}`, 'db')
|
||||
fsState.present.add(`/staged/other${suffix}`)
|
||||
fsState.files.set(`/staged/default${suffix}`, 'db')
|
||||
fsState.present.add(`/staged/default${suffix}`)
|
||||
}
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.clearPendingCookieImport(otherPartition)
|
||||
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.pendingCookieImports).toEqual({ 'persist:orca-browser': '/staged/default' })
|
||||
expect(written.pendingCookieDbPath).toBe('/staged/default')
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
expect(fsState.present.has(`/staged/other${suffix}`)).toBe(false)
|
||||
expect(fsState.present.has(`/staged/default${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the legacy pointer when the default partition is the one cleared', async () => {
|
||||
const otherPartition = 'persist:orca-browser-session-cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: '/staged/default',
|
||||
pendingCookieImports: {
|
||||
'persist:orca-browser': '/staged/default',
|
||||
[otherPartition]: '/staged/other'
|
||||
},
|
||||
profiles: []
|
||||
})
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.clearPendingCookieImport('persist:orca-browser')
|
||||
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.pendingCookieImports).toEqual({ [otherPartition]: '/staged/other' })
|
||||
expect(written.pendingCookieDbPath).toBeNull()
|
||||
})
|
||||
|
||||
it('is a no-op when the partition has no pending import', async () => {
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: '/staged/default',
|
||||
pendingCookieImports: { 'persist:orca-browser': '/staged/default' },
|
||||
profiles: []
|
||||
})
|
||||
fsState.files.set('/staged/default', 'db')
|
||||
fsState.present.add('/staged/default')
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
const metaBefore = fsState.files.get(META_PATH)
|
||||
|
||||
browserSessionRegistry.clearPendingCookieImport('persist:orca-browser-session-unknown')
|
||||
|
||||
expect(fsState.files.get(META_PATH)).toBe(metaBefore)
|
||||
expect(fsState.present.has('/staged/default')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets up default-partition policies on restore', async () => {
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {},
|
||||
profiles: []
|
||||
})
|
||||
|
||||
seedSitePermissions(fsState, { default: { 'https://example.com': { camera: 'allow' } } })
|
||||
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
browserManagerHandleGuestWillDownloadMock,
|
||||
browserManagerNotifyPermissionDeniedMock
|
||||
} = installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
const defaultSessions = sessionFromPartitionMock.mock.results
|
||||
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser')
|
||||
.map((r) => r.value)
|
||||
expect(defaultSessions.length).toBeGreaterThan(0)
|
||||
const defaultSession = defaultSessions[0]
|
||||
const requestHandler = defaultSession.setPermissionRequestHandler.mock.calls[0][0]
|
||||
const checkHandler = defaultSession.setPermissionCheckHandler.mock.calls[0][0]
|
||||
const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
|
||||
const permissionCallback = vi.fn()
|
||||
const decisions = new Map<string, boolean>()
|
||||
const request = (
|
||||
permission: string,
|
||||
details: Partial<Electron.PermissionRequest & Electron.MediaAccessPermissionRequest> = {}
|
||||
): void =>
|
||||
requestHandler(
|
||||
guestWc,
|
||||
permission,
|
||||
(allowed: boolean) => decisions.set(permission, allowed),
|
||||
details
|
||||
)
|
||||
|
||||
// Only camera has a saved answer; this guest has no prompt window.
|
||||
request('fullscreen')
|
||||
request('clipboard-read')
|
||||
request('clipboard-sanitized-write')
|
||||
request('notifications')
|
||||
request('persistent-storage')
|
||||
request('geolocation')
|
||||
request('media', { mediaTypes: ['video'] })
|
||||
|
||||
await vi.waitFor(() => expect(decisions.size).toBe(7))
|
||||
expect(Object.fromEntries(decisions)).toEqual({
|
||||
fullscreen: true,
|
||||
'clipboard-read': false,
|
||||
'clipboard-sanitized-write': true,
|
||||
notifications: false,
|
||||
'persistent-storage': true,
|
||||
geolocation: false,
|
||||
media: true
|
||||
})
|
||||
expect(browserManagerNotifyPermissionDeniedMock).not.toHaveBeenCalled()
|
||||
|
||||
requestHandler(guestWc, 'mediaKeySystem', permissionCallback, {
|
||||
requestingUrl: 'https://widget.example.net/embed',
|
||||
isMainFrame: false
|
||||
})
|
||||
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 401,
|
||||
permission: 'mediaKeySystem',
|
||||
rawUrl: 'https://widget.example.net/embed'
|
||||
})
|
||||
|
||||
browserManagerNotifyPermissionDeniedMock.mockClear()
|
||||
requestHandler(guestWc, 'mediaKeySystem', permissionCallback, { isMainFrame: true })
|
||||
requestHandler(guestWc, 'mediaKeySystem', permissionCallback, {
|
||||
requestingUrl: '',
|
||||
isMainFrame: false
|
||||
})
|
||||
expect(
|
||||
browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.rawUrl)
|
||||
).toEqual(['https://example.com/account', 'https://example.com/account'])
|
||||
|
||||
browserManagerNotifyPermissionDeniedMock.mockClear()
|
||||
requestHandler(guestWc, 'mediaKeySystem', permissionCallback, {
|
||||
requestingUrl: 'about:blank',
|
||||
isMainFrame: false
|
||||
})
|
||||
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 401,
|
||||
permission: 'mediaKeySystem',
|
||||
rawUrl: ''
|
||||
})
|
||||
expect(checkHandler(null, 'fullscreen', '')).toBe(true)
|
||||
expect(checkHandler(null, 'clipboard-read', 'https://example.com')).toBe(false)
|
||||
expect(checkHandler(null, 'clipboard-sanitized-write', '')).toBe(true)
|
||||
expect(checkHandler(null, 'notifications', 'https://example.com')).toBe(false)
|
||||
expect(checkHandler(null, 'persistent-storage', '')).toBe(true)
|
||||
expect(checkHandler(null, 'geolocation', 'https://example.com')).toBe(false)
|
||||
expect(checkHandler(guestWc, 'media', '', { mediaType: 'video' })).toBe(true)
|
||||
expect(checkHandler(guestWc, 'media', '', { mediaType: 'audio' })).toBe(false)
|
||||
|
||||
requestHandler(guestWc, 'storage-access', permissionCallback, {})
|
||||
expect(permissionCallback).toHaveBeenLastCalledWith(true)
|
||||
expect(checkHandler(null, 'storage-access', '')).toBe(true)
|
||||
|
||||
// Related Website Sets gate top-level storage access; Orca has no such data source.
|
||||
requestHandler(guestWc, 'top-level-storage-access', permissionCallback, {})
|
||||
expect(permissionCallback).toHaveBeenLastCalledWith(false)
|
||||
expect(checkHandler(null, 'top-level-storage-access', '')).toBe(false)
|
||||
|
||||
expect(
|
||||
browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission)
|
||||
).toEqual(['mediaKeySystem', 'top-level-storage-access'])
|
||||
expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled()
|
||||
const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0]
|
||||
const displayMediaCallback = vi.fn()
|
||||
displayMediaHandler(null, displayMediaCallback)
|
||||
expect(displayMediaCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined })
|
||||
|
||||
const devicePermissionHandler = defaultSession.setDevicePermissionHandler.mock.calls[0][0]
|
||||
expect(
|
||||
devicePermissionHandler({
|
||||
deviceType: 'hid',
|
||||
origin: 'https://github.com',
|
||||
device: { collections: [{ usagePage: 0xf1d0 }] }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(checkHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(true)
|
||||
|
||||
const selectHidHandler = defaultSession.on.mock.calls.find(
|
||||
([eventName]: unknown[]) => eventName === 'select-hid-device'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
details: {
|
||||
deviceList: { deviceId: string; collections?: { usagePage?: number }[] }[]
|
||||
frame: { url: string }
|
||||
},
|
||||
callback: (deviceId?: string) => void
|
||||
) => void
|
||||
const hidCallback = vi.fn()
|
||||
selectHidHandler(
|
||||
{ preventDefault: vi.fn() },
|
||||
{
|
||||
frame: { url: 'https://github.com' },
|
||||
deviceList: [
|
||||
{ deviceId: 'keyboard', collections: [{ usagePage: 1 }] },
|
||||
{ deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] }
|
||||
]
|
||||
},
|
||||
hidCallback
|
||||
)
|
||||
expect(hidCallback).toHaveBeenCalledWith('security-key')
|
||||
|
||||
const selectWebAuthnHandler = defaultSession.on.mock.calls.find(
|
||||
([eventName]: unknown[]) => eventName === 'select-webauthn-account'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
details: { accounts: { credentialId: string }[] },
|
||||
callback: (credentialId?: string | null) => void
|
||||
) => void
|
||||
const webAuthnCallback = vi.fn()
|
||||
selectWebAuthnHandler(
|
||||
{ preventDefault: vi.fn() },
|
||||
{ accounts: [{ credentialId: 'credential-1' }] },
|
||||
webAuthnCallback
|
||||
)
|
||||
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
|
||||
|
||||
const willDownloadHandler = defaultSession.on.mock.calls.find(
|
||||
([eventName]: unknown[]) => eventName === 'will-download'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
item: { getFilename: () => string },
|
||||
webContents: { id: number }
|
||||
) => void
|
||||
expect(willDownloadHandler).toBeTypeOf('function')
|
||||
const item = { getFilename: vi.fn(() => 'report.pdf') }
|
||||
willDownloadHandler({}, item, { id: 402 })
|
||||
expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 402,
|
||||
item
|
||||
})
|
||||
})
|
||||
|
||||
it('does not stack default-partition policy handlers on repeated restore', async () => {
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {},
|
||||
profiles: []
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock } = installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
const defaultSessions = sessionFromPartitionMock.mock.results
|
||||
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser')
|
||||
.map((r) => r.value)
|
||||
const policySessions = defaultSessions.filter(
|
||||
(s) => s.setPermissionRequestHandler.mock.calls.length > 0
|
||||
)
|
||||
expect(policySessions).toHaveLength(1)
|
||||
expect(
|
||||
policySessions[0].on.mock.calls.filter(
|
||||
([eventName]: unknown[]) => eventName === 'will-download'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
policySessions[0].on.mock.calls.filter(
|
||||
([eventName]: unknown[]) => eventName === 'select-hid-device'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
policySessions[0].on.mock.calls.filter(
|
||||
([eventName]: unknown[]) => eventName === 'select-webauthn-account'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('notifies when default-partition media permission is denied', async () => {
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {},
|
||||
profiles: []
|
||||
})
|
||||
|
||||
seedSitePermissions(fsState, { default: { 'https://example.com': { camera: 'allow' } } })
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
browserManagerNotifyPermissionDeniedMock,
|
||||
requestSystemMediaAccessMock
|
||||
} = installModuleMocks(fsState)
|
||||
requestSystemMediaAccessMock.mockResolvedValue(false)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
|
||||
const defaultSession = sessionFromPartitionMock.mock.results.find(
|
||||
(_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser'
|
||||
)?.value
|
||||
const requestHandler = defaultSession.setPermissionRequestHandler.mock.calls[0][0]
|
||||
const guestWc = { id: 403, getURL: vi.fn(() => 'https://example.com/camera') }
|
||||
const callback = vi.fn()
|
||||
|
||||
requestHandler(guestWc, 'media', callback, { mediaTypes: ['video'] })
|
||||
guestWc.getURL.mockReturnValue('https://example.com/after-navigation')
|
||||
|
||||
await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false))
|
||||
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
|
||||
guestWebContentsId: 403,
|
||||
permission: 'media',
|
||||
rawUrl: 'https://example.com/camera'
|
||||
})
|
||||
})
|
||||
|
||||
it('expires an old unmarked stage and removes unrelated missing entries', async () => {
|
||||
const importedPartition = 'persist:orca-browser-session-22222222-2222-4222-8222-222222222222'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {
|
||||
[importedPartition]: '/staged/imported',
|
||||
'persist:orca-browser': '/staged/missing'
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
scope: 'imported',
|
||||
partition: importedPartition,
|
||||
label: 'Imported',
|
||||
source: { browserFamily: 'comet', importedAt: 1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
fsState.present.add('/staged/imported')
|
||||
|
||||
installModuleMocks(fsState, new Set(['/staged/imported']))
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.applyPendingCookieImport()
|
||||
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.pendingCookieImports).toEqual({})
|
||||
expect(written.pendingCookieDbPath).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores pending cookie imports for invalid persisted profile partitions', async () => {
|
||||
const invalidPartition = 'persist:../../outside'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
userAgentByPartition: {},
|
||||
pendingCookieDbPath: null,
|
||||
pendingCookieImports: {
|
||||
[invalidPartition]: '/staged/evil'
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-1',
|
||||
scope: 'imported',
|
||||
partition: invalidPartition,
|
||||
label: 'Invalid',
|
||||
source: null
|
||||
}
|
||||
]
|
||||
})
|
||||
fsState.present.add('/staged/evil')
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.applyPendingCookieImport()
|
||||
|
||||
const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}')
|
||||
expect(written.pendingCookieImports).toEqual({})
|
||||
expect(fsState.present.has('/outside/Cookies')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
@@ -69,20 +69,24 @@ import {
|
||||
listBrowserSitePermissions,
|
||||
setBrowserSitePermissionDecisions
|
||||
} from '../site-settings/browser-site-permission-store'
|
||||
import { googleAuthUserAgent } from '../identity/browser-google-auth-ua'
|
||||
import { installBrowserSessionUserAgentPolicy } from '../identity/browser-session-ua'
|
||||
import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../../network/electron-proxy-credentials'
|
||||
import { applyProxySettingsToSession } from '../../network/proxy-settings'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../../shared/constants'
|
||||
import {
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
getOrcaProfileBrowserDefaultPartition,
|
||||
getOrcaProfileBrowserSessionPartition
|
||||
} from '../../../shared/orca-profiles'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../shared/orca-profiles'
|
||||
|
||||
describe('BrowserSessionRegistry', () => {
|
||||
let profileDirectory: string
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(profileDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
profileDirectory = mkdtempSync(join(tmpdir(), 'orca-session-registry-'))
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
profileDirectory
|
||||
})
|
||||
sessionFromPartitionMock.mockReset()
|
||||
askForMediaAccessMock.mockReset()
|
||||
getMediaAccessStatusMock.mockReset()
|
||||
@@ -108,84 +112,6 @@ describe('BrowserSessionRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('has a default profile on construction', () => {
|
||||
const defaultProfile = browserSessionRegistry.getDefaultProfile()
|
||||
expect(defaultProfile.id).toBe('default')
|
||||
expect(defaultProfile.scope).toBe('default')
|
||||
expect(defaultProfile.partition).toBe(ORCA_BROWSER_PARTITION)
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'resolves only known actual Sessions with linear profile work at %s',
|
||||
(population) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-session-media-'))
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
profileDirectory: directory
|
||||
})
|
||||
const policySession = sessionFromPartitionMock()
|
||||
const sessions = new Map<string, typeof policySession>()
|
||||
sessionFromPartitionMock.mockImplementation((partition: string) => {
|
||||
const known = sessions.get(partition)
|
||||
if (!known) {
|
||||
throw new Error('Lookup attempted to materialize an unknown Session')
|
||||
}
|
||||
return known
|
||||
})
|
||||
const profiles = Array.from({ length: population }, (_, index) => {
|
||||
const id = `00000000-0000-0000-0000-${String(index + 1).padStart(12, '0')}`
|
||||
const partition = getOrcaProfileBrowserSessionPartition(DEFAULT_LOCAL_ORCA_PROFILE_ID, id)
|
||||
sessions.set(partition, { ...policySession })
|
||||
return {
|
||||
id,
|
||||
partition,
|
||||
scope: 'isolated' as const,
|
||||
label: `Profile ${index}`,
|
||||
source: null
|
||||
}
|
||||
})
|
||||
const defaultProfile = browserSessionRegistry.getDefaultProfile()
|
||||
sessions.set(defaultProfile.partition, { ...policySession })
|
||||
browserSessionRegistry.hydrateFromPersisted(profiles)
|
||||
sessionFromPartitionMock.mockClear()
|
||||
expect(
|
||||
browserSessionRegistry.profileIdForSession(sessions.get(defaultProfile.partition)!)
|
||||
).toBe('default')
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledTimes(1)
|
||||
sessionFromPartitionMock.mockClear()
|
||||
const last = profiles.at(-1)!
|
||||
expect(browserSessionRegistry.profileIdForSession(sessions.get(last.partition)!)).toBe(
|
||||
last.id
|
||||
)
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledTimes(population + 1)
|
||||
sessionFromPartitionMock.mockClear()
|
||||
expect(browserSessionRegistry.profileIdForSession({ ...policySession })).toBeNull()
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledTimes(population + 1)
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
profileDirectory: directory
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('allows the default partition', () => {
|
||||
expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown partitions', () => {
|
||||
expect(browserSessionRegistry.isAllowedPartition('persist:evil-partition')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates an isolated profile with a unique partition', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Test Isolated')
|
||||
expect(profile).not.toBeNull()
|
||||
expect(profile!.scope).toBe('isolated')
|
||||
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
expect(profile!.partition).not.toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(profile!.label).toBe('Test Isolated')
|
||||
expect(profile!.source).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return a runtime profile until its proxy is ready', async () => {
|
||||
let finishWrite: (() => void) | undefined
|
||||
let proxyReady = false
|
||||
@@ -282,79 +208,6 @@ describe('BrowserSessionRegistry', () => {
|
||||
).rejects.toThrow('retired')
|
||||
})
|
||||
|
||||
it('rejects creating a profile with scope default', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('default', 'Sneaky')
|
||||
expect(profile).toBeNull()
|
||||
})
|
||||
|
||||
it('allows created profile partitions', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Allowed')
|
||||
expect(profile).not.toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
|
||||
})
|
||||
|
||||
it('creates an imported profile', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('imported', 'My Import')
|
||||
expect(profile).not.toBeNull()
|
||||
expect(profile!.scope).toBe('imported')
|
||||
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
})
|
||||
|
||||
it('resolves partition for a known profile', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Resolve Test')
|
||||
expect(profile).not.toBeNull()
|
||||
expect(browserSessionRegistry.resolvePartition(profile!.id)).toBe(profile!.partition)
|
||||
})
|
||||
|
||||
it('resolves default partition for null/undefined profileId', () => {
|
||||
expect(browserSessionRegistry.resolvePartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolvePartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
||||
})
|
||||
|
||||
it('resolves default partition for unknown profileId', () => {
|
||||
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
|
||||
})
|
||||
|
||||
it('strictly resolves known profile partitions without downgrading unknown profiles', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
|
||||
expect(profile).not.toBeNull()
|
||||
|
||||
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists all profiles', async () => {
|
||||
const before = browserSessionRegistry.listProfiles().length
|
||||
await browserSessionRegistry.createProfile('isolated', 'List Test')
|
||||
const after = browserSessionRegistry.listProfiles()
|
||||
expect(after.length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('updates profile source', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('imported', 'Source Test')
|
||||
expect(profile).not.toBeNull()
|
||||
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
|
||||
browserFamily: 'edge',
|
||||
importedAt: Date.now()
|
||||
})
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.source?.browserFamily).toBe('edge')
|
||||
})
|
||||
|
||||
it('updates profile source with comet family', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('imported', 'Comet Source Test')
|
||||
expect(profile).not.toBeNull()
|
||||
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
|
||||
browserFamily: 'comet',
|
||||
importedAt: Date.now()
|
||||
})
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.source?.browserFamily).toBe('comet')
|
||||
})
|
||||
|
||||
it('deletes a non-default profile and its remembered site answers', async () => {
|
||||
configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-')))
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Delete Test')
|
||||
@@ -368,63 +221,7 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(listBrowserSitePermissions(profile!.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a profile whose saved logins could not be deleted', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Vault Stuck')
|
||||
forgetBrowserPasswordVaultMock.mockReturnValueOnce(false)
|
||||
|
||||
await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(false)
|
||||
expect(browserSessionRegistry.getProfile(profile!.id)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('retains session security policies when deleting a profile', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Policy Delete Test')
|
||||
expect(profile).not.toBeNull()
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
const permissionWrites = mockSession.setPermissionRequestHandler.mock.calls.length
|
||||
const downloadListenerWrites = mockSession.removeListener.mock.calls.length
|
||||
|
||||
await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(true)
|
||||
|
||||
expect(mockSession.setPermissionRequestHandler).toHaveBeenCalledTimes(permissionWrites)
|
||||
expect(mockSession.removeListener).toHaveBeenCalledTimes(downloadListenerWrites)
|
||||
expect(removeCertificateRequestGuardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Electron Sessions outlive deleted profiles; their header hooks must be retired.
|
||||
it('retires the user agent policy when deleting a profile', async () => {
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'UA Delete Test')
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
expect(mockSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(true)
|
||||
|
||||
expect(mockSession.webRequest.onBeforeSendHeaders).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('keeps the request guard installed while deleted-profile guests remain', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Delayed Delete')
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
let finishClose: (() => void) | undefined
|
||||
mockSession.closeAllConnections.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (finishClose = resolve))
|
||||
)
|
||||
removeCertificateRequestGuardMock.mockClear()
|
||||
|
||||
const deletion = browserSessionRegistry.deleteProfile(profile!.id)
|
||||
await vi.waitFor(() => expect(mockSession.setProxy).toHaveBeenCalledWith({ mode: 'system' }))
|
||||
|
||||
expect(removeCertificateRequestGuardMock).not.toHaveBeenCalled()
|
||||
finishClose?.()
|
||||
await expect(deletion).resolves.toBe(true)
|
||||
expect(removeCertificateRequestGuardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates one managed profile per owner and a fresh one after its owner is removed', async () => {
|
||||
const owner = { kind: 'worktree' as const, id: 'repo-1::/work/feature' }
|
||||
@@ -476,301 +273,4 @@ describe('BrowserSessionRegistry', () => {
|
||||
browserSessionRegistry.onProfilesChanged = null
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to delete the default profile', async () => {
|
||||
const deleted = await browserSessionRegistry.deleteProfile('default')
|
||||
expect(deleted).toBe(false)
|
||||
expect(browserSessionRegistry.getDefaultProfile()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hydrates profiles from persisted data', () => {
|
||||
const fakeProfile = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
scope: 'imported' as const,
|
||||
partition: 'persist:orca-browser-session-00000000-0000-0000-0000-000000000001',
|
||||
label: 'Hydrated',
|
||||
source: { browserFamily: 'manual' as const, importedAt: 1000 }
|
||||
}
|
||||
browserSessionRegistry.hydrateFromPersisted([fakeProfile])
|
||||
expect(browserSessionRegistry.getProfile('00000000-0000-0000-0000-000000000001')).not.toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a persisted profile whose partition belongs to a different profile id', () => {
|
||||
const profileId = '00000000-0000-4000-8000-000000000021'
|
||||
const claimedPartition = 'persist:orca-browser-session-00000000-0000-4000-8000-000000000022'
|
||||
|
||||
browserSessionRegistry.hydrateFromPersisted([
|
||||
{
|
||||
id: profileId,
|
||||
scope: 'isolated',
|
||||
partition: claimedPartition,
|
||||
label: 'Conflicting identity',
|
||||
source: null
|
||||
}
|
||||
])
|
||||
|
||||
expect(browserSessionRegistry.getProfile(profileId)).toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(claimedPartition)).toBe(false)
|
||||
})
|
||||
|
||||
it('applies and clears existing browser-profile policy on an opaque route partition', async () => {
|
||||
const partition =
|
||||
'persist:orca-browser-v1-1111111111111111222222222222222233333333333333334444444444444444'
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://app-proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
|
||||
browserSessionRegistry.setupRoutePartitionPolicies(partition, 'default')
|
||||
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith(partition)
|
||||
const configuredSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
expect(configuredSession.setPermissionRequestHandler).toHaveBeenCalled()
|
||||
expect(configuredSession.setPermissionCheckHandler).toHaveBeenCalled()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(configuredSession.setProxy).not.toHaveBeenCalled()
|
||||
|
||||
browserSessionRegistry.clearRoutePartitionPolicies(partition)
|
||||
const clearedSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
expect(clearedSession.setPermissionRequestHandler).toHaveBeenCalledWith(null)
|
||||
expect(clearedSession.setPermissionCheckHandler).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('rejects route partitions for missing browser profiles', () => {
|
||||
const partition =
|
||||
'persist:orca-browser-v1-aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd'
|
||||
|
||||
expect(() =>
|
||||
browserSessionRegistry.setupRoutePartitionPolicies(partition, 'missing-profile')
|
||||
).toThrow('browser_route_partition_profile_unavailable')
|
||||
})
|
||||
|
||||
it('auto-grants pointer lock for browser partitions', async () => {
|
||||
await browserSessionRegistry.createProfile('isolated', 'Pointer Lock Test')
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0]
|
||||
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
|
||||
const callback = vi.fn()
|
||||
const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') }
|
||||
|
||||
requestHandler(guestWc, 'pointerLock', callback, {})
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(true)
|
||||
expect(checkHandler(null, 'pointerLock', '', {})).toBe(true)
|
||||
})
|
||||
|
||||
it('asks macOS for the camera only after the user allowed the site', async () => {
|
||||
configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-')))
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Media Test')
|
||||
if (!profile) {
|
||||
throw new Error('Expected the profile')
|
||||
}
|
||||
setBrowserSitePermissionDecisions(profile.id, 'https://example.com', ['camera'], 'allow')
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0]
|
||||
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
|
||||
|
||||
const allowedSite = vi.fn()
|
||||
const otherSite = vi.fn()
|
||||
const allowedGuest = { id: 7, getURL: vi.fn(() => 'https://example.com/') }
|
||||
const otherGuest = { id: 8, getURL: vi.fn(() => 'https://other.test/') }
|
||||
requestHandler(allowedGuest, 'media', allowedSite, { mediaTypes: ['video'] })
|
||||
requestHandler(otherGuest, 'media', otherSite, { mediaTypes: ['video'] })
|
||||
await vi.waitFor(() => expect(allowedSite).toHaveBeenCalledWith(true))
|
||||
await vi.waitFor(() => expect(otherSite).toHaveBeenCalledWith(false))
|
||||
|
||||
expect(checkHandler(allowedGuest, 'media', '', { mediaType: 'video' })).toBe(true)
|
||||
expect(checkHandler(otherGuest, 'media', '', { mediaType: 'video' })).toBe(false)
|
||||
expect(checkHandler(null, 'notifications', 'https://example.com', {})).toBe(false)
|
||||
expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true)
|
||||
expect(checkHandler(null, 'geolocation', 'https://example.com', {})).toBe(false)
|
||||
})
|
||||
|
||||
it('wires WebAuthn device selection for isolated partitions', async () => {
|
||||
await browserSessionRegistry.createProfile('isolated', 'Security Key Test')
|
||||
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
||||
const devicePermissionHandler = mockSession.setDevicePermissionHandler.mock.calls[0][0]
|
||||
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
|
||||
|
||||
expect(
|
||||
devicePermissionHandler({
|
||||
deviceType: 'hid',
|
||||
origin: 'https://github.com',
|
||||
device: { collections: [{ usagePage: 0xf1d0 }] }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
devicePermissionHandler({
|
||||
deviceType: 'hid',
|
||||
origin: 'http://[::1]:5173',
|
||||
device: { collections: [{ usagePage: 0xf1d0 }] }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
devicePermissionHandler({
|
||||
deviceType: 'hid',
|
||||
origin: 'https://github.com',
|
||||
device: { collections: [{ usagePage: 1 }] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(checkHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(true)
|
||||
|
||||
const selectHidHandler = mockSession.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'select-hid-device'
|
||||
)?.[1]
|
||||
const hidCallback = vi.fn()
|
||||
selectHidHandler(
|
||||
{ preventDefault: vi.fn() },
|
||||
{
|
||||
frame: { url: 'https://github.com' },
|
||||
deviceList: [
|
||||
{ deviceId: 'keyboard', collections: [{ usagePage: 1 }] },
|
||||
{ deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] }
|
||||
]
|
||||
},
|
||||
hidCallback
|
||||
)
|
||||
expect(hidCallback).toHaveBeenCalledWith('security-key')
|
||||
|
||||
const selectWebAuthnHandler = mockSession.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'select-webauthn-account'
|
||||
)?.[1]
|
||||
const webAuthnCallback = vi.fn()
|
||||
selectWebAuthnHandler(
|
||||
{ preventDefault: vi.fn() },
|
||||
{ accounts: [{ credentialId: 'credential-1' }] },
|
||||
webAuthnCallback
|
||||
)
|
||||
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
|
||||
})
|
||||
|
||||
it('uses profile-owned partitions for non-default Orca profiles', async () => {
|
||||
const orcaProfileId = 'local-work'
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId,
|
||||
profileDirectory: '/profiles/local-work'
|
||||
})
|
||||
|
||||
expect(browserSessionRegistry.getDefaultProfile().partition).toBe(
|
||||
getOrcaProfileBrowserDefaultPartition(orcaProfileId)
|
||||
)
|
||||
expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(false)
|
||||
|
||||
const profile = await browserSessionRegistry.createProfile('isolated', 'Work Browser')
|
||||
expect(profile).not.toBeNull()
|
||||
expect(profile!.partition).toBe(
|
||||
getOrcaProfileBrowserSessionPartition(orcaProfileId, profile!.id)
|
||||
)
|
||||
|
||||
browserSessionRegistry.configureForOrcaProfile({
|
||||
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
profileDirectory: '/profiles/local-default'
|
||||
})
|
||||
})
|
||||
|
||||
describe('installBrowserSessionUserAgentPolicy', () => {
|
||||
function install(): (details: unknown, callback: ReturnType<typeof vi.fn>) => void {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
installBrowserSessionUserAgentPolicy(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only the mocked webRequest member exercised here.
|
||||
{ webRequest: { onBeforeSendHeaders } } as never,
|
||||
(request) =>
|
||||
request.currentUserAgent === googleAuthUserAgent()
|
||||
? { kind: 'google-auth', userAgent: googleAuthUserAgent() }
|
||||
: undefined
|
||||
)
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] },
|
||||
expect.any(Function)
|
||||
)
|
||||
return onBeforeSendHeaders.mock.calls[0][1]
|
||||
}
|
||||
|
||||
it('leaves ordinary-host identity headers untouched', () => {
|
||||
const callback = vi.fn()
|
||||
install()(
|
||||
{
|
||||
url: 'https://example.com/',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Cookie: 'abc=123'
|
||||
}
|
||||
},
|
||||
callback
|
||||
)
|
||||
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Cookie: 'abc=123'
|
||||
})
|
||||
})
|
||||
|
||||
it("presents Electron's own UA on Google auth hosts and keeps its client hints", () => {
|
||||
const callback = vi.fn()
|
||||
install()(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Accept: 'text/html'
|
||||
}
|
||||
},
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': googleAuthUserAgent(),
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Accept: 'text/html'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops mobile-preset client hints on Google auth hosts', () => {
|
||||
const callback = vi.fn()
|
||||
install()(
|
||||
{
|
||||
url: 'https://accounts.google.com/gsi/button',
|
||||
resourceType: 'subFrame',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) CriOS/150.0.0.0',
|
||||
'sec-ch-ua': '"Google Chrome";v="150"',
|
||||
'sec-ch-ua-mobile': '?1',
|
||||
'sec-ch-ua-platform': '"iOS"'
|
||||
}
|
||||
},
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': googleAuthUserAgent()
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a request already carrying Electron's UA untouched on Google auth hosts", () => {
|
||||
const headers = { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'browser-owned' }
|
||||
const callback = vi.fn()
|
||||
install()(
|
||||
{ url: 'https://accounts.google.com/v3/signin/identifier', requestHeaders: { ...headers } },
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual(headers)
|
||||
})
|
||||
|
||||
it('keeps the session identity on Google app subdomains', () => {
|
||||
const callback = vi.fn()
|
||||
install()(
|
||||
{
|
||||
url: 'https://myaccount.google.com/',
|
||||
requestHeaders: { 'User-Agent': 'Chrome/150', 'sec-ch-ua': 'browser-owned' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': 'Chrome/150',
|
||||
'sec-ch-ua': 'browser-owned'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,44 +176,6 @@ describe('BrowserSiteDisplayController color scheme', () => {
|
||||
expect(preview.debugger.isAttached()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([10, 100])('sends one media update per eligible guest at population %s', (population) => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const ordinary = fakeGuest('https://ordinary.example/')
|
||||
controller.onGuestRegistered(ordinary, null)
|
||||
const guests = Array.from({ length: population }, () =>
|
||||
fakeGuest('orca-preview://grant/a.html')
|
||||
)
|
||||
for (const guest of guests) {
|
||||
controller.onGuestDocumentStart(guest, {
|
||||
url: guest.url,
|
||||
profileId: null,
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
controller.onGuestDocumentStart(guest, {
|
||||
url: guest.url,
|
||||
profileId: null,
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
expect(guest.mediaSent).toEqual(['dark', 'dark'])
|
||||
expect(guest.debugger.listenerCount('detach')).toBe(1)
|
||||
guest.mediaSent.length = 0
|
||||
}
|
||||
for (const guest of guests) {
|
||||
controller.setGuestColorSchemeFallback(guest, 'light')
|
||||
}
|
||||
expect(guests.reduce((total, guest) => total + guest.mediaSent.length, 0)).toBe(population)
|
||||
for (const guest of guests) {
|
||||
controller.setGuestColorSchemeFallback(guest, 'light')
|
||||
controller.onGuestRetired(guest.id)
|
||||
expect(guest.debugger.isAttached()).toBe(false)
|
||||
expect(guest.debugger.listenerCount('detach')).toBe(0)
|
||||
controller.setGuestColorSchemeFallback(guest, 'dark')
|
||||
}
|
||||
expect(guests.reduce((total, guest) => total + guest.mediaSent.length, 0)).toBe(population)
|
||||
expect(ordinary.mediaSent).toEqual([])
|
||||
expect(ordinary.debugger.isAttached()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' },
|
||||
{ order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' },
|
||||
@@ -261,48 +223,6 @@ describe('BrowserSiteDisplayController color scheme', () => {
|
||||
expect(guest.debugger.listenerCount('detach')).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('sets the site scheme as each page commits and clears it once on leaving', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const guest = fakeGuest('https://github.com/')
|
||||
controller.onGuestRegistered(guest, null)
|
||||
controller.setColorScheme(guest.id, 'dark')
|
||||
|
||||
controller.zoomPage(guest.id, 'in')
|
||||
commit(controller, guest, 'https://docs.example.com/')
|
||||
commit(controller, guest, 'https://news.example.com/')
|
||||
commit(controller, guest, 'https://github.com/x')
|
||||
|
||||
expect(guest.mediaSent).toEqual(['dark', '', 'dark'])
|
||||
})
|
||||
|
||||
it('gives a restored tab its scheme when its first load started before it registered', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const first = fakeGuest('https://github.com/')
|
||||
controller.onGuestRegistered(first, null)
|
||||
controller.setColorScheme(first.id, 'light')
|
||||
|
||||
const restored = fakeGuest('')
|
||||
controller.onGuestRegistered(restored, null)
|
||||
restored.url = 'https://github.com/restored'
|
||||
controller.onMainFrameNavigationCommitted(restored.id)
|
||||
|
||||
expect(restored.mediaSent).toEqual(['light'])
|
||||
})
|
||||
|
||||
it('attaches again after DevTools detaches the debugger', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const guest = fakeGuest('https://github.com/')
|
||||
controller.onGuestRegistered(guest, null)
|
||||
controller.setColorScheme(guest.id, 'dark')
|
||||
|
||||
guest.debugger.detach()
|
||||
expect(guest.debugger.isAttached()).toBe(false)
|
||||
commit(controller, guest, 'https://github.com/next')
|
||||
|
||||
expect(guest.debugger.isAttached()).toBe(true)
|
||||
expect(guest.mediaSent.at(-1)).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } }))
|
||||
|
||||
@@ -126,25 +126,6 @@ describe('browser site permission prompts', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('asks once for camera and microphone together, then remembers Allow', async () => {
|
||||
const call = ask(['camera', 'microphone'])
|
||||
expect(sentRequests(renderer)).toMatchObject([
|
||||
{
|
||||
kind: 'site-permission',
|
||||
details: {
|
||||
origin: 'https://meet.example.com',
|
||||
permissions: ['camera', 'microphone']
|
||||
}
|
||||
}
|
||||
])
|
||||
await answerLatest('allow')
|
||||
await expect(call).resolves.toBe(true)
|
||||
|
||||
await expect(ask(['camera'])).resolves.toBe(true)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
expect(allowedOnCheck(['camera', 'microphone'])).toBe(true)
|
||||
})
|
||||
|
||||
it('asks only for what is unanswered, and a remembered Block refuses the whole request', async () => {
|
||||
const camera = ask(['camera'])
|
||||
await answerLatest('allow')
|
||||
@@ -158,25 +139,6 @@ describe('browser site permission prompts', () => {
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('forgets a dismissed prompt, so the site is asked again later', async () => {
|
||||
const call = ask(['notifications'])
|
||||
await answerLatest(null)
|
||||
await expect(call).resolves.toBe(false)
|
||||
|
||||
expect(listBrowserSitePermissions('default')).toEqual([])
|
||||
void ask(['notifications'])
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps each browser profile to its own answers', async () => {
|
||||
const call = ask(['clipboard'], { memory: savedBrowserSitePermissions('work') })
|
||||
await answerLatest('allow')
|
||||
await call
|
||||
|
||||
void ask(['clipboard'], { memory: savedBrowserSitePermissions('default') })
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("keeps a paired server's answers for its session only, where checks still see them", async () => {
|
||||
const pairedSession = sessionBrowserSitePermissions('persist:paired-a')
|
||||
const call = ask(['notifications'], { memory: pairedSession })
|
||||
@@ -191,15 +153,6 @@ describe('browser site permission prompts', () => {
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("gives a frame the tab's answers for what the tab delegates", async () => {
|
||||
const call = ask(['camera'])
|
||||
await answerLatest('allow')
|
||||
await call
|
||||
|
||||
await expect(ask(['camera'], { frameOrigin: 'https://video.example.net' })).resolves.toBe(true)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("gives another site's frame only its own notification answer, and never asks for it", async () => {
|
||||
const tab = ask(['notifications'])
|
||||
await answerLatest('allow')
|
||||
@@ -223,32 +176,6 @@ describe('browser site permission prompts', () => {
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('asks for a local file, and every local file shares the answer', async () => {
|
||||
const call = ask(['camera'], { guest: mockGuest('file:///Users/me/lab/permission-lab.html') })
|
||||
expect(sentRequests(renderer).at(-1)?.details.origin).toBe('file://')
|
||||
await answerLatest('allow')
|
||||
await expect(call).resolves.toBe(true)
|
||||
|
||||
await expect(
|
||||
ask(['camera'], { guest: mockGuest('file:///Users/me/other/page.html') })
|
||||
).resolves.toBe(true)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses an opaque page without asking', async () => {
|
||||
await expect(ask(['camera'], { guest: mockGuest('data:text/html,<p>hi</p>') })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(sentRequests(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses without asking when no window can show the question', async () => {
|
||||
mocks.resolvePagePromptTarget.mockReturnValue(null)
|
||||
|
||||
await expect(ask(['camera'])).resolves.toBe(false)
|
||||
expect(sentRequests(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('remembers answers across a restart', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))
|
||||
configureBrowserSitePermissionStore(directory)
|
||||
|
||||
@@ -13,11 +13,8 @@ vi.mock('electron', () => ({
|
||||
vi.mock('../browser-manager', () => ({
|
||||
browserManager: { getRendererContextForGuest: getRendererContextForGuestMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS,
|
||||
cancelAllBrowserWebAuthnAccountRequests,
|
||||
cancelBrowserWebAuthnAccountRequests,
|
||||
requestBrowserWebAuthnAccount,
|
||||
respondToBrowserWebAuthnAccountRequest
|
||||
} from './browser-webauthn-account-picker'
|
||||
@@ -59,32 +56,6 @@ describe('browser WebAuthn account picker', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns the credential selected by the owning renderer', async () => {
|
||||
const guest = mockWebContents(41)
|
||||
const renderer = mockWebContents(42)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-1', renderer })
|
||||
|
||||
const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession())
|
||||
const request = vi.mocked(renderer.send).mock.calls[0][1]
|
||||
|
||||
expect(request).toMatchObject({
|
||||
browserPageId: 'page-1',
|
||||
relyingPartyId: 'accounts.example.com',
|
||||
accounts: [
|
||||
{ credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' },
|
||||
{ credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' }
|
||||
]
|
||||
})
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(renderer, {
|
||||
requestId: request.requestId,
|
||||
credentialId: 'credential-2'
|
||||
})
|
||||
).toBe(true)
|
||||
await expect(selection).resolves.toBe('credential-2')
|
||||
})
|
||||
|
||||
it('rejects responses from another renderer or with an unoffered credential', async () => {
|
||||
const guest = mockWebContents(51)
|
||||
const renderer = mockWebContents(52)
|
||||
@@ -112,92 +83,6 @@ describe('browser WebAuthn account picker', () => {
|
||||
await expect(selection).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels when the tab closes or the prompt times out', async () => {
|
||||
const guest = mockWebContents(61)
|
||||
const renderer = mockWebContents(62)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-3', renderer })
|
||||
const browserSession = mockSession()
|
||||
const closedSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
vi.mocked(renderer.send).mockImplementation((channel) => {
|
||||
if (channel === 'browser:webauthn-account-request-closed') {
|
||||
throw new Error('renderer destroyed during send')
|
||||
}
|
||||
})
|
||||
|
||||
cancelBrowserWebAuthnAccountRequests('page-3')
|
||||
await expect(closedSelection).resolves.toBeNull()
|
||||
|
||||
const timedOutSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
await vi.advanceTimersByTimeAsync(BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS)
|
||||
await expect(timedOutSelection).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels when the guest or window is destroyed', async () => {
|
||||
const guest = mockWebContents(71)
|
||||
const renderer = mockWebContents(72)
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-4', renderer })
|
||||
const browserSession = mockSession()
|
||||
const guestDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
guest.emit('destroyed')
|
||||
await expect(guestDestroyed).resolves.toBeNull()
|
||||
|
||||
const windowDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
renderer.emit('destroyed')
|
||||
await expect(windowDestroyed).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('cancels immediately when the guest or window renderer process exits', async () => {
|
||||
const guest = mockWebContents(81)
|
||||
const renderer = mockWebContents(82)
|
||||
const browserSession = mockSession()
|
||||
fromFrameMock.mockReturnValue(guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-5', renderer })
|
||||
|
||||
const guestCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
guest.emit('render-process-gone')
|
||||
await expect(guestCrashed).resolves.toBeNull()
|
||||
|
||||
const windowCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession)
|
||||
renderer.emit('render-process-gone')
|
||||
await expect(windowCrashed).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['guest', 'renderer'] as const)(
|
||||
'retires the request when the %s commits a navigation',
|
||||
async (target) => {
|
||||
const owners = { guest: mockWebContents(91), renderer: mockWebContents(92) }
|
||||
fromFrameMock.mockReturnValue(owners.guest)
|
||||
getRendererContextForGuestMock.mockReturnValue({
|
||||
browserPageId: 'page-6',
|
||||
renderer: owners.renderer
|
||||
})
|
||||
|
||||
const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession())
|
||||
const requestId = vi.mocked(owners.renderer.send).mock.calls[0][1].requestId
|
||||
owners[target].emit('did-navigate')
|
||||
|
||||
await expect(selection).resolves.toBeNull()
|
||||
expect(owners.renderer.send).toHaveBeenLastCalledWith(
|
||||
'browser:webauthn-account-request-closed',
|
||||
{ requestId }
|
||||
)
|
||||
expect(
|
||||
respondToBrowserWebAuthnAccountRequest(owners.renderer, {
|
||||
requestId,
|
||||
credentialId: 'credential-1'
|
||||
})
|
||||
).toBe(false)
|
||||
for (const contents of Object.values(owners)) {
|
||||
for (const event of ['did-navigate', 'render-process-gone', 'destroyed']) {
|
||||
expect(contents.listenerCount(event)).toBe(0)
|
||||
}
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps sibling and successor requests independent of a retired document', async () => {
|
||||
const retiredGuest = mockWebContents(101)
|
||||
const siblingGuest = mockWebContents(102)
|
||||
|
||||
Reference in New Issue
Block a user