cc-haha/desktop/src/lib/desktopNotifications.test.ts
程序员阿江(Relakkes) 81845fbc49 fix: finalize Electron migration boundaries
Complete the Electron replacement boundary before merging by removing the renderer-side Tauri host fallback, tightening H5/browser access so only desktop navigation is tokenless, and moving desktop release publication to a tag-driven GitHub Actions matrix with a single final publish job.

Constraint: H5/browser capability access must not gain tokenless access through localhost or retired Tauri origins

Constraint: Desktop release artifacts must be built by GitHub Actions from version tags, not treated as local build outputs

Rejected: Keep localhost browser origins trusted for convenience | local browser contexts can access loopback services and must use the H5 token path

Rejected: Publish from each matrix job | partial releases can be created before all platforms finish

Confidence: high

Scope-risk: broad

Directive: Do not reintroduce Tauri origins or localhost browser origins into the trusted desktop origin set without a reviewed security design

Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/diagnostics-service.test.ts src/server/middleware/cors.test.ts

Tested: bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts

Tested: bun run check:desktop

Tested: bun run check:native

Tested: git diff --check

Not-tested: bun run check:server is blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
2026-06-02 22:42:53 +08:00

504 lines
19 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const notificationPluginMock = vi.hoisted(() => ({
isPermissionGranted: vi.fn(),
requestPermission: vi.fn(),
sendNotification: vi.fn(),
onAction: vi.fn(),
}))
const coreApiMock = vi.hoisted(() => ({
invoke: vi.fn(),
}))
const eventApiMock = vi.hoisted(() => ({
listen: vi.fn(),
}))
const shellApiMock = vi.hoisted(() => ({
open: vi.fn(),
}))
const requestUserAttentionMock = vi.hoisted(() => vi.fn())
const windowApiMock = vi.hoisted(() => ({
requestUserAttention: requestUserAttentionMock,
getCurrentWindow: vi.fn(() => ({
requestUserAttention: requestUserAttentionMock,
})),
UserAttentionType: {
Critical: 1,
Informational: 2,
},
}))
vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock)
vi.mock('@tauri-apps/api/core', () => coreApiMock)
vi.mock('@tauri-apps/api/event', () => eventApiMock)
vi.mock('@tauri-apps/api/window', () => windowApiMock)
vi.mock('@tauri-apps/plugin-shell', () => shellApiMock)
import {
getDesktopNotificationPermission,
installDesktopNotificationClickListener,
notifyDesktop,
openDesktopNotificationSettings,
requestDesktopNotificationPermission,
resetDesktopNotificationsForTests,
setNativeNotificationSenderForTests,
} from './desktopNotifications'
import { browserHost } from './desktopHost/browserHost'
import { useSettingsStore } from '../stores/settingsStore'
describe('desktopNotifications', () => {
const installElectronNotificationHost = () => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
notifications: true,
shell: true,
},
commands: {
...browserHost.commands,
invoke: (command, args) => args === undefined
? coreApiMock.invoke(command)
: coreApiMock.invoke(command, args),
},
events: {
...browserHost.events,
listen: (eventName, handler) => eventApiMock.listen(eventName, handler),
},
shell: {
...browserHost.shell,
open: shellApiMock.open,
},
notifications: {
...browserHost.notifications,
permissionState: async () => {
const granted = await notificationPluginMock.isPermissionGranted()
return granted ? 'granted' : 'denied'
},
requestPermission: notificationPluginMock.requestPermission,
send: notificationPluginMock.sendNotification,
onAction: async (handler) => {
const unlisten = await notificationPluginMock.onAction(handler)
if (typeof unlisten === 'function') return unlisten
if (unlisten && typeof unlisten === 'object' && 'unregister' in unlisten) {
return () => {
void (unlisten as { unregister: () => unknown }).unregister()
}
}
return () => {}
},
},
window: {
...browserHost.window,
requestAttention: requestUserAttentionMock,
focus: vi.fn().mockResolvedValue(undefined),
},
}
}
beforeEach(() => {
vi.useRealTimers()
resetDesktopNotificationsForTests()
coreApiMock.invoke.mockReset()
eventApiMock.listen.mockReset()
shellApiMock.open.mockReset()
notificationPluginMock.isPermissionGranted.mockReset()
notificationPluginMock.requestPermission.mockReset()
notificationPluginMock.sendNotification.mockReset()
notificationPluginMock.onAction.mockReset()
windowApiMock.getCurrentWindow.mockClear()
windowApiMock.requestUserAttention.mockReset()
useSettingsStore.setState({ desktopNotificationsEnabled: true })
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Linux x86_64',
})
installElectronNotificationHost()
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
it('sends through the desktop notification host when permission is already granted', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
notifyDesktop({
dedupeKey: 'permission:1',
title: 'Permission required',
body: 'Approve command execution',
})
await vi.waitFor(() => expect(notificationPluginMock.sendNotification).toHaveBeenCalledTimes(1))
expect(notificationPluginMock.isPermissionGranted).toHaveBeenCalledTimes(1)
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({
title: 'Permission required',
body: 'Approve command execution',
})
})
it('passes notification targets through the desktop notification payload', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
await expect(notifyDesktop({
dedupeKey: 'permission:targeted',
title: 'Permission required',
body: 'Approve command execution',
target,
})).resolves.toBe(true)
expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
title: 'Permission required',
body: 'Approve command execution',
id: expect.any(Number),
extra: {
ccHahaTarget: JSON.stringify(target),
},
}))
})
it('does not request notification permission from a blocking permission prompt', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
notificationPluginMock.isPermissionGranted.mockResolvedValue(false)
notifyDesktop({ title: 'Permission required' })
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled())
expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith(
'[desktopNotifications] native notification permission was not granted',
)
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('uses the macOS native bridge for foreground-visible notifications', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockResolvedValueOnce(true)
await expect(notifyDesktop({
title: 'Permission required',
body: 'Approve command execution',
})).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', {
title: 'Permission required',
body: 'Approve command execution',
})
expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled()
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
})
it('passes notification targets through the macOS native bridge', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockResolvedValueOnce(true)
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
await expect(notifyDesktop({
title: 'Permission required',
body: 'Approve command execution',
target,
})).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', {
title: 'Permission required',
body: 'Approve command execution',
target: JSON.stringify(target),
})
})
it('does not request macOS permission from a blocking permission prompt', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockResolvedValueOnce(false)
await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false)
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', {
title: 'Permission required',
body: undefined,
})
expect(coreApiMock.invoke).not.toHaveBeenCalledWith('macos_request_notification_permission')
expect(warnSpy).toHaveBeenCalledWith(
'[desktopNotifications] native notification permission was not granted',
)
warnSpy.mockRestore()
})
it('does not fall back to the generic notification bridge when the macOS bridge fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable'))
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false)
expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled()
expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('does not send or consume dedupe keys when desktop notifications are disabled', async () => {
const sender = vi.fn(async () => true)
setNativeNotificationSenderForTests(sender)
useSettingsStore.setState({ desktopNotificationsEnabled: false })
notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sender).not.toHaveBeenCalled()
useSettingsStore.setState({ desktopNotificationsEnabled: true })
notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' })
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
})
it('does not consume dedupe keys when native notification delivery fails', async () => {
const sender = vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setNativeNotificationSenderForTests(sender)
await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(false)
await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(true)
expect(sender).toHaveBeenCalledTimes(2)
warnSpy.mockRestore()
})
it('reports and requests native notification permission', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false)
notificationPluginMock.requestPermission.mockResolvedValue('granted')
Object.defineProperty(window, 'Notification', {
configurable: true,
value: { permission: 'default' },
})
await expect(getDesktopNotificationPermission()).resolves.toBe('default')
await expect(requestDesktopNotificationPermission()).resolves.toBe('granted')
expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1)
})
it('reads and requests Windows notification permission through the native plugin command', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
})
coreApiMock.invoke
.mockResolvedValueOnce(true)
.mockResolvedValueOnce('granted')
await expect(getDesktopNotificationPermission()).resolves.toBe('granted')
await expect(requestDesktopNotificationPermission()).resolves.toBe('granted')
expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'plugin:notification|is_permission_granted')
expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'plugin:notification|request_permission')
expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled()
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
})
it('sends Windows notifications when the native plugin reports permission granted', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
})
coreApiMock.invoke.mockResolvedValueOnce(true)
await expect(notifyDesktop({
title: 'Permission required',
body: 'Approve command execution',
})).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('plugin:notification|is_permission_granted')
expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled()
expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({
title: 'Permission required',
body: 'Approve command execution',
})
})
it('opens Windows notification settings through the native command', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
})
coreApiMock.invoke.mockResolvedValueOnce(true)
await expect(openDesktopNotificationSettings()).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('open_windows_notification_settings')
expect(shellApiMock.open).not.toHaveBeenCalled()
})
it('opens macOS notification settings through the native command before shell fallback', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockResolvedValueOnce(true)
await expect(openDesktopNotificationSettings()).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings')
expect(shellApiMock.open).not.toHaveBeenCalled()
})
it('falls back to shell.open for macOS notification settings when native command is unavailable', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockRejectedValueOnce(new Error('unavailable'))
await expect(openDesktopNotificationSettings()).resolves.toBe(true)
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings')
expect(shellApiMock.open).toHaveBeenCalledWith('x-apple.systempreferences:com.apple.preference.notifications')
})
it('reports and requests macOS notification permission through the native bridge', async () => {
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke
.mockResolvedValueOnce('default')
.mockResolvedValueOnce('granted')
await expect(getDesktopNotificationPermission()).resolves.toBe('default')
await expect(requestDesktopNotificationPermission()).resolves.toBe('granted')
expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'macos_notification_permission_state')
expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'macos_request_notification_permission')
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
})
it('does not use the generic notification permission fallback on macOS bridge errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable'))
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
await expect(getDesktopNotificationPermission()).resolves.toBe('unsupported')
expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('sends a native notification once for a dedupe key', async () => {
const sender = vi.fn(async () => true)
setNativeNotificationSenderForTests(sender)
void notifyDesktop({
dedupeKey: 'permission:1',
title: 'Permission required',
body: 'Approve command execution',
})
void notifyDesktop({
dedupeKey: 'permission:1',
title: 'Permission required',
body: 'Approve command execution',
})
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
expect(sender).toHaveBeenCalledWith({
title: 'Permission required',
body: 'Approve command execution',
})
})
it('forwards notification click targets from native and plugin listeners', async () => {
const unlistenNative = vi.fn()
const pluginListener = {
unregister: vi.fn(function (this: unknown) {
expect(this).toBe(pluginListener)
}),
}
type NativeClickCallback = (event: { payload: unknown }) => void
type PluginClickCallback = (notification: unknown) => void
let nativeCallback: NativeClickCallback = () => {
throw new Error('native listener was not registered')
}
let pluginCallback: PluginClickCallback = () => {
throw new Error('plugin listener was not registered')
}
let nativeRegistered = false
let pluginRegistered = false
const sessionTarget = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
const scheduledTarget = { type: 'scheduled' as const }
eventApiMock.listen.mockImplementation(async (_eventName: string, callback: (event: { payload: unknown }) => void) => {
nativeCallback = callback
nativeRegistered = true
return unlistenNative
})
notificationPluginMock.onAction.mockImplementation(async (callback: (notification: unknown) => void) => {
pluginCallback = callback
pluginRegistered = true
return pluginListener
})
const onTarget = vi.fn()
const cleanup = await installDesktopNotificationClickListener(onTarget)
expect(nativeRegistered).toBe(true)
expect(pluginRegistered).toBe(true)
nativeCallback({ payload: { target: JSON.stringify(sessionTarget) } })
pluginCallback({ extra: { ccHahaTarget: JSON.stringify(scheduledTarget) } })
expect(onTarget).toHaveBeenCalledWith(sessionTarget)
expect(onTarget).toHaveBeenCalledWith(scheduledTarget)
cleanup()
expect(unlistenNative).toHaveBeenCalledTimes(1)
expect(pluginListener.unregister).toHaveBeenCalledTimes(1)
})
it('requests OS-level window attention for blocking prompts', async () => {
const sender = vi.fn(async () => true)
setNativeNotificationSenderForTests(sender)
notifyDesktop({
requestAttention: true,
title: 'Permission required',
body: 'Approve command execution',
})
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(windowApiMock.requestUserAttention).toHaveBeenCalledTimes(1))
expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith()
})
it('throttles bursts within the same cooldown scope', async () => {
vi.useFakeTimers()
const sender = vi.fn(async () => true)
setNativeNotificationSenderForTests(sender)
notifyDesktop({ dedupeKey: 'permission:1', cooldownScope: 'permission', title: 'One' })
notifyDesktop({ dedupeKey: 'permission:2', cooldownScope: 'permission', title: 'Two' })
await vi.runAllTimersAsync()
expect(sender).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(751)
notifyDesktop({ dedupeKey: 'permission:3', cooldownScope: 'permission', title: 'Three' })
await vi.runAllTimersAsync()
expect(sender).toHaveBeenCalledTimes(2)
})
})