Files
TREK/client/src/hooks/useWebPush.test.ts
T
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00

285 lines
12 KiB
TypeScript

/**
* FE-WEBPUSH-HOOK-001 to FE-WEBPUSH-HOOK-015: the one hook behind the desktop
* and phone "Push notifications on this device" cards.
*
* The browser glue (`push/webPush.ts`) has its own spec against a fake browser
* and is mocked here, so these cases are about what the card does with it:
*
* 1. **Every state has its sentence.** Unsupported, insecure, "install first",
* blocked and on each read differently, and "off" adds nothing to the hint.
* 2. **The gesture.** The key and the registration are fetched on mount, so
* the click can hand them straight to `enableWebPush` in the same tick.
* 3. **The browser is re-read** after every switch and when the page becomes
* visible again, so the card never shows a state of its own invention.
* 4. **Toasts only for real failures.** A block or a dismissed prompt is the
* user's answer, not an error.
*
* Translation is the real provider, so the sentences asserted are the shipped
* English.
*/
import { createElement, type ReactNode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { TranslationProvider } from '../i18n/TranslationContext'
import { WebPushError, type WebPushDeviceState } from '../push/webPush'
import { useWebPush } from './useWebPush'
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
const push = vi.hoisted(() => ({
readWebPushDeviceState: vi.fn(),
getWebPushSupport: vi.fn(),
loadPushPublicKey: vi.fn(),
waitForPushRegistration: vi.fn(),
enableWebPush: vi.fn(),
disableWebPush: vi.fn(),
}))
vi.mock('../push/webPush', async importOriginal => {
const actual = await importOriginal<typeof import('../push/webPush')>()
return { ...actual, ...push }
})
const testChannel = vi.hoisted(() => vi.fn())
vi.mock('../api/client', () => ({ notificationsApi: { testChannel } }))
const REGISTRATION = { scope: '/' } as unknown as ServiceWorkerRegistration
const EN = {
on: 'On for this device',
enable: 'Turn on for this device',
disable: 'Turn off for this device',
unsupported: 'This browser cannot receive push notifications.',
insecure: 'Push needs TREK to be opened over HTTPS.',
iosInstall: 'On iPhone and iPad, add TREK to the Home Screen first and open it from there.',
denied: 'Notifications are blocked for TREK in this browser. Allow them in the browser settings, then try again.',
failed: 'Push could not be turned on for this device.',
testSuccess: 'Test notification sent.',
testFailed: 'Test failed.',
}
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children)
async function mounted(state: WebPushDeviceState) {
push.readWebPushDeviceState.mockResolvedValue(state)
const hook = renderHook(() => useWebPush(), { wrapper })
await waitFor(() => expect(hook.result.current.state).toBe(state))
return hook
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
beforeEach(() => {
vi.clearAllMocks()
push.getWebPushSupport.mockReturnValue({ supported: true, reason: null })
push.loadPushPublicKey.mockResolvedValue('BAECAwQ')
push.waitForPushRegistration.mockResolvedValue(REGISTRATION)
push.enableWebPush.mockResolvedValue(undefined)
push.disableWebPush.mockResolvedValue(undefined)
testChannel.mockResolvedValue({ success: true })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useWebPush: what the card says', () => {
it('FE-WEBPUSH-HOOK-001: nothing is claimed before the device has been read', () => {
push.readWebPushDeviceState.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useWebPush(), { wrapper })
expect(result.current.state).toBeNull()
expect(result.current.stateText).toBeNull()
expect(result.current.canSwitch).toBe(false)
expect(result.current.canTest).toBe(false)
})
it.each([
['unsupported', EN.unsupported],
['insecure', EN.insecure],
['ios-install', EN.iosInstall],
['denied', EN.denied],
] as const)('FE-WEBPUSH-HOOK-002: %s has its own sentence and no switch', async (state, text) => {
const { result } = await mounted(state)
expect(result.current.stateText).toBe(text)
expect(result.current.canSwitch).toBe(false)
expect(result.current.canTest).toBe(false)
})
it('FE-WEBPUSH-HOOK-003: off offers to switch on and adds no sentence', async () => {
const { result } = await mounted('off')
expect(result.current.stateText).toBeNull()
expect(result.current.canSwitch).toBe(true)
expect(result.current.on).toBe(false)
expect(result.current.switchLabel).toBe(EN.enable)
expect(result.current.canTest).toBe(false)
})
it('FE-WEBPUSH-HOOK-004: on says so, offers to switch off and allows a test', async () => {
const { result } = await mounted('on')
expect(result.current.stateText).toBe(EN.on)
expect(result.current.on).toBe(true)
expect(result.current.switchLabel).toBe(EN.disable)
expect(result.current.canTest).toBe(true)
})
})
describe('useWebPush: ahead of the click', () => {
it('FE-WEBPUSH-HOOK-005: fetches the key and the registration on mount where push is supported', async () => {
await mounted('off')
expect(push.loadPushPublicKey).toHaveBeenCalledTimes(1)
expect(push.waitForPushRegistration).toHaveBeenCalledTimes(1)
})
it('FE-WEBPUSH-HOOK-006: fetches neither where push cannot work', async () => {
push.getWebPushSupport.mockReturnValue({ supported: false, reason: 'insecure' })
await mounted('insecure')
expect(push.loadPushPublicKey).not.toHaveBeenCalled()
expect(push.waitForPushRegistration).not.toHaveBeenCalled()
})
})
describe('useWebPush: switching', () => {
it('FE-WEBPUSH-HOOK-007: switching on hands the prefetched key and registration over in the same tick', async () => {
const hook = await mounted('off')
await waitFor(() => expect(push.waitForPushRegistration).toHaveBeenCalled())
await act(async () => {})
const pending = deferred<void>()
push.enableWebPush.mockReturnValueOnce(pending.promise)
act(() => hook.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledWith('BAECAwQ', REGISTRATION)
expect(hook.result.current.busy).toBe(true)
push.readWebPushDeviceState.mockResolvedValue('on')
await act(async () => pending.resolve())
await waitFor(() => expect(hook.result.current.state).toBe('on'))
expect(hook.result.current.busy).toBe(false)
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-008: without a prefetched key it fetches one first, and fails cleanly without', async () => {
push.loadPushPublicKey.mockResolvedValueOnce(null).mockResolvedValueOnce('LATEKEY')
const hook = await mounted('off')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(push.enableWebPush).toHaveBeenCalledWith('LATEKEY', REGISTRATION))
push.loadPushPublicKey.mockResolvedValueOnce(null)
vi.spyOn(console, 'warn').mockImplementation(() => {})
await waitFor(() => expect(hook.result.current.busy).toBe(false))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.failed))
})
it('FE-WEBPUSH-HOOK-009: a block or a dismissed prompt is an answer, not an error', async () => {
const hook = await mounted('off')
push.enableWebPush.mockRejectedValueOnce(new WebPushError('dismissed'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.busy).toBe(false))
push.enableWebPush.mockRejectedValueOnce(new WebPushError('denied'))
push.readWebPushDeviceState.mockResolvedValue('denied')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.state).toBe('denied'))
expect(hook.result.current.stateText).toBe(EN.denied)
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-010: a failure to switch on is toasted and the device read again', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const hook = await mounted('off')
push.enableWebPush.mockRejectedValueOnce(new WebPushError('failed'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.failed))
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(2)
})
it('FE-WEBPUSH-HOOK-011: switching off goes through disableWebPush', async () => {
const hook = await mounted('on')
push.readWebPushDeviceState.mockResolvedValue('off')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.state).toBe('off'))
expect(push.disableWebPush).toHaveBeenCalledTimes(1)
expect(push.enableWebPush).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-012: a browser that refuses to unsubscribe is toasted and stays on', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const hook = await mounted('on')
push.disableWebPush.mockRejectedValueOnce(new Error('refused'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'))
await waitFor(() => expect(hook.result.current.busy).toBe(false))
expect(hook.result.current.state).toBe('on')
})
it('FE-WEBPUSH-HOOK-013: a second click while busy, or with nothing to switch, does nothing', async () => {
const hook = await mounted('off')
push.enableWebPush.mockReturnValueOnce(new Promise(() => {}))
act(() => hook.result.current.toggle())
act(() => hook.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledTimes(1)
const denied = await mounted('denied')
act(() => denied.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledTimes(1)
expect(push.disableWebPush).not.toHaveBeenCalled()
})
})
describe('useWebPush: the rest', () => {
it('FE-WEBPUSH-HOOK-014: the test goes through the generic channel route', async () => {
const hook = await mounted('on')
const pending = deferred<{ success: boolean; error?: string }>()
testChannel.mockReturnValueOnce(pending.promise)
act(() => hook.result.current.sendTest())
expect(testChannel).toHaveBeenCalledWith('push')
expect(hook.result.current.canTest).toBe(false)
act(() => hook.result.current.sendTest())
expect(testChannel).toHaveBeenCalledTimes(1)
await act(async () => pending.resolve({ success: true }))
expect(toast.success).toHaveBeenCalledWith(EN.testSuccess)
expect(hook.result.current.canTest).toBe(true)
testChannel.mockResolvedValueOnce({ success: false, error: 'No devices' })
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No devices'))
testChannel.mockResolvedValueOnce({ success: false })
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.testFailed))
testChannel.mockRejectedValueOnce(new Error('timeout'))
toast.error.mockClear()
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.testFailed))
})
it('FE-WEBPUSH-HOOK-015: coming back to the page reads the device again, until unmount', async () => {
const hook = await mounted('denied')
push.readWebPushDeviceState.mockResolvedValue('off')
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(1)
visibility.mockReturnValue('visible')
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
await waitFor(() => expect(hook.result.current.state).toBe('off'))
hook.unmount()
document.dispatchEvent(new Event('visibilitychange'))
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(2)
})
})