mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
504 lines
19 KiB
TypeScript
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)
|
|
})
|
|
})
|