From f45cc21a9c4a7c583314c3065f943b072ca5b5d9 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:15:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20push-=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=B5=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=B2=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85=20(7.16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиент подписывает устройство на Web Push, а показывает уведомление service worker — приложение при этом может быть и закрыто. - `web/public/sw.js`: обработчики `push` (показывает уведомление, иначе браузер может отозвать подписку) и `notificationclick` (фокусирует окно и переводит его в комнату, иначе открывает новое). Путь из payload берётся только внутренний: `//host` и абсолютные адреса отбрасываются; - `web/src/lib/webPush.ts`: поддержка API, запрос разрешения, подписка и отписка, преобразование ключей в base64url, слушатель перехода по клику (`push-navigate` → `history.pushState` без перезагрузки); - `web/src/api/push.ts`: `GET /push/config`, `GET|POST|DELETE /push/subscriptions`; - раздел «Push-уведомления» в настройках: тумблер «Включить на этом устройстве», состояние «включено», объяснение отказа браузера в разрешении и честная деградация — «браузер не поддерживает» или «на инстансе не настроено»; - владелец инстанса управляет ключами, клиент только спрашивает публичный. Тесты: `web/tests/webPush.test.ts` (поддержка API, ключи, подписка, отписка, отказ в разрешении, переход по клику) и `web/tests/pushSettings.test.tsx` (раздел настроек: деградация, включение, отключение, ошибка загрузки). --- web/public/sw.js | 68 ++++++ web/src/api/push.ts | 54 +++++ web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/ru.json | 18 +- web/src/lib/webPush.ts | 171 +++++++++++++++ web/src/main.tsx | 3 + .../settings/NotificationsSettingsSection.tsx | 172 ++++++++++++++- web/tests/pushSettings.test.tsx | 190 ++++++++++++++++ web/tests/webPush.test.ts | 202 ++++++++++++++++++ 9 files changed, 888 insertions(+), 8 deletions(-) create mode 100644 web/src/api/push.ts create mode 100644 web/src/lib/webPush.ts create mode 100644 web/tests/pushSettings.test.tsx create mode 100644 web/tests/webPush.test.ts diff --git a/web/public/sw.js b/web/public/sw.js index 8ab42cb..e9ea345 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -89,3 +89,71 @@ self.addEventListener('fetch', (event) => { .catch(() => caches.match(request).then((cached) => cached ?? Response.error())), ); }); + +/* + * Web Push (AGENT.md 7.16, Фаза 7). + * + * Текст уведомления готовит сервер (internal/push/rules.go) по тем же + * правилам, что нативные уведомления desktop-обёртки, а воркер только + * показывает его и открывает комнату по клику. Уведомление обязано + * показываться на каждый push: иначе браузер может отозвать подписку. + */ + +/** sameOriginPath оставляет только внутренний путь: payload приходит извне. */ +function sameOriginPath(value) { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) { + return '/app'; + } + return value; +} + +self.addEventListener('push', (event) => { + if (!event.data) { + return; + } + let payload = null; + try { + payload = event.data.json(); + } catch { + return; + } + if (payload === null || typeof payload !== 'object') { + return; + } + const title = + typeof payload.title === 'string' && payload.title !== '' ? payload.title : 'glchat'; + const body = typeof payload.body === 'string' ? payload.body : ''; + const options = { + body, + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + // Уведомления одной комнаты заменяют друг друга (Topic на сервере). + tag: typeof payload.tag === 'string' && payload.tag !== '' ? payload.tag : undefined, + data: { + url: sameOriginPath(payload.url), + channel_id: typeof payload.channel_id === 'string' ? payload.channel_id : '', + guild_id: typeof payload.guild_id === 'string' ? payload.guild_id : '', + }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = sameOriginPath(event.notification.data && event.notification.data.url); + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of windows) { + if ('focus' in client) { + await client.focus(); + // Открытое окно переходит в комнату без перезагрузки: клиент слушает + // это сообщение и делает history.pushState (web/src/lib/webPush.ts). + client.postMessage({ type: 'push-navigate', url: target }); + return; + } + } + await self.clients.openWindow(target); + })(), + ); +}); diff --git a/web/src/api/push.ts b/web/src/api/push.ts new file mode 100644 index 0000000..674a7c7 --- /dev/null +++ b/web/src/api/push.ts @@ -0,0 +1,54 @@ +import { request } from './client'; + +/** + * Web Push (AGENT.md 7.16, Фаза 7): параметры push и подписки устройств. + * + * Публичный VAPID-ключ нужен браузеру как `applicationServerKey`; приватный + * остаётся на сервере. Подписка — это устройство: одно и то же устройство + * может переподписаться, тогда запись обновляется. + */ + +export interface PushConfig { + enabled: boolean; + public_key?: string; +} + +export interface PushSubscriptionInfo { + id: string; + endpoint: string; + user_agent?: string; + created_at: string; +} + +export interface PushSubscriptionList { + subscriptions: PushSubscriptionInfo[]; + /** Сколько устройств можно подписать (лимит инстанса). */ + limit: number; +} + +export const pushConfigQueryKey = ['push-config'] as const; +export const pushSubscriptionsQueryKey = ['push-subscriptions'] as const; + +export function fetchPushConfig(signal?: AbortSignal): Promise { + return request('/push/config', signal === undefined ? {} : { signal }); +} + +export function listPushSubscriptions(signal?: AbortSignal): Promise { + return request( + '/push/subscriptions', + signal === undefined ? {} : { signal }, + ); +} + +export function subscribePush(body: { + endpoint: string; + keys: { p256dh: string; auth: string }; +}): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>('/push/subscriptions', { method: 'POST', body }); +} + +/** Отписка: без эндпоинта сервер убирает все устройства пользователя. */ +export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> { + const query = endpoint === undefined ? '' : `?endpoint=${encodeURIComponent(endpoint)}`; + return request<{ ok: boolean }>(`/push/subscriptions${query}`, { method: 'DELETE' }); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a1e784d..ef95711 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -124,7 +124,11 @@ "event_required": "Choose an event for a palette sound." }, "voice.not_connected": "Join a voice channel first.", - "user.banned": "This account is banned on the instance. Contact the administrator." + "user.banned": "This account is banned on the instance. Contact the administrator.", + "push.disabled": "Push notifications are disabled on this instance.", + "push.invalid_endpoint": "The browser sent an invalid push service endpoint.", + "push.invalid_keys": "The browser sent invalid subscription keys.", + "push.too_many_subscriptions": "Too many devices with push notifications: disable some of them." }, "theme": { "switchToLight": "Light theme", @@ -1203,7 +1207,17 @@ "desktop": "Desktop notifications", "desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.", "desktopSettings": "Desktop app settings", - "unsupported": "This browser does not show system notifications: open the client in the desktop app." + "unsupported": "This browser does not show system notifications: open the client in the desktop app.", + "push": "Push notifications", + "pushHint": "Notifications are delivered to this device even when the browser is closed. Works in Chrome, Firefox, Safari and Edge on desktop and mobile.", + "pushEnable": "Enable on this device", + "pushDisable": "Disable on this device", + "pushActive": "Push notifications are enabled on this device.", + "pushDenied": "The browser blocked notifications. Allow them in the site settings and try again.", + "pushUnsupported": "This browser does not support push notifications.", + "pushUnavailable": "Push notifications are not configured on this instance.", + "pushError": "Could not enable push notifications. Please try again.", + "pushWorking": "Setting up…" }, "voice": { "title": "Audio & video", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index e5c1dc6..158665c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -124,7 +124,11 @@ "event_required": "Для звука палитры выберите событие." }, "voice.not_connected": "Сначала войдите в голосовую комнату.", - "user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору." + "user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору.", + "push.disabled": "Push-уведомления на инстансе выключены.", + "push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.", + "push.invalid_keys": "Браузер прислал некорректные ключи подписки.", + "push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние." }, "theme": { "switchToLight": "Светлая тема", @@ -1203,7 +1207,17 @@ "desktop": "Уведомления рабочего стола", "desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.", "desktopSettings": "Настройки desktop-приложения", - "unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении." + "unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении.", + "push": "Push-уведомления", + "pushHint": "Уведомления приходят на это устройство, даже когда браузер закрыт. Работает в Chrome, Firefox, Safari, Edge на компьютере и на телефоне.", + "pushEnable": "Включить на этом устройстве", + "pushDisable": "Выключить на этом устройстве", + "pushActive": "Push-уведомления включены на этом устройстве.", + "pushDenied": "Браузер запретил уведомления. Разрешите их в настройках сайта и повторите.", + "pushUnsupported": "Этот браузер не поддерживает push-уведомления.", + "pushUnavailable": "На этом инстансе push-уведомления не настроены.", + "pushError": "Не удалось включить push-уведомления. Попробуйте ещё раз.", + "pushWorking": "Настраиваем…" }, "voice": { "title": "Аудио-видео", diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts new file mode 100644 index 0000000..cbcf489 --- /dev/null +++ b/web/src/lib/webPush.ts @@ -0,0 +1,171 @@ +/** + * Web Push в браузере (AGENT.md 7.16, Фаза 7). + * + * Модуль отвечает только за подписку устройства: поддержку API, запрос + * разрешения, регистрацию service worker и преобразование подписки в тело + * запроса к серверу. Решение «показывать уведомление или нет» принимает + * сервер (internal/push/rules.go), а показывает его service worker + * (web/public/sw.js) — приложение для этого может быть и закрыто. + */ + +/** Состояние push на этом устройстве. */ +export type PushAvailability = + | 'unsupported' // браузер не умеет push или service worker + | 'unavailable' // на инстансе не настроены VAPID-ключи + | 'denied' // пользователь запретил уведомления в браузере + | 'off' // поддерживается, но устройство не подписано + | 'on'; // устройство подписано + +/** Поддерживает ли браузер всё необходимое. */ +export function pushSupported(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +/** Текущее разрешение браузера на уведомления. */ +export function notificationPermission(): NotificationPermission | null { + if (!pushSupported()) { + return null; + } + return Notification.permission; +} + +/** + * urlBase64ToUint8Array переводит VAPID-ключ сервера в `applicationServerKey`. + * Ключ приходит в base64url без выравнивания — добавляем padding сами. + */ +export function urlBase64ToUint8Array(base64: string): Uint8Array { + const normalized = base64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + const binary = window.atob(padded); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +/** Тело запроса подписки: то, что ждёт сервер (endpoint + ключи). */ +export interface PushSubscriptionBody { + endpoint: string; + keys: { p256dh: string; auth: string }; +} + +/** arrayBufferToBase64URL кодирует ключи подписки в base64url без padding. */ +export function arrayBufferToBase64URL(buffer: ArrayBuffer | null): string { + if (buffer === null) { + return ''; + } + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Приводит PushSubscription к телу запроса; null, если ключей нет. */ +export function subscriptionBody(subscription: PushSubscription): PushSubscriptionBody | null { + const p256dh = arrayBufferToBase64URL(subscription.getKey('p256dh')); + const auth = arrayBufferToBase64URL(subscription.getKey('auth')); + if (p256dh === '' || auth === '' || subscription.endpoint === '') { + return null; + } + return { endpoint: subscription.endpoint, keys: { p256dh, auth } }; +} + +/** + * ensureServiceWorker дожидается регистрации воркера. Обычно он уже + * зарегистрирован при запуске приложения (web/src/lib/serviceWorker.ts), но в + * dev-сборке регистрация не выполняется — для push регистрируем явно. + */ +async function ensureServiceWorker(): Promise { + if (!pushSupported()) { + return null; + } + const existing = await navigator.serviceWorker.getRegistration('/'); + if (existing !== undefined) { + return existing; + } + try { + return await navigator.serviceWorker.register('/sw.js', { scope: '/' }); + } catch (error) { + console.warn('service worker registration failed', error); + return null; + } +} + +/** Текущая подписка устройства или null. */ +export async function currentPushSubscription(): Promise { + const registration = await ensureServiceWorker(); + if (registration === null) { + return null; + } + return registration.pushManager.getSubscription(); +} + +/** + * Подписывает устройство. Возвращает тело запроса для сервера или null, если + * подписка не удалась (нет поддержки, отказ пользователя, ошибка браузера). + */ +export async function subscribeToPush(publicKey: string): Promise { + const registration = await ensureServiceWorker(); + if (registration === null || publicKey === '') { + return null; + } + // Разрешение запрашиваем до subscribe: браузеры требуют пользовательский жест. + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + return null; + } + const existing = await registration.pushManager.getSubscription(); + const subscription = + existing ?? + (await registration.pushManager.subscribe({ + // userVisibleOnly обязателен: браузер требует показывать уведомление. + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + })); + return subscriptionBody(subscription); +} + +/** Отписывает устройство и возвращает его эндпоинт (для запроса к серверу). */ +export async function unsubscribeFromPush(): Promise { + const subscription = await currentPushSubscription(); + if (subscription === null) { + return null; + } + const endpoint = subscription.endpoint; + try { + await subscription.unsubscribe(); + } catch (error) { + console.warn('push unsubscribe failed', error); + } + return endpoint; +} + +/** + * Слушает переход по клику на уведомление: service worker присылает + * сообщение `push-navigate`, а приложение меняет маршрут без перезагрузки. + * Возвращает функцию отписки. + */ +export function listenForPushNavigation(): () => void { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return () => {}; + } + const listener = (event: MessageEvent): void => { + const data = event.data as { type?: unknown; url?: unknown } | null; + if (data === null || data.type !== 'push-navigate' || typeof data.url !== 'string') { + return; + } + if (!data.url.startsWith('/')) { + return; + } + window.history.pushState(null, '', data.url); + window.dispatchEvent(new PopStateEvent('popstate')); + }; + navigator.serviceWorker.addEventListener('message', listener); + return () => navigator.serviceWorker.removeEventListener('message', listener); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b06dfa1..1a75c7f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,12 +5,15 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { App } from '@/App'; import { getQueryClient } from '@/lib/queryClient'; import { registerServiceWorker } from '@/lib/serviceWorker'; +import { listenForPushNavigation } from '@/lib/webPush'; import { applyTheme, useUiStore } from '@/stores/ui'; import '@/i18n'; import '@/index.css'; applyTheme(useUiStore.getState().theme); registerServiceWorker(); +// Клик по push-уведомлению переводит уже открытое окно в комнату (Фаза 7). +listenForPushNavigation(); const container = document.getElementById('root'); if (!container) { diff --git a/web/src/pages/settings/NotificationsSettingsSection.tsx b/web/src/pages/settings/NotificationsSettingsSection.tsx index 2221013..713d01a 100644 --- a/web/src/pages/settings/NotificationsSettingsSection.tsx +++ b/web/src/pages/settings/NotificationsSettingsSection.tsx @@ -1,22 +1,132 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { fetchPushConfig, pushConfigQueryKey, subscribePush, unsubscribePush } from '@/api/push'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Button } from '@/components/ui/primitives'; import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications'; import { platform } from '@/lib/platform'; +import { + currentPushSubscription, + notificationPermission, + pushSupported, + subscribeToPush, + unsubscribeFromPush, + type PushAvailability, +} from '@/lib/webPush'; import { SettingsSection } from '@/pages/settings/SettingsSection'; /** * «Уведомления»: системные уведомления об упоминаниях и личных сообщениях. * - * Предпочтение хранится на устройстве (localStorage): в desktop-обёртке - * уведомления нативные, в браузере — только при выданном разрешении, а - * веб-пуш (Фаза 7) появится отдельным пунктом. + * Предпочтение «показывать уведомления» хранится на устройстве (localStorage): + * в desktop-обёртке уведомления нативные, в браузере — системные. Отдельно + * включается Web Push (Фаза 7): тогда уведомления приходят, даже когда + * браузер закрыт, а подписку хранит сервер. */ export function NotificationsSettingsSection() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [enabled, setEnabled] = useState(notificationsEnabled); + // В обёртке уведомления всегда доступны, в браузере — по разрешению. const supported = platform.kind === 'tauri' || 'Notification' in window; + const pushApiSupported = pushSupported(); + + const config = useQuery({ + queryKey: pushConfigQueryKey, + queryFn: ({ signal }) => fetchPushConfig(signal), + enabled: pushApiSupported, + // Ключи меняются только при переустановке инстанса: кэш свеж долго. + staleTime: 30 * 60 * 1000, + retry: 0, + }); + const pushEnabledOnInstance = config.data?.enabled === true; + const publicKey = config.data?.public_key ?? ''; + + const [subscriptionState, setSubscriptionState] = useState<'unknown' | 'on' | 'off' | 'denied'>( + 'unknown', + ); + const [busy, setBusy] = useState(false); + + /* + * Состояние подписки читаем у браузера: сервер знает только про устройства, + * а нам важно именно это. В эффекте — только асинхронная работа: состояние + * «не поддерживается» и «на инстансе выключено» вычисляются при отрисовке, + * иначе получаются лишние перерисовки (правило react-hooks). + */ + useEffect(() => { + if (!pushApiSupported || !config.isSuccess || !pushEnabledOnInstance) { + return; + } + let cancelled = false; + void currentPushSubscription().then((subscription) => { + if (cancelled) { + return; + } + if (subscription !== null) { + setSubscriptionState('on'); + return; + } + setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off'); + }); + return () => { + cancelled = true; + }; + }, [pushApiSupported, config.isSuccess, pushEnabledOnInstance]); + + const availability: PushAvailability = !pushApiSupported + ? 'unsupported' + : config.isSuccess && !pushEnabledOnInstance + ? 'unavailable' + : subscriptionState === 'unknown' + ? 'off' + : subscriptionState; + + const enable = useMutation({ + mutationFn: async () => { + const body = await subscribeToPush(publicKey); + if (body === null) { + // Разрешение могли не выдать: уточняем состояние у браузера. + setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off'); + return false; + } + await subscribePush(body); + return true; + }, + onSuccess: (subscribed) => { + if (subscribed) { + setSubscriptionState('on'); + } + void queryClient.invalidateQueries({ queryKey: pushConfigQueryKey }); + }, + }); + + const disable = useMutation({ + mutationFn: async () => { + const endpoint = await unsubscribeFromPush(); + // Даже если браузер уже потерял подписку, сервер должен убрать запись. + await unsubscribePush(endpoint ?? undefined); + }, + onSuccess: () => { + setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off'); + }, + }); + + const pushBusy = busy || enable.isPending || disable.isPending; + + const togglePush = (): void => { + if (pushBusy) { + return; + } + setBusy(true); + if (availability === 'on') { + disable.mutate(undefined, { onSettled: () => setBusy(false) }); + return; + } + enable.mutate(undefined, { onSettled: () => setBusy(false) }); + }; return ( ) : null} + + {platform.kind === 'tauri' ? null : ( +
+

{t('settings.notifications.push')}

+

{t('settings.notifications.pushHint')}

+ + {availability === 'unsupported' ? ( +

+ {t('settings.notifications.pushUnsupported')} +

+ ) : config.isError ? ( + void config.refetch()} + /> + ) : availability === 'unavailable' ? ( +

+ {t('settings.notifications.pushUnavailable')} +

+ ) : ( +
+ + {availability === 'on' ? ( +

+ {t('settings.notifications.pushActive')} +

+ ) : null} + {availability === 'denied' ? ( +

+ {t('settings.notifications.pushDenied')} +

+ ) : null} + {enable.isError ? ( +

+ {t('settings.notifications.pushError')} +

+ ) : null} +
+ )} +
+ )}
); } diff --git a/web/tests/pushSettings.test.tsx b/web/tests/pushSettings.test.tsx new file mode 100644 index 0000000..0e9c035 --- /dev/null +++ b/web/tests/pushSettings.test.tsx @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection'; +import { + createTestQueryClient, + installFetch, + json, + recordedRequests, + type FetchRoute, +} from './helpers'; + +/** + * Web Push в настройках уведомлений (AGENT.md 7.16, Фаза 7): раздел с + * тумблером, запросом разрешения и честной деградацией, когда браузер или + * инстанс push не поддерживают. + */ + +const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/settings-device'; + +/** Подписка, которую «выдаёт» браузер после subscribe(). */ +function fakeSubscription( + endpoint = ENDPOINT, + unsubscribe: () => Promise = () => Promise.resolve(true), +) { + const buffer = (bytes: number[]): ArrayBuffer => new Uint8Array(bytes).buffer; + return { + endpoint, + getKey: (name: string) => + name === 'p256dh' + ? buffer(Array.from({ length: 65 }, (_, index) => index)) + : buffer(Array.from({ length: 16 }, (_, index) => index + 1)), + unsubscribe, + } as unknown as PushSubscription; +} + +interface BrowserOptions { + subscription?: PushSubscription | null; + permission?: NotificationPermission; + requestPermission?: () => Promise; +} + +/** stubBrowser подменяет Notification, PushManager и service worker. */ +function stubBrowser(options: BrowserOptions = {}) { + const unsubscribe = vi.fn(() => Promise.resolve(true)); + const subscription = + options.subscription === undefined + ? fakeSubscription(ENDPOINT, unsubscribe) + : options.subscription; + const registration = { + pushManager: { + getSubscription: vi.fn(() => Promise.resolve(subscription)), + subscribe: vi.fn(() => Promise.resolve(subscription ?? fakeSubscription())), + }, + }; + const serviceWorker = { + getRegistration: vi.fn(() => Promise.resolve(registration)), + register: vi.fn(() => Promise.resolve(registration)), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + vi.stubGlobal('PushManager', class PushManagerStub {}); + vi.stubGlobal('Notification', { + permission: options.permission ?? 'granted', + requestPermission: vi.fn( + options.requestPermission ?? (() => Promise.resolve('granted' as const)), + ), + }); + Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }); + return { + registration, + serviceWorker, + subscription: subscription ?? fakeSubscription(), + unsubscribe, + }; +} + +function renderSection(routes: FetchRoute[]) { + const fetchMock = installFetch(routes); + const client = createTestQueryClient(); + render( + + + , + ); + return fetchMock; +} + +describe('раздел push в настройках уведомлений', () => { + it('без поддержки push показывает пояснение и не спрашивает сервер', async () => { + const fetchMock = renderSection([]); + expect(await screen.findByText(/не поддерживает push-уведомления/i)).toBeInTheDocument(); + expect(screen.queryByTestId('push-toggle')).not.toBeInTheDocument(); + expect(recordedRequests(fetchMock)).toHaveLength(0); + }); + + it('на инстансе без ключей честно сообщает, что push выключен', async () => { + stubBrowser({ subscription: null }); + renderSection([{ match: '/push/config', response: () => json({ enabled: false }) }]); + expect(await screen.findByText(/push-уведомления не настроены/i)).toBeInTheDocument(); + expect(screen.queryByTestId('push-toggle')).not.toBeInTheDocument(); + }); + + it('включает push: подписывает браузер и отправляет подписку на сервер', async () => { + stubBrowser({ subscription: null }); + const fetchMock = renderSection([ + { + match: '/push/config', + response: () => json({ enabled: true, public_key: 'BPv_public_key' }), + }, + { match: '/push/subscriptions', method: 'POST', response: () => json({ ok: true }) }, + ]); + + const toggle = await screen.findByTestId('push-toggle'); + await waitFor(() => expect(toggle).toBeEnabled()); + await userEvent.click(toggle); + + expect(await screen.findByTestId('push-active')).toBeInTheDocument(); + const request = recordedRequests(fetchMock).find( + (item) => item.method === 'POST' && item.url.includes('/push/subscriptions'), + ); + expect(request?.body).toMatchObject({ endpoint: ENDPOINT }); + }); + + it('показывает активное состояние, если устройство уже подписано', async () => { + stubBrowser(); + renderSection([ + { + match: '/push/config', + response: () => json({ enabled: true, public_key: 'BPv_public_key' }), + }, + ]); + expect(await screen.findByTestId('push-active')).toBeInTheDocument(); + expect(await screen.findByTestId('push-toggle')).toHaveTextContent(/выключить/i); + }); + + it('выключает push: отписывает браузер и сервер', async () => { + const { unsubscribe } = stubBrowser(); + const fetchMock = renderSection([ + { + match: '/push/config', + response: () => json({ enabled: true, public_key: 'BPv_public_key' }), + }, + { + match: '/push/subscriptions', + method: 'DELETE', + response: () => json({ ok: true }), + }, + ]); + + const toggle = await screen.findByTestId('push-toggle'); + await waitFor(() => expect(toggle).toHaveTextContent(/выключить/i)); + await userEvent.click(toggle); + + await waitFor(() => expect(screen.queryByTestId('push-active')).not.toBeInTheDocument()); + expect(unsubscribe).toHaveBeenCalled(); + const request = recordedRequests(fetchMock).find( + (item) => item.method === 'DELETE' && item.url.includes('/push/subscriptions'), + ); + expect(request?.url).toContain(encodeURIComponent(ENDPOINT)); + }); + + it('объясняет отказ браузера в разрешении', async () => { + stubBrowser({ + subscription: null, + permission: 'denied', + requestPermission: () => Promise.resolve('denied' as const), + }); + renderSection([ + { + match: '/push/config', + response: () => json({ enabled: true, public_key: 'BPv_public_key' }), + }, + ]); + expect(await screen.findByTestId('push-denied')).toBeInTheDocument(); + }); + + it('показывает ошибку загрузки параметров и умеет повторить', async () => { + stubBrowser({ subscription: null }); + renderSection([ + { + match: '/push/config', + response: () => json({ error: { code: 'internal.error', message: 'x' } }, 500), + }, + ]); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); +}); diff --git a/web/tests/webPush.test.ts b/web/tests/webPush.test.ts new file mode 100644 index 0000000..48e63be --- /dev/null +++ b/web/tests/webPush.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + arrayBufferToBase64URL, + currentPushSubscription, + listenForPushNavigation, + notificationPermission, + pushSupported, + subscribeToPush, + subscriptionBody, + unsubscribeFromPush, + urlBase64ToUint8Array, +} from '@/lib/webPush'; + +/** + * Web Push на клиенте (AGENT.md 7.16, Фаза 7): поддержка API, запрос + * разрешения, подписка и отписка устройства. Service worker и PushManager в + * jsdom отсутствуют, поэтому подменяем их вручную. + */ + +const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/device-one'; + +/** fakeSubscription — подписка браузера: ключи приходят как ArrayBuffer. */ +function fakeSubscription( + endpoint = ENDPOINT, + unsubscribe: () => Promise = () => Promise.resolve(true), +) { + const buffer = (bytes: number[]): ArrayBuffer => new Uint8Array(bytes).buffer; + return { + endpoint, + getKey: (name: string) => + name === 'p256dh' + ? buffer(Array.from({ length: 65 }, (_, index) => index)) + : buffer(Array.from({ length: 16 }, (_, index) => index + 1)), + unsubscribe, + } as unknown as PushSubscription; +} + +interface StubOptions { + subscription?: PushSubscription | null; + permission?: NotificationPermission; + requestPermission?: () => Promise; +} + +/** stubBrowser подменяет Notification, PushManager и serviceWorker. */ +function stubBrowser(options: StubOptions = {}) { + // Отдельная ссылка на мок отписки: `expect(subscription.unsubscribe)` линтер + // считает «оторванным» методом, а так проверка остаётся явной. + const unsubscribe = vi.fn(() => Promise.resolve(true)); + const subscription = + options.subscription === undefined + ? fakeSubscription(ENDPOINT, unsubscribe) + : options.subscription; + const registration = { + pushManager: { + getSubscription: vi.fn(() => Promise.resolve(subscription)), + subscribe: vi.fn(() => Promise.resolve(subscription ?? fakeSubscription())), + }, + }; + const serviceWorker = { + getRegistration: vi.fn(() => Promise.resolve(registration)), + register: vi.fn(() => Promise.resolve(registration)), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const requestPermission = vi.fn( + options.requestPermission ?? (() => Promise.resolve('granted' as const)), + ); + vi.stubGlobal('PushManager', class PushManagerStub {}); + vi.stubGlobal('Notification', { permission: options.permission ?? 'granted', requestPermission }); + Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }); + return { + registration, + serviceWorker, + subscription, + unsubscribe, + subscribe: registration.pushManager.subscribe, + requestPermission, + }; +} + +describe('pushSupported', () => { + it('без PushManager и service worker push недоступен', () => { + // В jsdom этих API нет по умолчанию: клиент обязан деградировать честно. + expect(pushSupported()).toBe(false); + expect(notificationPermission()).toBeNull(); + }); + + it('с полным набором API push доступен', () => { + stubBrowser(); + expect(pushSupported()).toBe(true); + expect(notificationPermission()).toBe('granted'); + }); +}); + +describe('ключи подписки', () => { + it('urlBase64ToUint8Array разбирает base64url без выравнивания', () => { + // 0x04 0xfb 0xff — байты, которые дают и «+», и «/», и «-», и «_». + const bytes = urlBase64ToUint8Array('BPv_'); + expect(Array.from(bytes)).toEqual([4, 251, 255]); + }); + + it('arrayBufferToBase64URL кодирует без padding и в base64url', () => { + expect(arrayBufferToBase64URL(new Uint8Array([4, 251, 255]).buffer)).toBe('BPv_'); + expect(arrayBufferToBase64URL(null)).toBe(''); + }); + + it('subscriptionBody отдаёт эндпоинт и ключи, а без ключей — null', () => { + const body = subscriptionBody(fakeSubscription()); + expect(body?.endpoint).toBe(ENDPOINT); + expect(body?.keys.p256dh).toHaveLength(87); + expect(body?.keys.auth).toHaveLength(22); + expect(base64UrlBytes(body?.keys.p256dh ?? '')).toBe(65); + expect(base64UrlBytes(body?.keys.auth ?? '')).toBe(16); + + const broken = { + endpoint: ENDPOINT, + getKey: () => null, + } as unknown as PushSubscription; + expect(subscriptionBody(broken)).toBeNull(); + }); +}); + +describe('подписка устройства', () => { + it('запрашивает разрешение и возвращает тело запроса', async () => { + const { subscribe, requestPermission } = stubBrowser({ subscription: null }); + const body = await subscribeToPush('BPv_'); + expect(requestPermission).toHaveBeenCalled(); + expect(subscribe).toHaveBeenCalledWith(expect.objectContaining({ userVisibleOnly: true })); + expect(body?.endpoint).toBe(ENDPOINT); + }); + + it('повторно использует уже созданную подписку', async () => { + const { subscribe } = stubBrowser(); + const body = await subscribeToPush('BPv_'); + expect(subscribe).not.toHaveBeenCalled(); + expect(body?.endpoint).toBe(ENDPOINT); + }); + + it('молчит, если пользователь запретил уведомления', async () => { + const { subscribe } = stubBrowser({ + subscription: null, + requestPermission: () => Promise.resolve('denied' as const), + }); + expect(await subscribeToPush('BPv_')).toBeNull(); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('не подписывается без публичного ключа инстанса', async () => { + stubBrowser(); + expect(await subscribeToPush('')).toBeNull(); + }); + + it('отписка возвращает эндпоинт для запроса к серверу', async () => { + const { unsubscribe } = stubBrowser(); + const endpoint = await unsubscribeFromPush(); + expect(endpoint).toBe(ENDPOINT); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('текущая подписка читается у браузера', async () => { + stubBrowser({ subscription: null }); + expect(await currentPushSubscription()).toBeNull(); + }); +}); + +describe('переход по клику на уведомление', () => { + it('меняет маршрут без перезагрузки', () => { + const { serviceWorker } = stubBrowser(); + const listener = vi.fn(); + window.addEventListener('popstate', listener); + const stop = listenForPushNavigation(); + const handler = serviceWorker.addEventListener.mock.calls[0]?.[1] as ( + event: MessageEvent, + ) => void; + handler({ data: { type: 'push-navigate', url: '/app/1/2' } } as MessageEvent); + expect(window.location.pathname).toBe('/app/1/2'); + expect(listener).toHaveBeenCalled(); + stop(); + window.removeEventListener('popstate', listener); + expect(serviceWorker.removeEventListener).toHaveBeenCalled(); + }); + + it('игнорирует чужие сообщения и внешние адреса', () => { + const { serviceWorker } = stubBrowser(); + const before = window.location.pathname; + listenForPushNavigation(); + const handler = serviceWorker.addEventListener.mock.calls[0]?.[1] as ( + event: MessageEvent, + ) => void; + handler({ data: { type: 'other', url: '/app/1/2' } } as MessageEvent); + handler({ data: { type: 'push-navigate', url: 'https://evil.example/' } } as MessageEvent); + expect(window.location.pathname).toBe(before); + }); +}); + +/** base64UrlBytes — длина декодированного base64url-значения. */ +function base64UrlBytes(value: string): number { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + return window.atob(padded).length; +}