mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -168,7 +168,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
|
||||
@@ -53,4 +53,5 @@ See `values.yaml` for more options.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
|
||||
@@ -97,3 +97,9 @@ data:
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.VAPID_SUBJECT }}
|
||||
VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -93,6 +93,12 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_SECRET
|
||||
optional: true
|
||||
- name: VAPID_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: VAPID_PRIVATE_KEY
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -29,6 +29,9 @@ data:
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -68,4 +71,7 @@ stringData:
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -108,6 +108,15 @@ env:
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
|
||||
# VAPID_PUBLIC_KEY: ""
|
||||
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
|
||||
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
|
||||
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
|
||||
# means every device has to turn push on again.
|
||||
# VAPID_SUBJECT: ""
|
||||
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
|
||||
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
|
||||
# address is sent unless you set one here.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -147,6 +156,11 @@ secretEnv:
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
|
||||
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
|
||||
# push off with an error in the log until it is fixed, and every device stays registered.
|
||||
# Leave both empty to let TREK keep a generated pair in its database.
|
||||
VAPID_PRIVATE_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Web Push handlers for the generated service worker.
|
||||
*
|
||||
* vite-plugin-pwa builds sw.js with Workbox (generateSW), and Workbox pulls
|
||||
* this file in through `workbox.importScripts` in vite.config.js, ahead of its
|
||||
* own precaching. A CLASSIC, dependency-free script on purpose, in the style of
|
||||
* theme-boot.js: importScripts cannot load a module, and switching the whole
|
||||
* worker to injectManifest to get TypeScript here would mean rewriting every
|
||||
* runtime caching rule by hand. The behaviour is pinned by
|
||||
* tests/unit/pwa/swPush.test.ts, which runs this file against a fake worker.
|
||||
*
|
||||
* What arrives is the shared pushPayloadSchema: { title, body, url?, tag? },
|
||||
* rendered on the server in the recipient's language, because a worker has no
|
||||
* translations of its own. Only chat messages carry a tag, so one trip's chat
|
||||
* collapses into one notification; everything else stands on its own.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FALLBACK_TITLE = 'TREK';
|
||||
var ICON = '/icons/icon-192x192.png';
|
||||
var SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions';
|
||||
|
||||
/** The payload as an object; a push that is not JSON still shows, with its text as the body. */
|
||||
function readPayload(data) {
|
||||
if (!data) return {};
|
||||
try {
|
||||
var parsed = data.json();
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (e) {
|
||||
try {
|
||||
return { body: data.text() };
|
||||
} catch (e2) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A path on this origin, or '/'. The url comes from the server, but a click
|
||||
* must never be able to open another site in the installed app's window.
|
||||
*/
|
||||
function sameOriginPath(value) {
|
||||
if (typeof value !== 'string' || !value) return '/';
|
||||
try {
|
||||
var resolved = new URL(value, self.location.origin);
|
||||
if (resolved.origin !== self.location.origin) return '/';
|
||||
return resolved.pathname + resolved.search + resolved.hash;
|
||||
} catch (e) {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('push', function (event) {
|
||||
var payload = readPayload(event.data);
|
||||
var options = {
|
||||
body: text(payload.body),
|
||||
icon: ICON,
|
||||
data: { url: sameOriginPath(payload.url) },
|
||||
};
|
||||
// A tag makes this notification replace an earlier one with the same tag
|
||||
// (the server only sets one for chat, per trip). renotify goes with it, or
|
||||
// the replacement would arrive without sound or vibration.
|
||||
if (text(payload.tag)) {
|
||||
options.tag = payload.tag;
|
||||
options.renotify = true;
|
||||
}
|
||||
// Always show something. Chrome requires a visible notification for every
|
||||
// push (userVisibleOnly), and WebKit revokes the subscription after a few
|
||||
// silent ones, even while a TREK window has the focus.
|
||||
event.waitUntil(self.registration.showNotification(text(payload.title) || FALLBACK_TITLE, options));
|
||||
});
|
||||
|
||||
/** Focus a TREK window and take it to `path`, or open one there when none is left. */
|
||||
function openPath(path) {
|
||||
var target = self.location.origin + path;
|
||||
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (windows) {
|
||||
var client = windows[0];
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (windows[i].focused) client = windows[i];
|
||||
}
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
return client
|
||||
.focus()
|
||||
.then(function (focused) {
|
||||
// navigate() is only allowed on a window this worker controls.
|
||||
var win = focused || client;
|
||||
return typeof win.navigate === 'function' ? win.navigate(target) : win;
|
||||
})
|
||||
.catch(function () {
|
||||
return self.clients.openWindow(target);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
event.notification.close();
|
||||
var data = event.notification.data || {};
|
||||
event.waitUntil(openPath(sameOriginPath(data.url)));
|
||||
});
|
||||
|
||||
/**
|
||||
* The browser replaced or dropped the subscription (Firefox does this now and
|
||||
* then). Subscribe again with the same server key and tell the server. The
|
||||
* same-origin fetch carries the session cookie; when the session is gone this
|
||||
* fails, and the re-sync after the next sign-in is the backstop.
|
||||
*/
|
||||
function resubscribe(event) {
|
||||
var old = event.oldSubscription;
|
||||
var key = old && old.options ? old.options.applicationServerKey : null;
|
||||
var next = event.newSubscription
|
||||
? Promise.resolve(event.newSubscription)
|
||||
: key
|
||||
? self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||
: Promise.resolve(null);
|
||||
return next
|
||||
.then(function (subscription) {
|
||||
if (!subscription) return undefined;
|
||||
return fetch(SUBSCRIPTIONS_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
}).then(function (response) {
|
||||
// The old endpoint is dead either way; forgetting it only tidies the
|
||||
// server's device list, and only once the new one is safely stored.
|
||||
if (!response.ok || !old || old.endpoint === subscription.endpoint) return undefined;
|
||||
return fetch(SUBSCRIPTIONS_URL, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: old.endpoint }),
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('[push] could not renew the subscription', err);
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', function (event) {
|
||||
event.waitUntil(resubscribe(event));
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* FE-PUSH-API-001 to FE-PUSH-API-005: the Web Push device endpoints.
|
||||
*
|
||||
* One line per endpoint and no logic, which is why a slip here is quiet: a
|
||||
* wrong path or a body under the wrong key does not throw, the server answers
|
||||
* 400 or 404 and push simply never arrives. So each call is driven through MSW
|
||||
* with its verb, path and body pinned, and a failure has to reach the caller
|
||||
* intact, because `webPush.ts` decides from it whether to roll a subscription
|
||||
* back.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw'
|
||||
import type { PushSubscriptionInput } from '@trek/shared'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { pushApi } from './push'
|
||||
|
||||
const BASE = '/api/notifications/push'
|
||||
|
||||
let seen: { method: string; url: string; body: unknown } | null = null
|
||||
|
||||
function record<T extends JsonBodyType>(data: T) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
const text = await request.text()
|
||||
seen = { method: request.method, url: new URL(request.url).pathname, body: text ? JSON.parse(text) : undefined }
|
||||
return HttpResponse.json(data)
|
||||
}
|
||||
}
|
||||
|
||||
const subscription: PushSubscriptionInput = {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: 'BPk', auth: 'aGk' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seen = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('pushApi', () => {
|
||||
it('FE-PUSH-API-001: reads the public key', async () => {
|
||||
server.use(http.get(`${BASE}/public-key`, record({ publicKey: 'BAECAwQ' })))
|
||||
await expect(pushApi.getPublicKey()).resolves.toEqual({ publicKey: 'BAECAwQ' })
|
||||
expect(seen).toMatchObject({ method: 'GET', url: `${BASE}/public-key` })
|
||||
})
|
||||
|
||||
it('FE-PUSH-API-002: registers the subscription under `subscription`', async () => {
|
||||
server.use(http.post(`${BASE}/subscriptions`, record({ success: true, devices: 2 })))
|
||||
await expect(pushApi.subscribe(subscription)).resolves.toEqual({ success: true, devices: 2 })
|
||||
expect(seen).toEqual({ method: 'POST', url: `${BASE}/subscriptions`, body: { subscription } })
|
||||
})
|
||||
|
||||
it('FE-PUSH-API-003: forgets a device by its endpoint, in a DELETE body', async () => {
|
||||
server.use(http.delete(`${BASE}/subscriptions`, record({ success: true })))
|
||||
await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: true })
|
||||
expect(seen).toEqual({ method: 'DELETE', url: `${BASE}/subscriptions`, body: { endpoint: subscription.endpoint } })
|
||||
})
|
||||
|
||||
it('FE-PUSH-API-004: a refusal reaches the caller instead of passing as success', async () => {
|
||||
server.use(http.post(`${BASE}/subscriptions`, () => HttpResponse.json({ error: 'Unsupported push service' }, { status: 400 })))
|
||||
await expect(pushApi.subscribe(subscription)).rejects.toMatchObject({ response: { status: 400 } })
|
||||
})
|
||||
|
||||
it('FE-PUSH-API-005: the DELETE answer is checked against the shared contract, like the subscribe answer', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.delete(`${BASE}/subscriptions`, record({ success: false })))
|
||||
await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: false })
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[api] push.unsubscribe: response did not match the @trek/shared schema',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import apiClient, { parseInDev } from './client'
|
||||
import {
|
||||
pushPublicKeyResultSchema,
|
||||
pushSubscribeResultSchema,
|
||||
pushUnsubscribeResultSchema,
|
||||
type PushPublicKeyResult,
|
||||
type PushSubscribeRequest,
|
||||
type PushSubscribeResult,
|
||||
type PushSubscriptionInput,
|
||||
type PushUnsubscribeRequest,
|
||||
type PushUnsubscribeResult,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The Web Push device endpoints, in their own file rather than in the
|
||||
* `client.ts` god module. Types come from the shared contracts, so a server
|
||||
* change the client has not caught up with is a typecheck failure rather than
|
||||
* a runtime surprise.
|
||||
*
|
||||
* These calls are online only by nature: a subscription is a device talking to
|
||||
* the server and to the browser's push service at the same moment, so there is
|
||||
* nothing to queue or to cache. `push/webPush.ts` is the only caller. The one
|
||||
* DELETE that does not come through here is the logout teardown's: it is a
|
||||
* plain fetch in webPush.ts, so a session that already ran out cannot set off
|
||||
* apiClient's 401 redirect in the middle of a logout.
|
||||
*/
|
||||
export const pushApi = {
|
||||
/** The VAPID public key a browser subscribes with. */
|
||||
getPublicKey: (): Promise<PushPublicKeyResult> =>
|
||||
apiClient
|
||||
.get('/notifications/push/public-key')
|
||||
.then(r => parseInDev(pushPublicKeyResultSchema, r.data, 'push.publicKey')),
|
||||
|
||||
/** Registers this device. Repeating it for the same endpoint only refreshes the row. */
|
||||
subscribe: (subscription: PushSubscriptionInput): Promise<PushSubscribeResult> => {
|
||||
const body: PushSubscribeRequest = { subscription }
|
||||
return apiClient
|
||||
.post('/notifications/push/subscriptions', body)
|
||||
.then(r => parseInDev(pushSubscribeResultSchema, r.data, 'push.subscribe'))
|
||||
},
|
||||
|
||||
/** Forgets one device, by the endpoint its subscription delivers to. */
|
||||
unsubscribe: (endpoint: string): Promise<PushUnsubscribeResult> => {
|
||||
const body: PushUnsubscribeRequest = { endpoint }
|
||||
return apiClient
|
||||
.delete('/notifications/push/subscriptions', { data: body })
|
||||
.then(r => parseInDev(pushUnsubscribeResultSchema, r.data, 'push.unsubscribe'))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* FE-ADMCHAN-001 to FE-ADMCHAN-008: the admin's channel switches, shared by
|
||||
* the desktop Notifications tab and the phone section.
|
||||
*
|
||||
* Every switch rewrites one comma-separated app setting. The two things that
|
||||
* must never happen are the ones a rebuild-from-booleans used to do: drop an id
|
||||
* this build does not know (a plugin channel, or a newer built-in), and leave
|
||||
* the screen claiming a state the server refused to store.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
BUILTIN_CHANNEL_ORDER,
|
||||
parseChannelList,
|
||||
serializeChannelList,
|
||||
SWITCH_ONLY_CHANNELS,
|
||||
toggleChannelList,
|
||||
useNotificationChannels,
|
||||
} from './useNotificationChannels'
|
||||
|
||||
const updateAppSettings = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../api/client', () => ({ authApi: { updateAppSettings } }))
|
||||
|
||||
const t = (key: string) => (key === 'common.error' ? 'Error' : key)
|
||||
|
||||
function mount(initial: Record<string, string>) {
|
||||
const toast = { error: vi.fn() }
|
||||
const hook = renderHook(() => {
|
||||
const [smtpValues, setSmtpValues] = useState(initial)
|
||||
return { smtpValues, channels: useNotificationChannels({ smtpValues, setSmtpValues, toast }, t) }
|
||||
})
|
||||
return { hook, toast }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateAppSettings.mockReset()
|
||||
updateAppSettings.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('the stored list', () => {
|
||||
it('FE-ADMCHAN-001: reads the plural setting, the legacy singular one, and none', () => {
|
||||
expect(parseChannelList({ notification_channels: 'email, push ,plugin-gotify' })).toEqual(['email', 'push', 'plugin-gotify'])
|
||||
expect(parseChannelList({ notification_channel: 'webhook' })).toEqual(['webhook'])
|
||||
expect(parseChannelList({ notification_channels: 'none' })).toEqual([])
|
||||
expect(parseChannelList({})).toEqual([])
|
||||
expect(parseChannelList({ notification_channels: 'email,' })).toEqual(['email'])
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-002: writes built-ins in their fixed order and keeps every other id', () => {
|
||||
expect(serializeChannelList(['plugin-gotify', 'push', 'email'])).toBe('email,push,plugin-gotify')
|
||||
expect(serializeChannelList([])).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-003: flips exactly one id', () => {
|
||||
expect(toggleChannelList(['email', 'plugin-gotify'], 'push')).toBe('email,push,plugin-gotify')
|
||||
expect(toggleChannelList(['email', 'push'], 'push')).toBe('email')
|
||||
expect(toggleChannelList(['push'], 'push')).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-004: Web Push is a built-in with its own switch card', () => {
|
||||
expect(BUILTIN_CHANNEL_ORDER).toEqual(['email', 'webhook', 'ntfy', 'push'])
|
||||
expect(SWITCH_ONLY_CHANNELS.map(c => c.id)).toEqual(['webhook', 'ntfy', 'push'])
|
||||
expect(SWITCH_ONLY_CHANNELS[2]).toEqual({
|
||||
id: 'push',
|
||||
titleKey: 'admin.notifications.webPushPanel.title',
|
||||
hintKey: 'admin.notifications.webPushPanel.hint',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNotificationChannels', () => {
|
||||
it('FE-ADMCHAN-005: answers which channels are on', () => {
|
||||
const { hook } = mount({ notification_channels: 'email,push' })
|
||||
expect(hook.result.current.channels.isActive('push')).toBe(true)
|
||||
expect(hook.result.current.channels.isActive('ntfy')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-006: switching one on saves the whole list and shows it at once', async () => {
|
||||
const { hook, toast } = mount({ notification_channels: 'email,plugin-gotify' })
|
||||
await act(() => hook.result.current.channels.toggle('push'))
|
||||
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push,plugin-gotify' })
|
||||
expect(hook.result.current.smtpValues.notification_channels).toBe('email,push,plugin-gotify')
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-007: a failed save puts the previous list back and says so', async () => {
|
||||
updateAppSettings.mockRejectedValueOnce(new Error('500'))
|
||||
const { hook, toast } = mount({ notification_channels: 'email' })
|
||||
await act(() => hook.result.current.channels.toggle('push'))
|
||||
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push' })
|
||||
expect(hook.result.current.smtpValues.notification_channels).toBe('email')
|
||||
expect(hook.result.current.channels.isActive('push')).toBe(false)
|
||||
expect(toast.error).toHaveBeenCalledWith('Error')
|
||||
})
|
||||
|
||||
it('FE-ADMCHAN-008: the rollback writes the normalised form, never an id less', async () => {
|
||||
updateAppSettings.mockRejectedValueOnce(new Error('500'))
|
||||
const { hook } = mount({ notification_channels: 'plugin-gotify, email' })
|
||||
await act(() => hook.result.current.channels.toggle('webhook'))
|
||||
expect(hook.result.current.smtpValues.notification_channels).toBe('email,plugin-gotify')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
|
||||
import { authApi } from '../../api/client'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* The admin's channel switches, shared by the desktop Notifications tab and its
|
||||
* phone twin. Both used to carry their own copy of a three-boolean
|
||||
* `setChannels`, and a fourth channel would have meant a fourth argument in
|
||||
* two places; SonarCloud's duplication budget has no room for that either.
|
||||
*
|
||||
* All switches write one app setting, `notification_channels`, a comma
|
||||
* separated list of channel ids. Ids this build does not know (a plugin
|
||||
* channel, or one a newer server added) must survive a toggle untouched, so
|
||||
* the list is edited, never rebuilt from the switches.
|
||||
*/
|
||||
|
||||
/** The built-in channels, in the order they are written to the list. Other ids follow as stored. */
|
||||
export const BUILTIN_CHANNEL_ORDER: readonly string[] = ['email', 'webhook', 'ntfy', WEB_PUSH_CHANNEL_ID]
|
||||
|
||||
/** The channels whose admin card is only a title, a hint and the switch (email carries the SMTP form). */
|
||||
export const SWITCH_ONLY_CHANNELS: ReadonlyArray<{ id: string; titleKey: string; hintKey: string }> = [
|
||||
{ id: 'webhook', titleKey: 'admin.notifications.webhookPanel.title', hintKey: 'admin.webhook.hint' },
|
||||
{ id: 'ntfy', titleKey: 'admin.notifications.ntfy', hintKey: 'admin.ntfy.hint' },
|
||||
{
|
||||
id: WEB_PUSH_CHANNEL_ID,
|
||||
titleKey: 'admin.notifications.webPushPanel.title',
|
||||
hintKey: 'admin.notifications.webPushPanel.hint',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* The active ids, from `notification_channels` or, on installs that predate
|
||||
* it, the singular `notification_channel`. 'none' is the stored empty list.
|
||||
*/
|
||||
export function parseChannelList(values: Record<string, string>): string[] {
|
||||
const raw = values.notification_channels ?? values.notification_channel ?? 'none'
|
||||
if (raw === 'none') return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** The stored form: built-ins in their fixed order, then every other id as it was, or 'none'. */
|
||||
export function serializeChannelList(active: readonly string[]): string {
|
||||
const builtins = BUILTIN_CHANNEL_ORDER.filter(id => active.includes(id))
|
||||
const others = active.filter(id => !BUILTIN_CHANNEL_ORDER.includes(id))
|
||||
return [...builtins, ...others].join(',') || 'none'
|
||||
}
|
||||
|
||||
/** The stored list with `id` switched the other way. */
|
||||
export function toggleChannelList(active: readonly string[], id: string): string {
|
||||
return serializeChannelList(active.includes(id) ? active.filter(c => c !== id) : [...active, id])
|
||||
}
|
||||
|
||||
interface ChannelSettingsHost {
|
||||
smtpValues: Record<string, string>
|
||||
setSmtpValues: Dispatch<SetStateAction<Record<string, string>>>
|
||||
toast: { error: (message: string) => void }
|
||||
}
|
||||
|
||||
export interface NotificationChannelSwitches {
|
||||
isActive: (id: string) => boolean
|
||||
/** Flips one channel and saves the list; puts the old list back and toasts when the save fails. */
|
||||
toggle: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useNotificationChannels(
|
||||
{ smtpValues, setSmtpValues, toast }: ChannelSettingsHost,
|
||||
t: TranslationFn,
|
||||
): NotificationChannelSwitches {
|
||||
const active = parseChannelList(smtpValues)
|
||||
|
||||
const toggle = async (id: string) => {
|
||||
const next = toggleChannelList(active, id)
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: next }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: next })
|
||||
} catch {
|
||||
const reverted = serializeChannelList(active)
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive: id => active.includes(id), toggle }
|
||||
}
|
||||
@@ -1185,3 +1185,47 @@ describe('NotificationsTab — webhook and channel-test failures', () => {
|
||||
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Web Push (#894)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The Push column comes from the server like every other; what this tab adds is
|
||||
// the per-device card, shown only while the admin has the channel switched on.
|
||||
// jsdom has no push APIs, so the real hook reads this "browser" as unable to
|
||||
// receive push, which is exactly what the card must then say.
|
||||
|
||||
const pushMatrix = (active: boolean) => ({
|
||||
preferences: { trip_invite: { inapp: true, push: true } },
|
||||
channels: [
|
||||
{ id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
|
||||
{ id: 'push', source: 'builtin', labelKey: 'settings.notificationPreferences.push', active, configured: false },
|
||||
],
|
||||
event_types: ['trip_invite'],
|
||||
implemented_combos: { trip_invite: ['inapp', 'push'] },
|
||||
});
|
||||
|
||||
describe('NotificationsTab: Web Push', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-PUSH-001: an active push channel gets its column and the device card', async () => {
|
||||
mockMatrix(pushMatrix(true));
|
||||
render(<NotificationsTab />);
|
||||
|
||||
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
|
||||
expect(screen.getByText('Push')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-PUSH-002: no card and no column while the admin has push off', async () => {
|
||||
mockMatrix(pushMatrix(false));
|
||||
render(<NotificationsTab />);
|
||||
|
||||
await screen.findByText(/in-app/i);
|
||||
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Push')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Section from './Section'
|
||||
import WebPushCard from './WebPushCard'
|
||||
|
||||
interface ChannelDescriptor {
|
||||
id: string
|
||||
@@ -323,6 +325,9 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Per device rather than per account: the column below picks the events,
|
||||
this card decides whether this browser is one of the places they go. */}
|
||||
{hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
|
||||
{pluginChannels.map(ch => (
|
||||
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* FE-COMP-WEBPUSHCARD-001 to FE-COMP-WEBPUSHCARD-004: the desktop device card.
|
||||
*
|
||||
* Markup only: `useWebPush` is mocked and has its own spec, so these cases pin
|
||||
* what each hook state puts on screen and that the buttons reach the hook.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import type { WebPushCardState } from '../../hooks/useWebPush'
|
||||
import WebPushCard from './WebPushCard'
|
||||
|
||||
const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState }))
|
||||
vi.mock('../../hooks/useWebPush', () => ({ useWebPush: () => hookState.current }))
|
||||
|
||||
function state(over: Partial<WebPushCardState> = {}): WebPushCardState {
|
||||
return {
|
||||
state: 'off',
|
||||
stateText: null,
|
||||
canSwitch: true,
|
||||
on: false,
|
||||
switchLabel: 'Turn on for this device',
|
||||
busy: false,
|
||||
canTest: false,
|
||||
toggle: vi.fn(),
|
||||
sendTest: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hookState.current = state()
|
||||
})
|
||||
|
||||
describe('WebPushCard', () => {
|
||||
it('FE-COMP-WEBPUSHCARD-001: explains itself and offers to switch on', async () => {
|
||||
render(<WebPushCard />)
|
||||
expect(screen.getByText('Push notifications on this device')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
const turnOn = screen.getByRole('button', { name: 'Turn on for this device' })
|
||||
expect(turnOn.className).toContain('bg-accent')
|
||||
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled()
|
||||
|
||||
await userEvent.click(turnOn)
|
||||
expect(hookState.current.toggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-WEBPUSHCARD-002: when on, says so, offers to switch off and to test', async () => {
|
||||
hookState.current = state({ state: 'on', on: true, stateText: 'On for this device', switchLabel: 'Turn off for this device', canTest: true })
|
||||
render(<WebPushCard />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('On for this device')
|
||||
expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-accent')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
|
||||
expect(hookState.current.sendTest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-WEBPUSHCARD-003: a state that cannot be switched shows its sentence and no buttons', () => {
|
||||
hookState.current = state({ state: 'ios-install', canSwitch: false, stateText: 'Add TREK to the Home Screen first.' })
|
||||
render(<WebPushCard />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Add TREK to the Home Screen first.')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WEBPUSHCARD-004: the switch is disabled while busy', () => {
|
||||
hookState.current = state({ busy: true })
|
||||
render(<WebPushCard />)
|
||||
expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useWebPush } from '../../hooks/useWebPush'
|
||||
|
||||
/**
|
||||
* The "Push notifications on this device" card of the Notifications tab. Only
|
||||
* markup: the logic is `useWebPush`, shared with the phone's `MWebPushCard`.
|
||||
* The tab renders it while the admin has the push channel switched on.
|
||||
*/
|
||||
export default function WebPushCard(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const push = useWebPush()
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-edge bg-surface-secondary p-3">
|
||||
<p className="mb-1 text-caption font-semibold text-content-secondary">{t('settings.webPush.title')}</p>
|
||||
<p className="mb-2 text-caption text-content-faint">{t('settings.webPush.hint')}</p>
|
||||
{push.stateText && (
|
||||
<p role="status" className="mb-2 text-caption text-content-secondary">
|
||||
{push.stateText}
|
||||
</p>
|
||||
)}
|
||||
{push.canSwitch && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.toggle}
|
||||
disabled={push.busy}
|
||||
className={`rounded-md px-3 py-1.5 text-caption disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
push.on ? 'border border-edge bg-transparent text-content-secondary' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{push.switchLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.sendTest}
|
||||
disabled={!push.canTest}
|
||||
className="rounded-md border border-edge bg-transparent px-3 py-1.5 text-caption text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const settingsTabContexts: HelpContext[] = [
|
||||
tab('settings-display', 'sliders', 3, [{ slug: GENERAL }]),
|
||||
tab('settings-appearance', 'palette', 4, [{ slug: APPEARANCE }]),
|
||||
tab('settings-map', 'map', 3, [{ slug: MAP }]),
|
||||
tab('settings-notifications', 'bell', 3, [{ slug: NOTIFICATIONS }]),
|
||||
tab('settings-notifications', 'bell', 4, [{ slug: NOTIFICATIONS }]),
|
||||
tab('settings-integrations', 'plug', 4, [{ slug: SETTINGS, anchor: 'integrations-tab' }, { slug: 'MCP-Setup' }, { slug: 'Photo-Providers' }]),
|
||||
tab('settings-offline', 'cloudOff', 4, [{ slug: OFFLINE }]),
|
||||
tab('settings-account', 'user', 4, [{ slug: SETTINGS, anchor: 'account-tab-summary' }, { slug: 'Two-Factor-Authentication' }, { slug: 'Passkeys' }]),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
|
||||
import { notificationsApi } from '../api/client'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
disableWebPush,
|
||||
enableWebPush,
|
||||
getWebPushSupport,
|
||||
loadPushPublicKey,
|
||||
readWebPushDeviceState,
|
||||
waitForPushRegistration,
|
||||
WebPushError,
|
||||
type WebPushDeviceState,
|
||||
} from '../push/webPush'
|
||||
|
||||
/**
|
||||
* Everything the "Push notifications on this device" card does, in one hook.
|
||||
*
|
||||
* Shared by the desktop Settings tab and its phone twin on purpose: the two
|
||||
* shells render different markup around identical logic, and SonarCloud's 3%
|
||||
* duplication budget on new code has no room for a second copy
|
||||
* (`useDawarichConnection` is the same pattern for the same reason).
|
||||
*
|
||||
* The browser is the only record of whether this device is subscribed, so the
|
||||
* hook keeps no copy of its own beyond the last reading, and reads again after
|
||||
* every switch and whenever the page comes back into view (which is how a user
|
||||
* returns from unblocking notifications in the browser's site settings).
|
||||
*/
|
||||
export interface WebPushCardState {
|
||||
/** Null until the first look at this device has finished. */
|
||||
state: WebPushDeviceState | null
|
||||
/** The sentence for that state, already translated; null when the hint says it all. */
|
||||
stateText: string | null
|
||||
/** Push is possible here and not blocked, so the card offers the switch. */
|
||||
canSwitch: boolean
|
||||
on: boolean
|
||||
switchLabel: string
|
||||
busy: boolean
|
||||
canTest: boolean
|
||||
toggle: () => void
|
||||
sendTest: () => void
|
||||
}
|
||||
|
||||
const STATE_TEXT_KEYS: Partial<Record<WebPushDeviceState, string>> = {
|
||||
unsupported: 'settings.webPush.unsupported',
|
||||
insecure: 'settings.webPush.insecure',
|
||||
'ios-install': 'settings.webPush.iosInstall',
|
||||
denied: 'settings.webPush.denied',
|
||||
on: 'settings.webPush.enabled',
|
||||
}
|
||||
|
||||
export function useWebPush(): WebPushCardState {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [state, setState] = useState<WebPushDeviceState | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const publicKey = useRef<string | null>(null)
|
||||
const registration = useRef<ServiceWorkerRegistration | null>(null)
|
||||
const mounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
let cancelled = false
|
||||
const look = () => {
|
||||
void readWebPushDeviceState().then(next => {
|
||||
if (!cancelled) setState(next)
|
||||
})
|
||||
}
|
||||
look()
|
||||
if (getWebPushSupport().supported) {
|
||||
// Fetched before any click. WebKit honours the permission prompt and
|
||||
// subscribe() only inside the user's gesture, and awaiting either of
|
||||
// these in the click handler would spend it.
|
||||
void loadPushPublicKey().then(key => {
|
||||
if (!cancelled) publicKey.current = key
|
||||
})
|
||||
void waitForPushRegistration().then(reg => {
|
||||
if (!cancelled) registration.current = reg
|
||||
})
|
||||
}
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') look()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
cancelled = true
|
||||
mounted.current = false
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const settle = async () => {
|
||||
const next = await readWebPushDeviceState()
|
||||
if (!mounted.current) return
|
||||
setState(next)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
const switchOn = (): Promise<void> => {
|
||||
const key = publicKey.current
|
||||
if (key) return enableWebPush(key, registration.current)
|
||||
// The key could not be fetched ahead of time. Fetching it now may cost the
|
||||
// gesture on WebKit, but it is the only way left to try.
|
||||
return loadPushPublicKey().then(late => {
|
||||
if (!late) throw new WebPushError('failed', 'no public key')
|
||||
return enableWebPush(late, registration.current)
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (busy || (state !== 'on' && state !== 'off')) return
|
||||
const turningOn = state === 'off'
|
||||
setBusy(true)
|
||||
const action = turningOn ? switchOn() : disableWebPush()
|
||||
action
|
||||
.catch(err => {
|
||||
if (!mounted.current) return
|
||||
// A block or a dismissed prompt is the user's answer, and the card
|
||||
// shows the first on its own; only a real failure deserves a toast.
|
||||
if (err instanceof WebPushError && (err.code === 'denied' || err.code === 'dismissed')) return
|
||||
console.warn('[push] switching this device failed', err)
|
||||
toast.error(turningOn ? t('settings.webPush.failed') : t('common.error'))
|
||||
})
|
||||
.finally(() => void settle())
|
||||
}
|
||||
|
||||
const sendTest = () => {
|
||||
if (testing) return
|
||||
setTesting(true)
|
||||
notificationsApi
|
||||
.testChannel(WEB_PUSH_CHANNEL_ID)
|
||||
.then(result => {
|
||||
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'))
|
||||
else toast.error(result.error || t('settings.notificationPreferences.testFailed'))
|
||||
})
|
||||
.catch(() => toast.error(t('settings.notificationPreferences.testFailed')))
|
||||
.finally(() => {
|
||||
if (mounted.current) setTesting(false)
|
||||
})
|
||||
}
|
||||
|
||||
const textKey = state ? STATE_TEXT_KEYS[state] : undefined
|
||||
return {
|
||||
state,
|
||||
stateText: textKey ? t(textKey) : null,
|
||||
canSwitch: state === 'on' || state === 'off',
|
||||
on: state === 'on',
|
||||
switchLabel: state === 'on' ? t('settings.webPush.disable') : t('settings.webPush.enable'),
|
||||
busy,
|
||||
canTest: state === 'on' && !testing && !busy,
|
||||
toggle,
|
||||
sendTest,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authApi, notificationsApi } from '../../../api/client'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../../components/Admin/useNotificationChannels'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MAdminNotifyMatrix from './MAdminNotifyMatrix'
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
|
||||
@@ -18,39 +19,18 @@ const SMTP_FIELDS = [
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
]
|
||||
|
||||
// Notifications section: channel toggles (email/webhook/ntfy/in-app), SMTP
|
||||
// credentials, trip reminders, admin webhook + ntfy targets and the per-event
|
||||
// preference matrix — the desktop notifications tab in mobile cards.
|
||||
// Notifications section: channel toggles (email/webhook/ntfy/web push/in-app),
|
||||
// SMTP credentials, trip reminders, admin webhook + ntfy targets and the
|
||||
// per-event preference matrix: the desktop notifications tab in mobile cards.
|
||||
// The channel switches come from useNotificationChannels, shared with desktop.
|
||||
export default function MAdminNotificationsSection({ admin, t }: MAdminNotificationsSectionProps) {
|
||||
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled } = admin
|
||||
|
||||
// Derive active channels from notification_channels (plural) with fallback
|
||||
// to notification_channel (singular) for existing installs.
|
||||
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const channels = useNotificationChannels(admin, t)
|
||||
const emailActive = channels.isActive('email')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
const smtpConfigured = !!smtpValues.smtp_host?.trim()
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
// Preserve channel ids this toggle doesn't know about instead of
|
||||
// rebuilding the CSV from just these three booleans.
|
||||
const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues((prev) => ({ ...prev, notification_channels: chans }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: chans })
|
||||
} catch {
|
||||
const reverted =
|
||||
[emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') ||
|
||||
'none'
|
||||
setSmtpValues((prev) => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveSmtp = async () => {
|
||||
// Saves credentials only — channel activation is auto-saved by the toggle.
|
||||
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
@@ -179,7 +159,7 @@ export default function MAdminNotificationsSection({ admin, t }: MAdminNotificat
|
||||
<MToggle
|
||||
checked={emailActive}
|
||||
ariaLabel={t('admin.notifications.emailPanel.title')}
|
||||
onChange={() => setChannels(!emailActive, webhookActive, ntfyActive)}
|
||||
onChange={() => channels.toggle('email')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -219,35 +199,22 @@ export default function MAdminNotificationsSection({ admin, t }: MAdminNotificat
|
||||
</div>
|
||||
</MAdminCard>
|
||||
|
||||
{/* Webhook */}
|
||||
<MAdminCard>
|
||||
<MAdminCardHead
|
||||
title={t('admin.notifications.webhookPanel.title')}
|
||||
hint={t('admin.webhook.hint')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={webhookActive}
|
||||
ariaLabel={t('admin.notifications.webhookPanel.title')}
|
||||
onChange={() => setChannels(emailActive, !webhookActive, ntfyActive)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MAdminCard>
|
||||
|
||||
{/* Ntfy */}
|
||||
<MAdminCard>
|
||||
<MAdminCardHead
|
||||
title={t('admin.notifications.ntfy')}
|
||||
hint={t('admin.ntfy.hint')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={ntfyActive}
|
||||
ariaLabel={t('admin.notifications.ntfy')}
|
||||
onChange={() => setChannels(emailActive, webhookActive, !ntfyActive)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MAdminCard>
|
||||
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */}
|
||||
{SWITCH_ONLY_CHANNELS.map((ch) => (
|
||||
<MAdminCard key={ch.id}>
|
||||
<MAdminCardHead
|
||||
title={t(ch.titleKey)}
|
||||
hint={t(ch.hintKey)}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={channels.isActive(ch.id)}
|
||||
ariaLabel={t(ch.titleKey)}
|
||||
onChange={() => channels.toggle(ch.id)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MAdminCard>
|
||||
))}
|
||||
|
||||
{/* In-App (always on) */}
|
||||
<MAdminCard>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bell, Link2, Send } from 'lucide-react'
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../../api/client'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
|
||||
import MChip from '../../components/MChip'
|
||||
import MWebPushCard from './MWebPushCard'
|
||||
|
||||
interface ChannelDescriptor {
|
||||
id: string
|
||||
@@ -52,8 +54,8 @@ const MASKED = '••••••••'
|
||||
|
||||
/**
|
||||
* "Notifications" section — NotificationsTab parity: webhook + ntfy channel
|
||||
* credentials, plugin channels and the event/channel preference matrix,
|
||||
* rendered as chip rows instead of the desktop grid.
|
||||
* credentials, push on this device, plugin channels and the event/channel
|
||||
* preference matrix, rendered as chip rows instead of the desktop grid.
|
||||
*/
|
||||
export default function MSettingsNotifications() {
|
||||
const { t } = useTranslation()
|
||||
@@ -282,6 +284,8 @@ export default function MSettingsNotifications() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChannel(WEB_PUSH_CHANNEL_ID) && <MWebPushCard />}
|
||||
|
||||
{pluginChannels.map((ch) => (
|
||||
<div key={ch.id} className="mb-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
|
||||
<MSetEyebrow className="mb-1">{channelLabel(ch, t)}</MSetEyebrow>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BellRing, BellOff, Send } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useWebPush } from '../../../hooks/useWebPush'
|
||||
import { MSetButton, MSetEyebrow, MSetHint } from './MSettingsUi'
|
||||
|
||||
/**
|
||||
* Phone twin of the desktop `WebPushCard`: markup only, over the same
|
||||
* `useWebPush` hook. MSettingsNotifications renders it while the admin has the
|
||||
* push channel switched on.
|
||||
*/
|
||||
export default function MWebPushCard() {
|
||||
const { t } = useTranslation()
|
||||
const push = useWebPush()
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
|
||||
<MSetEyebrow className="mb-1">{t('settings.webPush.title')}</MSetEyebrow>
|
||||
<MSetHint className="mb-2 mt-0">{t('settings.webPush.hint')}</MSetHint>
|
||||
{push.stateText && (
|
||||
<p role="status" className="mb-2 text-[0.75rem] font-semibold text-m-ink">
|
||||
{push.stateText}
|
||||
</p>
|
||||
)}
|
||||
{push.canSwitch && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MSetButton variant={push.on ? 'ghost' : 'primary'} onClick={push.toggle} disabled={push.busy}>
|
||||
{push.on ? <BellOff size={13} /> : <BellRing size={13} />}
|
||||
{push.switchLabel}
|
||||
</MSetButton>
|
||||
<MSetButton variant="ghost" onClick={push.sendTest} disabled={!push.canTest}>
|
||||
<Send size={13} />
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1441,7 +1441,8 @@ describe('AdminPage', () => {
|
||||
|
||||
// Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook'
|
||||
const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i });
|
||||
const webhookCard = webhookHeading.closest<HTMLElement>('.bg-white');
|
||||
// The channel switch cards are drawn with theme tokens, so the card is found by its shape.
|
||||
const webhookCard = webhookHeading.closest<HTMLElement>('.rounded-xl');
|
||||
// Find the toggle button in webhook card
|
||||
const webhookToggle = within(webhookCard!).getByRole('button');
|
||||
fireEvent.click(webhookToggle);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMNOT-001 to FE-ADMNOT-043
|
||||
// FE-ADMNOT-001 to FE-ADMNOT-046
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -571,4 +571,47 @@ describe('AdminNotificationsTab', () => {
|
||||
expect(screen.queryByRole('heading', { name: 'Admin Webhook' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Admin Ntfy' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMNOT-044: Web Push has its own card after Ntfy, before In-App', () => {
|
||||
renderTab({ smtpValues: { notification_channels: 'email' } });
|
||||
|
||||
const headings = screen.getAllByRole('heading').map(h => h.textContent);
|
||||
expect(headings.indexOf('Web Push')).toBe(headings.indexOf('Ntfy') + 1);
|
||||
expect(headings.indexOf('In-App')).toBe(headings.indexOf('Web Push') + 1);
|
||||
expect(within(card('Web Push')).getByText(/receive notifications on their phones/)).toBeInTheDocument();
|
||||
expect(within(card('Web Push')).getByRole('button', { name: 'Web Push' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-ADMNOT-045: switching Web Push on adds push to the list and keeps the rest', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
const admin = renderTab({ smtpValues: { notification_channels: 'email,plugin-gotify' } });
|
||||
|
||||
fireEvent.click(within(card('Web Push')).getByRole('button', { name: 'Web Push' }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ notification_channels: 'email,push,plugin-gotify' }));
|
||||
expect(replaySmtp(admin.setSmtpValues, 0).notification_channels).toBe('email,push,plugin-gotify');
|
||||
});
|
||||
|
||||
it('FE-ADMNOT-046: an active push channel shows its switch on, and switching it off keeps email', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
renderTab({ smtpValues: { notification_channels: 'email,push' } });
|
||||
|
||||
const toggle = within(card('Web Push')).getByRole('button', { name: 'Web Push' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ notification_channels: 'email' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,43 +4,39 @@ import { Save } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
import AdminNotificationsPanel from './AdminNotificationsPanel'
|
||||
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels'
|
||||
|
||||
interface AdminNotificationsTabProps {
|
||||
admin: ReturnType<typeof useAdmin>
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
// "Notifications" admin tab: email/webhook/ntfy channel toggles, SMTP credentials,
|
||||
// trip reminders, admin webhook + ntfy targets, and the per-event preference matrix.
|
||||
// Derives channel state from smtpValues exactly as the original inline IIFE did.
|
||||
/** The on/off switch at the right of a channel card. */
|
||||
function ChannelSwitch({ on, label, onToggle }: { on: boolean; label: string; onToggle: () => void }): React.ReactElement {
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={on}
|
||||
aria-label={label}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${on ? 'bg-content' : 'bg-edge'}`}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-surface-card transition-transform duration-200"
|
||||
style={{ transform: on ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// "Notifications" admin tab: email/webhook/ntfy/web push channel toggles, SMTP
|
||||
// credentials, trip reminders, admin webhook + ntfy targets, and the per-event
|
||||
// preference matrix. The channel switches come from useNotificationChannels,
|
||||
// which the phone section shares.
|
||||
export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement {
|
||||
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin
|
||||
|
||||
// Derive active channels from smtpValues.notification_channels (plural)
|
||||
// with fallback to notification_channel (singular) for existing installs
|
||||
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const channels = useNotificationChannels(admin, t)
|
||||
const emailActive = channels.isActive('email')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
// Preserve any id this toggle doesn't know about instead of rebuilding the CSV from
|
||||
// just these three booleans — that used to silently DROP anything else stored here.
|
||||
const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: chans })
|
||||
} catch {
|
||||
// Revert state on failure
|
||||
const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
|
||||
const saveNotifications = async () => {
|
||||
// Saves credentials only — channel activation is auto-saved by the toggle
|
||||
@@ -64,7 +60,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
pairs cards row by row and leaves a hole under the shorter one, and the SMTP
|
||||
card is taller than all the toggle rows together. Grouped by audience, not by
|
||||
height — the channels a user can receive on the left, everything the operator
|
||||
sends or receives themselves on the right. The three channel switches stay
|
||||
sends or receives themselves on the right. The channel switches stay
|
||||
together because they all write the same notification_channels list.
|
||||
On a managed install the two admin-target cards are gone and the right column
|
||||
is the matrix alone — still a column, so the grid keeps working. */}
|
||||
@@ -81,13 +77,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.emailPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setChannels(!emailActive, webhookActive, ntfyActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${emailActive ? 'bg-content' : 'bg-edge'}`}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
<ChannelSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />
|
||||
</div>
|
||||
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{smtpLoaded && [
|
||||
@@ -152,39 +142,18 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
</div>
|
||||
|
||||
</>)}
|
||||
{/* Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */}
|
||||
{SWITCH_ONLY_CHANNELS.map(ch => (
|
||||
<div key={ch.id} className="bg-surface-card rounded-xl border border-edge overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t(ch.titleKey)}</h2>
|
||||
<p className="text-caption text-content-faint mt-1">{t(ch.hintKey)}</p>
|
||||
</div>
|
||||
<ChannelSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setChannels(emailActive, !webhookActive, ntfyActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${webhookActive ? 'bg-content' : 'bg-edge'}`}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ntfy Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.ntfy')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setChannels(emailActive, webhookActive, !ntfyActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${ntfyActive ? 'bg-content' : 'bg-edge'}`}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: ntfyActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* In-App Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* FE-PUSH-001 to FE-PUSH-044: this device's Web Push, against a fake browser.
|
||||
*
|
||||
* jsdom has no service worker, no PushManager and no Notification, so every
|
||||
* case installs the three by hand. What is worth pinning here:
|
||||
*
|
||||
* 1. **Support is a reason, not a yes/no.** An insecure origin, an iPhone in
|
||||
* a Safari tab and a browser without push each need a different sentence,
|
||||
* because each has a different fix.
|
||||
* 2. **The gesture.** WebKit honours the permission prompt only inside the
|
||||
* click, so `enableWebPush` must ask before its first await.
|
||||
* 3. **The browser is the record.** A subscription the server refused is
|
||||
* rolled back, switching off unsubscribes even when the server cannot be
|
||||
* told, and logout forgets the device before the cookie goes, bounded so it
|
||||
* can never hang, and with a plain fetch so a 401 cannot redirect.
|
||||
* 4. **The re-sync after sign-in** repeats what the device holds, replaces a
|
||||
* subscription made with an old server key, and never calls update().
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPublicKey: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/push', () => ({ pushApi: api }))
|
||||
|
||||
const network = vi.hoisted(() => ({ offline: false }))
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }))
|
||||
|
||||
import {
|
||||
base64UrlToUint8Array,
|
||||
currentPushSubscription,
|
||||
disableWebPush,
|
||||
enableWebPush,
|
||||
forgetPushDeviceOnLogout,
|
||||
getPushPermission,
|
||||
getWebPushSupport,
|
||||
loadPushPublicKey,
|
||||
readWebPushDeviceState,
|
||||
resyncPushSubscription,
|
||||
toSubscriptionInput,
|
||||
waitForPushRegistration,
|
||||
WebPushError,
|
||||
} from './webPush'
|
||||
|
||||
/** A P-256 point is 65 bytes; these tests only need two keys that differ. */
|
||||
const SERVER_KEY = 'BAECAwQ'
|
||||
const OTHER_KEY = 'BAUGBwg'
|
||||
|
||||
class FakeSubscription {
|
||||
readonly options: { applicationServerKey: ArrayBuffer | null; userVisibleOnly: boolean }
|
||||
unsubscribe = vi.fn(async () => true)
|
||||
|
||||
constructor(
|
||||
readonly endpoint: string,
|
||||
key: Uint8Array | null,
|
||||
) {
|
||||
this.options = { applicationServerKey: key ? new Uint8Array(key).buffer : null, userVisibleOnly: true }
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { endpoint: this.endpoint, expirationTime: null, keys: { p256dh: 'p256-' + this.endpoint, auth: 'auth' } }
|
||||
}
|
||||
}
|
||||
|
||||
class FakePushManager {
|
||||
current: FakeSubscription | null = null
|
||||
created = 0
|
||||
subscribe = vi.fn(async (options: { userVisibleOnly?: boolean; applicationServerKey: Uint8Array }) => {
|
||||
this.created++
|
||||
this.current = new FakeSubscription(`https://push.example/sub-${this.created}`, options.applicationServerKey)
|
||||
return this.current
|
||||
})
|
||||
getSubscription = vi.fn(async () => this.current)
|
||||
}
|
||||
|
||||
interface Browser {
|
||||
pushManager: FakePushManager
|
||||
registration: { pushManager: FakePushManager; update: ReturnType<typeof vi.fn> }
|
||||
container: { getRegistration: ReturnType<typeof vi.fn>; ready: Promise<unknown> }
|
||||
notification: { permission: NotificationPermission; requestPermission: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function installBrowser(opts: { permission?: NotificationPermission; answer?: NotificationPermission } = {}): Browser {
|
||||
const pushManager = new FakePushManager()
|
||||
const registration = { pushManager, update: vi.fn() }
|
||||
const container = { getRegistration: vi.fn(async () => registration), ready: Promise.resolve(registration) }
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const notification = {
|
||||
permission: opts.permission ?? 'default',
|
||||
requestPermission: vi.fn(async () => {
|
||||
notification.permission = opts.answer ?? 'granted'
|
||||
return notification.permission
|
||||
}),
|
||||
}
|
||||
vi.stubGlobal('Notification', notification)
|
||||
vi.stubGlobal('PushManager', function PushManager() {})
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
return { pushManager, registration, container, notification }
|
||||
}
|
||||
|
||||
function subscribed(browser: Browser, key = SERVER_KEY, endpoint = 'https://push.example/existing') {
|
||||
browser.pushManager.current = new FakeSubscription(endpoint, base64UrlToUint8Array(key))
|
||||
return browser.pushManager.current
|
||||
}
|
||||
|
||||
/** Own properties shadow jsdom's prototype getters; afterEach deletes them again. */
|
||||
function setUserAgent(ua: string, touchPoints = 0) {
|
||||
Object.defineProperty(navigator, 'userAgent', { configurable: true, get: () => ua })
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, get: () => touchPoints })
|
||||
}
|
||||
|
||||
const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile Safari/604.1'
|
||||
const IPAD_DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15'
|
||||
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
network.offline = false
|
||||
api.getPublicKey.mockResolvedValue({ publicKey: SERVER_KEY })
|
||||
api.subscribe.mockResolvedValue({ success: true, devices: 1 })
|
||||
api.unsubscribe.mockResolvedValue({ success: true })
|
||||
setUserAgent(CHROME)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
|
||||
delete (navigator as unknown as { standalone?: unknown }).standalone
|
||||
delete (navigator as unknown as { userAgent?: unknown }).userAgent
|
||||
delete (navigator as unknown as { maxTouchPoints?: unknown }).maxTouchPoints
|
||||
})
|
||||
|
||||
describe('getWebPushSupport', () => {
|
||||
it('FE-PUSH-001: a secure browser with a worker, PushManager and Notification is supported', () => {
|
||||
installBrowser()
|
||||
expect(getWebPushSupport()).toEqual({ supported: true, reason: null })
|
||||
})
|
||||
|
||||
it('FE-PUSH-002: an insecure origin says so before anything else', () => {
|
||||
installBrowser()
|
||||
vi.stubGlobal('isSecureContext', false)
|
||||
setUserAgent(IPHONE)
|
||||
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'insecure' })
|
||||
})
|
||||
|
||||
it('FE-PUSH-003: an iPhone in a Safari tab is told to add TREK to the Home Screen', () => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
setUserAgent(IPHONE, 5)
|
||||
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'ios-install' })
|
||||
})
|
||||
|
||||
it('FE-PUSH-004: an iPad asking for the desktop site counts as iOS too', () => {
|
||||
installBrowser()
|
||||
setUserAgent(IPAD_DESKTOP, 5)
|
||||
expect(getWebPushSupport().reason).toBe('ios-install')
|
||||
})
|
||||
|
||||
it('FE-PUSH-005: a real Mac (no touch screen) is not mistaken for an iPad', () => {
|
||||
installBrowser()
|
||||
setUserAgent(IPAD_DESKTOP, 0)
|
||||
expect(getWebPushSupport().supported).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-PUSH-006: the installed iOS app is supported', () => {
|
||||
installBrowser()
|
||||
setUserAgent(IPHONE, 5)
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
expect(getWebPushSupport().supported).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-PUSH-007: the installed app on an iOS without push still reads as unsupported', () => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
setUserAgent(IPHONE, 5)
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' })
|
||||
})
|
||||
|
||||
it('FE-PUSH-008: a desktop browser without the APIs is unsupported', () => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('FE-PUSH-009: the permission falls back to default where Notification is missing', () => {
|
||||
expect(getPushPermission()).toBe('default')
|
||||
installBrowser({ permission: 'denied' })
|
||||
expect(getPushPermission()).toBe('denied')
|
||||
})
|
||||
|
||||
it('FE-PUSH-010: base64url keys decode to their raw bytes, padding or not', () => {
|
||||
expect(Array.from(base64UrlToUint8Array('AQID'))).toEqual([1, 2, 3])
|
||||
expect(Array.from(base64UrlToUint8Array('-_8'))).toEqual([251, 255])
|
||||
expect(Array.from(base64UrlToUint8Array('-_8='))).toEqual([251, 255])
|
||||
})
|
||||
|
||||
it('FE-PUSH-011: a subscription becomes the shared contract shape', () => {
|
||||
const sub = new FakeSubscription('https://push.example/a', null)
|
||||
expect(toSubscriptionInput(sub as unknown as PushSubscription)).toEqual({
|
||||
endpoint: 'https://push.example/a',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: 'p256-https://push.example/a', auth: 'auth' },
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PUSH-012: a subscription without keys is refused rather than sent half', () => {
|
||||
const broken = { toJSON: () => ({ endpoint: 'https://push.example/a', keys: {} }) }
|
||||
expect(() => toSubscriptionInput(broken as unknown as PushSubscription)).toThrow(WebPushError)
|
||||
})
|
||||
|
||||
it('FE-PUSH-013: the public key loads, and a failure is null rather than a rejection', async () => {
|
||||
await expect(loadPushPublicKey()).resolves.toBe(SERVER_KEY)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
api.getPublicKey.mockRejectedValueOnce(new Error('offline'))
|
||||
await expect(loadPushPublicKey()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PUSH-014: there is no subscription to read without the APIs or a registration', async () => {
|
||||
await expect(currentPushSubscription()).resolves.toBeNull()
|
||||
const browser = installBrowser()
|
||||
browser.container.getRegistration.mockResolvedValueOnce(undefined)
|
||||
await expect(currentPushSubscription()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForPushRegistration', () => {
|
||||
it('FE-PUSH-015: hands over the active registration', async () => {
|
||||
const browser = installBrowser()
|
||||
await expect(waitForPushRegistration()).resolves.toBe(browser.registration)
|
||||
})
|
||||
|
||||
it('FE-PUSH-016: gives up after the timeout on a page that never gets a worker', async () => {
|
||||
const browser = installBrowser()
|
||||
browser.container.ready = new Promise(() => {})
|
||||
vi.useFakeTimers()
|
||||
const waiting = waitForPushRegistration(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(waiting).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PUSH-017: is null straight away where the APIs are missing', async () => {
|
||||
await expect(waitForPushRegistration()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWebPushDeviceState', () => {
|
||||
it('FE-PUSH-018: an unsupported browser reports its reason', async () => {
|
||||
vi.stubGlobal('isSecureContext', false)
|
||||
await expect(readWebPushDeviceState()).resolves.toBe('insecure')
|
||||
})
|
||||
|
||||
it('FE-PUSH-019: a blocked permission wins over an old subscription', async () => {
|
||||
const browser = installBrowser({ permission: 'denied' })
|
||||
subscribed(browser)
|
||||
await expect(readWebPushDeviceState()).resolves.toBe('denied')
|
||||
})
|
||||
|
||||
it('FE-PUSH-020: on with a subscription, off without', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
await expect(readWebPushDeviceState()).resolves.toBe('off')
|
||||
subscribed(browser)
|
||||
await expect(readWebPushDeviceState()).resolves.toBe('on')
|
||||
})
|
||||
|
||||
it('FE-PUSH-021: a registration that cannot be read counts as off, not as an error', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
browser.container.getRegistration.mockRejectedValueOnce(new Error('site data blocked'))
|
||||
await expect(readWebPushDeviceState()).resolves.toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableWebPush', () => {
|
||||
it('FE-PUSH-022: asks for permission before its first await, so the gesture still counts', async () => {
|
||||
const browser = installBrowser()
|
||||
const done = enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
|
||||
expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
await done
|
||||
})
|
||||
|
||||
it('FE-PUSH-023: subscribes visibly with the server key and registers the device', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
|
||||
|
||||
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||
const options = browser.pushManager.subscribe.mock.calls[0][0]
|
||||
expect(options.userVisibleOnly).toBe(true)
|
||||
expect(Array.from(options.applicationServerKey)).toEqual(Array.from(base64UrlToUint8Array(SERVER_KEY)))
|
||||
expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(browser.pushManager.current as unknown as PushSubscription))
|
||||
})
|
||||
|
||||
it('FE-PUSH-024: waits for the worker when no registration was handed over', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
await enableWebPush(SERVER_KEY)
|
||||
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PUSH-025: a block is reported as denied and nothing is subscribed', async () => {
|
||||
const browser = installBrowser({ answer: 'denied' })
|
||||
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'denied' })
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-026: a prompt closed without an answer is dismissed, not a failure', async () => {
|
||||
const browser = installBrowser({ answer: 'default' })
|
||||
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'dismissed' })
|
||||
})
|
||||
|
||||
it('FE-PUSH-027: without a worker it fails instead of spinning', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
browser.container.ready = new Promise(() => {})
|
||||
vi.useFakeTimers()
|
||||
const done = enableWebPush(SERVER_KEY).catch(err => err)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(await done).toMatchObject({ code: 'failed' })
|
||||
})
|
||||
|
||||
it('FE-PUSH-028: a subscription left over from an older server key is replaced', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale')
|
||||
browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('key mismatch', 'InvalidStateError'))
|
||||
|
||||
await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
|
||||
|
||||
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale')
|
||||
expect(stale.unsubscribe).toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(api.subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PUSH-029: any other subscribe failure is final', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('push service down', 'AbortError'))
|
||||
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' })
|
||||
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
|
||||
expect(api.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-030: a subscription the server refused is rolled back in the browser', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
api.subscribe.mockRejectedValueOnce(new Error('400'))
|
||||
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' })
|
||||
expect(browser.pushManager.current?.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disableWebPush', () => {
|
||||
it('FE-PUSH-031: forgets the device on the server, then unsubscribes the browser', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
const order: string[] = []
|
||||
api.unsubscribe.mockImplementationOnce(async () => {
|
||||
order.push('server')
|
||||
return { success: true }
|
||||
})
|
||||
sub.unsubscribe.mockImplementationOnce(async () => {
|
||||
order.push('browser')
|
||||
return true
|
||||
})
|
||||
|
||||
await disableWebPush()
|
||||
|
||||
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/existing')
|
||||
expect(order).toEqual(['server', 'browser'])
|
||||
})
|
||||
|
||||
it('FE-PUSH-032: still unsubscribes when the server cannot be told', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
api.unsubscribe.mockRejectedValueOnce(new Error('offline'))
|
||||
await disableWebPush()
|
||||
expect(sub.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-033: does nothing without a subscription, and reports a browser that refuses', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
await disableWebPush()
|
||||
expect(api.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
const sub = subscribed(browser)
|
||||
sub.unsubscribe.mockRejectedValueOnce(new Error('refused'))
|
||||
await expect(disableWebPush()).rejects.toThrow('refused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resyncPushSubscription', () => {
|
||||
it('FE-PUSH-034: stays quiet when push is unsupported, not granted or the app is offline', async () => {
|
||||
await resyncPushSubscription()
|
||||
const browser = installBrowser({ permission: 'default' })
|
||||
subscribed(browser)
|
||||
await resyncPushSubscription()
|
||||
browser.notification.permission = 'granted'
|
||||
network.offline = true
|
||||
await resyncPushSubscription()
|
||||
expect(browser.container.getRegistration).not.toHaveBeenCalled()
|
||||
expect(api.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-035: registers the subscription the device holds again, without update()', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
await resyncPushSubscription()
|
||||
expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(sub as unknown as PushSubscription))
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(browser.registration.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-036: does nothing for a device that never switched push on', async () => {
|
||||
installBrowser({ permission: 'granted' })
|
||||
await resyncPushSubscription()
|
||||
expect(api.getPublicKey).not.toHaveBeenCalled()
|
||||
expect(api.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-037: replaces a subscription made with an old server key', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale')
|
||||
await resyncPushSubscription()
|
||||
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale')
|
||||
expect(stale.unsubscribe).toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
|
||||
expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/sub-1' }))
|
||||
})
|
||||
|
||||
it('FE-PUSH-038: a browser that does not report its key is taken at its word', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
browser.pushManager.current = new FakeSubscription('https://push.example/unknown-key', null)
|
||||
await resyncPushSubscription()
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/unknown-key' }))
|
||||
})
|
||||
|
||||
it('FE-PUSH-039: never rejects, even when the server does', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
subscribed(browser)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
api.subscribe.mockRejectedValueOnce(new Error('500'))
|
||||
await expect(resyncPushSubscription()).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('forgetPushDeviceOnLogout', () => {
|
||||
/** The logout DELETE is a plain fetch, so a 401 can never reach apiClient's login redirect. */
|
||||
function stubFetch(answer: (init?: RequestInit) => Promise<Response>) {
|
||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => answer(init))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
it('FE-PUSH-040: forgets the device on the server with a plain fetch, and in the browser', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
const fetchMock = stubFetch(async () => Response.json({ success: true }))
|
||||
|
||||
await forgetPushDeviceOnLogout()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/notifications/push/subscriptions')
|
||||
expect(init).toMatchObject({
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ endpoint: 'https://push.example/existing' })
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(api.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(sub.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-041: returns at once where there is no push at all', async () => {
|
||||
const fetchMock = stubFetch(async () => Response.json({ success: true }))
|
||||
await forgetPushDeviceOnLogout()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(api.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PUSH-042: a server that never answers cannot hold the logout up or keep the browser subscribed', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Like a real fetch to a server that never answers: it settles only once its signal aborts.
|
||||
const fetchMock = stubFetch(
|
||||
init =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
}),
|
||||
)
|
||||
vi.useFakeTimers()
|
||||
let finished = false
|
||||
const done = forgetPushDeviceOnLogout(3000).then(() => {
|
||||
finished = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(2999)
|
||||
expect(finished).toBe(false)
|
||||
expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(false)
|
||||
// The browser half never waited for the server half.
|
||||
expect(sub.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await done
|
||||
expect(finished).toBe(true)
|
||||
expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(DOMException))
|
||||
})
|
||||
|
||||
it('FE-PUSH-043: a 401 from a session that already ran out neither redirects nor stops the browser half', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const before = window.location.href
|
||||
stubFetch(async () => Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 }))
|
||||
|
||||
await expect(forgetPushDeviceOnLogout()).resolves.toBeUndefined()
|
||||
|
||||
expect(window.location.href).toBe(before)
|
||||
expect(api.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(sub.unsubscribe).toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(Error))
|
||||
})
|
||||
|
||||
it('FE-PUSH-044: a network failure on the server half still unsubscribes the browser', async () => {
|
||||
const browser = installBrowser({ permission: 'granted' })
|
||||
const sub = subscribed(browser)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
stubFetch(() => Promise.reject(new TypeError('Failed to fetch')))
|
||||
await forgetPushDeviceOnLogout()
|
||||
expect(sub.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { PushSubscriptionInput, PushUnsubscribeRequest } from '@trek/shared'
|
||||
import { pushApi } from '../api/push'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { isIosDevice, isIosStandalone } from '../utils/iosDevice'
|
||||
|
||||
/**
|
||||
* Web Push on this device: whether it can work here, and switching it on and
|
||||
* off.
|
||||
*
|
||||
* Deliberately online only, and deliberately without a mirror. The browser's
|
||||
* push subscription is the one record of "this device receives push"; the
|
||||
* server keeps its copy keyed by the subscription's endpoint, and nothing in
|
||||
* Zustand or Dexie repeats either. Making a subscription needs the browser's
|
||||
* push service and the server at the same moment, so an offline queue would
|
||||
* have nothing to replay, and a cached copy could only ever disagree with the
|
||||
* browser.
|
||||
*
|
||||
* React-free on purpose: the settings cards (through `useWebPush`), the auth
|
||||
* store (logout and the re-sync after sign-in) and the tests all drive these
|
||||
* same functions.
|
||||
*/
|
||||
|
||||
export type WebPushUnsupportedReason = 'insecure' | 'unsupported' | 'ios-install'
|
||||
|
||||
/** `reason` is null exactly when `supported` is true. */
|
||||
export interface WebPushSupport {
|
||||
supported: boolean
|
||||
reason: WebPushUnsupportedReason | null
|
||||
}
|
||||
|
||||
/** What the settings card has to say about this device. */
|
||||
export type WebPushDeviceState = WebPushUnsupportedReason | 'denied' | 'off' | 'on'
|
||||
|
||||
export type WebPushErrorCode = 'denied' | 'dismissed' | 'failed'
|
||||
|
||||
/** Why switching push on did not work: blocked, the prompt closed without an answer, or anything else. */
|
||||
export class WebPushError extends Error {
|
||||
readonly code: WebPushErrorCode
|
||||
readonly reason: unknown
|
||||
|
||||
constructor(code: WebPushErrorCode, reason?: unknown) {
|
||||
super(`Web Push: ${code}`)
|
||||
this.name = 'WebPushError'
|
||||
this.code = code
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long switching push on waits for the service worker. `registerSW.js`
|
||||
* installs it on page load, so this only runs out on a page that never got
|
||||
* one, and there the card should say so rather than spin.
|
||||
*/
|
||||
export const REGISTRATION_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Logout waits at most this long for this device to be forgotten. It must never hang on it. */
|
||||
export const LOGOUT_PUSH_TIMEOUT_MS = 3_000
|
||||
|
||||
function hasPushApis(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether push can work in this browser, and if not, the reason the user can
|
||||
* do something about.
|
||||
*/
|
||||
export function getWebPushSupport(): WebPushSupport {
|
||||
if (typeof window === 'undefined') return unsupported('unsupported')
|
||||
// A service worker, and push with it, exists only in a secure context: HTTPS,
|
||||
// or localhost while developing. A plain http LAN address never gets one.
|
||||
if (window.isSecureContext === false) return unsupported('insecure')
|
||||
// iOS and iPadOS deliver push only to TREK added to the Home Screen. In a
|
||||
// Safari tab the APIs are simply missing, which would read as "this browser
|
||||
// cannot" when the fix is one tap in the share menu.
|
||||
if (isIosDevice() && !isIosStandalone()) return unsupported('ios-install')
|
||||
if (!hasPushApis()) return unsupported('unsupported')
|
||||
return { supported: true, reason: null }
|
||||
}
|
||||
|
||||
function unsupported(reason: WebPushUnsupportedReason): WebPushSupport {
|
||||
return { supported: false, reason }
|
||||
}
|
||||
|
||||
/** The notification permission as the browser holds it right now. */
|
||||
export function getPushPermission(): NotificationPermission {
|
||||
return typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : 'default'
|
||||
}
|
||||
|
||||
/** The applicationServerKey the browser wants: the raw bytes behind the server's base64url string. */
|
||||
export function base64UrlToUint8Array(value: string): Uint8Array<ArrayBuffer> {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
|
||||
return Uint8Array.from(atob(padded), ch => ch.codePointAt(0) ?? 0)
|
||||
}
|
||||
|
||||
/** A key the browser reports it subscribed with, compared with the server's. Unknown counts as the same. */
|
||||
function keyMatches(subscribedWith: ArrayBuffer | null | undefined, serverKey: Uint8Array): boolean {
|
||||
if (!subscribedWith) return true
|
||||
const bytes = new Uint8Array(subscribedWith)
|
||||
return bytes.length === serverKey.length && bytes.every((byte, i) => byte === serverKey[i])
|
||||
}
|
||||
|
||||
/** What `PushSubscription.toJSON()` hands over, in the shape of the shared contract. */
|
||||
export function toSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput {
|
||||
const json = subscription.toJSON()
|
||||
const p256dh = json.keys?.p256dh
|
||||
const auth = json.keys?.auth
|
||||
if (!json.endpoint || !p256dh || !auth) throw new WebPushError('failed', 'subscription without keys')
|
||||
return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } }
|
||||
}
|
||||
|
||||
/** Settles with `fallback` once `ms` have passed, unless `promise` settled first. */
|
||||
function withTimeout<T, F>(promise: Promise<T>, ms: number, fallback: F): Promise<T | F> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<F>(resolve => {
|
||||
timer = setTimeout(() => resolve(fallback), ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* A step whose failure changes nothing for the caller. It still gets a line in
|
||||
* the console, because "push quietly stopped" is otherwise impossible to trace.
|
||||
*/
|
||||
async function bestEffort<T>(what: string, step: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await step()
|
||||
} catch (err) {
|
||||
console.warn(`[push] ${what} failed`, err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration already in charge of this origin, or null. It neither waits
|
||||
* for one to appear nor calls update(): the version handover owns updates, and
|
||||
* its tests count every call.
|
||||
*/
|
||||
async function existingRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
const container = navigator.serviceWorker
|
||||
if (!container || typeof container.getRegistration !== 'function') return null
|
||||
return (await container.getRegistration()) ?? null
|
||||
}
|
||||
|
||||
/** This device's subscription, or null when it has none or cannot have one. */
|
||||
export async function currentPushSubscription(): Promise<PushSubscription | null> {
|
||||
if (!hasPushApis()) return null
|
||||
const registration = await existingRegistration()
|
||||
if (!registration?.pushManager) return null
|
||||
return registration.pushManager.getSubscription()
|
||||
}
|
||||
|
||||
/**
|
||||
* The active registration, for subscribing. `ready` never settles on a page
|
||||
* without a service worker, hence the timeout.
|
||||
*/
|
||||
export async function waitForPushRegistration(
|
||||
timeoutMs = REGISTRATION_TIMEOUT_MS,
|
||||
): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!hasPushApis()) return null
|
||||
return withTimeout(navigator.serviceWorker.ready, timeoutMs, null)
|
||||
}
|
||||
|
||||
/** The server's public key, or null when it could not be fetched now. Never rejects. */
|
||||
export async function loadPushPublicKey(): Promise<string | null> {
|
||||
const result = await bestEffort('loading the public key', () => pushApi.getPublicKey())
|
||||
return result?.publicKey || null
|
||||
}
|
||||
|
||||
/** What the settings card shows for this device. Never rejects. */
|
||||
export async function readWebPushDeviceState(): Promise<WebPushDeviceState> {
|
||||
const { reason } = getWebPushSupport()
|
||||
if (reason) return reason
|
||||
if (getPushPermission() === 'denied') return 'denied'
|
||||
const subscription = await bestEffort('reading the subscription', () => currentPushSubscription())
|
||||
return subscription ? 'on' : 'off'
|
||||
}
|
||||
|
||||
/** Both halves of dropping a subscription, each best effort: the server's row and the browser's own. */
|
||||
async function forgetSubscription(subscription: PushSubscription): Promise<void> {
|
||||
await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint))
|
||||
await bestEffort('unsubscribing in the browser', () => subscription.unsubscribe())
|
||||
}
|
||||
|
||||
async function subscribeWithKey(
|
||||
registration: ServiceWorkerRegistration,
|
||||
key: Uint8Array<ArrayBuffer>,
|
||||
): Promise<PushSubscription> {
|
||||
const options: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: key }
|
||||
try {
|
||||
return await registration.pushManager.subscribe(options)
|
||||
} catch (err) {
|
||||
// A subscription made with an earlier server key blocks a new one. Drop it
|
||||
// and try once more; any other failure is final.
|
||||
const stale = await registration.pushManager.getSubscription()
|
||||
if (!stale || keyMatches(stale.options?.applicationServerKey, key)) throw err
|
||||
await forgetSubscription(stale)
|
||||
return registration.pushManager.subscribe(options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches push on for this device: the permission, then the browser's
|
||||
* subscription, then the server.
|
||||
*
|
||||
* Call it straight from the click handler, with the key and the registration
|
||||
* fetched beforehand. WebKit honours the permission prompt and subscribe() only
|
||||
* inside the user's gesture, and an awaited network call in front of them would
|
||||
* spend it. The permission request is therefore the first thing that runs.
|
||||
*/
|
||||
export async function enableWebPush(publicKey: string, registration?: ServiceWorkerRegistration | null): Promise<void> {
|
||||
const permission =
|
||||
Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
|
||||
if (permission === 'denied') throw new WebPushError('denied')
|
||||
if (permission !== 'granted') throw new WebPushError('dismissed')
|
||||
|
||||
let subscription: PushSubscription
|
||||
try {
|
||||
const active = registration?.pushManager ? registration : await waitForPushRegistration()
|
||||
if (!active?.pushManager) throw new WebPushError('failed', 'no service worker')
|
||||
subscription = await subscribeWithKey(active, base64UrlToUint8Array(publicKey))
|
||||
} catch (err) {
|
||||
throw err instanceof WebPushError ? err : new WebPushError('failed', err)
|
||||
}
|
||||
|
||||
try {
|
||||
await pushApi.subscribe(toSubscriptionInput(subscription))
|
||||
} catch (err) {
|
||||
// The server never took this subscription, so it must not outlive the
|
||||
// failure: the card reads the browser's record, and would claim push works
|
||||
// on a device the server will never send to.
|
||||
await bestEffort('rolling back the browser subscription', () => subscription.unsubscribe())
|
||||
throw err instanceof WebPushError ? err : new WebPushError('failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches push off for this device. The server's row goes first, while this
|
||||
* session can still name the endpoint. If that request fails the browser still
|
||||
* unsubscribes: that alone stops delivery here, and the server drops a row the
|
||||
* push service answers 404 or 410 for on its next send.
|
||||
*/
|
||||
export async function disableWebPush(): Promise<void> {
|
||||
const subscription = await currentPushSubscription()
|
||||
if (!subscription) return
|
||||
await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint))
|
||||
await subscription.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the server about this device's subscription again after sign-in. The
|
||||
* server upserts by endpoint, so repeating it is harmless, and it heals a row
|
||||
* that was lost or still belongs to the account that used this browser before.
|
||||
*
|
||||
* When the server's key changed since the device subscribed, every push to the
|
||||
* old subscription fails its signature check, so it is replaced. Permission is
|
||||
* already granted then, which lets the browser subscribe again without a
|
||||
* gesture; where it refuses, the device simply reads as off and the card offers
|
||||
* to switch it on. Never rejects.
|
||||
*/
|
||||
export async function resyncPushSubscription(): Promise<void> {
|
||||
if (!getWebPushSupport().supported || getPushPermission() !== 'granted' || isEffectivelyOffline()) return
|
||||
await bestEffort('re-registering this device', async () => {
|
||||
const registration = await existingRegistration()
|
||||
const subscription = await registration?.pushManager?.getSubscription()
|
||||
if (!registration || !subscription) return
|
||||
const { publicKey } = await pushApi.getPublicKey()
|
||||
const key = base64UrlToUint8Array(publicKey)
|
||||
let current = subscription
|
||||
if (!keyMatches(subscription.options?.applicationServerKey, key)) {
|
||||
await forgetSubscription(subscription)
|
||||
current = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||
}
|
||||
await pushApi.subscribe(toSubscriptionInput(current))
|
||||
})
|
||||
}
|
||||
|
||||
/** Where the logout step tells the server to forget this device. */
|
||||
const PUSH_SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions'
|
||||
|
||||
/**
|
||||
* The server half of the logout teardown, as a plain fetch rather than through
|
||||
* apiClient: when the session has already run out the answer is a 401, and
|
||||
* apiClient meets that with its redirect to the login page, in the middle of
|
||||
* the logout. It carries its own timeout, so the request itself is cut off
|
||||
* rather than left running behind a logout that has moved on.
|
||||
*/
|
||||
async function forgetOnServerAtLogout(endpoint: string, timeoutMs: number): Promise<void> {
|
||||
const body: PushUnsubscribeRequest = { endpoint }
|
||||
const abort = new AbortController()
|
||||
const timer = setTimeout(() => abort.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(PUSH_SUBSCRIPTIONS_URL, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: abort.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The logout step: forget this device on the server and in the browser, so the
|
||||
* next account on a shared device does not receive this one's notifications.
|
||||
* It has to run while the session cookie still exists, and it waits at most
|
||||
* `timeoutMs`. Never rejects, and never redirects: a server that already
|
||||
* dropped the session, never answers or cannot be reached only costs the
|
||||
* server half, the browser still unsubscribes.
|
||||
*
|
||||
* The two halves run side by side rather than one after the other. The server
|
||||
* half may use up the whole timeout, and the browser half is the one that
|
||||
* stops delivery here, so it must not queue behind it. The server needs
|
||||
* nothing the browser still has to hold: the endpoint is already in hand, and
|
||||
* a row left behind goes on the next send, when the push service answers 404
|
||||
* or 410.
|
||||
*/
|
||||
export async function forgetPushDeviceOnLogout(timeoutMs = LOGOUT_PUSH_TIMEOUT_MS): Promise<void> {
|
||||
if (!hasPushApis()) return
|
||||
const teardown = bestEffort('forgetting this device at logout', async () => {
|
||||
const subscription = await currentPushSubscription()
|
||||
if (!subscription) return
|
||||
await Promise.all([
|
||||
bestEffort('forgetting the device on the server', () => forgetOnServerAtLogout(subscription.endpoint, timeoutMs)),
|
||||
bestEffort('unsubscribing in the browser', () => subscription.unsubscribe()),
|
||||
])
|
||||
})
|
||||
await withTimeout(teardown, timeoutMs, undefined)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { clearAllPluginSessions } from './pluginStore'
|
||||
import { forgetStartDestination } from '../utils/startDestination'
|
||||
import { forgetServerLanguage } from './settingsStore'
|
||||
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
|
||||
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
|
||||
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
@@ -119,6 +120,9 @@ async function onAuthSuccess(userId: number): Promise<void> {
|
||||
// an SPA session, so a second login in the same tab would leave the mutation
|
||||
// queue without a flush trigger. Re-registering is a no-op while they are up.
|
||||
registerSyncTriggers()
|
||||
// Tell the server again which push subscription this device holds, in the
|
||||
// background: sign-in must not wait on it, and it never rejects.
|
||||
void resyncPushSubscription()
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@@ -264,6 +268,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
// and the next account would come up believing it is offline over a working
|
||||
// connection, with nothing cached to answer from.
|
||||
setForcedOffline(false)
|
||||
// Forget this device's push subscription, on the server and in the browser,
|
||||
// or the next account on a shared device keeps receiving this one's
|
||||
// notifications. It has to happen here: the DELETE needs the session cookie
|
||||
// that step 4 clears. Best effort and bounded, so logout never hangs on it.
|
||||
await forgetPushDeviceOnLogout()
|
||||
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// 5. Clear service worker caches containing sensitive data.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getCachedBlob } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { isIosStandalone } from './iosDevice'
|
||||
|
||||
// MIME types safe to open inline (will not execute script in any browser).
|
||||
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
||||
@@ -35,13 +36,6 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||
}
|
||||
|
||||
// navigator.standalone is true only on iOS when running as an
|
||||
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||
function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a protected file to a Blob, preferring the live server but falling
|
||||
* back to the offline cache (pre-downloaded by the trip sync manager). This is
|
||||
@@ -124,7 +118,8 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||
// iOS PWA: target="_blank" hands off to Safari, which cannot reach a blob URL
|
||||
// sandboxed to the installed app's WebView.
|
||||
if (isIosStandalone()) {
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* FE-IOS-001 to FE-IOS-004: the two iPhone and iPad facts shared by file
|
||||
* opening and Web Push.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { isIosDevice, isIosStandalone } from './iosDevice'
|
||||
|
||||
function define(name: 'userAgent' | 'maxTouchPoints' | 'standalone', value: unknown) {
|
||||
Object.defineProperty(navigator, name, { configurable: true, get: () => value })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of ['userAgent', 'maxTouchPoints', 'standalone']) {
|
||||
delete (navigator as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
})
|
||||
|
||||
describe('isIosStandalone', () => {
|
||||
it("FE-IOS-001: is true only for Apple's own home-screen flag", () => {
|
||||
expect(isIosStandalone()).toBe(false)
|
||||
define('standalone', false)
|
||||
expect(isIosStandalone()).toBe(false)
|
||||
define('standalone', true)
|
||||
expect(isIosStandalone()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIosDevice', () => {
|
||||
it('FE-IOS-002: recognises iPhone, iPod and iPad by their user agent', () => {
|
||||
for (const device of ['iPhone', 'iPod touch', 'iPad']) {
|
||||
define('userAgent', `Mozilla/5.0 (${device}; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15`)
|
||||
expect(isIosDevice()).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-IOS-003: an iPad asking for the desktop site is a Mac with a touch screen', () => {
|
||||
define('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15')
|
||||
define('maxTouchPoints', 5)
|
||||
expect(isIosDevice()).toBe(true)
|
||||
define('maxTouchPoints', 0)
|
||||
expect(isIosDevice()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-IOS-004: Android and Windows are not iOS', () => {
|
||||
define('maxTouchPoints', 5)
|
||||
define('userAgent', 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/140.0 Mobile Safari/537.36')
|
||||
expect(isIosDevice()).toBe(false)
|
||||
define('userAgent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36')
|
||||
expect(isIosDevice()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* iPhone and iPad facts that more than one feature has to know about: file
|
||||
* opening needs to know whether a new tab would leave the installed app, and
|
||||
* Web Push needs to know whether it can work here at all.
|
||||
*/
|
||||
|
||||
type NavigatorWithStandalone = Navigator & { standalone?: boolean }
|
||||
|
||||
/**
|
||||
* True only on iOS and iPadOS while TREK runs as an app added to the Home
|
||||
* Screen. `navigator.standalone` is Apple's own flag and exists nowhere else,
|
||||
* so every other platform answers false.
|
||||
*/
|
||||
export function isIosStandalone(): boolean {
|
||||
return typeof navigator !== 'undefined' && (navigator as NavigatorWithStandalone).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* True on iPhone, iPod touch and iPad. iPadOS 13 and later asks for the desktop
|
||||
* site by default and then introduces itself as a Mac, so a Mac with a touch
|
||||
* screen counts too: no real Mac has one.
|
||||
*/
|
||||
export function isIosDevice(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent || ''
|
||||
if (/iPad|iPhone|iPod/.test(ua)) return true
|
||||
return /Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-031
|
||||
// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-035
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -558,4 +558,38 @@ describe('MAdminNotificationsSection', () => {
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0].smtp_pass).toBe('hunter2');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-033: Web Push has its own card between Ntfy and In-App', () => {
|
||||
render(<Harness initial={{ notification_channels: 'email' }} />);
|
||||
|
||||
const switches = screen.getAllByRole('switch').map((el) => el.getAttribute('aria-label'));
|
||||
expect(switches.indexOf('Web Push')).toBe(switches.indexOf('Ntfy') + 1);
|
||||
expect(switches.indexOf('In-App')).toBe(switches.indexOf('Web Push') + 1);
|
||||
expect(screen.getByText(/receive notifications on their phones/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-034: switching Web Push on saves the list and keeps unknown ids', async () => {
|
||||
const bodies = captureAppSettings();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'webhook,pushover' }} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Web Push' }));
|
||||
|
||||
await waitFor(() => expect(bodies).toHaveLength(1));
|
||||
expect(bodies[0]).toEqual({ notification_channels: 'webhook,push,pushover' });
|
||||
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-ANOTIF-035: a failed Web Push save rolls the switch back', async () => {
|
||||
failAppSettings();
|
||||
const toast = buildToast();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial={{ notification_channels: 'push' }} toast={toast} />);
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Web Push' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
|
||||
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -594,3 +594,37 @@ describe('MSettingsNotifications', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Web Push (#894): the per-device card joins the other channel cards while the
|
||||
// admin has push switched on. jsdom has no push APIs, so the real hook reads
|
||||
// this "browser" as unable to receive push, and the card has to say so.
|
||||
describe('MSettingsNotifications: Web Push', () => {
|
||||
it('FE-MOB-SETNOTIF-033: an active push channel gets its chip and the device card', async () => {
|
||||
usePrefs(
|
||||
matrix({
|
||||
preferences: { trip_invite: { inapp: true, push: true } },
|
||||
channels: [builtin('inapp'), builtin('push', { configured: false })],
|
||||
implemented_combos: { trip_invite: ['inapp', 'push'] },
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
|
||||
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
|
||||
expect(within(row).getByRole('button', { name: 'Push' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
|
||||
});
|
||||
|
||||
it('FE-MOB-SETNOTIF-034: no card while the admin has push off', async () => {
|
||||
usePrefs(
|
||||
matrix({
|
||||
channels: [builtin('inapp'), builtin('push', { active: false })],
|
||||
implemented_combos: { trip_invite: ['inapp', 'push'] },
|
||||
}),
|
||||
);
|
||||
render(<MSettingsNotifications />);
|
||||
|
||||
await screen.findByText('Trip invitations');
|
||||
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// FE-MOB-WEBPUSH-001 to FE-MOB-WEBPUSH-004: the phone device card.
|
||||
//
|
||||
// Markup only, like its desktop twin: useWebPush is mocked and has its own
|
||||
// spec, so these cases pin what each hook state renders and that the buttons
|
||||
// reach the hook.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '../../../helpers/render';
|
||||
import type { WebPushCardState } from '../../../../src/hooks/useWebPush';
|
||||
import MWebPushCard from '../../../../src/mobile/screens/settings/MWebPushCard';
|
||||
|
||||
const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState }));
|
||||
vi.mock('../../../../src/hooks/useWebPush', () => ({ useWebPush: () => hookState.current }));
|
||||
|
||||
function state(over: Partial<WebPushCardState> = {}): WebPushCardState {
|
||||
return {
|
||||
state: 'off',
|
||||
stateText: null,
|
||||
canSwitch: true,
|
||||
on: false,
|
||||
switchLabel: 'Turn on for this device',
|
||||
busy: false,
|
||||
canTest: false,
|
||||
toggle: vi.fn(),
|
||||
sendTest: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hookState.current = state();
|
||||
});
|
||||
|
||||
describe('MWebPushCard', () => {
|
||||
it('FE-MOB-WEBPUSH-001: explains itself and offers to switch on', async () => {
|
||||
render(<MWebPushCard />);
|
||||
expect(screen.getByText('Push notifications on this device')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
|
||||
const turnOn = screen.getByRole('button', { name: 'Turn on for this device' });
|
||||
expect(turnOn.className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled();
|
||||
|
||||
await userEvent.click(turnOn);
|
||||
expect(hookState.current.toggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-WEBPUSH-002: when on, says so, offers to switch off and to test', async () => {
|
||||
hookState.current = state({
|
||||
state: 'on',
|
||||
on: true,
|
||||
stateText: 'On for this device',
|
||||
switchLabel: 'Turn off for this device',
|
||||
canTest: true,
|
||||
});
|
||||
render(<MWebPushCard />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('On for this device');
|
||||
expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-m-act');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Send test' }));
|
||||
expect(hookState.current.sendTest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-WEBPUSH-003: a state that cannot be switched shows its sentence and no buttons', () => {
|
||||
hookState.current = state({ state: 'denied', canSwitch: false, stateText: 'Notifications are blocked.' });
|
||||
render(<MWebPushCard />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Notifications are blocked.');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-WEBPUSH-004: the switch is disabled while busy', () => {
|
||||
hookState.current = state({ busy: true });
|
||||
render(<MWebPushCard />);
|
||||
expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
// FE-SW-PUSH-001 to FE-SW-PUSH-016: the Web Push handlers in public/sw-push.js.
|
||||
//
|
||||
// The file is a classic script that Workbox imports into the generated service
|
||||
// worker, so it sits outside src/, ESLint and coverage. This spec is its only
|
||||
// guard: it evaluates the real file against a fake worker global and drives
|
||||
// the three events the way a browser would.
|
||||
//
|
||||
// What matters most:
|
||||
// - every push shows a notification inside waitUntil, whatever arrives, since
|
||||
// Chrome demands one per push and WebKit revokes a subscription that stays
|
||||
// silent;
|
||||
// - a click only ever opens a path on this origin;
|
||||
// - a renewed subscription reaches the server, and the old one is forgotten
|
||||
// only once the new one is stored.
|
||||
// Plus the drift guard: vite.config.js must import the file, and it must exist,
|
||||
// or the SPA fallback answers importScripts with index.html and the new worker
|
||||
// never installs.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ORIGIN = 'https://trek.example';
|
||||
const SOURCE_PATH = resolve(process.cwd(), 'public/sw-push.js');
|
||||
const SOURCE = readFileSync(SOURCE_PATH, 'utf8');
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
|
||||
interface FakeWindow {
|
||||
url: string;
|
||||
focused: boolean;
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function fakeWindow(over: Partial<FakeWindow> = {}): FakeWindow {
|
||||
const win: FakeWindow = {
|
||||
url: `${ORIGIN}/dashboard`,
|
||||
focused: false,
|
||||
focus: vi.fn(async () => win),
|
||||
navigate: vi.fn(async () => win),
|
||||
...over,
|
||||
};
|
||||
return win;
|
||||
}
|
||||
|
||||
function boot(windows: FakeWindow[] = []) {
|
||||
const listeners: Record<string, Listener> = {};
|
||||
const registration = {
|
||||
showNotification: vi.fn(async (_title: string, _options: NotificationOptions) => undefined),
|
||||
pushManager: { subscribe: vi.fn() },
|
||||
};
|
||||
const clients = {
|
||||
matchAll: vi.fn(async () => windows),
|
||||
openWindow: vi.fn(async (_url: string) => null),
|
||||
};
|
||||
const self = {
|
||||
addEventListener: (type: string, fn: Listener) => {
|
||||
listeners[type] = fn;
|
||||
},
|
||||
registration,
|
||||
clients,
|
||||
location: { origin: ORIGIN },
|
||||
};
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(null, { status: 200 }));
|
||||
new Function('self', 'fetch', SOURCE)(self, fetchMock);
|
||||
return { listeners, registration, clients, fetch: fetchMock };
|
||||
}
|
||||
|
||||
/** Dispatches one event and hands back what it passed to waitUntil. */
|
||||
function fire(listener: Listener, event: Record<string, unknown>): Promise<unknown> {
|
||||
let waited: Promise<unknown> | undefined;
|
||||
listener({ ...event, waitUntil: (p: Promise<unknown>) => { waited = p; } });
|
||||
expect(waited).toBeInstanceOf(Promise);
|
||||
return waited as Promise<unknown>;
|
||||
}
|
||||
|
||||
function pushData(value: unknown) {
|
||||
return {
|
||||
json: () => (typeof value === 'string' ? JSON.parse(value) : value),
|
||||
text: () => (typeof value === 'string' ? value : JSON.stringify(value)),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('sw-push.js: push', () => {
|
||||
it('FE-SW-PUSH-001: shows the payload as sent, with the icon and the path to open', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.push, {
|
||||
data: pushData({ title: 'Trip invite', body: 'Anna invited you to Rome', url: '/trips/5' }),
|
||||
});
|
||||
expect(sw.registration.showNotification).toHaveBeenCalledWith('Trip invite', {
|
||||
body: 'Anna invited you to Rome',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
data: { url: '/trips/5' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-001b: a tagged push replaces the last one with that tag and still alerts', async () => {
|
||||
const sw = boot();
|
||||
const message = { title: 'New message in "Rome"', body: 'anna: see you there', url: '/trips/5' };
|
||||
await fire(sw.listeners.push, { data: pushData({ ...message, tag: 'collab_message:/trips/5' }) });
|
||||
expect(sw.registration.showNotification).toHaveBeenCalledWith(message.title, {
|
||||
body: message.body,
|
||||
icon: '/icons/icon-192x192.png',
|
||||
tag: 'collab_message:/trips/5',
|
||||
renotify: true,
|
||||
data: { url: '/trips/5' },
|
||||
});
|
||||
// Without a tag there is nothing to replace, and renotify would be refused.
|
||||
await fire(sw.listeners.push, { data: pushData({ ...message, tag: '' }) });
|
||||
expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('tag');
|
||||
expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('renotify');
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-001c: renotify follows the tag alone, never the payload', async () => {
|
||||
const sw = boot();
|
||||
// showNotification throws on renotify without a tag, so a payload asking for it must not get it.
|
||||
await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', renotify: true }) });
|
||||
expect(sw.registration.showNotification.mock.calls[0][1]).not.toHaveProperty('renotify');
|
||||
// Any tag at all brings it, not only the chat one.
|
||||
await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', tag: 'any' }) });
|
||||
expect(sw.registration.showNotification.mock.calls[1][1]).toMatchObject({ tag: 'any', renotify: true });
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-002: a payload that is not JSON still shows, with its text as the body', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.push, { data: pushData('plain words, no JSON') });
|
||||
expect(sw.registration.showNotification).toHaveBeenCalledWith('TREK', {
|
||||
body: 'plain words, no JSON',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
data: { url: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-003: a push without data, or with the wrong types, still shows something', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.push, { data: null });
|
||||
await fire(sw.listeners.push, { data: pushData({ title: 42, body: ['x'], tag: 7 }) });
|
||||
await fire(sw.listeners.push, { data: pushData('"just a string"') });
|
||||
for (const call of sw.registration.showNotification.mock.calls) {
|
||||
expect(call[0]).toBe('TREK');
|
||||
expect(call[1]).toEqual({ body: '', icon: '/icons/icon-192x192.png', data: { url: '/' } });
|
||||
}
|
||||
expect(sw.registration.showNotification).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-004: a url pointing anywhere else is replaced by the start page', async () => {
|
||||
const sw = boot();
|
||||
for (const url of ['https://evil.example/phish', '//evil.example/x', 'javascript:alert(1)']) {
|
||||
await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url }) });
|
||||
}
|
||||
for (const call of sw.registration.showNotification.mock.calls) {
|
||||
expect(call[1].data.url).toBe('/');
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-005: a same-origin absolute url is reduced to its path, query and hash', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url: `${ORIGIN}/trips/5?tab=plan#day-2` }) });
|
||||
expect(sw.registration.showNotification.mock.calls[0][1]).toMatchObject({ data: { url: '/trips/5?tab=plan#day-2' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sw-push.js: notificationclick', () => {
|
||||
function click(sw: ReturnType<typeof boot>, url: unknown) {
|
||||
const notification = { close: vi.fn(), data: { url } };
|
||||
return { notification, done: fire(sw.listeners.notificationclick, { notification }) };
|
||||
}
|
||||
|
||||
it('FE-SW-PUSH-006: closes the notification, focuses a TREK window and takes it there', async () => {
|
||||
const win = fakeWindow();
|
||||
const sw = boot([win]);
|
||||
const { notification, done } = click(sw, '/trips/5');
|
||||
await done;
|
||||
expect(notification.close).toHaveBeenCalled();
|
||||
expect(sw.clients.matchAll).toHaveBeenCalledWith({ type: 'window', includeUncontrolled: true });
|
||||
expect(win.focus).toHaveBeenCalled();
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
|
||||
expect(sw.clients.openWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-007: prefers the window that already has the focus', async () => {
|
||||
const background = fakeWindow();
|
||||
const focused = fakeWindow({ focused: true });
|
||||
const sw = boot([background, focused]);
|
||||
await click(sw, '/notifications').done;
|
||||
expect(focused.navigate).toHaveBeenCalledWith(`${ORIGIN}/notifications`);
|
||||
expect(background.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-008: opens a window when none is left', async () => {
|
||||
const sw = boot([]);
|
||||
await click(sw, '/trips/5').done;
|
||||
expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-009: a window the worker may not navigate gets a new one instead', async () => {
|
||||
const uncontrolled = fakeWindow();
|
||||
uncontrolled.navigate.mockRejectedValueOnce(new TypeError('not controlled'));
|
||||
const sw = boot([uncontrolled]);
|
||||
await click(sw, '/trips/5').done;
|
||||
expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-010: a url from elsewhere, or none at all, opens the start page', async () => {
|
||||
const sw = boot([]);
|
||||
await click(sw, 'https://evil.example/').done;
|
||||
const notification = { close: vi.fn(), data: null };
|
||||
await fire(sw.listeners.notificationclick, { notification });
|
||||
expect(sw.clients.openWindow.mock.calls).toEqual([[`${ORIGIN}/`], [`${ORIGIN}/`]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sw-push.js: pushsubscriptionchange', () => {
|
||||
const OLD_KEY = new Uint8Array([4, 1, 2, 3]).buffer;
|
||||
|
||||
function renewed(endpoint: string) {
|
||||
return { endpoint, toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }) };
|
||||
}
|
||||
|
||||
it('FE-SW-PUSH-011: subscribes again with the old key, stores the new one, then forgets the old', async () => {
|
||||
const sw = boot();
|
||||
sw.registration.pushManager.subscribe.mockResolvedValue(renewed('https://push.example/new'));
|
||||
await fire(sw.listeners.pushsubscriptionchange, {
|
||||
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
|
||||
newSubscription: null,
|
||||
});
|
||||
|
||||
expect(sw.registration.pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: OLD_KEY });
|
||||
expect(sw.fetch).toHaveBeenCalledTimes(2);
|
||||
const [postUrl, post] = sw.fetch.mock.calls[0];
|
||||
expect(postUrl).toBe('/api/notifications/push/subscriptions');
|
||||
expect(post).toMatchObject({ method: 'POST', credentials: 'same-origin' });
|
||||
expect(JSON.parse(String(post?.body))).toEqual({
|
||||
subscription: { endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } },
|
||||
});
|
||||
const [, del] = sw.fetch.mock.calls[1];
|
||||
expect(del).toMatchObject({ method: 'DELETE', credentials: 'same-origin' });
|
||||
expect(JSON.parse(String(del?.body))).toEqual({ endpoint: 'https://push.example/old' });
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-012: uses a subscription the browser already made', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.pushsubscriptionchange, {
|
||||
oldSubscription: null,
|
||||
newSubscription: renewed('https://push.example/fresh'),
|
||||
});
|
||||
expect(sw.registration.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(sw.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-013: keeps the row when the endpoint did not change', async () => {
|
||||
const sw = boot();
|
||||
await fire(sw.listeners.pushsubscriptionchange, {
|
||||
oldSubscription: { endpoint: 'https://push.example/same', options: { applicationServerKey: OLD_KEY } },
|
||||
newSubscription: renewed('https://push.example/same'),
|
||||
});
|
||||
expect(sw.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-014: forgets nothing when the server did not take the new subscription', async () => {
|
||||
const sw = boot();
|
||||
sw.fetch.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
||||
await fire(sw.listeners.pushsubscriptionchange, {
|
||||
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
|
||||
newSubscription: renewed('https://push.example/new'),
|
||||
});
|
||||
expect(sw.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-SW-PUSH-015: gives up quietly, never with a rejected waitUntil', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const sw = boot();
|
||||
sw.registration.pushManager.subscribe.mockRejectedValueOnce(new Error('push service down'));
|
||||
await expect(
|
||||
fire(sw.listeners.pushsubscriptionchange, {
|
||||
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
|
||||
newSubscription: null,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
|
||||
await fire(sw.listeners.pushsubscriptionchange, { oldSubscription: null, newSubscription: null });
|
||||
expect(sw.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sw-push.js is wired into the service worker (drift guard)', () => {
|
||||
it('FE-SW-PUSH-016: vite.config.js imports it and public/ ships it', () => {
|
||||
const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8');
|
||||
expect(config).toMatch(/importScripts:\s*\[\s*'sw-push\.js'\s*\]/);
|
||||
expect(existsSync(SOURCE_PATH)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// FE-STORE-AUTH-PUSH-001 to FE-STORE-AUTH-PUSH-005: where Web Push sits in the
|
||||
// sign-in and sign-out sequences.
|
||||
//
|
||||
// Logout has to forget this device's push subscription while the session cookie
|
||||
// still exists, because the DELETE that tells the server needs it and the next
|
||||
// step clears it. Sign-in re-registers the subscription the device holds, in
|
||||
// the background, so a slow push service can never hold a login up. The push
|
||||
// module has its own spec; here it is a pair of spies, except in the one case
|
||||
// that runs the real logout teardown against a session that already ran out.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildUser } from '../../helpers/factories';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
|
||||
const push = vi.hoisted(() => ({
|
||||
forgetPushDeviceOnLogout: vi.fn(),
|
||||
resyncPushSubscription: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/push/webPush', () => push);
|
||||
|
||||
vi.mock('../../../src/sync/syncTriggers', () => ({
|
||||
registerSyncTriggers: vi.fn(),
|
||||
unregisterSyncTriggers: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
push.forgetPushDeviceOnLogout.mockResolvedValue(undefined);
|
||||
push.resyncPushSubscription.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('authStore and Web Push', () => {
|
||||
it('FE-STORE-AUTH-PUSH-001: logout forgets the push device before the logout request clears the cookie', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
const order: string[] = [];
|
||||
push.forgetPushDeviceOnLogout.mockImplementation(async () => {
|
||||
order.push('forget push device');
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
order.push(`fetch ${String(input)}`);
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
expect(order).toEqual(['forget push device', 'fetch /api/auth/logout']);
|
||||
});
|
||||
|
||||
it('FE-STORE-AUTH-PUSH-002: the logout request waits until the push step has finished', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
let finishPush!: () => void;
|
||||
push.forgetPushDeviceOnLogout.mockReturnValue(new Promise<void>((resolve) => { finishPush = resolve; }));
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
const loggingOut = useAuthStore.getState().logout();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
finishPush();
|
||||
await loggingOut;
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' }));
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STORE-AUTH-PUSH-003: an expired session answers the push DELETE with 401, and logout still runs to the end', async () => {
|
||||
// The real teardown this time: its DELETE must be a plain fetch, because
|
||||
// apiClient would meet the 401 with its redirect to the login page.
|
||||
const real = await vi.importActual<typeof import('../../../src/push/webPush')>('../../../src/push/webPush');
|
||||
push.forgetPushDeviceOnLogout.mockImplementation(real.forgetPushDeviceOnLogout);
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const subscription = { endpoint: 'https://fcm.googleapis.com/fcm/send/this-device', unsubscribe };
|
||||
const registration = { pushManager: { getSubscription: vi.fn(async () => subscription) } };
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn(async () => registration) },
|
||||
});
|
||||
vi.stubGlobal('PushManager', function PushManager() {});
|
||||
vi.stubGlobal('Notification', { permission: 'granted' });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) =>
|
||||
String(input) === '/api/notifications/push/subscriptions'
|
||||
? Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 })
|
||||
: new Response(null, { status: 200 }),
|
||||
);
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
const before = window.location.href;
|
||||
|
||||
try {
|
||||
await useAuthStore.getState().logout();
|
||||
} finally {
|
||||
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(fetchSpy.mock.calls.map(([input]) => String(input))).toEqual([
|
||||
'/api/notifications/push/subscriptions',
|
||||
'/api/auth/logout',
|
||||
]);
|
||||
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'DELETE', credentials: 'include' });
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
expect(window.location.href).toBe(before);
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STORE-AUTH-PUSH-004: a successful login re-registers this device in the background', async () => {
|
||||
const user = buildUser();
|
||||
server.use(http.post('/api/auth/login', () => HttpResponse.json({ user, token: 'tok' })));
|
||||
// Never settles: the login must not be waiting on it.
|
||||
push.resyncPushSubscription.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
await useAuthStore.getState().login(user.email, 'password');
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(push.resyncPushSubscription).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-AUTH-PUSH-005: a failed login does not touch push', async () => {
|
||||
server.use(http.post('/api/auth/login', () => HttpResponse.json({ error: 'Bad credentials' }, { status: 401 })));
|
||||
|
||||
await expect(useAuthStore.getState().login('bad@example.com', 'wrong')).rejects.toThrow();
|
||||
|
||||
expect(push.resyncPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,12 @@ export default defineConfig(({ mode }) => ({
|
||||
navigateFallback: undefined,
|
||||
},
|
||||
workbox: {
|
||||
// The Web Push handlers (push, notificationclick, pushsubscriptionchange).
|
||||
// Workbox writes importScripts('sw-push.js') at the top of the generated
|
||||
// worker, in dev too. The file lives in public/: were it missing, the SPA
|
||||
// fallback would answer with index.html and the new worker would fail to
|
||||
// install, which tests/unit/pwa/swPush.test.ts guards against.
|
||||
importScripts: ['sw-push.js'],
|
||||
// Anything above this is dropped from the precache manifest. The build does
|
||||
// not fail over it, it only prints "won't be precached", so the ceiling has
|
||||
// to sit close to the real bundle or an accidental heavyweight goes
|
||||
|
||||
@@ -59,6 +59,9 @@ services:
|
||||
# - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
|
||||
# - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
|
||||
# - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
|
||||
# - VAPID_PUBLIC_KEY= # Optional. Web Push key pair, public half (base64url). Unset, TREK generates a pair on first start and keeps it in the database, so it travels with backups. Set it together with VAPID_PRIVATE_KEY to use your own, e.g. from `npx web-push generate-vapid-keys`. Changing the pair means every device has to turn push on again.
|
||||
# - VAPID_PRIVATE_KEY= # Optional. Web Push key pair, private half (base64url). Only used together with VAPID_PUBLIC_KEY; a mismatched pair, or only one half, turns push off with an error in the log until it is fixed, and every device stays registered.
|
||||
# - VAPID_SUBJECT=mailto:admin@example.com # Optional. The contact push services see with every message: a mailto: address or an https:// URL. Defaults to APP_URL when it is https, else the TREK project page.
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -68,6 +68,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
|
||||
|
||||
# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again.
|
||||
# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered.
|
||||
# VAPID_SUBJECT=mailto:admin@example.com # Web Push: the contact push services see with every message, a mailto: address or an https:// URL. Unset, TREK uses APP_URL when it is https, else the TREK project page.
|
||||
|
||||
# UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
|
||||
|
||||
# Initial admin account — ONLY applied on the first boot, when the database has no
|
||||
|
||||
@@ -283,6 +283,10 @@ async function main() {
|
||||
// rotation leaves it encrypted under the old key, which reads back as "no
|
||||
// key" and silently drops the install to OpenStreetMap, so the copy is
|
||||
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
|
||||
//
|
||||
// web_push_vapid_private_key is the Web Push signing key VapidKeysService
|
||||
// generates. Missed here it would no longer decrypt under the new key, and
|
||||
// push would stay off on every device until the old key came back.
|
||||
for (const key of [
|
||||
'oidc_client_secret',
|
||||
'smtp_pass',
|
||||
@@ -291,6 +295,7 @@ async function main() {
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
'amap_api_key',
|
||||
'web_push_vapid_private_key',
|
||||
]) {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
if (!row?.value) continue;
|
||||
|
||||
@@ -31,6 +31,10 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions.
|
||||
together wrong. It is empty without the switch, so a self-hoster never meets
|
||||
it. Put a rule there only if booting anyway would be a security or data
|
||||
problem — not to enforce a preference.
|
||||
The report prints each malformed value, because that is what makes a typo
|
||||
findable, except for the credentials in `SECRET_ENV_KEYS` (`env.ts`), which
|
||||
print as `***`. A new password, token, key or secret variable goes into that
|
||||
set; `validate.test.ts` fails on a credential-sounding name missing from it.
|
||||
3. **Parity is law — with one deliberate exception.** Every derived field pins
|
||||
the exact coercion of the call site(s) it replaced (`Number(x) || d`
|
||||
treating `"0"` as unset, per-site defaults for the same variable, …). Do not
|
||||
@@ -82,7 +86,8 @@ TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL.
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
service, which the notification suites build without the container).
|
||||
|
||||
## Exemptions — raw `process.env` stays
|
||||
|
||||
|
||||
@@ -334,6 +334,16 @@ export function deriveNet(raw: RawEnv) {
|
||||
};
|
||||
}
|
||||
|
||||
export function derivePush(raw: RawEnv) {
|
||||
return {
|
||||
// Trimmed, and blank counts as unset: the schema validated the trimmed value,
|
||||
// so a padded key must not reach the crypto as a different string.
|
||||
vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined,
|
||||
vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined,
|
||||
vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveAll(raw: RawEnv) {
|
||||
return {
|
||||
app: deriveApp(raw),
|
||||
@@ -353,6 +363,7 @@ export function deriveAll(raw: RawEnv) {
|
||||
db: deriveDb(raw),
|
||||
paths: derivePaths(raw),
|
||||
net: deriveNet(raw),
|
||||
push: derivePush(raw),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
|
||||
import { parseDurationMs, parseLinkLocalAllowList } from './parsers';
|
||||
import {
|
||||
isP256PrivateKey,
|
||||
isUncompressedP256Key,
|
||||
isVapidSubject,
|
||||
parseDurationMs,
|
||||
parseLinkLocalAllowList,
|
||||
} from './parsers';
|
||||
|
||||
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
|
||||
function optionalWith(test: (v: string) => boolean, message: string) {
|
||||
@@ -104,6 +110,18 @@ export const envSchema = z.object({
|
||||
WEBAUTHN_RP_ID: anyString,
|
||||
WEBAUTHN_ORIGINS: anyString,
|
||||
|
||||
// Web Push (VAPID). All optional: without them the server keeps a pair of its
|
||||
// own in the database. Only the shape is checked here; whether the two keys
|
||||
// belong together is checked where they are used, which turns push off while
|
||||
// they do not (never signing with a stored pair in their place) and says so
|
||||
// in the log instead of refusing to boot.
|
||||
VAPID_PUBLIC_KEY: optionalWith(
|
||||
isUncompressedP256Key,
|
||||
'must be a base64url-encoded P-256 public key (65 bytes, uncompressed, starting with 0x04)',
|
||||
),
|
||||
VAPID_PRIVATE_KEY: optionalWith(isP256PrivateKey, 'must be a base64url-encoded P-256 private key (32 bytes)'),
|
||||
VAPID_SUBJECT: optionalWith(isVapidSubject, 'must be a mailto: address or an https:// URL'),
|
||||
|
||||
// MCP
|
||||
MCP_SESSION_TTL: positiveNumber,
|
||||
MCP_MAX_SESSION_PER_USER: positiveNumber,
|
||||
|
||||
@@ -14,6 +14,31 @@ export function readEnv(): AppEnv {
|
||||
return deriveAll(process.env as RawEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables whose value never goes into the boot report, even when it is the
|
||||
* malformed one: the report lands in container logs, and a key that is merely
|
||||
* mistyped is still a real key. The name and the problem are enough to fix it.
|
||||
*/
|
||||
export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
||||
'ENCRYPTION_KEY',
|
||||
'OIDC_CLIENT_SECRET',
|
||||
'SMTP_PASS',
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'UNSPLASH_ACCESS_KEY',
|
||||
'ADMIN_PASSWORD',
|
||||
'PLACES_API_KEY',
|
||||
'AMAP_API_KEY',
|
||||
'AMAP_API_SECRET',
|
||||
'MAPBOX_ACCESS_TOKEN',
|
||||
'CARTO_API_KEY',
|
||||
'DEMO_ADMIN_PASS',
|
||||
]);
|
||||
|
||||
/** How a variable's value appears in the boot report. */
|
||||
function reportedValue(key: string, value: string | undefined): string {
|
||||
return SECRET_ENV_KEYS.has(key) ? '***' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast startup validation: a variable that is PRESENT but malformed aborts
|
||||
* boot with an aggregated report; unset/blank variables always pass (their
|
||||
@@ -28,7 +53,7 @@ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void {
|
||||
? []
|
||||
: result.error.issues.map((issue) => {
|
||||
const key = String(issue.path[0]);
|
||||
return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`;
|
||||
return ` - ${key}=${reportedValue(key, raw[key])}: ${issue.message}`;
|
||||
});
|
||||
lines.push(...managedPreconditions(raw));
|
||||
if (lines.length === 0) return;
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
deriveDb,
|
||||
derivePaths,
|
||||
deriveNet,
|
||||
derivePush,
|
||||
} from './derive';
|
||||
export * from './parsers';
|
||||
export { envSchema } from './env.schema';
|
||||
|
||||
@@ -209,3 +209,46 @@ export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[
|
||||
}
|
||||
return { ips, invalid };
|
||||
}
|
||||
|
||||
// ── Web Push key material ──────────────────────────────────────────────────
|
||||
|
||||
const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/;
|
||||
|
||||
/**
|
||||
* Strict base64url decode, null for anything that is not base64url. Web Push
|
||||
* keys arrive in this alphabet from the browser and from the VAPID_* variables.
|
||||
* Buffer.from(value, 'base64url') alone skips characters it does not know, so a
|
||||
* mangled key would decode to fewer bytes instead of being refused, and the size
|
||||
* checks below would then report the wrong problem.
|
||||
*/
|
||||
export function decodeBase64Url(value: string): Buffer | null {
|
||||
const trimmed = value.trim();
|
||||
if (!BASE64URL.test(trimmed)) return null;
|
||||
return Buffer.from(trimmed, 'base64url');
|
||||
}
|
||||
|
||||
/** An uncompressed P-256 point: 65 bytes, the first one 0x04 (VAPID public key, subscription p256dh). */
|
||||
export function isUncompressedP256Key(value: string): boolean {
|
||||
const bytes = decodeBase64Url(value);
|
||||
return bytes?.length === 65 && bytes[0] === 0x04;
|
||||
}
|
||||
|
||||
/** A P-256 private scalar as the Web Push tools print it: 32 bytes. */
|
||||
export function isP256PrivateKey(value: string): boolean {
|
||||
return decodeBase64Url(value)?.length === 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* The VAPID `sub` claim (RFC 8292 section 2.1): a mailto: address or an https:
|
||||
* URL. Apple refuses anything else, and so does this check, so a subject that
|
||||
* boots is one every push service accepts.
|
||||
*/
|
||||
export function isVapidSubject(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (/^mailto:[^\s@]+@[^\s@]+$/i.test(trimmed)) return true;
|
||||
try {
|
||||
return new URL(trimmed).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5257,6 +5257,36 @@ function runMigrations(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Web Push (#894): one row per browser a user switched push on in.
|
||||
*
|
||||
* The endpoint is the push service URL that browser handed out, so it is
|
||||
* unique: a browser shared by two accounts belongs to whoever subscribed on
|
||||
* it last. p256dh and auth are the browser's keys for the RFC 8291 message
|
||||
* encryption. vapid_public_key is the server key the subscription was made
|
||||
* against, so a changed key pair shows up as a mismatch the sender can clean
|
||||
* up, rather than as a push service refusing every message. failure_count
|
||||
* and last_success_at are bookkeeping for the sender; the rows go with the
|
||||
* user.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
vapid_public_key TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_success_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -28,6 +28,11 @@ export const SENSITIVE_KEYS = new Set([
|
||||
'code',
|
||||
'smtp_pass',
|
||||
'secretaccesskey',
|
||||
// A Web Push subscription's keys: whoever holds them together with the
|
||||
// endpoint can encrypt messages for that browser. No other request body has
|
||||
// either field, and an `auth` anywhere else would be a credential as well.
|
||||
'p256dh',
|
||||
'auth',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -43,7 +48,26 @@ function isSensitiveName(name: string): boolean {
|
||||
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
|
||||
/**
|
||||
* The fields PushSubscription.toJSON() puts next to `endpoint`. Either one is
|
||||
* enough to know the object is a subscription, so a body that arrives without
|
||||
* its keys (which the route then refuses) still keeps its endpoint out of the log.
|
||||
*/
|
||||
const PUSH_SUBSCRIPTION_FIELDS = new Set(['keys', 'expirationtime']);
|
||||
|
||||
/**
|
||||
* A Web Push endpoint is a capability URL: anyone holding it can post to that
|
||||
* browser's push service. It arrives inside a subscription, or on its own in
|
||||
* the body that forgets a device. Anywhere else `endpoint` is an ordinary
|
||||
* setting (the S3 backend's URL, for one) and stays readable.
|
||||
*/
|
||||
function isPushEndpoint(entries: Record<string, unknown>, name: string): boolean {
|
||||
if (name.toLowerCase() !== 'endpoint') return false;
|
||||
const siblings = Object.keys(entries).filter((k) => k !== name);
|
||||
return siblings.length === 0 || siblings.some((k) => PUSH_SUBSCRIPTION_FIELDS.has(k.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) and every Web Push endpoint from a request-log value. */
|
||||
export function redact(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
@@ -54,7 +78,8 @@ export function redact(value: unknown): unknown {
|
||||
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
|
||||
const hidden = isSensitiveName(k) || (namedSecret && k === 'value') || isPushEndpoint(entries, k);
|
||||
out[k] = hidden ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,9 @@ export class AdminService {
|
||||
id,
|
||||
);
|
||||
} catch { /* very old installs predate oauth_tokens */ }
|
||||
// Push devices outlive every session, so the intruder's browser would
|
||||
// keep receiving this account's notifications. They go with the rest.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
@@ -285,6 +286,7 @@ export class AuthService {
|
||||
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const hasPushEnabled = activeChannels.includes(WEB_PUSH_CHANNEL_ID);
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
|
||||
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||
@@ -343,7 +345,7 @@ export class AuthService {
|
||||
timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, push: hasPushEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
places_photos_enabled: placesPhotosEnabled,
|
||||
places_autocomplete_enabled: placesAutocompleteEnabled,
|
||||
@@ -596,6 +598,11 @@ export class AuthService {
|
||||
try {
|
||||
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
// Push devices keep receiving notifications without any session, so they
|
||||
// go too. The device the change was made on registers again right away:
|
||||
// the client reloads the user after the change, and that re-syncs its
|
||||
// subscription. Other devices do so after their next sign-in.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', userId);
|
||||
});
|
||||
|
||||
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
|
||||
@@ -1056,6 +1063,9 @@ export class AuthService {
|
||||
user.id
|
||||
);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
// Push devices are a delivery channel that outlives every session, so an
|
||||
// intruder's browser would keep reading this account's notifications.
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', user.id);
|
||||
});
|
||||
|
||||
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
|
||||
import { registerChannel } from '../channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notification-events';
|
||||
import { ADMIN_SCOPED_EVENTS, type ChannelMessage, type ExternalChannel } from '../notification-events';
|
||||
import type { MailerService } from '../mailer/mailer.service';
|
||||
import { resolveAdminNtfyUrl, resolveNtfyToken, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
|
||||
import type { WebhookService } from '../transports/webhook.service';
|
||||
import type { WebPushService } from '../transports/web-push.service';
|
||||
|
||||
// The three built-in external channels, wrapping the transports that were free
|
||||
// functions in services/notifications.ts before the fold. No delivery logic is
|
||||
// rewritten here - it is only relocated behind the ExternalChannel interface so
|
||||
// NotificationsService.send() can iterate instead of branching.
|
||||
// The built-in external channels. Email, webhook and ntfy wrap the transports
|
||||
// that were free functions in services/notifications.ts before the fold; no
|
||||
// delivery logic is rewritten here, it is only relocated behind the
|
||||
// ExternalChannel interface so NotificationsService.send() can iterate instead
|
||||
// of branching. Web Push (#894) joined later on the same terms.
|
||||
|
||||
function supportsAllButSynology(event: string): boolean {
|
||||
return event !== 'synology_session_cleared';
|
||||
@@ -17,10 +20,11 @@ export interface BuiltinChannelDeps {
|
||||
mailer: MailerService;
|
||||
webhook: WebhookService;
|
||||
ntfy: NtfyService;
|
||||
push: WebPushService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three built-in channels over the given transports.
|
||||
* Build the built-in channels over the given transports.
|
||||
*
|
||||
* They take their dependencies as an argument rather than importing them,
|
||||
* because the registry they land in is a module singleton while the transports
|
||||
@@ -32,7 +36,7 @@ export interface BuiltinChannelDeps {
|
||||
* preferences; dropping that import would have silenced email, webhook and ntfy
|
||||
* without a single error.
|
||||
*/
|
||||
export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] {
|
||||
export function buildBuiltinChannels({ mailer, webhook, ntfy, push }: BuiltinChannelDeps): ExternalChannel[] {
|
||||
const emailChannel: ExternalChannel = {
|
||||
id: 'email',
|
||||
source: 'builtin',
|
||||
@@ -114,7 +118,26 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
},
|
||||
};
|
||||
|
||||
return [emailChannel, webhookChannel, ntfyChannel];
|
||||
// Per user and per browser. It carries no admin-scoped events: those have
|
||||
// their own admin-global copy on the channels above, and a push column in
|
||||
// the admin matrix could never be switched on. There is no sendGlobal for the
|
||||
// same reason, and no credentials to check beyond the user having turned it
|
||||
// on in at least one browser. Subscribing is a browser-only act, so there is
|
||||
// no MCP twin for the routes behind it. While the server's key pair cannot be
|
||||
// used, push is not sent and not offered, and every device stays subscribed.
|
||||
const pushChannel: ExternalChannel = {
|
||||
id: WEB_PUSH_CHANNEL_ID,
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.push',
|
||||
hiddenWhileInstanceUnconfigured: true,
|
||||
supportsEvent: (event) => supportsAllButSynology(event) && !ADMIN_SCOPED_EVENTS.has(event),
|
||||
isInstanceConfigured: () => push.isAvailable(),
|
||||
isConfiguredFor: (userId) => push.hasDevices(userId),
|
||||
sendToUser: (userId, msg) => push.sendToUser(userId, msg),
|
||||
test: (userId) => push.sendTest(userId),
|
||||
};
|
||||
|
||||
return [emailChannel, webhookChannel, ntfyChannel, pushChannel];
|
||||
}
|
||||
|
||||
/** Idempotent - safe to call from every entry point that needs the registry populated. */
|
||||
|
||||
@@ -127,6 +127,13 @@ export interface ExternalChannel {
|
||||
readonly bypassesActiveToggleForAdminEvents?: boolean;
|
||||
/** Delivers the one admin-scoped global copy (not per-recipient). */
|
||||
readonly supportsAdminGlobal?: boolean;
|
||||
/**
|
||||
* The user's matrix marks this channel inactive (no column, no settings card)
|
||||
* while isInstanceConfigured() says no. Web Push sets it: without a usable key
|
||||
* pair a browser could not even subscribe. Email does not, and keeps its
|
||||
* column while SMTP is not set up, as it always has.
|
||||
*/
|
||||
readonly hiddenWhileInstanceUnconfigured?: boolean;
|
||||
|
||||
supportsEvent(event: NotifEventType): boolean;
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,16 @@ export class NotificationPreferencesService {
|
||||
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the channel offered to users at all right now? Switched on, and for a
|
||||
* channel that says so, ready on this instance too: Web Push without a usable
|
||||
* key pair is off for everyone, whatever the admin switch says.
|
||||
*/
|
||||
private isChannelOffered(channel: ExternalChannel): boolean {
|
||||
if (!this.isChannelActive(channel)) return false;
|
||||
return !channel.hiddenWhileInstanceUnconfigured || channel.isInstanceConfigured?.() !== false;
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -123,8 +133,10 @@ export class NotificationPreferencesService {
|
||||
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
|
||||
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
|
||||
for (const channel of listChannels()) {
|
||||
// Plugin channels are user-scoped only — they never carry admin-global events.
|
||||
if (channel.source !== 'builtin') continue;
|
||||
// Only the channels with an admin-global copy. Plugin channels and push are
|
||||
// user-scoped: they never carry an admin-scoped event, so a column for them
|
||||
// here could never be switched on.
|
||||
if (channel.source !== 'builtin' || !isAdminGlobalChannel(channel.id)) continue;
|
||||
const active = adminActive[channel.id] ?? false;
|
||||
out.push({
|
||||
id: channel.id,
|
||||
@@ -147,7 +159,7 @@ export class NotificationPreferencesService {
|
||||
settingsPath: channel.settingsPath,
|
||||
// A live plugin channel is always a column. `configured` tells the user whether
|
||||
// they still need to enter credentials — it does not hide the channel from them.
|
||||
active: this.isChannelActive(channel),
|
||||
active: this.isChannelOffered(channel),
|
||||
configured: channel.isConfiguredFor(userId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
testWebhookRequestSchema,
|
||||
testNtfyRequestSchema,
|
||||
notificationRespondRequestSchema,
|
||||
pushSubscribeRequestSchema,
|
||||
pushUnsubscribeRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -19,3 +21,5 @@ export class TestSmtpDto extends createZodDto(testSmtpRequestSchema) {}
|
||||
export class TestWebhookDto extends createZodDto(testWebhookRequestSchema) {}
|
||||
export class TestNtfyDto extends createZodDto(testNtfyRequestSchema) {}
|
||||
export class NotificationRespondDto extends createZodDto(notificationRespondRequestSchema) {}
|
||||
export class PushSubscribeDto extends createZodDto(pushSubscribeRequestSchema) {}
|
||||
export class PushUnsubscribeDto extends createZodDto(pushUnsubscribeRequestSchema) {}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { ReminderJobsService } from './reminder-jobs.service';
|
||||
import { StorageHealthNotifierService } from './storage-health-notifier.service';
|
||||
import { NtfyService } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { WebPushService } from './transports/web-push.service';
|
||||
import { PushController } from './push/push.controller';
|
||||
import { PushSubscriptionsService } from './push/push-subscriptions.service';
|
||||
import { VapidKeysService } from './push/vapid-keys.service';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
@@ -19,17 +23,23 @@ import { StorageModule } from '../storage/storage.module';
|
||||
* StorageModule feeds StorageHealthNotifierService, which bridges replica
|
||||
* failures into admin notifications — this direction has no cycle (Storage
|
||||
* imports only AppConfig+Audit+Scheduling).
|
||||
* Web Push (#894) lives in push/: VapidKeysService holds the server's key
|
||||
* pair, PushSubscriptionsService the browsers, PushController the routes
|
||||
* a browser signs up through; the transport is transports/web-push.service.ts.
|
||||
* NotificationsService and NotificationPreferencesService are exported for
|
||||
* in-container consumers (AdminController's dev test send and preferences tab,
|
||||
* the plugin RPC surface, HostSurfaceRpc). */
|
||||
@Module({
|
||||
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
|
||||
controllers: [NotificationsController, AdminNotificationPreferencesController],
|
||||
controllers: [NotificationsController, AdminNotificationPreferencesController, PushController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NotificationPreferencesService,
|
||||
WebhookService,
|
||||
NtfyService,
|
||||
WebPushService,
|
||||
VapidKeysService,
|
||||
PushSubscriptionsService,
|
||||
NotificationsMcp,
|
||||
ReminderJobsService,
|
||||
StorageHealthNotifierService,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEventText } from './mailer/email-html';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { NtfyService, type NtfyConfig } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { WebPushService } from './transports/web-push.service';
|
||||
import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service';
|
||||
import { registerBuiltinChannels } from './channels/builtins';
|
||||
import {
|
||||
@@ -272,7 +273,6 @@ function shouldSendToUser(
|
||||
prefs: NotificationPreferencesService,
|
||||
): boolean {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
@@ -285,7 +285,10 @@ function shouldSendToUser(
|
||||
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
}
|
||||
|
||||
return channel.isConfiguredFor(recipientId);
|
||||
if (!channel.isConfiguredFor(recipientId)) return false;
|
||||
// Last, since it is the costly check: Web Push reads and verifies the server's
|
||||
// key pair. Every check here is a plain AND, so the order changes no answer.
|
||||
return !channel.isInstanceConfigured || channel.isInstanceConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,12 +319,13 @@ export class NotificationsService {
|
||||
private readonly webhook: WebhookService,
|
||||
private readonly ntfy: NtfyService,
|
||||
private readonly prefs: NotificationPreferencesService,
|
||||
push: WebPushService,
|
||||
) {
|
||||
// The registry is a module singleton shared with the plugin runtime and any
|
||||
// separately-constructed instance (which never runs onModuleInit), so
|
||||
// registering from here means every path that can dispatch has the
|
||||
// built-ins. registerChannel is an idempotent Map.set.
|
||||
registerBuiltinChannels({ mailer, webhook, ntfy });
|
||||
registerBuiltinChannels({ mailer, webhook, ntfy, push });
|
||||
}
|
||||
|
||||
getPreferences(userId: number, role: string): PreferencesMatrix {
|
||||
@@ -779,7 +783,7 @@ export class NotificationsService {
|
||||
);
|
||||
}
|
||||
|
||||
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
|
||||
// ── External channels (email, webhook, ntfy, push, plugin:*) ─────────
|
||||
// One loop over the registry. The message is rendered once per recipient, in
|
||||
// their language, and handed to every channel that wants it — so a plugin
|
||||
// channel never touches i18n.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MAX_PUSH_ENDPOINT_LENGTH, type PushSubscriptionInput } from '@trek/shared';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
import { isP256Point } from './web-push-crypto';
|
||||
|
||||
/**
|
||||
* The browser hands us a URL and the server POSTs to it for as long as the row
|
||||
* lives, so the URL is checked like any other user-supplied target, and more
|
||||
* narrowly: it has to be one of the push services browsers actually use. The
|
||||
* SSRF guard still runs on every send; this list is what keeps a subscription
|
||||
* from turning the server into a POST relay towards some other public host.
|
||||
*/
|
||||
export const PUSH_SERVICE_HOSTS: readonly string[] = [
|
||||
// Chrome, Edge on Android, and every Chromium browser that uses Google's push.
|
||||
'fcm.googleapis.com',
|
||||
'android.googleapis.com',
|
||||
// Firefox (autopush).
|
||||
'updates.push.services.mozilla.com',
|
||||
'push.services.mozilla.com',
|
||||
// Safari on macOS, and Home Screen apps on iOS and iPadOS 16.4+.
|
||||
'web.push.apple.com',
|
||||
];
|
||||
|
||||
/** Services that hand out per-region or per-cluster hosts under one domain. */
|
||||
export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [
|
||||
'.push.apple.com',
|
||||
// Edge on Windows (WNS).
|
||||
'.notify.windows.com',
|
||||
];
|
||||
|
||||
/** The bespoke 400 bodies for a subscription the browser should not have sent. */
|
||||
export const PUSH_SUBSCRIPTION_ERRORS = {
|
||||
endpoint: 'Invalid push endpoint',
|
||||
service: 'Push endpoint is not a known push service',
|
||||
p256dh: 'Invalid push subscription key',
|
||||
auth: 'Invalid push subscription auth secret',
|
||||
} as const;
|
||||
|
||||
export interface CheckedPushSubscription {
|
||||
endpoint: string;
|
||||
/** Canonical base64url, no padding. */
|
||||
p256dh: string;
|
||||
/** Canonical base64url, no padding. */
|
||||
auth: string;
|
||||
}
|
||||
|
||||
export type PushSubscriptionCheck = { ok: true; value: CheckedPushSubscription } | { ok: false; error: string };
|
||||
|
||||
function isKnownPushServiceHost(host: string): boolean {
|
||||
return (
|
||||
PUSH_SERVICE_HOSTS.includes(host) ||
|
||||
PUSH_SERVICE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix) && host.length > suffix.length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint half of the check, shared with the sender so a row that no
|
||||
* longer passes (a host dropped from the list) is not posted to again.
|
||||
* Answers 'ok', or which of the errors applies: 'endpoint' for anything that is
|
||||
* not a plain https URL on the default port without credentials, 'service' for
|
||||
* a host that is not on the list.
|
||||
*/
|
||||
export function checkPushEndpoint(endpoint: string): 'ok' | 'endpoint' | 'service' {
|
||||
if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) return 'endpoint';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return 'endpoint';
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port) return 'endpoint';
|
||||
return isKnownPushServiceHost(url.hostname) ? 'ok' : 'service';
|
||||
}
|
||||
|
||||
export function isPushServiceEndpoint(endpoint: string): boolean {
|
||||
return checkPushEndpoint(endpoint) === 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the Zod contract cannot say about a subscription: where it points,
|
||||
* and that its keys have the sizes RFC 8291 needs (and the p256dh a point on
|
||||
* the curve), so the sender never meets a row it cannot encrypt for.
|
||||
*/
|
||||
export function checkPushSubscription(input: PushSubscriptionInput): PushSubscriptionCheck {
|
||||
const endpointVerdict = checkPushEndpoint(input.endpoint);
|
||||
if (endpointVerdict !== 'ok') return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS[endpointVerdict] };
|
||||
|
||||
const p256dh = decodeBase64Url(input.keys.p256dh);
|
||||
if (!p256dh || !isP256Point(p256dh)) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.p256dh };
|
||||
|
||||
const auth = decodeBase64Url(input.keys.auth);
|
||||
if (auth?.length !== 16) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.auth };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: { endpoint: input.endpoint, p256dh: p256dh.toString('base64url'), auth: auth.toString('base64url') },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import type { CheckedPushSubscription } from './push-subscription.helpers';
|
||||
|
||||
export interface PushSubscriptionRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
vapid_public_key: string;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
last_success_at: string | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many browsers one account can receive push on. Generous for phones,
|
||||
* tablets and a few desktop profiles; the cap only exists so a client that
|
||||
* subscribes in a loop cannot grow the table or the fan-out without bound.
|
||||
*/
|
||||
export const MAX_PUSH_DEVICES_PER_USER = 20;
|
||||
|
||||
/** Only there to tell one user's devices apart in the table, never parsed, so a cut-off one does no harm. */
|
||||
const USER_AGENT_MAX_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* The push_subscriptions table: one row per browser a user switched push on
|
||||
* in. Only rows the checks in push-subscription.helpers.ts accepted get here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushSubscriptionsService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Register a browser, or refresh it when it subscribes again. The endpoint
|
||||
* is unique, so a browser shared by two accounts moves to whoever subscribed
|
||||
* on it last instead of notifying both. A refresh renews created_at, which
|
||||
* makes the cap drop the device that registered longest ago rather than one
|
||||
* that is in daily use. Answers how many devices the user now has.
|
||||
*/
|
||||
upsert(
|
||||
userId: number,
|
||||
subscription: CheckedPushSubscription,
|
||||
vapidPublicKey: string,
|
||||
userAgent?: string | null,
|
||||
): number {
|
||||
const agent = userAgent ? userAgent.slice(0, USER_AGENT_MAX_LENGTH) : null;
|
||||
return this.db.transaction(() => {
|
||||
this.db.run(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
vapid_public_key = excluded.vapid_public_key,
|
||||
user_agent = excluded.user_agent,
|
||||
created_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0`,
|
||||
userId,
|
||||
subscription.endpoint,
|
||||
subscription.p256dh,
|
||||
subscription.auth,
|
||||
vapidPublicKey,
|
||||
agent,
|
||||
);
|
||||
const overflow = this.countForUser(userId) - MAX_PUSH_DEVICES_PER_USER;
|
||||
if (overflow > 0) {
|
||||
this.db.run(
|
||||
`DELETE FROM push_subscriptions WHERE id IN (
|
||||
SELECT id FROM push_subscriptions
|
||||
WHERE user_id = ? AND endpoint <> ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
)`,
|
||||
userId,
|
||||
subscription.endpoint,
|
||||
overflow,
|
||||
);
|
||||
}
|
||||
return this.countForUser(userId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one of the caller's own browsers. Someone else's endpoint is never touched. */
|
||||
removeForUser(userId: number, endpoint: string): boolean {
|
||||
return (
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', userId, endpoint).changes > 0
|
||||
);
|
||||
}
|
||||
|
||||
listForUser(userId: number): PushSubscriptionRow[] {
|
||||
return this.db.all<PushSubscriptionRow>('SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id', userId);
|
||||
}
|
||||
|
||||
countForUser(userId: number): number {
|
||||
return this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?', userId)?.n ?? 0;
|
||||
}
|
||||
|
||||
hasAny(userId: number): boolean {
|
||||
return !!this.db.get('SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1', userId);
|
||||
}
|
||||
|
||||
/** The sender's clean-up: the push service said the browser is gone, or the row can no longer be sent to. */
|
||||
deleteById(id: number): void {
|
||||
this.db.run('DELETE FROM push_subscriptions WHERE id = ?', id);
|
||||
}
|
||||
|
||||
recordSuccess(id: number): void {
|
||||
this.db.run(
|
||||
'UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?',
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts one more failed send since the last success and answers the new
|
||||
* count, read in the same statement so two sends at once cannot both see
|
||||
* the old value. 0 when the row is gone already.
|
||||
*/
|
||||
recordFailure(id: number): number {
|
||||
return (
|
||||
this.db.get<{ failure_count: number }>(
|
||||
'UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count',
|
||||
id,
|
||||
)?.failure_count ?? 0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Post, UseGuards } from '@nestjs/common';
|
||||
import type { PushPublicKeyResult, PushSubscribeResult, PushUnsubscribeResult } from '@trek/shared';
|
||||
import type { User } from '../../../types';
|
||||
import { CurrentUser } from '../../auth/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { PushSubscribeDto, PushUnsubscribeDto } from '../notifications.dto';
|
||||
import { checkPushSubscription } from './push-subscription.helpers';
|
||||
import { PushSubscriptionsService } from './push-subscriptions.service';
|
||||
import { PUSH_UNAVAILABLE_ERROR, PushUnavailableError, VapidKeysService } from './vapid-keys.service';
|
||||
|
||||
/**
|
||||
* /api/notifications/push: how a browser signs up for Web Push (#894).
|
||||
*
|
||||
* The browser fetches the server's public key, subscribes with its push
|
||||
* service, and hands the subscription over here; logging out or switching push
|
||||
* off in that browser deletes it again. Which events arrive is the ordinary
|
||||
* preference matrix ('push' column), and the test send is the generic
|
||||
* POST /api/notifications/test/push.
|
||||
*
|
||||
* No MCP tool mirrors these routes on purpose: a subscription is created by a
|
||||
* browser's push manager for that one browser, and there is nothing an MCP
|
||||
* client could subscribe with.
|
||||
*/
|
||||
@Controller('api/notifications/push')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PushController {
|
||||
constructor(
|
||||
private readonly keys: VapidKeysService,
|
||||
private readonly subscriptions: PushSubscriptionsService,
|
||||
) {}
|
||||
|
||||
@Get('public-key')
|
||||
publicKey(): PushPublicKeyResult {
|
||||
return { publicKey: this.currentPublicKey() };
|
||||
}
|
||||
|
||||
@Post('subscriptions')
|
||||
@HttpCode(200)
|
||||
subscribe(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: PushSubscribeDto,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
): PushSubscribeResult {
|
||||
const checked = checkPushSubscription(body.subscription);
|
||||
if ('error' in checked) throw new HttpException({ error: checked.error }, 400);
|
||||
const devices = this.subscriptions.upsert(user.id, checked.value, this.currentPublicKey(), userAgent);
|
||||
return { success: true, devices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: the push service may already have expired the row, and a
|
||||
* browser logging out must not get an error for that. Only the caller's own
|
||||
* endpoint can go; the same URL on someone else's account is left alone.
|
||||
* Works while push is unavailable too, so a device can always be forgotten.
|
||||
*/
|
||||
@Delete('subscriptions')
|
||||
@HttpCode(200)
|
||||
unsubscribe(@CurrentUser() user: User, @Body() body: PushUnsubscribeDto): PushUnsubscribeResult {
|
||||
this.subscriptions.removeForUser(user.id, body.endpoint);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The key browsers subscribe with. While the key pair cannot be used, stored
|
||||
* or from VAPID_* (the log says why), the answer is a 503: nothing could be
|
||||
* signed for a new subscription, and the devices already subscribed stay as
|
||||
* they are.
|
||||
*/
|
||||
private currentPublicKey(): string {
|
||||
try {
|
||||
return this.keys.getPublicKey();
|
||||
} catch (err) {
|
||||
if (err instanceof PushUnavailableError) throw new HttpException({ error: PUSH_UNAVAILABLE_ERROR }, 503);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { decodeBase64Url, isVapidSubject, readEnv, stripTrailingSlashes } from '../../../app-config';
|
||||
import { logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key, encrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { generateVapidKeyPair, isVapidKeyPair, type VapidKeyPair } from './web-push-crypto';
|
||||
|
||||
/** app_settings rows holding the generated pair. The private half is stored encrypted. */
|
||||
export const VAPID_PUBLIC_KEY_SETTING = 'web_push_vapid_public_key';
|
||||
export const VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key';
|
||||
|
||||
/** The VAPID contact when nothing better is configured: a real https URL every push service accepts. */
|
||||
export const FALLBACK_VAPID_SUBJECT = 'https://github.com/liketrek/TREK';
|
||||
|
||||
/** What the push routes answer while the key pair cannot be used. */
|
||||
export const PUSH_UNAVAILABLE_ERROR = 'Web Push is currently unavailable on this server';
|
||||
|
||||
export interface ResolvedVapidKeys extends VapidKeyPair {
|
||||
source: 'env' | 'database';
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by getKeys() while push is off because the pair it would sign with
|
||||
* cannot be used: the VAPID_* variables name one that cannot, or, with them
|
||||
* unset, the stored one cannot. The reason is in the server log (once, without
|
||||
* key material); this error only says that nothing can be signed right now.
|
||||
*/
|
||||
export class PushUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('there is no usable VAPID key pair; see the earlier Web Push error in the log');
|
||||
this.name = 'PushUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
type UnusableReason = 'incomplete' | 'undecryptable' | 'mismatched';
|
||||
|
||||
type StoredPair =
|
||||
| { state: 'absent' }
|
||||
| { state: 'usable'; keys: VapidKeyPair }
|
||||
| { state: UnusableReason; fingerprint: string };
|
||||
|
||||
type PresentPair = Exclude<StoredPair, { state: 'absent' }>;
|
||||
|
||||
type EnvPair =
|
||||
| { state: 'unset' }
|
||||
| { state: 'usable'; keys: VapidKeyPair }
|
||||
| { state: 'unusable'; fingerprint: string; problem: string };
|
||||
|
||||
const DELETE_BOTH_ROWS =
|
||||
`delete both app_settings rows ${VAPID_PUBLIC_KEY_SETTING} and ${VAPID_PRIVATE_KEY_SETTING} ` +
|
||||
'to generate a new pair (every device then has to turn push on again)';
|
||||
|
||||
/** The admin-facing explanation for each unusable state. Never contains key material. */
|
||||
const UNUSABLE_MESSAGES: Record<UnusableReason, string> = {
|
||||
incomplete: `only one half of the stored VAPID key pair exists. To start over, ${DELETE_BOTH_ROWS}.`,
|
||||
undecryptable:
|
||||
'the stored VAPID private key cannot be decrypted with the current ENCRYPTION_KEY (for example after a ' +
|
||||
'restore under a different key). Start the server with the ENCRYPTION_KEY it was encrypted with to bring ' +
|
||||
`push back as it was, or ${DELETE_BOTH_ROWS}.`,
|
||||
mismatched: `the stored VAPID private key does not belong to the stored public key. To start over, ${DELETE_BOTH_ROWS}.`,
|
||||
};
|
||||
|
||||
/** Re-encoded without padding, so the key the browser gets and the one rows are compared with is one string. */
|
||||
function canonical(value: string): string {
|
||||
return decodeBase64Url(value)?.toString('base64url') ?? value;
|
||||
}
|
||||
|
||||
/** An https URL on a host other than localhost: what APP_URL has to be to serve as the VAPID contact. */
|
||||
function isPublicHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && url.hostname !== 'localhost';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's VAPID identity: the key pair every subscription is bound to, and
|
||||
* the contact (`sub`) sent with each request.
|
||||
*
|
||||
* VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY win when both are set and belong
|
||||
* together. Otherwise the pair lives in app_settings, created once and then
|
||||
* kept: it travels with the database into every backup, where a key file in
|
||||
* data/ would not, and a restore that minted a new pair would silently cut off
|
||||
* every device.
|
||||
*
|
||||
* When the pair push should sign with cannot be used, push is off: it never
|
||||
* falls back on another pair. A pair in the environment that does not fit
|
||||
* together (or only one half of it) is logged rather than refusing to boot,
|
||||
* and push stays off until the variables are fixed, even while a usable pair
|
||||
* is stored: the devices subscribed with the environment key, so the first
|
||||
* send under any other pair would drop every one of them. A stored pair that
|
||||
* is there but cannot be used (a private key the current ENCRYPTION_KEY cannot
|
||||
* open, halves that do not match, one half missing) is never replaced either.
|
||||
* Either way the log says why, every device stays subscribed, and fixing the
|
||||
* variables or starting with the right ENCRYPTION_KEY again brings everything
|
||||
* back unchanged. A new pair is only ever generated while neither row exists
|
||||
* and neither VAPID_* variable is set.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VapidKeysService implements OnApplicationBootstrap {
|
||||
/** The last environment problem logged, so a bad pair is reported once and not on every send. */
|
||||
private reportedEnvProblem: string | null = null;
|
||||
/** The last stored-pair problem logged, for the same reason. */
|
||||
private reportedStoredProblem: string | null = null;
|
||||
/** The decrypted stored pair, keyed by the ciphertext it came from. */
|
||||
private stored: { ciphertext: string; keys: VapidKeyPair } | null = null;
|
||||
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Make sure the pair exists before the first request. The public-key GET is
|
||||
* the first thing a browser asks for, and a GET must not write; creating the
|
||||
* pair here keeps that route a read. The lazy path in getKeys() covers
|
||||
* instances built without the Nest lifecycle and a database restored under a
|
||||
* running server.
|
||||
*/
|
||||
onApplicationBootstrap(): void {
|
||||
this.succeeds(() => this.getKeys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is a pair to sign with right now. Only reads, so the
|
||||
* dispatcher and the preference matrix GET can ask on every call: while
|
||||
* neither row exists the answer is yes and nothing is written, because the
|
||||
* next send or subscribe creates the pair through getKeys(). Never throws;
|
||||
* the reason for a no is in the log.
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.succeeds(() => this.checkUsable());
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair to sign with, creating the stored one first while neither row
|
||||
* exists. Throws PushUnavailableError while the pair it would use cannot be.
|
||||
*/
|
||||
getKeys(): ResolvedVapidKeys {
|
||||
const fromEnv = this.envKeys();
|
||||
if (fromEnv) return { ...fromEnv, source: 'env' };
|
||||
return { ...this.usableStored(this.readOrCreateStored()), source: 'database' };
|
||||
}
|
||||
|
||||
getPublicKey(): string {
|
||||
return this.getKeys().publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sub` claim: VAPID_SUBJECT, else APP_URL when it is public https, else
|
||||
* the project URL. Push services use it to reach whoever runs the sender, and
|
||||
* Apple refuses a token without a valid one. Only APP_URL itself counts, not
|
||||
* getAppUrl()'s stand-ins: the first ALLOWED_ORIGINS entry can be another
|
||||
* site that is merely allowed to call this API, and http://localhost is no
|
||||
* valid subject.
|
||||
*/
|
||||
getSubject(): string {
|
||||
const env = readEnv();
|
||||
const configured = env.push.vapidSubject;
|
||||
if (configured && isVapidSubject(configured)) return configured;
|
||||
const appUrl = env.app.appUrl?.trim();
|
||||
return appUrl && isPublicHttpsUrl(appUrl) ? stripTrailingSlashes(appUrl) : FALLBACK_VAPID_SUBJECT;
|
||||
}
|
||||
|
||||
/** getKeys() without the create: the same refusals, and a missing stored pair counts as usable. */
|
||||
private checkUsable(): void {
|
||||
if (this.envKeys()) return;
|
||||
const stored = this.readStored();
|
||||
if (stored.state !== 'absent') this.usableStored(stored);
|
||||
}
|
||||
|
||||
private succeeds(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// An unusable pair has already been explained where it was found.
|
||||
if (!(err instanceof PushUnavailableError)) {
|
||||
logError(`Web Push: could not prepare the VAPID key pair: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair from VAPID_*, or null while neither variable is set. A pair that
|
||||
* is set but cannot be used turns push off rather than handing out the
|
||||
* stored pair (or a new one) in its place: the devices subscribed with the
|
||||
* environment key, and the first send under another pair would drop them
|
||||
* all. Push comes back unchanged once the variables are fixed.
|
||||
*/
|
||||
private envKeys(): VapidKeyPair | null {
|
||||
const env = this.readEnvPair();
|
||||
if (env.state !== 'unusable') {
|
||||
this.reportedEnvProblem = null;
|
||||
return env.state === 'usable' ? env.keys : null;
|
||||
}
|
||||
if (this.reportedEnvProblem !== env.fingerprint) {
|
||||
this.reportedEnvProblem = env.fingerprint;
|
||||
logError(
|
||||
`Web Push is off: ${env.problem}. Set both variables to the two halves of one pair to turn it back on. ` +
|
||||
'Until then nothing is sent, every device stays subscribed, and no pair stored in the database is ' +
|
||||
'used in its place.',
|
||||
);
|
||||
}
|
||||
throw new PushUnavailableError();
|
||||
}
|
||||
|
||||
/** What the VAPID_* variables hold. The fingerprint only tells one broken state from the next. */
|
||||
private readEnvPair(): EnvPair {
|
||||
const { vapidPublicKey, vapidPrivateKey } = readEnv().push;
|
||||
if (!vapidPublicKey && !vapidPrivateKey) return { state: 'unset' };
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
return {
|
||||
state: 'unusable',
|
||||
fingerprint: `half:${vapidPublicKey ?? ''}`,
|
||||
problem: `${vapidPublicKey ? 'VAPID_PUBLIC_KEY' : 'VAPID_PRIVATE_KEY'} is set without its other half`,
|
||||
};
|
||||
}
|
||||
if (!isVapidKeyPair(vapidPublicKey, vapidPrivateKey)) {
|
||||
return {
|
||||
state: 'unusable',
|
||||
fingerprint: `pair:${vapidPublicKey}`,
|
||||
problem: 'VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY',
|
||||
};
|
||||
}
|
||||
return { state: 'usable', keys: { publicKey: canonical(vapidPublicKey), privateKey: canonical(vapidPrivateKey) } };
|
||||
}
|
||||
|
||||
private setting(key: string): string | null {
|
||||
return this.db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
private writeSetting(key: string, value: string): void {
|
||||
this.db.run(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/** What app_settings holds right now. The fingerprint only tells one broken state from the next. */
|
||||
private readStored(): StoredPair {
|
||||
const publicKey = this.setting(VAPID_PUBLIC_KEY_SETTING);
|
||||
const ciphertext = this.setting(VAPID_PRIVATE_KEY_SETTING);
|
||||
if (!publicKey && !ciphertext) return { state: 'absent' };
|
||||
const fingerprint = `${publicKey ?? ''}\n${ciphertext ?? ''}`;
|
||||
if (!publicKey || !ciphertext) return { state: 'incomplete', fingerprint };
|
||||
if (this.stored?.ciphertext === ciphertext && this.stored.keys.publicKey === publicKey) {
|
||||
return { state: 'usable', keys: this.stored.keys };
|
||||
}
|
||||
const privateKey = decrypt_api_key(ciphertext);
|
||||
if (!privateKey) return { state: 'undecryptable', fingerprint };
|
||||
if (!isVapidKeyPair(publicKey, privateKey)) return { state: 'mismatched', fingerprint };
|
||||
const keys = { publicKey, privateKey };
|
||||
this.stored = { ciphertext, keys };
|
||||
return { state: 'usable', keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored pair, created first only when neither row exists: checked again
|
||||
* inside the transaction and written in it, both rows or neither, so the
|
||||
* pair is created exactly once.
|
||||
*/
|
||||
private readOrCreateStored(): PresentPair {
|
||||
const first = this.readStored();
|
||||
if (first.state !== 'absent') return first;
|
||||
return this.db.transaction((): PresentPair => {
|
||||
const again = this.readStored();
|
||||
if (again.state !== 'absent') return again;
|
||||
return { state: 'usable', keys: this.generateAndStore() };
|
||||
});
|
||||
}
|
||||
|
||||
/** The keys of a stored pair, or PushUnavailableError (logged once per state) when it cannot be used. */
|
||||
private usableStored(found: PresentPair): VapidKeyPair {
|
||||
if (found.state === 'usable') {
|
||||
this.reportedStoredProblem = null;
|
||||
return found.keys;
|
||||
}
|
||||
this.reportStoredProblem(found.state, found.fingerprint);
|
||||
throw new PushUnavailableError();
|
||||
}
|
||||
|
||||
private reportStoredProblem(reason: UnusableReason, fingerprint: string): void {
|
||||
const key = `${reason}:${fingerprint}`;
|
||||
if (this.reportedStoredProblem === key) return;
|
||||
this.reportedStoredProblem = key;
|
||||
logError(`Web Push is off: ${UNUSABLE_MESSAGES[reason]}`);
|
||||
}
|
||||
|
||||
private generateAndStore(): VapidKeyPair {
|
||||
const keys = generateVapidKeyPair();
|
||||
const ciphertext = encrypt_api_key(keys.privateKey);
|
||||
this.writeSetting(VAPID_PUBLIC_KEY_SETTING, keys.publicKey);
|
||||
this.writeSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext);
|
||||
this.stored = { ciphertext, keys };
|
||||
logInfo('Web Push: generated the VAPID key pair for this instance.');
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createECDH,
|
||||
createPrivateKey,
|
||||
ECDH,
|
||||
generateKeyPairSync,
|
||||
hkdfSync,
|
||||
randomBytes,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
|
||||
/**
|
||||
* Web Push message encryption (RFC 8291, `aes128gcm` from RFC 8188) and the
|
||||
* VAPID Authorization header (RFC 8292), on node:crypto and the jsonwebtoken
|
||||
* the server already signs its sessions with.
|
||||
*
|
||||
* Pure: no database, no network, no DI. The `web-push` package does the same
|
||||
* arithmetic, but it posts through its own https.request and so past the SSRF
|
||||
* guard; the request is the part this domain has to own, and what is left of
|
||||
* the package is this file.
|
||||
*/
|
||||
|
||||
/** Record size announced in the header. One record carries the whole message. */
|
||||
export const PUSH_RECORD_SIZE = 4096;
|
||||
|
||||
const P256_POINT_BYTES = 65;
|
||||
const AUTH_SECRET_BYTES = 16;
|
||||
const SALT_BYTES = 16;
|
||||
const GCM_TAG_BYTES = 16;
|
||||
/** salt (16) + rs (4) + idlen (1) + keyid (the 65-byte sender key). */
|
||||
const HEADER_BYTES = SALT_BYTES + 4 + 1 + P256_POINT_BYTES;
|
||||
|
||||
const KEY_INFO_PREFIX = Buffer.from('WebPush: info\0', 'latin1');
|
||||
const CEK_INFO = Buffer.from('Content-Encoding: aes128gcm\0', 'latin1');
|
||||
const NONCE_INFO = Buffer.from('Content-Encoding: nonce\0', 'latin1');
|
||||
/** Padding delimiter of the last (here: only) record, RFC 8188 section 2. */
|
||||
const LAST_RECORD_DELIMITER = Buffer.from([0x02]);
|
||||
|
||||
/** Largest plaintext that still fits one record next to its delimiter and tag. */
|
||||
export const MAX_PUSH_PLAINTEXT_BYTES = PUSH_RECORD_SIZE - HEADER_BYTES - 1 - GCM_TAG_BYTES;
|
||||
|
||||
/**
|
||||
* How long a VAPID token is good for. RFC 8292 allows up to 24 hours and Apple
|
||||
* refuses anything longer, so this stays well inside it.
|
||||
*/
|
||||
export const VAPID_TOKEN_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
/** A VAPID key pair in the form browsers and the Web Push tools use: base64url, raw. */
|
||||
export interface VapidKeyPair {
|
||||
/** Uncompressed P-256 point, 65 bytes. What the browser subscribes with. */
|
||||
publicKey: string;
|
||||
/** The 32-byte private scalar. */
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
/** The two secrets a browser hands over with its subscription. */
|
||||
export interface PushEncryptionKeys {
|
||||
/** The browser's P-256 public key, uncompressed. */
|
||||
p256dh: Buffer;
|
||||
/** The 16-byte authentication secret. */
|
||||
auth: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed inputs for the RFC 8291 test vector. Leave both unset for real
|
||||
* messages: every message needs a fresh salt and a fresh sender key, or the
|
||||
* same key and nonce would encrypt two plaintexts.
|
||||
*/
|
||||
export interface PushEncryptionOptions {
|
||||
salt?: Buffer;
|
||||
senderPrivateKey?: Buffer;
|
||||
}
|
||||
|
||||
function hkdf(ikm: Buffer, salt: Buffer, info: Buffer, length: number): Buffer {
|
||||
return Buffer.from(hkdfSync('sha256', ikm, salt, info, length));
|
||||
}
|
||||
|
||||
/** True for a point that is actually on P-256, not just 65 bytes that start with 0x04. */
|
||||
export function isP256Point(bytes: Buffer): boolean {
|
||||
if (bytes.length !== P256_POINT_BYTES || bytes[0] !== 0x04) return false;
|
||||
try {
|
||||
ECDH.convertKey(bytes, 'prime256v1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh P-256 pair. Exported as JWK so both halves come out at full length. */
|
||||
export function generateVapidKeyPair(): VapidKeyPair {
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const { x, y, d } = privateKey.export({ format: 'jwk' });
|
||||
if (!x || !y || !d) throw new Error('P-256 key export did not produce x, y and d');
|
||||
return {
|
||||
publicKey: Buffer.concat([Buffer.from([0x04]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')]).toString(
|
||||
'base64url',
|
||||
),
|
||||
privateKey: d,
|
||||
};
|
||||
}
|
||||
|
||||
/** The public key a private scalar belongs to, or null when the scalar is not a usable P-256 key. */
|
||||
export function derivePublicKey(privateKey: string): Buffer | null {
|
||||
const scalar = decodeBase64Url(privateKey);
|
||||
if (scalar?.length !== 32) return null;
|
||||
try {
|
||||
const ecdh = createECDH('prime256v1');
|
||||
ecdh.setPrivateKey(scalar);
|
||||
return ecdh.getPublicKey();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the two halves belong together. Compared as bytes, so padding and the like do not matter. */
|
||||
export function isVapidKeyPair(publicKey: string, privateKey: string): boolean {
|
||||
const expected = derivePublicKey(privateKey);
|
||||
const given = decodeBase64Url(publicKey);
|
||||
return !!expected && !!given && given.equals(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one push message for one browser (RFC 8291 section 3.4): ECDH with
|
||||
* the browser key, HKDF over the shared secret and the auth secret, then a
|
||||
* single AES-128-GCM record behind the aes128gcm header, whose key id is the
|
||||
* sender's public key.
|
||||
*/
|
||||
export function encryptPushMessage(
|
||||
plaintext: Buffer,
|
||||
keys: PushEncryptionKeys,
|
||||
options: PushEncryptionOptions = {},
|
||||
): Buffer {
|
||||
if (!isP256Point(keys.p256dh)) throw new Error('p256dh is not an uncompressed P-256 public key');
|
||||
if (keys.auth.length !== AUTH_SECRET_BYTES) throw new Error('auth secret must be 16 bytes');
|
||||
if (plaintext.length > MAX_PUSH_PLAINTEXT_BYTES) {
|
||||
throw new Error(`push payload of ${plaintext.length} bytes does not fit one record`);
|
||||
}
|
||||
|
||||
const salt = options.salt ?? randomBytes(SALT_BYTES);
|
||||
if (salt.length !== SALT_BYTES) throw new Error('salt must be 16 bytes');
|
||||
const sender = createECDH('prime256v1');
|
||||
if (options.senderPrivateKey) sender.setPrivateKey(options.senderPrivateKey);
|
||||
else sender.generateKeys();
|
||||
const senderPublicKey = sender.getPublicKey();
|
||||
|
||||
const ecdhSecret = sender.computeSecret(keys.p256dh);
|
||||
const keyInfo = Buffer.concat([KEY_INFO_PREFIX, keys.p256dh, senderPublicKey]);
|
||||
const ikm = hkdf(ecdhSecret, keys.auth, keyInfo, 32);
|
||||
const contentKey = hkdf(ikm, salt, CEK_INFO, 16);
|
||||
const nonce = hkdf(ikm, salt, NONCE_INFO, 12);
|
||||
|
||||
const cipher = createCipheriv('aes-128-gcm', contentKey, nonce);
|
||||
const record = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.update(LAST_RECORD_DELIMITER),
|
||||
cipher.final(),
|
||||
cipher.getAuthTag(),
|
||||
]);
|
||||
|
||||
const header = Buffer.alloc(SALT_BYTES + 4 + 1);
|
||||
salt.copy(header, 0);
|
||||
header.writeUInt32BE(PUSH_RECORD_SIZE, SALT_BYTES);
|
||||
header.writeUInt8(senderPublicKey.length, SALT_BYTES + 4);
|
||||
return Buffer.concat([header, senderPublicKey, record]);
|
||||
}
|
||||
|
||||
/** The signing key, and the public key re-encoded the way the `k=` parameter wants it (no padding). */
|
||||
function vapidSigningKey(keys: VapidKeyPair): { signingKey: KeyObject; publicKey: string } {
|
||||
const point = decodeBase64Url(keys.publicKey);
|
||||
const scalar = decodeBase64Url(keys.privateKey);
|
||||
if (point?.length !== P256_POINT_BYTES || scalar?.length !== 32) throw new Error('VAPID key pair is malformed');
|
||||
const signingKey = createPrivateKey({
|
||||
key: {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: point.subarray(1, 33).toString('base64url'),
|
||||
y: point.subarray(33).toString('base64url'),
|
||||
d: scalar.toString('base64url'),
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
return { signingKey, publicKey: point.toString('base64url') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function that builds the Authorization header for an endpoint
|
||||
* (RFC 8292): `vapid t=<ES256 JWT>, k=<public key>`, the token's audience being
|
||||
* the endpoint's origin. Tokens are reused per origin for the life of the
|
||||
* signer, which is one delivery, so a user with three Chrome devices signs once.
|
||||
*/
|
||||
export function createVapidSigner(keys: VapidKeyPair, subject: string, now: () => number = Date.now) {
|
||||
const { signingKey, publicKey } = vapidSigningKey(keys);
|
||||
const byOrigin = new Map<string, string>();
|
||||
return (endpoint: string): string => {
|
||||
const audience = new URL(endpoint).origin;
|
||||
let header = byOrigin.get(audience);
|
||||
if (!header) {
|
||||
const token = jwt.sign(
|
||||
{ aud: audience, exp: Math.floor(now() / 1000) + VAPID_TOKEN_TTL_SECONDS, sub: subject },
|
||||
signingKey,
|
||||
// The three claims RFC 8292 names and nothing else: an extra iat buys
|
||||
// nothing and is one more thing a strict push service could object to.
|
||||
{ algorithm: 'ES256', noTimestamp: true },
|
||||
);
|
||||
header = `vapid t=${token}, k=${publicKey}`;
|
||||
byOrigin.set(audience, header);
|
||||
}
|
||||
return header;
|
||||
};
|
||||
}
|
||||
|
||||
export type VapidSigner = ReturnType<typeof createVapidSigner>;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ChannelTestResult, PushPayload } from '@trek/shared';
|
||||
import { decodeBase64Url } from '../../../app-config';
|
||||
import { discardBody, readCappedText } from '../../../utils/cappedFetch';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
|
||||
import { logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import type { ChannelMessage } from '../notification-events';
|
||||
import { isPushServiceEndpoint } from '../push/push-subscription.helpers';
|
||||
import { PushSubscriptionsService, type PushSubscriptionRow } from '../push/push-subscriptions.service';
|
||||
import { PUSH_UNAVAILABLE_ERROR, VapidKeysService } from '../push/vapid-keys.service';
|
||||
import { createVapidSigner, encryptPushMessage, type VapidSigner } from '../push/web-push-crypto';
|
||||
|
||||
/**
|
||||
* Ceiling for the JSON the service worker receives. A single aes128gcm record
|
||||
* can carry just under 4 KB and some push services refuse anything larger, so
|
||||
* the payload stays well clear of that and the body gives way first.
|
||||
*/
|
||||
export const MAX_PUSH_PAYLOAD_BYTES = 3000;
|
||||
|
||||
const TITLE_MAX_CHARS = 120;
|
||||
/** A navigate target is an app path like /trips/12; anything longer is not one. */
|
||||
const URL_MAX_LENGTH = 256;
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
const PUSH_TIMEOUT_MS = 10_000;
|
||||
/** A day: a reminder that reaches a phone after a longer flight is still worth showing, a week-old one is not. */
|
||||
const PUSH_TTL_SECONDS = 24 * 60 * 60;
|
||||
const ERROR_BODY_MAX_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* How many failed sends in a row (with no success between them) a device gets
|
||||
* before a 403 removes it. A 403 is not proof the subscription is dead: Apple
|
||||
* also answers it for a token it rejects (BadJwtToken, a bad subject, clock
|
||||
* skew), which is the server's problem and must not cost the user their device
|
||||
* on the first try.
|
||||
*/
|
||||
export const FORBIDDEN_FAILURES_BEFORE_REMOVAL = 3;
|
||||
|
||||
/**
|
||||
* The one event whose notifications replace each other: a burst of chat
|
||||
* messages in one trip shows as one notification that keeps updating. Every
|
||||
* other event stands on its own, so two todos, two bookings or two plugin
|
||||
* notices never hide one another.
|
||||
*/
|
||||
const COLLAPSING_EVENT = 'collab_message';
|
||||
|
||||
type Urgency = 'normal' | 'high';
|
||||
/** The events that are about a date, where arriving late defeats the point (RFC 8030 section 5.3). */
|
||||
const HIGH_URGENCY_EVENTS: ReadonlySet<string> = new Set(['trip_reminder', 'todo_due']);
|
||||
|
||||
/** No tag either: two presses of Send test are two notifications, like two of anything but chat. */
|
||||
const TEST_PAYLOAD: PushPayload = {
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from TREK. If you received this, push notifications are working on this device.',
|
||||
url: '/settings?tab=notifications',
|
||||
};
|
||||
|
||||
/**
|
||||
* The target the worker may open: a path on this origin only. Absolute URLs
|
||||
* and protocol-relative ones (`//host`, `/\host`) would take the click to
|
||||
* another site, so they are dropped rather than passed on.
|
||||
*/
|
||||
export function samePagePath(target: string | undefined): string | undefined {
|
||||
if (!target || target.length > URL_MAX_LENGTH) return undefined;
|
||||
if (!/^\/(?![/\\])[\x21-\x7e]*$/.test(target)) return undefined;
|
||||
return target;
|
||||
}
|
||||
|
||||
function truncateChars(value: string, max: number): string {
|
||||
const chars = Array.from(value);
|
||||
return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}${ELLIPSIS}`;
|
||||
}
|
||||
|
||||
function payloadBytes(payload: PushPayload): number {
|
||||
return Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered notification as the service worker gets it. Title and body are
|
||||
* already in the recipient's language; the worker has no translations. Only
|
||||
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
|
||||
* one trip shows as one notification that keeps updating instead of a pile of
|
||||
* them. Everything else has no tag and never replaces an earlier notification.
|
||||
*
|
||||
* The body is cut, at a character boundary and with an ellipsis, until the
|
||||
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
|
||||
* whatever they are, the body always keeps room of its own.
|
||||
*/
|
||||
export function buildPushPayload(
|
||||
msg: Pick<ChannelMessage, 'event' | 'title' | 'body' | 'navigateTarget'>,
|
||||
): PushPayload {
|
||||
const url = samePagePath(msg.navigateTarget);
|
||||
const base: PushPayload = {
|
||||
title: truncateChars(msg.title, TITLE_MAX_CHARS),
|
||||
body: '',
|
||||
...(url ? { url } : {}),
|
||||
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
|
||||
};
|
||||
const full = { ...base, body: msg.body };
|
||||
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
|
||||
|
||||
const chars = Array.from(msg.body);
|
||||
let fits = 0;
|
||||
let tooLong = chars.length;
|
||||
// Largest prefix that still fits with the ellipsis behind it.
|
||||
while (tooLong - fits > 1) {
|
||||
const mid = Math.floor((fits + tooLong) / 2);
|
||||
if (payloadBytes({ ...base, body: `${chars.slice(0, mid).join('')}${ELLIPSIS}` }) <= MAX_PUSH_PAYLOAD_BYTES)
|
||||
fits = mid;
|
||||
else tooLong = mid;
|
||||
}
|
||||
return { ...base, body: `${chars.slice(0, fits).join('')}${ELLIPSIS}` };
|
||||
}
|
||||
|
||||
function hostOf(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch {
|
||||
return 'invalid-endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Push delivery: encrypt the payload for each of a user's browsers and
|
||||
* POST it to that browser's push service, through the SSRF guard.
|
||||
*
|
||||
* Mirrors the ntfy and webhook transports: every failure is logged and turned
|
||||
* into `false`, nothing throws into the dispatcher. It also tends the table as
|
||||
* it goes, because the push services are the only ones who know a browser is
|
||||
* gone: 404 and 410 mean the subscription expired or was revoked, and the row
|
||||
* is deleted at once. A 403 is counted like any other failure and only removes
|
||||
* the row once it has failed FORBIDDEN_FAILURES_BEFORE_REMOVAL times in a row,
|
||||
* because the same status also means a rejected token. While the server's key
|
||||
* pair is unusable (VapidKeysService logs why) nothing is sent, every row
|
||||
* stays and the test send says so. Endpoints are capability URLs, so the log
|
||||
* names the push service host, never the URL.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebPushService {
|
||||
constructor(
|
||||
private readonly keys: VapidKeysService,
|
||||
private readonly subscriptions: PushSubscriptionsService,
|
||||
) {}
|
||||
|
||||
hasDevices(userId: number): boolean {
|
||||
return this.subscriptions.hasAny(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* False while the server has no key pair it can sign with. The channel
|
||||
* reports it as its instance readiness, so the dispatcher skips push for
|
||||
* everyone (every device stays subscribed) and the user's settings drop the
|
||||
* Push card and column instead of offering a switch that could only fail.
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.keys.isAvailable();
|
||||
}
|
||||
|
||||
/** True when at least one of the user's browsers accepted the message. */
|
||||
sendToUser(userId: number, msg: ChannelMessage): Promise<boolean> {
|
||||
const urgency: Urgency = HIGH_URGENCY_EVENTS.has(msg.event) ? 'high' : 'normal';
|
||||
return this.deliver(userId, buildPushPayload(msg), urgency, msg.event);
|
||||
}
|
||||
|
||||
/** The Send test button: a short fixed message to every browser the user turned push on in. */
|
||||
async sendTest(userId: number): Promise<ChannelTestResult> {
|
||||
if (!this.isAvailable()) return { success: false, error: PUSH_UNAVAILABLE_ERROR };
|
||||
const sent = await this.deliver(userId, TEST_PAYLOAD, 'normal', 'test');
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send push notification' };
|
||||
}
|
||||
|
||||
private async deliver(userId: number, payload: PushPayload, urgency: Urgency, event: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = this.subscriptions.listForUser(userId);
|
||||
if (rows.length === 0) return false;
|
||||
const keys = this.keys.getKeys();
|
||||
const sign = createVapidSigner(keys, this.keys.getSubject());
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
const results = await Promise.all(
|
||||
rows.map((row) => this.deliverTo(row, plaintext, keys.publicKey, sign, urgency, event)),
|
||||
);
|
||||
return results.includes(true);
|
||||
} catch (err) {
|
||||
logError(`Web Push failed event=${event} user=${userId}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverTo(
|
||||
row: PushSubscriptionRow,
|
||||
plaintext: Buffer,
|
||||
publicKey: string,
|
||||
sign: VapidSigner,
|
||||
urgency: Urgency,
|
||||
event: string,
|
||||
): Promise<boolean> {
|
||||
const host = hostOf(row.endpoint);
|
||||
if (row.vapid_public_key !== publicKey) {
|
||||
// Subscribed against a key this server no longer signs with, so the push
|
||||
// service would refuse every message. Until the browser subscribes again
|
||||
// with the current key there is nothing to send to.
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logInfo(`Web Push dropped a device subscribed with a previous server key user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
if (!isPushServiceEndpoint(row.endpoint)) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logError(`Web Push dropped a device whose endpoint is not a known push service user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const p256dh = decodeBase64Url(row.p256dh);
|
||||
const auth = decodeBase64Url(row.auth);
|
||||
if (!p256dh || !auth) throw new Error('stored subscription keys are not base64url');
|
||||
const body = encryptPushMessage(plaintext, { p256dh, auth });
|
||||
// maxRedirects 0: a push service answers, it does not redirect, and a
|
||||
// redirect would carry the VAPID token somewhere it was not minted for.
|
||||
// bypassInternalIpAllowed keeps private addresses closed even on an
|
||||
// install that opened ALLOW_INTERNAL_NETWORK for its own services.
|
||||
const res = await safeFetchFollow(
|
||||
row.endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
TTL: String(PUSH_TTL_SECONDS),
|
||||
Urgency: urgency,
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Authorization: sign(row.endpoint),
|
||||
},
|
||||
body: new Uint8Array(body),
|
||||
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
|
||||
},
|
||||
{ maxRedirects: 0, bypassInternalIpAllowed: true },
|
||||
);
|
||||
return await this.settle(row, res, host, event);
|
||||
} catch (err) {
|
||||
this.subscriptions.recordFailure(row.id);
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
logError(
|
||||
`Web Push blocked by SSRF guard event=${event} user=${row.user_id} host=${host} reason=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
logError(
|
||||
`Web Push failed event=${event} user=${row.user_id} host=${host}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async settle(row: PushSubscriptionRow, res: Response, host: string, event: string): Promise<boolean> {
|
||||
if (res.ok) {
|
||||
discardBody(res);
|
||||
this.subscriptions.recordSuccess(row.id);
|
||||
logInfo(`Web Push sent event=${event} user=${row.user_id} host=${host}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { text } = await readCappedText(res, ERROR_BODY_MAX_BYTES).catch(() => ({ text: '' }));
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logInfo(`Web Push removed an expired device (HTTP ${res.status}) user=${row.user_id} host=${host}`);
|
||||
return false;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
// Either the subscription was made for a different key, or the push
|
||||
// service rejects the token itself (Apple's BadJwtToken), which is a
|
||||
// configuration problem and not the device's. Only a run of refusals
|
||||
// with no success between them removes the row; the reason is logged
|
||||
// every time so the second case can be told apart.
|
||||
const failures = this.subscriptions.recordFailure(row.id);
|
||||
if (failures >= FORBIDDEN_FAILURES_BEFORE_REMOVAL) {
|
||||
this.subscriptions.deleteById(row.id);
|
||||
logError(
|
||||
`Web Push removed a device after ${failures} failed sends in a row ending in HTTP 403 ` +
|
||||
`user=${row.user_id} host=${host}: ${text}`,
|
||||
);
|
||||
} else {
|
||||
logError(
|
||||
`Web Push HTTP 403 event=${event} user=${row.user_id} host=${host} ` +
|
||||
`(failure ${failures} of ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} before removal): ${text}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.subscriptions.recordFailure(row.id);
|
||||
logError(`Web Push HTTP ${res.status} event=${event} user=${row.user_id} host=${host}: ${text}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Web Push routes e2e: /api/notifications/push/* and the push test send
|
||||
* through the real JwtAuthGuard, the Zod pipe and the real providers on a temp
|
||||
* SQLite db. Only the outbound POST (safeFetchFollow) is replaced.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
// The harness db (users for the guard) plus the two tables push reads, built
|
||||
// inside the factory; the test reaches it through the mocked module.
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createTempDb } = await import('./harness');
|
||||
const db = createTempDb();
|
||||
db.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
// Same DDL as migration 244.
|
||||
db.exec(`CREATE TABLE push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
vapid_public_key TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_success_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);`);
|
||||
return { db, closeDb: () => {}, reinitialize: () => {} };
|
||||
});
|
||||
|
||||
const { safeFetchFollow } = vi.hoisted(() => ({ safeFetchFollow: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
||||
safeFetchFollow,
|
||||
}));
|
||||
|
||||
import { db } from '../../src/db/database';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/e2e-device';
|
||||
|
||||
function subscription(endpoint = ENDPOINT) {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
return {
|
||||
endpoint,
|
||||
expirationTime: null,
|
||||
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 3).toString('base64url') },
|
||||
};
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return db
|
||||
.prepare('SELECT user_id, endpoint, vapid_public_key, user_agent FROM push_subscriptions ORDER BY id')
|
||||
.all() as {
|
||||
user_id: number;
|
||||
endpoint: string;
|
||||
vapid_public_key: string;
|
||||
user_agent: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
describe('Web Push e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, RealtimeModule, NotificationsModule],
|
||||
}).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db, { id: 1, email: 'one@example.test' });
|
||||
seedUser(db, { id: 2, email: 'two@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare('DELETE FROM push_subscriptions').run();
|
||||
safeFetchFollow.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 on every push route without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/notifications/push/public-key')).status).toBe(401);
|
||||
expect(
|
||||
(await request(server).post('/api/notifications/push/subscriptions').send({ subscription: subscription() }))
|
||||
.status,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(await request(server).delete('/api/notifications/push/subscriptions').send({ endpoint: ENDPOINT })).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('GET public-key: the pair exists from bootstrap on, so the route only reads it', async () => {
|
||||
const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'web_push_vapid_public_key'").get() as {
|
||||
value: string;
|
||||
};
|
||||
const res = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ publicKey: stored.value });
|
||||
const raw = Buffer.from(res.body.publicKey, 'base64url');
|
||||
expect(raw.length).toBe(65);
|
||||
expect(raw[0]).toBe(0x04);
|
||||
});
|
||||
|
||||
it('POST subscriptions: 200 with the device count, stored against the served key', async () => {
|
||||
const { body: key } = await request(server)
|
||||
.get('/api/notifications/push/public-key')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
const res = await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.set('User-Agent', 'e2e-browser/1.0')
|
||||
.send({ subscription: subscription() });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, devices: 1 });
|
||||
expect(rows()).toEqual([
|
||||
{ user_id: 1, endpoint: ENDPOINT, vapid_public_key: key.publicKey, user_agent: 'e2e-browser/1.0' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST subscriptions: the same browser subscribing for another account moves to it', async () => {
|
||||
await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription() });
|
||||
const res = await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.send({ subscription: subscription() });
|
||||
expect(res.body).toEqual({ success: true, devices: 1 });
|
||||
expect(rows().map((r) => r.user_id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('POST subscriptions: 400 with the bespoke message for a host that is not a push service', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription('https://example.com/collect') });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Push endpoint is not a known push service' });
|
||||
expect(rows()).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST subscriptions: 400 from the contract when the keys are missing', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: { endpoint: ENDPOINT } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toEqual(expect.stringContaining('keys'));
|
||||
});
|
||||
|
||||
it('DELETE subscriptions: only the owner removes the endpoint, and a repeat is still 200', async () => {
|
||||
await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription() });
|
||||
|
||||
const stranger = await request(server)
|
||||
.delete('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.send({ endpoint: ENDPOINT });
|
||||
expect(stranger.status).toBe(200);
|
||||
expect(rows()).toHaveLength(1);
|
||||
|
||||
const owner = await request(server)
|
||||
.delete('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ endpoint: ENDPOINT });
|
||||
expect(owner.status).toBe(200);
|
||||
expect(owner.body).toEqual({ success: true });
|
||||
expect(rows()).toEqual([]);
|
||||
|
||||
const again = await request(server)
|
||||
.delete('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ endpoint: ENDPOINT });
|
||||
expect(again.status).toBe(200);
|
||||
});
|
||||
|
||||
it('DELETE subscriptions: 400 without an endpoint', async () => {
|
||||
const res = await request(server)
|
||||
.delete('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST test/push: sends the test message to the caller’s devices through the SSRF-guarded fetch', async () => {
|
||||
const none = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||||
expect(none.status).toBe(200);
|
||||
expect(none.body).toEqual({ success: false, error: 'Channel is not configured for this user' });
|
||||
|
||||
await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription() });
|
||||
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
|
||||
const res = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(safeFetchFollow).toHaveBeenCalledWith(
|
||||
ENDPOINT,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ 'Content-Encoding': 'aes128gcm' }),
|
||||
}),
|
||||
{ maxRedirects: 0, bypassInternalIpAllowed: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('a stored private key that no longer decrypts turns push off with a 503 and is never replaced', async () => {
|
||||
const readKey = (key: string) =>
|
||||
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string }).value;
|
||||
const unreadable = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
|
||||
const publicKey = readKey('web_push_vapid_public_key');
|
||||
const ciphertext = readKey('web_push_vapid_private_key');
|
||||
await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription() });
|
||||
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(unreadable, 'web_push_vapid_private_key');
|
||||
try {
|
||||
const key = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||||
expect(key.status).toBe(503);
|
||||
expect(key.body).toEqual({ error: 'Web Push is currently unavailable on this server' });
|
||||
const sub = await request(server)
|
||||
.post('/api/notifications/push/subscriptions')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ subscription: subscription(`${ENDPOINT}-second`) });
|
||||
expect(sub.status).toBe(503);
|
||||
const test = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||||
expect(test.status).toBe(200);
|
||||
expect(test.body).toEqual({ success: false, error: 'Web Push is currently unavailable on this server' });
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
// The device registered before stays, and neither stored row was touched.
|
||||
expect(rows().map((r) => r.endpoint)).toEqual([ENDPOINT]);
|
||||
expect(readKey('web_push_vapid_public_key')).toBe(publicKey);
|
||||
expect(readKey('web_push_vapid_private_key')).toBe(unreadable);
|
||||
} finally {
|
||||
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, 'web_push_vapid_private_key');
|
||||
}
|
||||
const back = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||||
expect(back.body).toEqual({ publicKey });
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,16 @@ import { NotificationPreferencesService } from '../../src/nest/notifications/not
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
|
||||
import { WebPushService } from '../../src/nest/notifications/transports/web-push.service';
|
||||
import { PushSubscriptionsService } from '../../src/nest/notifications/push/push-subscriptions.service';
|
||||
import { VapidKeysService } from '../../src/nest/notifications/push/vapid-keys.service';
|
||||
|
||||
/**
|
||||
* A NotificationsService wired the way Nest wires it.
|
||||
*
|
||||
* The domain takes six providers since the fold, and eight places used to build
|
||||
* it by hand — every added constructor parameter was an eight-file diff. One
|
||||
* helper keeps that at one.
|
||||
* The domain takes seven providers since Web Push joined, and eight places used
|
||||
* to build it by hand: every added constructor parameter was an eight-file
|
||||
* diff. One helper keeps that at one.
|
||||
*/
|
||||
export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService {
|
||||
const mailer = new MailerService(dbs);
|
||||
@@ -22,9 +25,15 @@ export function makeNotificationsService(dbs: DatabaseService, realtime = new Re
|
||||
new WebhookService(dbs),
|
||||
new NtfyService(dbs),
|
||||
new NotificationPreferencesService(dbs, mailer),
|
||||
makeWebPushService(dbs),
|
||||
);
|
||||
}
|
||||
|
||||
/** The Web Push transport over its two providers, on the same connection. */
|
||||
export function makeWebPushService(dbs: DatabaseService): WebPushService {
|
||||
return new WebPushService(new VapidKeysService(dbs), new PushSubscriptionsService(dbs));
|
||||
}
|
||||
|
||||
/** The preferences half on its own, over the same connection. */
|
||||
export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService {
|
||||
return new NotificationPreferencesService(dbs, new MailerService(dbs));
|
||||
|
||||
@@ -108,6 +108,7 @@ const RESET_TABLES = [
|
||||
'visited_countries',
|
||||
'bucket_list',
|
||||
// Notifications & audit
|
||||
'push_subscriptions',
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
|
||||
@@ -81,3 +81,62 @@ describe('the Places and Amap switches in the templates', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Web Push keys in the templates', () => {
|
||||
// All optional, since TREK keeps a generated pair in its database; an
|
||||
// operator who wants their own pair has to find the names somewhere.
|
||||
const documented = ['VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'VAPID_SUBJECT'];
|
||||
|
||||
it('.env.example and docker-compose.yml document every one of them', () => {
|
||||
for (const name of documented) {
|
||||
expect(envExample).toContain(`# ${name}=`);
|
||||
expect(compose).toContain(`# - ${name}=`);
|
||||
}
|
||||
});
|
||||
|
||||
it('the Helm ConfigMap passes through the public key and the subject', () => {
|
||||
for (const name of ['VAPID_PUBLIC_KEY', 'VAPID_SUBJECT']) {
|
||||
expect(helmValues).toContain(`# ${name}:`);
|
||||
expect(helmConfigMap).toContain(`${name}: {{ .Values.env.${name} | quote }}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('no template promises an admin address as the push contact, since TREK never sends one on its own', () => {
|
||||
const subjectDocs = [
|
||||
envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_SUBJECT=')),
|
||||
compose.split(/\r?\n/).find((l) => l.includes('- VAPID_SUBJECT=')),
|
||||
helmValues.slice(helmValues.indexOf('# VAPID_SUBJECT:')).split(/\r?\n\r?\n/)[0],
|
||||
];
|
||||
for (const doc of subjectDocs) {
|
||||
expect(doc).toContain('APP_URL');
|
||||
expect(doc).not.toContain('ADMIN_EMAIL');
|
||||
}
|
||||
});
|
||||
|
||||
it('no template says a broken pair is ignored, since push is off until it is fixed and no stored pair stands in', () => {
|
||||
// The Helm comment wraps, so its lines are joined back into one sentence first.
|
||||
const helmPrivateKey = helmValues
|
||||
.slice(helmValues.indexOf('# Optional Web Push key pair, private half'), helmValues.indexOf(' VAPID_PRIVATE_KEY: ""'))
|
||||
.replace(/\s*\n\s*#\s*/g, ' ');
|
||||
const privateKeyDocs = [
|
||||
envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_PRIVATE_KEY=')),
|
||||
compose.split(/\r?\n/).find((l) => l.includes('- VAPID_PRIVATE_KEY=')),
|
||||
helmPrivateKey,
|
||||
];
|
||||
for (const doc of privateKeyDocs) {
|
||||
expect(doc).toContain('turns push off');
|
||||
expect(doc).not.toContain('ignored');
|
||||
expect(doc).not.toContain('keeps working');
|
||||
}
|
||||
});
|
||||
|
||||
it('the Helm chart carries the private key in the Secret, never in the ConfigMap', () => {
|
||||
const name = 'VAPID_PRIVATE_KEY';
|
||||
expect(helmValues).toContain(` ${name}: ""`);
|
||||
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`);
|
||||
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`);
|
||||
expect(helmDeployment).toContain(`- name: ${name}\n valueFrom:\n secretKeyRef:`);
|
||||
expect(helmDeployment).toContain(`key: ${name}\n optional: true`);
|
||||
expect(helmConfigMap).not.toContain(name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
deriveBackup,
|
||||
deriveNet,
|
||||
derivePaths,
|
||||
derivePush,
|
||||
deriveAll,
|
||||
} from '../../../src/app-config/derive';
|
||||
|
||||
@@ -277,7 +278,7 @@ describe('deriveAll', () => {
|
||||
expect(env.demo.enabled).toBe(true);
|
||||
for (const ns of [
|
||||
'app', 'http', 'session', 'demo', 'adminBootstrap', 'oidc', 'smtp', 'mcp',
|
||||
'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net',
|
||||
'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net', 'push',
|
||||
] as const) {
|
||||
expect(env[ns]).toBeDefined();
|
||||
}
|
||||
@@ -309,3 +310,17 @@ describe('deriveMaps', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePush', () => {
|
||||
it('passes the VAPID_* values through trimmed, blank counting as unset', () => {
|
||||
expect(derivePush({})).toEqual({ vapidPublicKey: undefined, vapidPrivateKey: undefined, vapidSubject: undefined });
|
||||
expect(
|
||||
derivePush({ VAPID_PUBLIC_KEY: ' BPub ', VAPID_PRIVATE_KEY: 'priv', VAPID_SUBJECT: ' mailto:ops@example.com ' }),
|
||||
).toEqual({ vapidPublicKey: 'BPub', vapidPrivateKey: 'priv', vapidSubject: 'mailto:ops@example.com' });
|
||||
expect(derivePush({ VAPID_PUBLIC_KEY: ' ', VAPID_SUBJECT: '' })).toEqual({
|
||||
vapidPublicKey: undefined,
|
||||
vapidPrivateKey: undefined,
|
||||
vapidSubject: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
csvList,
|
||||
csvListFiltered,
|
||||
decodeBase64Url,
|
||||
isP256PrivateKey,
|
||||
isUncompressedP256Key,
|
||||
isVapidSubject,
|
||||
nonNegativeIntOr,
|
||||
numberOr,
|
||||
parseBool,
|
||||
@@ -176,3 +180,37 @@ describe('parseLinkLocalAllowList (ALLOW_LINK_LOCAL_IPS)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Push key material', () => {
|
||||
it('decodeBase64Url refuses anything outside the base64url alphabet instead of skipping it', () => {
|
||||
expect(decodeBase64Url('AQID')).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(decodeBase64Url(' AQID ')).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(decodeBase64Url('AQI=')).toEqual(Buffer.from([1, 2]));
|
||||
expect(decodeBase64Url('-_8')).toEqual(Buffer.from([0xfb, 0xff]));
|
||||
for (const value of ['', 'AQ+D', 'AQ/D', 'AQ ID', 'AQ=D', 'AQ===']) {
|
||||
expect(decodeBase64Url(value), value).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('isUncompressedP256Key and isP256PrivateKey check the sizes the Web Push tools print', () => {
|
||||
const point = Buffer.concat([Buffer.from([0x04]), Buffer.alloc(64, 1)]).toString('base64url');
|
||||
expect(isUncompressedP256Key(point)).toBe(true);
|
||||
const compressed = Buffer.concat([Buffer.from([0x02]), Buffer.alloc(64, 1)]).toString('base64url');
|
||||
expect(isUncompressedP256Key(compressed)).toBe(false);
|
||||
expect(isUncompressedP256Key(Buffer.alloc(33, 4).toString('base64url'))).toBe(false);
|
||||
expect(isP256PrivateKey(Buffer.alloc(32, 9).toString('base64url'))).toBe(true);
|
||||
expect(isP256PrivateKey(Buffer.alloc(31, 9).toString('base64url'))).toBe(false);
|
||||
expect(isP256PrivateKey('not base64url!')).toBe(false);
|
||||
});
|
||||
|
||||
it('isVapidSubject accepts a mailto: address or an https: URL and nothing else', () => {
|
||||
const accepted = ['mailto:ops@example.com', 'MAILTO:ops@example.com', 'https://trek.example.com', ' https://x.test/c '];
|
||||
for (const ok of accepted) {
|
||||
expect(isVapidSubject(ok), ok).toBe(true);
|
||||
}
|
||||
const refused = ['ops@example.com', 'mailto:', 'mailto:no-at-sign', 'mailto:a b@example.com', 'http://x.test', 'not a url'];
|
||||
for (const bad of refused) {
|
||||
expect(isVapidSubject(bad), bad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { validateEnvAtBoot, readEnv } from '../../../src/app-config/env';
|
||||
import { validateEnvAtBoot, readEnv, SECRET_ENV_KEYS } from '../../../src/app-config/env';
|
||||
import { envSchema } from '../../../src/app-config/env.schema';
|
||||
|
||||
const schemaShape: Record<string, z.ZodType> = envSchema.shape;
|
||||
|
||||
/**
|
||||
* A value no check of the schema accepts (not a number, URL, boolean, duration,
|
||||
* language or base64url key), and one that names its variable, so a leak is
|
||||
* traceable to the key it came from.
|
||||
*/
|
||||
const probe = (key: string): string => `probe+${key}/not valid=`;
|
||||
|
||||
/** Everything one boot check printed and threw. */
|
||||
function bootReport(raw: Record<string, string>): { printed: string; thrown: string } {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
let thrown = '';
|
||||
try {
|
||||
validateEnvAtBoot(raw);
|
||||
} catch (err) {
|
||||
thrown = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
const printed = error.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
error.mockRestore();
|
||||
return { printed, thrown };
|
||||
}
|
||||
|
||||
describe('validateEnvAtBoot', () => {
|
||||
afterEach(() => {
|
||||
@@ -58,6 +83,98 @@ describe('validateEnvAtBoot', () => {
|
||||
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('checks the VAPID_* shapes and lets a well-formed set through', () => {
|
||||
// The application server pair from RFC 8291 Appendix A, in the form the Web Push tools print.
|
||||
const publicKey = 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8';
|
||||
const privateKey = 'yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw';
|
||||
expect(() =>
|
||||
validateEnvAtBoot({
|
||||
VAPID_PUBLIC_KEY: publicKey,
|
||||
VAPID_PRIVATE_KEY: privateKey,
|
||||
VAPID_SUBJECT: 'mailto:ops@example.com',
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'https://trek.example.com' })).not.toThrow();
|
||||
// Whether the halves belong together is checked where they are used, not here.
|
||||
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: privateKey })).not.toThrow();
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: privateKey })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: `A${publicKey.slice(1)}` })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: publicKey })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw' })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'ops@example.com' })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'http://trek.example.com' })).toThrow();
|
||||
});
|
||||
|
||||
it('never prints the value of a secret variable, only its name and the problem', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// 43 characters like a real key, with one character base64url does not have.
|
||||
const leakedPrivateKey = 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw';
|
||||
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: leakedPrivateKey, PORT: 'not-a-port' })).toThrow(
|
||||
/2 problems/,
|
||||
);
|
||||
const report = error.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
expect(report).toContain('VAPID_PRIVATE_KEY=***: must be a base64url-encoded P-256 private key');
|
||||
expect(report).not.toContain(leakedPrivateKey);
|
||||
expect(report).not.toContain('yfWPiYE');
|
||||
// Everything else keeps its value, which is what makes a typo findable.
|
||||
expect(report).toContain('PORT="not-a-port"');
|
||||
});
|
||||
|
||||
it('never prints any secret variable, whether or not its own check refuses the value', () => {
|
||||
// A broken PORT guarantees a report, so every secret gets its chance to leak into it.
|
||||
const { printed, thrown } = bootReport({
|
||||
...Object.fromEntries([...SECRET_ENV_KEYS].map((key) => [key, probe(key)])),
|
||||
PORT: 'not-a-port',
|
||||
});
|
||||
expect(thrown).toMatch(/Invalid environment configuration/);
|
||||
expect(printed).toContain('PORT="not-a-port"');
|
||||
let refused = 0;
|
||||
for (const key of SECRET_ENV_KEYS) {
|
||||
expect(printed, key).not.toContain(probe(key));
|
||||
expect(thrown, key).not.toContain(probe(key));
|
||||
if (!schemaShape[key].safeParse(probe(key)).success) {
|
||||
refused += 1;
|
||||
expect(printed, key).toContain(` - ${key}=***: `);
|
||||
}
|
||||
}
|
||||
// The masking only proves itself on a variable the schema refuses.
|
||||
expect(refused).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still prints the value of every variable that is not a secret', () => {
|
||||
const open = Object.keys(schemaShape).filter((key) => !SECRET_ENV_KEYS.has(key));
|
||||
const { printed } = bootReport(Object.fromEntries(open.map((key) => [key, probe(key)])));
|
||||
// Free-form variables accept anything, so only the checked ones show up.
|
||||
const refused = open.filter((key) => !schemaShape[key].safeParse(probe(key)).success);
|
||||
expect(refused).toContain('VAPID_PUBLIC_KEY');
|
||||
for (const key of refused) expect(printed, key).toContain(` - ${key}=${JSON.stringify(probe(key))}: `);
|
||||
expect(printed.split('\n').filter((line) => line.startsWith(' - '))).toHaveLength(refused.length);
|
||||
});
|
||||
|
||||
it('knows every secret-looking variable of the schema as a secret, and nothing the schema does not know', () => {
|
||||
// A new variable with a word in its name that suggests a credential lands
|
||||
// here and has to be decided on. Whole words, so OVERPASS_URL and
|
||||
// WEBAUTHN_RP_ID do not count.
|
||||
const looksSecret = (key: string): boolean =>
|
||||
/(^|_)(SECRET|PASS(WORD|PHRASE)?|TOKEN|KEY|APIKEY|CREDENTIALS?|PRIVATE|LICEN[CS]E|WEBHOOK|AUTH|DSN|SALT)(_|$)/.test(key);
|
||||
const notSecret = new Set([
|
||||
'VAPID_PUBLIC_KEY', // the public half, which every subscribing browser receives
|
||||
'TREK_PLUGIN_ALLOW_PRIVATE_EGRESS', // a switch about private networks, not a private value
|
||||
]);
|
||||
const schemaKeys = Object.keys(schemaShape);
|
||||
const flagged = schemaKeys.filter((key) => looksSecret(key) && !notSecret.has(key));
|
||||
expect(flagged.length).toBeGreaterThan(5);
|
||||
for (const key of flagged) expect(SECRET_ENV_KEYS.has(key), key).toBe(true);
|
||||
for (const key of SECRET_ENV_KEYS) expect(schemaKeys, key).toContain(key);
|
||||
for (const key of notSecret) {
|
||||
expect(schemaKeys, key).toContain(key);
|
||||
expect(looksSecret(key), key).toBe(true);
|
||||
expect(SECRET_ENV_KEYS.has(key), key).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats blank values as unset (defaults apply, no error)', () => {
|
||||
expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -95,6 +95,19 @@ describe('migrate-encryption.ts app_settings parity', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// VapidKeysService stores the Web Push signing key with encrypt_api_key.
|
||||
// Missed by a rotation it no longer decrypts, and push stays off on every
|
||||
// device until the old key is back.
|
||||
it('ROTPAR-012: rotates the stored Web Push private key', () => {
|
||||
const vapid = fs.readFileSync(
|
||||
path.join(SERVER_ROOT, 'src', 'nest', 'notifications', 'push', 'vapid-keys.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(vapid).toContain("VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key'");
|
||||
expect(vapid).toContain('encrypt_api_key(keys.privateKey)');
|
||||
expect(appSettingsLoop).toContain("'web_push_vapid_private_key'");
|
||||
});
|
||||
|
||||
it('ROTPAR-003: the canonical list is not empty, so ROTPAR-001 cannot pass vacuously', () => {
|
||||
expect(INSTANCE_API_KEY_NAMES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,86 @@ describe('globalMiddleware request-log redaction', () => {
|
||||
expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 });
|
||||
});
|
||||
|
||||
it('hides the Web Push subscription body: the endpoint capability URL and both keys', () => {
|
||||
// POST /api/notifications/push/subscriptions, as PushSubscription.toJSON() hands it over.
|
||||
expect(
|
||||
redact({
|
||||
subscription: {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/device-capability',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
subscription: {
|
||||
endpoint: '[REDACTED]',
|
||||
expirationTime: null,
|
||||
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the subscription the service worker posts again after the browser renewed it', () => {
|
||||
// sw-push.js on pushsubscriptionchange: the same route and shape, and a renewed
|
||||
// subscription may carry a real expiry instead of null.
|
||||
expect(
|
||||
redact({
|
||||
subscription: {
|
||||
endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/renewed-capability',
|
||||
expirationTime: 1767225600000,
|
||||
keys: { p256dh: 'BOr1renewedPublicKey', auth: 'renewedAuthSecret' },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
subscription: {
|
||||
endpoint: '[REDACTED]',
|
||||
expirationTime: 1767225600000,
|
||||
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the endpoint of a subscription that arrives without its keys', () => {
|
||||
// The route refuses this body with a 400, but the request is logged all the same.
|
||||
expect(
|
||||
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/keyless', expirationTime: null } }),
|
||||
).toEqual({ subscription: { endpoint: '[REDACTED]', expirationTime: null } });
|
||||
expect(redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/lone' } })).toEqual({
|
||||
subscription: { endpoint: '[REDACTED]' },
|
||||
});
|
||||
expect(
|
||||
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/odd-keys', keys: 'not-an-object' } }),
|
||||
).toEqual({ subscription: { endpoint: '[REDACTED]', keys: 'not-an-object' } });
|
||||
});
|
||||
|
||||
it('hides the endpoint of the body that forgets a push device', () => {
|
||||
// DELETE /api/notifications/push/subscriptions (logout, switching push off, the worker's renewal).
|
||||
expect(redact({ endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ endpoint: '[REDACTED]' });
|
||||
expect(redact({ Endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ Endpoint: '[REDACTED]' });
|
||||
});
|
||||
|
||||
it('keeps an endpoint that is an ordinary setting readable, like the S3 backend URL', () => {
|
||||
expect(
|
||||
redact({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: 'sk' }),
|
||||
).toEqual({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: '[REDACTED]' });
|
||||
expect(redact({ endpoint: 'https://s3.example.com', forcePathStyle: true })).toEqual({
|
||||
endpoint: 'https://s3.example.com',
|
||||
forcePathStyle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('decides p256dh and auth by name wherever they appear, and nothing near them by accident', () => {
|
||||
// Only the push subscription sends either field today; an `auth` anywhere
|
||||
// else would carry credentials too. The fields around them stay readable.
|
||||
expect(redact({ p256dh: 'BPub', Auth: 'secret', auth_method: 'basic', author: 'Ada' })).toEqual({
|
||||
p256dh: '[REDACTED]',
|
||||
Auth: '[REDACTED]',
|
||||
auth_method: 'basic',
|
||||
author: 'Ada',
|
||||
});
|
||||
expect(redact({ auth: { user: 'mailer', pass: 'hunter2' } })).toEqual({ auth: '[REDACTED]' });
|
||||
});
|
||||
|
||||
it('passes non-object values through untouched', () => {
|
||||
expect(redact('plain string')).toBe('plain string');
|
||||
expect(redact(42)).toBe(42);
|
||||
|
||||
@@ -607,6 +607,20 @@ const pv = (id: number): number =>
|
||||
const mcpTokenCount = (id: number): number =>
|
||||
(testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n;
|
||||
|
||||
const addPushDevice = (userId: number, endpoint: string): void => {
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
|
||||
)
|
||||
.run(userId, endpoint);
|
||||
};
|
||||
|
||||
const pushDeviceCount = (id: number): number =>
|
||||
(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n;
|
||||
|
||||
const passwordHash = (id: number): string =>
|
||||
(testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(id) as { password_hash: string }).password_hash;
|
||||
|
||||
describe('admin password reset revokes what an intruder already holds', () => {
|
||||
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => {
|
||||
// An admin sets somebody else's password for one reason: the account is
|
||||
@@ -630,15 +644,49 @@ describe('admin password reset revokes what an intruder already holds', () => {
|
||||
expect(mcpTokenCount(user.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
|
||||
addPushDevice(user.id, 'https://web.push.apple.com/owner');
|
||||
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander');
|
||||
|
||||
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
||||
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(pushDeviceCount(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const before = pv(user.id);
|
||||
const hashBefore = passwordHash(user.id);
|
||||
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
|
||||
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
try {
|
||||
expect(() => updateUser(String(user.id), { password: 'ANewStrongPass123!' })).toThrow('boom');
|
||||
} finally {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
|
||||
expect(pv(user.id)).toBe(before);
|
||||
expect(passwordHash(user.id)).toBe(hashBefore);
|
||||
expect(mcpTokenCount(user.id)).toBe(1);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const before = pv(user.id);
|
||||
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed');
|
||||
|
||||
updateUser(String(user.id), { username: 'renamed' });
|
||||
|
||||
expect(pv(user.id)).toBe(before);
|
||||
expect(mcpTokenCount(user.id)).toBe(1);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,19 @@ const svc = new AuthService(
|
||||
new AllowedFileTypesService(new DatabaseService(testDb)),
|
||||
);
|
||||
|
||||
/** A Web Push device row; the password paths only care whose it is. */
|
||||
function addPushDevice(userId: number, endpoint: string): void {
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
|
||||
)
|
||||
.run(userId, endpoint);
|
||||
}
|
||||
|
||||
function pushDeviceCount(userId: number): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) c FROM push_subscriptions WHERE user_id = ?').get(userId) as { c: number }).c;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -495,6 +508,51 @@ describe('changePassword — session invalidation', () => {
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp - decoded.iat).toBe(2592000); // remember window survives the change
|
||||
});
|
||||
|
||||
it('AUTH-DB-036e: forgets every push device of the user, and only of that user', () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1');
|
||||
addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
|
||||
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
|
||||
const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(pushDeviceCount(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-036f: a refused change keeps the push devices', () => {
|
||||
const { user } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
|
||||
const result = svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' });
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-036g: drops the push devices in the same transaction as the password change, so a failure changes nothing', () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
tokens.createMcpToken(user.id, 'cli');
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
try {
|
||||
expect(() =>
|
||||
svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }),
|
||||
).toThrow('boom');
|
||||
} finally {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
|
||||
expect(pvOf(user.id)).toBe(0);
|
||||
expect(mcpCount(user.id)).toBe(1);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
// The new hash rolled back as well, so the old password is still the one that counts.
|
||||
expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }).success)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -918,6 +976,42 @@ describe('resetPassword', () => {
|
||||
.toEqual({ error: 'This reset link has already been used', status: 400 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-082b: forgets every push device of the user along with the MCP tokens', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
tokens.createMcpToken(user.id, 'cli');
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
const issued = svc.requestPasswordReset(user.email, null);
|
||||
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ success: true, userId: user.id });
|
||||
|
||||
expect((testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id) as { c: number }).c).toBe(0);
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(pushDeviceCount(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', () => {
|
||||
const { user } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
const issued = svc.requestPasswordReset(user.email, null);
|
||||
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
try {
|
||||
expect(() => svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toThrow('boom');
|
||||
} finally {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number };
|
||||
expect(row.password_version).toBe(0);
|
||||
// Nothing was burned, so the same link still completes the reset.
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ success: true, userId: user.id });
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => {
|
||||
expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
|
||||
expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Unit tests for the external notification channel registry.
|
||||
* Covers CHREG-001 to CHREG-008.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
listChannels,
|
||||
getChannel,
|
||||
@@ -18,14 +18,22 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel
|
||||
import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import type { WebPushService } from '../../../src/nest/notifications/transports/web-push.service';
|
||||
|
||||
// The built-ins take their transports as an argument now; these cases only care
|
||||
// that the three ids land in the registry with the right privileges, so the
|
||||
// that the four ids land in the registry with the right privileges, so the
|
||||
// transports are stubs.
|
||||
const pushStub = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
hasDevices: vi.fn((userId: number) => userId === 7),
|
||||
sendToUser: vi.fn(async () => true),
|
||||
sendTest: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
const stubTransports = {
|
||||
mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService,
|
||||
webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService,
|
||||
ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService,
|
||||
push: pushStub as unknown as WebPushService,
|
||||
};
|
||||
|
||||
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
|
||||
@@ -52,8 +60,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('channelRegistry', () => {
|
||||
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||||
it('CHREG-001: the four built-in external channels are registered; in-app is not', () => {
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
|
||||
expect(getChannel('inapp')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -71,14 +79,14 @@ describe('channelRegistry', () => {
|
||||
expect(getChannel('plugin:gotify')).toBeDefined();
|
||||
live = false;
|
||||
expect(getChannel('plugin:gotify')).toBeUndefined();
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
|
||||
});
|
||||
|
||||
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
|
||||
setPluginChannelSource(() => {
|
||||
throw new Error('runtime exploded');
|
||||
});
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
|
||||
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
|
||||
});
|
||||
|
||||
it('CHREG-005 — a plugin can never claim a built-in id', () => {
|
||||
@@ -92,12 +100,50 @@ describe('channelRegistry', () => {
|
||||
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
|
||||
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
|
||||
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
|
||||
expect(getChannel('push')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
|
||||
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
|
||||
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
|
||||
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
|
||||
expect(getChannel('push')?.supportsAdminGlobal).toBeUndefined();
|
||||
expect(getChannel('push')?.sendGlobal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CHREG-008b: push carries every user event, but neither admin-scoped ones nor synology_session_cleared', () => {
|
||||
const push = getChannel('push')!;
|
||||
expect(push.source).toBe('builtin');
|
||||
expect(push.labelKey).toBe('settings.notificationPreferences.push');
|
||||
for (const event of ['trip_invite', 'collab_message', 'trip_reminder', 'plugin_notification'] as const) {
|
||||
expect(push.supportsEvent(event)).toBe(true);
|
||||
}
|
||||
expect(push.supportsEvent('version_available')).toBe(false);
|
||||
expect(push.supportsEvent('replica_failure')).toBe(false);
|
||||
expect(push.supportsEvent('synology_session_cleared')).toBe(false);
|
||||
});
|
||||
|
||||
it('CHREG-008c: push is configured for a user with a device and hands sends and tests to the transport', async () => {
|
||||
const push = getChannel('push')!;
|
||||
expect(push.isConfiguredFor(7)).toBe(true);
|
||||
expect(push.isConfiguredFor(8)).toBe(false);
|
||||
await push.sendToUser(7, MSG);
|
||||
expect(pushStub.sendToUser).toHaveBeenCalledWith(7, MSG);
|
||||
await expect(push.test!(7)).resolves.toEqual({ success: true });
|
||||
expect(pushStub.sendTest).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('CHREG-008d: push is ready only while it can sign, and hides meanwhile; a plugin cannot claim that', () => {
|
||||
const push = getChannel('push')!;
|
||||
expect(push.hiddenWhileInstanceUnconfigured).toBe(true);
|
||||
expect(push.isInstanceConfigured!()).toBe(true);
|
||||
pushStub.isAvailable.mockReturnValueOnce(false);
|
||||
expect(push.isInstanceConfigured!()).toBe(false);
|
||||
// Email keeps its column without SMTP, as before.
|
||||
expect(getChannel('email')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
|
||||
|
||||
setPluginChannelSource(() => [fakeChannel('plugin:gotify', { hiddenWhileInstanceUnconfigured: true })]);
|
||||
expect(getChannel('plugin:gotify')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
|
||||
@@ -122,6 +168,6 @@ describe('channelRegistry', () => {
|
||||
setPluginChannelSource(() => [boom]);
|
||||
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
|
||||
// and the registry is unharmed
|
||||
expect(listChannels()).toHaveLength(4);
|
||||
expect(listChannels()).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,10 +43,16 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel
|
||||
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
|
||||
import { makeWebPushService } from '../../helpers/notifications';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const mailer = new MailerService(dbs);
|
||||
registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) });
|
||||
registerBuiltinChannels({
|
||||
mailer,
|
||||
webhook: new WebhookService(dbs),
|
||||
ntfy: new NtfyService(dbs),
|
||||
push: makeWebPushService(dbs),
|
||||
});
|
||||
const svc = new NotificationPreferencesService(dbs, mailer);
|
||||
|
||||
// Legacy free-function names bound to the service, so the moved cases read as before.
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and
|
||||
* the table behind it (PUSHSUB-*), on the real schema including migration 244.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
|
||||
|
||||
// One :memory: connection per file, created inside the factory so nothing has
|
||||
// to be hoisted above the imports; the tests reach it through the mocked module.
|
||||
vi.mock('../../../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { db as testDb } from '../../../../src/db/database';
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import {
|
||||
MAX_PUSH_DEVICES_PER_USER,
|
||||
PushSubscriptionsService,
|
||||
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
||||
import {
|
||||
PUSH_SUBSCRIPTION_ERRORS,
|
||||
checkPushEndpoint,
|
||||
checkPushSubscription,
|
||||
isPushServiceEndpoint,
|
||||
type CheckedPushSubscription,
|
||||
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
||||
|
||||
const subs = new PushSubscriptionsService(new DatabaseService(testDb));
|
||||
|
||||
function browserKeys() {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
return { p256dh: ua.getPublicKey('base64url'), auth: Buffer.from('0123456789abcdef').toString('base64url') };
|
||||
}
|
||||
|
||||
function checked(endpoint: string): CheckedPushSubscription {
|
||||
const result = checkPushSubscription({ endpoint, keys: browserKeys() });
|
||||
if ('error' in result) throw new Error(result.error);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
const FCM = 'https://fcm.googleapis.com/fcm/send/device-1';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('checkPushEndpoint / checkPushSubscription', () => {
|
||||
it('PUSHSUB-CHK-001: accepts the push services browsers use', () => {
|
||||
for (const endpoint of [
|
||||
FCM,
|
||||
'https://android.googleapis.com/gcm/send/x',
|
||||
'https://updates.push.services.mozilla.com/wpush/v2/gAAAA',
|
||||
'https://push.services.mozilla.com/wpush/v1/x',
|
||||
'https://web.push.apple.com/QGx9',
|
||||
'https://api.push.apple.com/3/device/x',
|
||||
'https://wns2-par02p.notify.windows.com/w/?token=BQYAAA',
|
||||
]) {
|
||||
expect(checkPushEndpoint(endpoint), endpoint).toBe('ok');
|
||||
expect(isPushServiceEndpoint(endpoint)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUSHSUB-CHK-002: refuses anything that is not a plain https URL', () => {
|
||||
for (const endpoint of [
|
||||
'not a url',
|
||||
'http://fcm.googleapis.com/fcm/send/x',
|
||||
'https://user:pw@fcm.googleapis.com/fcm/send/x',
|
||||
'https://fcm.googleapis.com:8443/fcm/send/x',
|
||||
`https://fcm.googleapis.com/${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH)}`,
|
||||
]) {
|
||||
expect(checkPushEndpoint(endpoint), endpoint).toBe('endpoint');
|
||||
}
|
||||
});
|
||||
|
||||
it('PUSHSUB-CHK-002b: takes an endpoint exactly as long as the contract allows, and not one character more', () => {
|
||||
const base = 'https://fcm.googleapis.com/fcm/send/';
|
||||
const longest = `${base}${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH - base.length)}`;
|
||||
// The contract and the check share one limit, so a device that could register can also be sent to and removed.
|
||||
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: longest }).success).toBe(true);
|
||||
expect(checkPushEndpoint(longest)).toBe('ok');
|
||||
expect(checkPushEndpoint(`${longest}a`)).toBe('endpoint');
|
||||
});
|
||||
|
||||
it('PUSHSUB-CHK-003: refuses hosts that are not push services, including lookalikes', () => {
|
||||
for (const endpoint of [
|
||||
'https://example.com/push',
|
||||
'https://127.0.0.1/push',
|
||||
'https://fcm.googleapis.com.evil.test/x',
|
||||
'https://evilnotify.windows.com/x',
|
||||
'https://.notify.windows.com/x',
|
||||
'https://push.apple.com/x',
|
||||
]) {
|
||||
expect(checkPushEndpoint(endpoint), endpoint).not.toBe('ok');
|
||||
}
|
||||
expect(checkPushSubscription({ endpoint: 'https://example.com/push', keys: browserKeys() })).toEqual({
|
||||
ok: false,
|
||||
error: PUSH_SUBSCRIPTION_ERRORS.service,
|
||||
});
|
||||
expect(checkPushSubscription({ endpoint: 'http://fcm.googleapis.com/x', keys: browserKeys() })).toEqual({
|
||||
ok: false,
|
||||
error: PUSH_SUBSCRIPTION_ERRORS.endpoint,
|
||||
});
|
||||
});
|
||||
|
||||
it('PUSHSUB-CHK-004: wants a p256dh on the curve and a 16-byte auth secret', () => {
|
||||
const keys = browserKeys();
|
||||
const offCurve = Buffer.from(keys.p256dh, 'base64url');
|
||||
offCurve[64] ^= 0x01;
|
||||
for (const p256dh of [
|
||||
'!!!',
|
||||
Buffer.alloc(65, 4).toString('base64url'),
|
||||
offCurve.toString('base64url'),
|
||||
keys.auth,
|
||||
]) {
|
||||
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, p256dh } })).toEqual({
|
||||
ok: false,
|
||||
error: PUSH_SUBSCRIPTION_ERRORS.p256dh,
|
||||
});
|
||||
}
|
||||
for (const auth of ['!!!', Buffer.alloc(8).toString('base64url')]) {
|
||||
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, auth } })).toEqual({
|
||||
ok: false,
|
||||
error: PUSH_SUBSCRIPTION_ERRORS.auth,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('PUSHSUB-CHK-005: hands the keys back canonical, without padding', () => {
|
||||
const keys = browserKeys();
|
||||
const result = checkPushSubscription({
|
||||
endpoint: FCM,
|
||||
keys: { p256dh: `${keys.p256dh}=`, auth: `${keys.auth}==` },
|
||||
});
|
||||
expect(result).toEqual({ ok: true, value: { endpoint: FCM, p256dh: keys.p256dh, auth: keys.auth } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PushSubscriptionsService', () => {
|
||||
it('PUSHSUB-001: stores a subscription with the key it was made for and counts devices', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const sub = checked(FCM);
|
||||
expect(subs.hasAny(user.id)).toBe(false);
|
||||
expect(subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1);
|
||||
const [row] = subs.listForUser(user.id);
|
||||
expect(row).toMatchObject({
|
||||
user_id: user.id,
|
||||
endpoint: FCM,
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
vapid_public_key: 'server-key',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
failure_count: 0,
|
||||
last_success_at: null,
|
||||
});
|
||||
expect(subs.hasAny(user.id)).toBe(true);
|
||||
expect(subs.countForUser(user.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', () => {
|
||||
const { user } = createUser(testDb);
|
||||
subs.upsert(user.id, checked(FCM), 'old-key');
|
||||
const [before] = subs.listForUser(user.id);
|
||||
subs.recordFailure(before.id);
|
||||
const fresh = checked(FCM);
|
||||
expect(subs.upsert(user.id, fresh, 'new-key', null)).toBe(1);
|
||||
const [after] = subs.listForUser(user.id);
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after).toMatchObject({
|
||||
p256dh: fresh.p256dh,
|
||||
vapid_public_key: 'new-key',
|
||||
failure_count: 0,
|
||||
user_agent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('PUSHSUB-003: a browser shared by two accounts belongs to whoever subscribed on it last', () => {
|
||||
const { user: first } = createUser(testDb);
|
||||
const { user: second } = createUser(testDb);
|
||||
subs.upsert(first.id, checked(FCM), 'k');
|
||||
expect(subs.upsert(second.id, checked(FCM), 'k')).toBe(1);
|
||||
expect(subs.countForUser(first.id)).toBe(0);
|
||||
expect(subs.listForUser(second.id).map((r) => r.endpoint)).toEqual([FCM]);
|
||||
});
|
||||
|
||||
it('PUSHSUB-004: caps the devices per user and drops the one registered longest ago', () => {
|
||||
const { user } = createUser(testDb);
|
||||
for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) {
|
||||
subs.upsert(user.id, checked(`https://fcm.googleapis.com/fcm/send/d${i}`), 'k');
|
||||
}
|
||||
// Same second for every row; the id breaks the tie, so d0 is the oldest.
|
||||
// d0 subscribing again renews it, which leaves d1 as the oldest.
|
||||
testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run();
|
||||
subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
|
||||
expect(subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
|
||||
MAX_PUSH_DEVICES_PER_USER,
|
||||
);
|
||||
const endpoints = subs.listForUser(user.id).map((r) => r.endpoint);
|
||||
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/d0');
|
||||
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/new');
|
||||
expect(endpoints).not.toContain('https://fcm.googleapis.com/fcm/send/d1');
|
||||
});
|
||||
|
||||
it('PUSHSUB-005: a user can only remove their own endpoint', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
subs.upsert(owner.id, checked(FCM), 'k');
|
||||
expect(subs.removeForUser(other.id, FCM)).toBe(false);
|
||||
expect(subs.countForUser(owner.id)).toBe(1);
|
||||
expect(subs.removeForUser(owner.id, FCM)).toBe(true);
|
||||
expect(subs.countForUser(owner.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', () => {
|
||||
const { user } = createUser(testDb);
|
||||
subs.upsert(user.id, checked(FCM), 'k');
|
||||
const [row] = subs.listForUser(user.id);
|
||||
expect(subs.recordFailure(row.id)).toBe(1);
|
||||
expect(subs.recordFailure(row.id)).toBe(2);
|
||||
expect(subs.listForUser(user.id)[0].failure_count).toBe(2);
|
||||
subs.recordSuccess(row.id);
|
||||
const [after] = subs.listForUser(user.id);
|
||||
expect(after.failure_count).toBe(0);
|
||||
expect(after.last_success_at).not.toBeNull();
|
||||
expect(subs.recordFailure(row.id)).toBe(1);
|
||||
subs.deleteById(row.id);
|
||||
expect(subs.hasAny(user.id)).toBe(false);
|
||||
// A failure recorded for a row another send already removed changes nothing.
|
||||
expect(subs.recordFailure(row.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('PUSHSUB-007: cuts a long user agent and the rows go with the user', () => {
|
||||
const { user } = createUser(testDb);
|
||||
subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
|
||||
expect(subs.listForUser(user.id)[0].user_agent).toHaveLength(256);
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('PUSHSUB-008: migration 244 created the table with its user index', () => {
|
||||
const index = testDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'")
|
||||
.all() as { name: string }[];
|
||||
expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* PushController parity (PUSHCTL-*): status codes, bodies and the bespoke 400
|
||||
* strings of /api/notifications/push, over stubbed providers.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
|
||||
import { PUSH_SUBSCRIPTION_ERRORS } from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
||||
import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
||||
import {
|
||||
PUSH_UNAVAILABLE_ERROR,
|
||||
PushUnavailableError,
|
||||
type VapidKeysService,
|
||||
} from '../../../../src/nest/notifications/push/vapid-keys.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
|
||||
const keys = { getPublicKey: vi.fn((): string => 'server-public-key') };
|
||||
const subscriptions = { upsert: vi.fn(() => 2), removeForUser: vi.fn(() => true) };
|
||||
const controller = new PushController(
|
||||
keys as unknown as VapidKeysService,
|
||||
subscriptions as unknown as PushSubscriptionsService,
|
||||
);
|
||||
const user = { id: 5 } as User;
|
||||
|
||||
function subscription(endpoint = 'https://fcm.googleapis.com/fcm/send/abc') {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
return {
|
||||
endpoint,
|
||||
expirationTime: null,
|
||||
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') },
|
||||
};
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): HttpException {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
return err as HttpException;
|
||||
}
|
||||
throw new Error('expected a throw');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PushController', () => {
|
||||
it('PUSHCTL-001: GET public-key answers the key browsers subscribe with', () => {
|
||||
expect(controller.publicKey()).toEqual({ publicKey: 'server-public-key' });
|
||||
});
|
||||
|
||||
it('PUSHCTL-002: POST subscriptions stores the checked subscription against the current key and answers 200', () => {
|
||||
const sub = subscription();
|
||||
expect(controller.subscribe(user, { subscription: sub }, 'Firefox/130')).toEqual({ success: true, devices: 2 });
|
||||
expect(subscriptions.upsert).toHaveBeenCalledWith(
|
||||
5,
|
||||
{ endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
||||
'server-public-key',
|
||||
'Firefox/130',
|
||||
);
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.subscribe)).toBe(200);
|
||||
});
|
||||
|
||||
it('PUSHCTL-003: POST subscriptions answers the bespoke 400 and stores nothing', () => {
|
||||
const err = thrown(() => controller.subscribe(user, { subscription: subscription('https://example.com/collect') }));
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
expect(err.getStatus()).toBe(400);
|
||||
expect(err.getResponse()).toEqual({ error: PUSH_SUBSCRIPTION_ERRORS.service });
|
||||
expect(subscriptions.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUSHCTL-004: DELETE subscriptions removes only the caller’s endpoint and is idempotent', () => {
|
||||
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(subscriptions.removeForUser).toHaveBeenCalledWith(5, 'https://fcm.googleapis.com/fcm/send/abc');
|
||||
subscriptions.removeForUser.mockReturnValueOnce(false);
|
||||
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/gone' })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.unsubscribe)).toBe(200);
|
||||
});
|
||||
|
||||
it('PUSHCTL-005: while the stored key pair is unusable, public-key and subscribe answer 503 and store nothing', () => {
|
||||
keys.getPublicKey.mockImplementation(() => {
|
||||
throw new PushUnavailableError();
|
||||
});
|
||||
try {
|
||||
const fromKey = thrown(() => controller.publicKey());
|
||||
expect(fromKey.getStatus()).toBe(503);
|
||||
expect(fromKey.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
|
||||
|
||||
const fromSubscribe = thrown(() => controller.subscribe(user, { subscription: subscription() }));
|
||||
expect(fromSubscribe.getStatus()).toBe(503);
|
||||
expect(fromSubscribe.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
|
||||
expect(subscriptions.upsert).not.toHaveBeenCalled();
|
||||
|
||||
// A device can still be forgotten.
|
||||
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
} finally {
|
||||
keys.getPublicKey.mockImplementation(() => 'server-public-key');
|
||||
}
|
||||
});
|
||||
|
||||
it('PUSHCTL-006: any other key failure is not dressed up as a 503', () => {
|
||||
keys.getPublicKey.mockImplementationOnce(() => {
|
||||
throw new Error('database is locked');
|
||||
});
|
||||
expect(() => controller.publicKey()).toThrow('database is locked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Where the server's VAPID identity comes from (VKEY-*): the environment pair
|
||||
* when it is complete and consistent, else a pair generated once into
|
||||
* app_settings with the private half encrypted. Push is off, with every row
|
||||
* left as it was, while the pair it would sign with cannot be used: a stored
|
||||
* pair is never regenerated, and a broken environment pair falls back on
|
||||
* neither a stored nor a new one. And the `sub` contact.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
|
||||
// One :memory: connection per file, created inside the factory so nothing has
|
||||
// to be hoisted above the imports; the tests reach it through the mocked module.
|
||||
vi.mock('../../../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
const { logInfo, logError } = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo,
|
||||
logDebug: vi.fn(),
|
||||
logError,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
import { db as testDb } from '../../../../src/db/database';
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { decrypt_api_key, encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
import {
|
||||
FALLBACK_VAPID_SUBJECT,
|
||||
PushUnavailableError,
|
||||
VAPID_PRIVATE_KEY_SETTING,
|
||||
VAPID_PUBLIC_KEY_SETTING,
|
||||
VapidKeysService,
|
||||
} from '../../../../src/nest/notifications/push/vapid-keys.service';
|
||||
import { generateVapidKeyPair, isVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
|
||||
/** An enc:v1: value the current ENCRYPTION_KEY cannot open. */
|
||||
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
|
||||
|
||||
function setting(key: string): string | undefined {
|
||||
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)
|
||||
?.value;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
logInfo.mockClear();
|
||||
logError.mockClear();
|
||||
for (const name of [
|
||||
'VAPID_PUBLIC_KEY',
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'VAPID_SUBJECT',
|
||||
'APP_URL',
|
||||
'ALLOWED_ORIGINS',
|
||||
'ADMIN_EMAIL',
|
||||
]) {
|
||||
vi.stubEnv(name, '');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('VapidKeysService key pair', () => {
|
||||
it('VKEY-001: generates the pair once, the private half encrypted, and keeps serving it', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
const keys = svc.getKeys();
|
||||
expect(keys.source).toBe('database');
|
||||
expect(isVapidKeyPair(keys.publicKey, keys.privateKey)).toBe(true);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(keys.publicKey);
|
||||
const stored = setting(VAPID_PRIVATE_KEY_SETTING)!;
|
||||
expect(stored.startsWith('enc:v1:')).toBe(true);
|
||||
expect(decrypt_api_key(stored)).toBe(keys.privateKey);
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Another instance over the same database (a restart) reads the same pair.
|
||||
const again = new VapidKeysService(dbs);
|
||||
expect(again.getKeys()).toEqual(keys);
|
||||
expect(svc.getPublicKey()).toBe(keys.publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(stored);
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('VKEY-001b: a pair another instance stored between the first look and the write is the one used', () => {
|
||||
const theirs = generateVapidKeyPair();
|
||||
const svc = new VapidKeysService(dbs);
|
||||
const transaction = dbs.transaction.bind(dbs);
|
||||
const spy = vi.spyOn(dbs, 'transaction').mockImplementationOnce((fn) => {
|
||||
testDb
|
||||
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)')
|
||||
.run(VAPID_PUBLIC_KEY_SETTING, theirs.publicKey, VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(theirs.privateKey));
|
||||
return transaction(fn);
|
||||
});
|
||||
try {
|
||||
expect(svc.getKeys()).toEqual({ ...theirs, source: 'database' });
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(theirs.publicKey);
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('VKEY-002: bootstrap creates the pair, so the public-key GET only reads', () => {
|
||||
new VapidKeysService(dbs).onApplicationBootstrap();
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeTruthy();
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('VKEY-003: a bootstrap failure is logged, not thrown', () => {
|
||||
const broken = {
|
||||
get: () => {
|
||||
throw new Error('no such table: app_settings');
|
||||
},
|
||||
} as unknown as DatabaseService;
|
||||
expect(() => new VapidKeysService(broken).onApplicationBootstrap()).not.toThrow();
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('no such table'));
|
||||
});
|
||||
|
||||
it('VKEY-004: a consistent pair in the environment wins and the database is left alone', () => {
|
||||
const pair = generateVapidKeyPair();
|
||||
// Padding on the way in is fine; the key handed to browsers has none.
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', `${pair.publicKey}=`);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
const keys = new VapidKeysService(dbs).getKeys();
|
||||
expect(keys).toEqual({ ...pair, source: 'env' });
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VKEY-005: a mismatched environment pair turns push off, reported once, and the stored pair is not used', () => {
|
||||
const stored = new VapidKeysService(dbs).getKeys();
|
||||
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING);
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
|
||||
const svc = new VapidKeysService(dbs);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(() => svc.getPublicKey()).toThrow(PushUnavailableError);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'),
|
||||
);
|
||||
// Both stored rows are exactly as they were, for when the variables go again.
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext);
|
||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('VKEY-006: half a pair in the environment turns push off over a stored pair, and is reported again after a fix and relapse', () => {
|
||||
const stored = new VapidKeysService(dbs).getKeys();
|
||||
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING);
|
||||
const pair = generateVapidKeyPair();
|
||||
const svc = new VapidKeysService(dbs);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('VAPID_PRIVATE_KEY is set without its other half'));
|
||||
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
|
||||
// An existingSecret rotated without the private half: the devices hold the
|
||||
// environment key, so signing with the stored pair would drop every one.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', '');
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(logError).toHaveBeenCalledTimes(2);
|
||||
expect(logError).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('VAPID_PUBLIC_KEY is set without its other half'),
|
||||
);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext);
|
||||
|
||||
// Both variables removed on purpose: the stored pair, as on any install without them.
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', '');
|
||||
expect(svc.getKeys()).toEqual(stored);
|
||||
});
|
||||
|
||||
it('VKEY-006b: an install that only ran on the environment pair generates nothing when that pair breaks', () => {
|
||||
const pair = generateVapidKeyPair();
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
const svc = new VapidKeysService(dbs);
|
||||
svc.onApplicationBootstrap();
|
||||
expect(svc.isAvailable()).toBe(true);
|
||||
|
||||
// An existingSecret without the private half: the variable is simply gone.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', '');
|
||||
new VapidKeysService(dbs).onApplicationBootstrap();
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
// Once per instance, with what to do and without the key.
|
||||
expect(logError).toHaveBeenCalledTimes(2);
|
||||
const line = logError.mock.calls[0][0] as string;
|
||||
expect(line).toContain('Web Push is off: VAPID_PUBLIC_KEY is set without its other half');
|
||||
expect(line).toContain('Set both variables');
|
||||
expect(line).not.toContain(pair.publicKey);
|
||||
|
||||
// Halves that do not belong together are refused the same way.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(logError).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'),
|
||||
);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
|
||||
|
||||
// The pair fixed: the same key as before, so every device receives again as it was.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
|
||||
});
|
||||
|
||||
it('VKEY-006c: a broken environment pair over an unusable stored pair names the variables and touches no row', () => {
|
||||
new VapidKeysService(dbs).getKeys();
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey);
|
||||
const svc = new VapidKeysService(dbs);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
|
||||
// The variables are what push would sign with, so they are what the log names.
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY is set without its other half'),
|
||||
);
|
||||
});
|
||||
|
||||
it('VKEY-007: a stored private key that no longer decrypts is kept, and push is off until the key is back', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
const original = svc.getKeys();
|
||||
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!;
|
||||
// What a restore under a different ENCRYPTION_KEY looks like from here.
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
logInfo.mockClear();
|
||||
|
||||
const broken = new VapidKeysService(dbs);
|
||||
expect(() => broken.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(() => broken.getPublicKey()).toThrow(PushUnavailableError);
|
||||
// Nothing was replaced: both rows are exactly as they were.
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(original.publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
// Said once, with the way out, and without any key material.
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
const line = logError.mock.calls[0][0] as string;
|
||||
expect(line).toContain('cannot be decrypted with the current ENCRYPTION_KEY');
|
||||
expect(line).not.toContain(original.publicKey);
|
||||
expect(line).not.toContain(UNREADABLE);
|
||||
expect(line).not.toContain(ciphertext);
|
||||
|
||||
// The right key again (here: the original ciphertext back) brings the same pair back.
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
|
||||
expect(broken.getKeys()).toEqual(original);
|
||||
});
|
||||
|
||||
it('VKEY-008: half a stored pair is not completed with a new pair either', () => {
|
||||
const publicKey = generateVapidKeyPair().publicKey;
|
||||
testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(VAPID_PUBLIC_KEY_SETTING, publicKey);
|
||||
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('only one half of the stored VAPID key pair'));
|
||||
expect(logError).toHaveBeenCalledWith(expect.not.stringContaining(publicKey));
|
||||
});
|
||||
|
||||
it('VKEY-008b: only the private half stored counts as incomplete too', () => {
|
||||
const { privateKey } = generateVapidKeyPair();
|
||||
testDb
|
||||
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run(VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(privateKey));
|
||||
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VKEY-008c: halves that decrypt but do not belong together are refused, not replaced', () => {
|
||||
const stored = generateVapidKeyPair();
|
||||
testDb
|
||||
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)')
|
||||
.run(
|
||||
VAPID_PUBLIC_KEY_SETTING,
|
||||
stored.publicKey,
|
||||
VAPID_PRIVATE_KEY_SETTING,
|
||||
encrypt_api_key(generateVapidKeyPair().privateKey),
|
||||
);
|
||||
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('does not belong to the stored public key'));
|
||||
});
|
||||
|
||||
it('VKEY-008d: an unusable pair is reported once per state, and again after it was fixed and broke again', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
svc.getKeys();
|
||||
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!;
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
for (let i = 0; i < 3; i++) expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
|
||||
svc.getKeys();
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
|
||||
expect(logError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('VKEY-008e: bootstrap over an unusable pair logs the reason once and does not throw', () => {
|
||||
new VapidKeysService(dbs).getKeys();
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
logError.mockClear();
|
||||
expect(() => new VapidKeysService(dbs).onApplicationBootstrap()).not.toThrow();
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Web Push is off'));
|
||||
});
|
||||
|
||||
it('VKEY-008f: a consistent environment pair still works while the stored one is unusable', () => {
|
||||
new VapidKeysService(dbs).getKeys();
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
const pair = generateVapidKeyPair();
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
const svc = new VapidKeysService(dbs);
|
||||
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
|
||||
expect(svc.isAvailable()).toBe(true);
|
||||
// The stored rows are still exactly what was there.
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
|
||||
});
|
||||
|
||||
it('VKEY-008g: isAvailable only reads: it creates nothing, and never touches an unusable pair', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
// Neither row yet: available, since the next send or subscribe creates the
|
||||
// pair, but nothing is written now (the preference matrix GET asks this).
|
||||
expect(svc.isAvailable()).toBe(true);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
|
||||
const { publicKey } = svc.getKeys();
|
||||
expect(svc.isAvailable()).toBe(true);
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey);
|
||||
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('VKEY-008h: isAvailable turns any other failure into a logged false', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
const spy = vi.spyOn(dbs, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('database is locked');
|
||||
});
|
||||
expect(svc.isAvailable()).toBe(false);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
|
||||
spy.mockRestore();
|
||||
expect(svc.isAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VapidKeysService subject', () => {
|
||||
const svc = new VapidKeysService(dbs);
|
||||
|
||||
it('VKEY-009: VAPID_SUBJECT comes first', () => {
|
||||
vi.stubEnv('VAPID_SUBJECT', 'mailto:push@example.test');
|
||||
vi.stubEnv('APP_URL', 'https://trek.example.test');
|
||||
expect(svc.getSubject()).toBe('mailto:push@example.test');
|
||||
});
|
||||
|
||||
it('VKEY-010: then APP_URL, when it is public https', () => {
|
||||
vi.stubEnv('APP_URL', ' https://trek.example.test/ ');
|
||||
expect(svc.getSubject()).toBe('https://trek.example.test');
|
||||
});
|
||||
|
||||
it('VKEY-010b: never an ALLOWED_ORIGINS entry, which can be another site allowed to call the API', () => {
|
||||
vi.stubEnv('ALLOWED_ORIGINS', 'https://companion.example.test,https://trek.example.test');
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
vi.stubEnv('APP_URL', 'https://trek.example.test');
|
||||
expect(svc.getSubject()).toBe('https://trek.example.test');
|
||||
});
|
||||
|
||||
it('VKEY-010c: an APP_URL that does not parse is no candidate', () => {
|
||||
vi.stubEnv('APP_URL', 'not a url');
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
});
|
||||
|
||||
it('VKEY-011: never the bootstrap admin address, which would travel to every push service', () => {
|
||||
vi.stubEnv('ADMIN_EMAIL', 'admin@example.test');
|
||||
vi.stubEnv('APP_URL', 'https://trek.example.test');
|
||||
expect(svc.getSubject()).toBe('https://trek.example.test');
|
||||
vi.stubEnv('APP_URL', 'http://trek.lan');
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
});
|
||||
|
||||
it('VKEY-012: then the project URL when the instance URL is plain http or localhost', () => {
|
||||
vi.stubEnv('APP_URL', 'http://trek.lan');
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
vi.stubEnv('APP_URL', 'https://localhost:3000');
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
});
|
||||
|
||||
it('VKEY-013: and never a subject Apple would refuse, even when one is configured', () => {
|
||||
vi.stubEnv('VAPID_SUBJECT', 'push@example.test'); // not mailto:, not https: (boot would have refused it)
|
||||
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
|
||||
vi.stubEnv('APP_URL', 'https://trek.example.test');
|
||||
expect(svc.getSubject()).toBe('https://trek.example.test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* RFC 8291 message encryption and RFC 8292 VAPID headers (WPCRYPTO-*).
|
||||
*
|
||||
* The encryption is pinned twice: byte for byte against the worked example in
|
||||
* RFC 8291 Appendix A, and by a decrypt written here from the receiving side,
|
||||
* the way a browser unwraps the message, for fresh keys and salts.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createDecipheriv, createECDH, createPublicKey, hkdfSync, type ECDH } from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
MAX_PUSH_PLAINTEXT_BYTES,
|
||||
PUSH_RECORD_SIZE,
|
||||
VAPID_TOKEN_TTL_SECONDS,
|
||||
createVapidSigner,
|
||||
derivePublicKey,
|
||||
encryptPushMessage,
|
||||
generateVapidKeyPair,
|
||||
isP256Point,
|
||||
isVapidKeyPair,
|
||||
} from '../../../../src/nest/notifications/push/web-push-crypto';
|
||||
|
||||
const b64 = (value: string) => Buffer.from(value, 'base64url');
|
||||
|
||||
// RFC 8291 Appendix A.
|
||||
const RFC = {
|
||||
plaintext: b64('V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24'),
|
||||
asPublic: 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8',
|
||||
asPrivate: b64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'),
|
||||
uaPublic: b64('BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'),
|
||||
uaPrivate: b64('q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'),
|
||||
salt: b64('DGv6ra1nlYgDCS1FRnbzlw'),
|
||||
auth: b64('BTBZMqHH6r4Tts7J_aSIgg'),
|
||||
// Section 5, the message body as sent (the three lines of the RFC joined).
|
||||
body:
|
||||
'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
|
||||
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
|
||||
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',
|
||||
};
|
||||
|
||||
/** The receiving side of RFC 8291, independent of the module under test. */
|
||||
function decrypt(body: Buffer, ua: ECDH, auth: Buffer): { plaintext: Buffer; rs: number; keyId: Buffer } {
|
||||
const salt = body.subarray(0, 16);
|
||||
const rs = body.readUInt32BE(16);
|
||||
const idlen = body[20];
|
||||
const keyId = body.subarray(21, 21 + idlen);
|
||||
const record = body.subarray(21 + idlen);
|
||||
const secret = ua.computeSecret(keyId);
|
||||
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), ua.getPublicKey(), keyId]);
|
||||
const ikm = Buffer.from(hkdfSync('sha256', secret, auth, keyInfo, 32));
|
||||
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
|
||||
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
|
||||
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
|
||||
decipher.setAuthTag(record.subarray(record.length - 16));
|
||||
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
|
||||
let end = padded.length - 1;
|
||||
while (end >= 0 && padded[end] === 0) end--;
|
||||
expect(padded[end]).toBe(0x02); // the last-record delimiter
|
||||
return { plaintext: padded.subarray(0, end), rs, keyId };
|
||||
}
|
||||
|
||||
function browser() {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
const auth = Buffer.from('0123456789abcdef');
|
||||
return { ua, keys: { p256dh: ua.getPublicKey(), auth } };
|
||||
}
|
||||
|
||||
describe('encryptPushMessage', () => {
|
||||
it('WPCRYPTO-001: reproduces the RFC 8291 Appendix A message byte for byte', () => {
|
||||
const body = encryptPushMessage(
|
||||
RFC.plaintext,
|
||||
{ p256dh: RFC.uaPublic, auth: RFC.auth },
|
||||
{ salt: RFC.salt, senderPrivateKey: RFC.asPrivate },
|
||||
);
|
||||
expect(body.toString('base64url')).toBe(RFC.body);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-002: the RFC message decrypts with the user agent key, which checks the reference decrypt too', () => {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.setPrivateKey(RFC.uaPrivate);
|
||||
const { plaintext, rs, keyId } = decrypt(b64(RFC.body), ua, RFC.auth);
|
||||
expect(plaintext.toString('utf8')).toBe('When I grow up, I want to be a watermelon');
|
||||
expect(rs).toBe(4096);
|
||||
expect(keyId.toString('base64url')).toBe(RFC.asPublic);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-003: a real message round-trips, with a fresh salt and sender key every time', () => {
|
||||
const { ua, keys } = browser();
|
||||
const message = Buffer.from(JSON.stringify({ title: 'Trip reminder: Kyōto', body: 'Tomorrow 🚄' }));
|
||||
const first = encryptPushMessage(message, keys);
|
||||
const second = encryptPushMessage(message, keys);
|
||||
expect(decrypt(first, ua, keys.auth).plaintext.equals(message)).toBe(true);
|
||||
expect(decrypt(second, ua, keys.auth).plaintext.equals(message)).toBe(true);
|
||||
expect(first.subarray(0, 16).equals(second.subarray(0, 16))).toBe(false);
|
||||
expect(first.subarray(21, 86).equals(second.subarray(21, 86))).toBe(false);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-004: the largest allowed plaintext still fits one 4096-byte record', () => {
|
||||
const { ua, keys } = browser();
|
||||
const message = Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES, 'a');
|
||||
const body = encryptPushMessage(message, keys);
|
||||
expect(body.length).toBe(PUSH_RECORD_SIZE);
|
||||
expect(decrypt(body, ua, keys.auth).plaintext.length).toBe(MAX_PUSH_PLAINTEXT_BYTES);
|
||||
expect(() => encryptPushMessage(Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES + 1), keys)).toThrow(
|
||||
/does not fit one record/,
|
||||
);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-005: refuses keys it cannot encrypt for', () => {
|
||||
const { keys } = browser();
|
||||
const offCurve = Buffer.from(keys.p256dh);
|
||||
offCurve[64] ^= 0x01;
|
||||
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, p256dh: offCurve })).toThrow(/p256dh/);
|
||||
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, auth: Buffer.alloc(8) })).toThrow(/auth secret/);
|
||||
expect(() => encryptPushMessage(Buffer.from('x'), keys, { salt: Buffer.alloc(4) })).toThrow(/salt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key helpers', () => {
|
||||
it('WPCRYPTO-006: generates a full-length pair whose halves belong together', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const pair = generateVapidKeyPair();
|
||||
const pub = b64(pair.publicKey);
|
||||
expect(pub.length).toBe(65);
|
||||
expect(pub[0]).toBe(0x04);
|
||||
expect(b64(pair.privateKey).length).toBe(32);
|
||||
expect(isVapidKeyPair(pair.publicKey, pair.privateKey)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('WPCRYPTO-007: tells a pair from two unrelated halves, and rejects what is not a key', () => {
|
||||
const a = generateVapidKeyPair();
|
||||
const b = generateVapidKeyPair();
|
||||
expect(isVapidKeyPair(a.publicKey, b.privateKey)).toBe(false);
|
||||
expect(isVapidKeyPair('not base64url!', a.privateKey)).toBe(false);
|
||||
expect(derivePublicKey('short')).toBeNull();
|
||||
expect(derivePublicKey(Buffer.alloc(32).toString('base64url'))).toBeNull(); // zero is no scalar
|
||||
expect(derivePublicKey(a.privateKey)?.toString('base64url')).toBe(a.publicKey);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-008: isP256Point wants an uncompressed point that is on the curve', () => {
|
||||
const { keys } = browser();
|
||||
expect(isP256Point(keys.p256dh)).toBe(true);
|
||||
const offCurve = Buffer.from(keys.p256dh);
|
||||
offCurve[40] ^= 0xff;
|
||||
expect(isP256Point(offCurve)).toBe(false);
|
||||
expect(isP256Point(Buffer.concat([Buffer.from([0x03]), keys.p256dh.subarray(1)]))).toBe(false);
|
||||
expect(isP256Point(keys.p256dh.subarray(0, 33))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVapidSigner', () => {
|
||||
const pair = generateVapidKeyPair();
|
||||
const pub = b64(pair.publicKey);
|
||||
const verifyKey = createPublicKey({
|
||||
key: {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: pub.subarray(1, 33).toString('base64url'),
|
||||
y: pub.subarray(33).toString('base64url'),
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
|
||||
function parse(header: string) {
|
||||
const m = /^vapid t=([^,]+), k=(.+)$/.exec(header);
|
||||
expect(m).not.toBeNull();
|
||||
return { token: m![1], k: m![2] };
|
||||
}
|
||||
|
||||
it('WPCRYPTO-009: builds `vapid t=<ES256 JWT>, k=<public key>` for the endpoint origin', () => {
|
||||
const now = Date.UTC(2026, 8, 24, 12, 0, 0);
|
||||
const sign = createVapidSigner(pair, 'mailto:ops@example.test', () => now);
|
||||
const { token, k } = parse(sign('https://fcm.googleapis.com/fcm/send/abc:def'));
|
||||
expect(k).toBe(pair.publicKey);
|
||||
|
||||
const claims = jwt.verify(token, verifyKey, {
|
||||
algorithms: ['ES256'],
|
||||
clockTimestamp: now / 1000,
|
||||
}) as jwt.JwtPayload;
|
||||
expect(claims).toEqual({
|
||||
aud: 'https://fcm.googleapis.com',
|
||||
exp: now / 1000 + VAPID_TOKEN_TTL_SECONDS,
|
||||
sub: 'mailto:ops@example.test',
|
||||
});
|
||||
expect(VAPID_TOKEN_TTL_SECONDS).toBeLessThanOrEqual(24 * 60 * 60);
|
||||
|
||||
const [header, , signature] = token.split('.');
|
||||
expect(JSON.parse(b64(header).toString('utf8'))).toEqual({ alg: 'ES256', typ: 'JWT' });
|
||||
// JOSE r||s, not DER: the form push services verify.
|
||||
expect(b64(signature).length).toBe(64);
|
||||
});
|
||||
|
||||
it('WPCRYPTO-010: signs once per origin and separately for another push service', () => {
|
||||
const sign = createVapidSigner(pair, 'https://trek.example.test');
|
||||
const a = sign('https://updates.push.services.mozilla.com/wpush/v2/one');
|
||||
const b = sign('https://updates.push.services.mozilla.com/wpush/v2/two');
|
||||
const c = sign('https://web.push.apple.com/QGx');
|
||||
expect(a).toBe(b);
|
||||
expect(c).not.toBe(a);
|
||||
const claims = jwt.decode(parse(c).token) as jwt.JwtPayload;
|
||||
expect(claims.aud).toBe('https://web.push.apple.com');
|
||||
});
|
||||
|
||||
it('WPCRYPTO-011: refuses a malformed key pair up front', () => {
|
||||
expect(() => createVapidSigner({ publicKey: 'abc', privateKey: pair.privateKey }, 'mailto:a@b.test')).toThrow(
|
||||
/malformed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Web Push inside the dispatcher (WPDISP-*): the admin switch, the per-user
|
||||
* Push column, admin-scoped events, the preference matrix, the generic test
|
||||
* route, and all of them while the key pair cannot be used, stored or from
|
||||
* VAPID_*. Real NotificationsService and real SQL; safeFetchFollow is the edge.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createECDH } from 'node:crypto';
|
||||
|
||||
// One :memory: connection per file, created inside the factory so nothing has
|
||||
// to be hoisted above the imports; the tests reach it through the mocked module.
|
||||
vi.mock('../../../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
const { safeFetchFollow, logError } = vi.hoisted(() => ({ safeFetchFollow: vi.fn(), logError: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../../../src/utils/ssrfGuard', () => {
|
||||
class SsrfBlockedError extends Error {}
|
||||
return { SsrfBlockedError, safeFetchFollow };
|
||||
});
|
||||
|
||||
import { db as testDb } from '../../../../src/db/database';
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories';
|
||||
import { makeNotificationPreferencesService, makeNotificationsService } from '../../../helpers/notifications';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
||||
import {
|
||||
PUSH_UNAVAILABLE_ERROR,
|
||||
VAPID_PRIVATE_KEY_SETTING,
|
||||
VapidKeysService,
|
||||
} from '../../../../src/nest/notifications/push/vapid-keys.service';
|
||||
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
||||
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const notifications = makeNotificationsService(dbs);
|
||||
const prefs = makeNotificationPreferencesService(dbs);
|
||||
const subscriptions = new PushSubscriptionsService(dbs);
|
||||
const keys = new VapidKeysService(dbs);
|
||||
|
||||
function addDevice(userId: number, endpoint = `https://fcm.googleapis.com/fcm/send/u${userId}`): string {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
const checked = checkPushSubscription({
|
||||
endpoint,
|
||||
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 7).toString('base64url') },
|
||||
});
|
||||
if ('error' in checked) throw new Error(checked.error);
|
||||
subscriptions.upsert(userId, checked.value, keys.getPublicKey());
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function invite(targetId: number, actorId: number) {
|
||||
return notifications.send({
|
||||
event: 'trip_invite',
|
||||
actorId,
|
||||
params: { trip: 'Lisbon', actor: 'bob@example.test', invitee: 'alice', tripId: '12' },
|
||||
scope: 'user',
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
|
||||
const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
/** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */
|
||||
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
|
||||
|
||||
function breakStoredPrivateKey(): void {
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
||||
}
|
||||
|
||||
const pushColumn = (userId: number) =>
|
||||
prefs.getPreferencesMatrix(userId, 'user').channels.find((c) => c.id === 'push');
|
||||
|
||||
const storedKeyRows = () =>
|
||||
testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
safeFetchFollow.mockReset();
|
||||
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
|
||||
logError.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Web Push in NotificationsService.send()', () => {
|
||||
it('WPDISP-001: goes out once the admin enabled push and the user has a device', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
const endpoint = addDevice(user.id);
|
||||
setNotificationChannels(testDb, 'push');
|
||||
await invite(user.id, actor.id);
|
||||
expect(pushedTo()).toEqual([endpoint]);
|
||||
});
|
||||
|
||||
it('WPDISP-002: stays quiet while the admin has not enabled push', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
addDevice(user.id);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
await invite(user.id, actor.id);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WPDISP-003: respects the user switching the event off in the Push column', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
addDevice(user.id);
|
||||
setNotificationChannels(testDb, 'push');
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'push');
|
||||
await invite(user.id, actor.id);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WPDISP-004: never carries an admin-scoped event', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
addDevice(admin.id);
|
||||
setNotificationChannels(testDb, 'push');
|
||||
await notifications.send({
|
||||
event: 'version_available',
|
||||
actorId: null,
|
||||
params: { version: '9.9.9' },
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
});
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Push in the preference matrix', () => {
|
||||
it('WPDISP-005: the user matrix gets a Push column, active by the admin switch, configured by a device', () => {
|
||||
const { user } = createUser(testDb);
|
||||
let push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push');
|
||||
expect(push).toMatchObject({
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.push',
|
||||
active: false,
|
||||
configured: false,
|
||||
});
|
||||
|
||||
setNotificationChannels(testDb, 'email,push');
|
||||
addDevice(user.id);
|
||||
push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push');
|
||||
expect(push).toMatchObject({ active: true, configured: true });
|
||||
expect(prefs.getActiveChannels()).toEqual(['email', 'push']);
|
||||
expect(prefs.getPreferencesMatrix(user.id, 'user').implemented_combos['trip_invite']).toContain('push');
|
||||
});
|
||||
|
||||
it('WPDISP-006: the admin matrix has no Push column, since push never carries admin events', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const matrix = prefs.getPreferencesMatrix(admin.id, 'admin', 'admin');
|
||||
expect(matrix.channels.map((c) => c.id)).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
|
||||
expect(matrix.implemented_combos['version_available']).not.toContain('push');
|
||||
expect(matrix.preferences['version_available']).not.toHaveProperty('push');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/notifications/test/push, through the registry', () => {
|
||||
it('WPDISP-007: refuses a user without a device and sends to one with', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Channel is not configured for this user',
|
||||
});
|
||||
const endpoint = addDevice(user.id);
|
||||
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ success: true });
|
||||
expect(pushedTo()).toEqual([endpoint]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Push while the stored key pair cannot be used', () => {
|
||||
it('WPDISP-008: a send skips push without an error while in-app and webhook still go out', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
const endpoint = addDevice(user.id);
|
||||
testDb
|
||||
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)")
|
||||
.run(user.id, 'https://hooks.example.test/trek');
|
||||
setNotificationChannels(testDb, 'webhook,push');
|
||||
breakStoredPrivateKey();
|
||||
|
||||
await expect(invite(user.id, actor.id)).resolves.toBeUndefined();
|
||||
|
||||
expect(pushedTo()).toEqual(['https://hooks.example.test/trek']);
|
||||
const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as {
|
||||
n: number;
|
||||
};
|
||||
expect(inApp.n).toBe(1);
|
||||
// The device is kept for when the key is back, and nothing failed on the way.
|
||||
expect(subscriptions.listForUser(user.id).map((r) => r.endpoint)).toEqual([endpoint]);
|
||||
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed'));
|
||||
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Web Push failed'));
|
||||
});
|
||||
|
||||
it('WPDISP-009: the matrix stops offering push, so the card and the column go, and both come back with the key', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email,push');
|
||||
addDevice(user.id);
|
||||
const ciphertext = (
|
||||
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string }
|
||||
).value;
|
||||
breakStoredPrivateKey();
|
||||
|
||||
expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true });
|
||||
// Only push: email keeps its column, SMTP or not, as it always has.
|
||||
expect(prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'email')?.active).toBe(true);
|
||||
|
||||
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
|
||||
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
||||
});
|
||||
|
||||
it('WPDISP-009b: reading the matrix while neither key row exists writes nothing, and push is still offered', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email,push');
|
||||
addDevice(user.id);
|
||||
// What deleting both rows to start over leaves under a running server.
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run();
|
||||
|
||||
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
||||
expect(storedKeyRows()).toEqual([]);
|
||||
});
|
||||
|
||||
it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
addDevice(user.id);
|
||||
breakStoredPrivateKey();
|
||||
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
|
||||
success: false,
|
||||
error: PUSH_UNAVAILABLE_ERROR,
|
||||
});
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web Push while the VAPID_* pair is broken and an older pair is stored', () => {
|
||||
it('WPDISP-011: nothing is sent, the device and both rows stay, the column goes, and all of it returns with the variables', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'push');
|
||||
// The first start without VAPID_* stored a pair; the operator then brought their own.
|
||||
keys.getPublicKey();
|
||||
const stored = storedKeyRows();
|
||||
const pair = generateVapidKeyPair();
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
const endpoint = addDevice(user.id);
|
||||
// A typo or a rotated Secret: still a valid 32-byte key, so boot is fine.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
|
||||
|
||||
await expect(invite(user.id, actor.id)).resolves.toBeUndefined();
|
||||
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(subscriptions.listForUser(user.id).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([
|
||||
[endpoint, pair.publicKey],
|
||||
]);
|
||||
expect(storedKeyRows()).toEqual(stored);
|
||||
expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true });
|
||||
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
||||
await invite(user.id, actor.id);
|
||||
expect(pushedTo()).toEqual([endpoint]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* The Web Push transport (WPUSH-*): what goes over the wire, what each push
|
||||
* service answer does to the table, and the payload the worker receives
|
||||
* (WPUSH-PAY-*). safeFetchFollow is the boundary; the database is real.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { createDecipheriv, createECDH, hkdfSync, type ECDH } from 'node:crypto';
|
||||
|
||||
// One :memory: connection per file, created inside the factory so nothing has
|
||||
// to be hoisted above the imports; the tests reach it through the mocked module.
|
||||
vi.mock('../../../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
const { logInfo, logError, safeFetchFollow } = vi.hoisted(() => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
safeFetchFollow: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo,
|
||||
logDebug: vi.fn(),
|
||||
logError,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../../src/utils/ssrfGuard', () => {
|
||||
class SsrfBlockedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SsrfBlockedError';
|
||||
}
|
||||
}
|
||||
return { SsrfBlockedError, safeFetchFollow };
|
||||
});
|
||||
|
||||
import { db as testDb } from '../../../../src/db/database';
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
|
||||
import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
||||
import {
|
||||
PUSH_UNAVAILABLE_ERROR,
|
||||
VAPID_PRIVATE_KEY_SETTING,
|
||||
VAPID_PUBLIC_KEY_SETTING,
|
||||
VapidKeysService,
|
||||
} from '../../../../src/nest/notifications/push/vapid-keys.service';
|
||||
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
|
||||
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
||||
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
|
||||
import {
|
||||
FORBIDDEN_FAILURES_BEFORE_REMOVAL,
|
||||
MAX_PUSH_PAYLOAD_BYTES,
|
||||
WebPushService,
|
||||
buildPushPayload,
|
||||
samePagePath,
|
||||
} from '../../../../src/nest/notifications/transports/web-push.service';
|
||||
import { ALL_EVENT_TYPES, type ChannelMessage } from '../../../../src/nest/notifications/notification-events';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const keys = new VapidKeysService(dbs);
|
||||
const subscriptions = new PushSubscriptionsService(dbs);
|
||||
const push = new WebPushService(keys, subscriptions);
|
||||
|
||||
const MSG: ChannelMessage = {
|
||||
event: 'collab_message',
|
||||
title: 'New message in "Lisbon"',
|
||||
body: 'alice: see you at the station',
|
||||
navigateTarget: '/trips/12',
|
||||
url: 'https://trek.example.test/trips/12',
|
||||
};
|
||||
|
||||
interface Device {
|
||||
endpoint: string;
|
||||
ua: ECDH;
|
||||
auth: Buffer;
|
||||
}
|
||||
|
||||
function subscribe(userId: number, endpoint: string): Device {
|
||||
const ua = createECDH('prime256v1');
|
||||
ua.generateKeys();
|
||||
const auth = Buffer.from('fedcba9876543210');
|
||||
const checked = checkPushSubscription({
|
||||
endpoint,
|
||||
keys: { p256dh: ua.getPublicKey('base64url'), auth: auth.toString('base64url') },
|
||||
});
|
||||
if ('error' in checked) throw new Error(checked.error);
|
||||
subscriptions.upsert(userId, checked.value, keys.getPublicKey());
|
||||
return { endpoint, ua, auth };
|
||||
}
|
||||
|
||||
/** The browser's side of RFC 8291, to read what was actually sent. */
|
||||
function open(body: Uint8Array, device: Device): unknown {
|
||||
const buf = Buffer.from(body);
|
||||
const salt = buf.subarray(0, 16);
|
||||
const keyId = buf.subarray(21, 21 + buf[20]);
|
||||
const record = buf.subarray(21 + buf[20]);
|
||||
const secret = device.ua.computeSecret(keyId);
|
||||
const info = Buffer.concat([Buffer.from('WebPush: info\0'), device.ua.getPublicKey(), keyId]);
|
||||
const ikm = Buffer.from(hkdfSync('sha256', secret, device.auth, info, 32));
|
||||
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
|
||||
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
|
||||
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
|
||||
decipher.setAuthTag(record.subarray(record.length - 16));
|
||||
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
|
||||
return JSON.parse(padded.subarray(0, padded.lastIndexOf(0x02)).toString('utf8'));
|
||||
}
|
||||
|
||||
function reply(status: number, text = '') {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: null,
|
||||
headers: { get: () => null },
|
||||
text: async () => text,
|
||||
};
|
||||
}
|
||||
|
||||
function rows(userId: number) {
|
||||
return subscriptions.listForUser(userId);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
safeFetchFollow.mockReset();
|
||||
logInfo.mockClear();
|
||||
logError.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('WebPushService delivery', () => {
|
||||
it('WPUSH-001: posts one encrypted, VAPID-signed message through the SSRF guard', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/abc');
|
||||
safeFetchFollow.mockResolvedValue(reply(201));
|
||||
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
|
||||
|
||||
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
|
||||
const [url, init, options] = safeFetchFollow.mock.calls[0];
|
||||
expect(url).toBe(device.endpoint);
|
||||
expect(options).toEqual({ maxRedirects: 0, bypassInternalIpAllowed: true });
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init.headers).toMatchObject({
|
||||
TTL: '86400',
|
||||
Urgency: 'normal',
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
});
|
||||
expect(init.headers.Authorization).toMatch(
|
||||
new RegExp(`^vapid t=[\\w-]+\\.[\\w-]+\\.[\\w-]+, k=${keys.getPublicKey()}$`),
|
||||
);
|
||||
expect(open(init.body, device)).toEqual({
|
||||
title: MSG.title,
|
||||
body: MSG.body,
|
||||
url: '/trips/12',
|
||||
tag: 'collab_message:/trips/12',
|
||||
});
|
||||
|
||||
const [row] = rows(user.id);
|
||||
expect(row.last_success_at).not.toBeNull();
|
||||
expect(row.failure_count).toBe(0);
|
||||
});
|
||||
|
||||
it('WPUSH-002: date-bound events go out with high urgency', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://web.push.apple.com/QGx');
|
||||
safeFetchFollow.mockResolvedValue(reply(201));
|
||||
await push.sendToUser(user.id, { ...MSG, event: 'trip_reminder' });
|
||||
expect(safeFetchFollow.mock.calls[0][1].headers.Urgency).toBe('high');
|
||||
});
|
||||
|
||||
it('WPUSH-003: 404 and 410 mean the browser is gone, and the row goes with it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/expired');
|
||||
subscribe(user.id, 'https://updates.push.services.mozilla.com/wpush/v2/revoked');
|
||||
safeFetchFollow.mockImplementation(async (url: string) => reply(url.includes('expired') ? 404 : 410));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(rows(user.id)).toEqual([]);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WPUSH-004: a first 403 with Apple’s BadJwtToken keeps the device, counts it and logs the reason', async () => {
|
||||
// Apple answers 403 for a token it rejects, which is the server's problem
|
||||
// (subject, clock, key), not a sign the device is gone.
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://web.push.apple.com/QGx');
|
||||
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(rows(user.id)).toHaveLength(1);
|
||||
expect(rows(user.id)[0].failure_count).toBe(1);
|
||||
const line = logError.mock.calls[0][0] as string;
|
||||
expect(line).toContain('HTTP 403');
|
||||
expect(line).toContain('BadJwtToken');
|
||||
expect(line).toContain('host=web.push.apple.com');
|
||||
// The endpoint is a capability URL: the log names the host only.
|
||||
expect(line).not.toContain('/QGx');
|
||||
});
|
||||
|
||||
it(`WPUSH-004b: the device goes after ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} failed sends in a row ending in 403`, async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://web.push.apple.com/QGx');
|
||||
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
|
||||
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) {
|
||||
await push.sendToUser(user.id, MSG);
|
||||
expect(rows(user.id)[0].failure_count).toBe(i);
|
||||
}
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(rows(user.id)).toEqual([]);
|
||||
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('removed a device'));
|
||||
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('BadJwtToken'));
|
||||
});
|
||||
|
||||
it('WPUSH-004c: a success between refusals starts the count over', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://web.push.apple.com/QGx');
|
||||
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
|
||||
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) await push.sendToUser(user.id, MSG);
|
||||
safeFetchFollow.mockResolvedValueOnce(reply(201));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
|
||||
expect(rows(user.id)[0].failure_count).toBe(0);
|
||||
await push.sendToUser(user.id, MSG);
|
||||
expect(rows(user.id)).toHaveLength(1);
|
||||
expect(rows(user.id)[0].failure_count).toBe(1);
|
||||
});
|
||||
|
||||
it('WPUSH-005: any other failure keeps the row, counts it and logs the capped body', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/busy');
|
||||
safeFetchFollow.mockResolvedValue(reply(503, `overloaded ${'x'.repeat(5000)}`));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(rows(user.id)[0].failure_count).toBe(1);
|
||||
const line = logError.mock.calls[0][0] as string;
|
||||
expect(line).toContain('HTTP 503');
|
||||
expect(line.length).toBeLessThan(1300);
|
||||
|
||||
// A body that cannot be read changes nothing but the log text.
|
||||
safeFetchFollow.mockResolvedValueOnce({
|
||||
...reply(500),
|
||||
text: async () => {
|
||||
throw new Error('stream reset');
|
||||
},
|
||||
});
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(rows(user.id)[0].failure_count).toBe(2);
|
||||
expect(logError).toHaveBeenLastCalledWith(expect.stringMatching(/HTTP 500 .*host=fcm\.googleapis\.com: $/));
|
||||
});
|
||||
|
||||
it('WPUSH-006: a refused or failed request is a failure, never a throw', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/a');
|
||||
safeFetchFollow.mockRejectedValueOnce(new SsrfBlockedError('Requests to private addresses are not allowed'));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('blocked by SSRF guard'));
|
||||
|
||||
safeFetchFollow.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('timeout'));
|
||||
expect(rows(user.id)[0].failure_count).toBe(2);
|
||||
});
|
||||
|
||||
it('WPUSH-007: one device getting it is enough', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/ok');
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/down');
|
||||
safeFetchFollow.mockImplementation(async (url: string) => reply(url.endsWith('/ok') ? 201 : 500));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
|
||||
// Both hosts are the same origin, so one token served both requests.
|
||||
const [a, b] = safeFetchFollow.mock.calls.map((c) => c[1].headers.Authorization);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('WPUSH-008: a row made for a previous server key is dropped without a request', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/old');
|
||||
testDb.prepare("UPDATE push_subscriptions SET vapid_public_key = 'previous-key'").run();
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(rows(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('WPUSH-009: a row whose host is not a push service any more is dropped without a request', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/y');
|
||||
testDb
|
||||
.prepare("UPDATE push_subscriptions SET endpoint = 'https://attacker.example.test/collect' WHERE endpoint LIKE '%/x'")
|
||||
.run();
|
||||
testDb.prepare("UPDATE push_subscriptions SET endpoint = 'not a url' WHERE endpoint LIKE '%/y'").run();
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(rows(user.id)).toEqual([]);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=attacker.example.test'));
|
||||
// Not even a URL: the log still has something to name instead of the value.
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('not a known push service user'));
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=invalid-endpoint'));
|
||||
});
|
||||
|
||||
it('WPUSH-010: broken stored keys count as a failure for that row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
testDb.prepare("UPDATE push_subscriptions SET auth = '***'").run();
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(rows(user.id)[0].failure_count).toBe(1);
|
||||
});
|
||||
|
||||
it('WPUSH-011: nothing to send to is simply false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(push.hasDevices(user.id)).toBe(false);
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WPUSH-012: a failure before any request (no key pair) is logged and false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
const spy = vi.spyOn(keys, 'getKeys').mockImplementationOnce(() => {
|
||||
throw new Error('database is locked');
|
||||
});
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('WPUSH-012b: while the stored key pair cannot be used nothing is sent and every device stays', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
const publicKey = keys.getPublicKey();
|
||||
// A restore under a different ENCRYPTION_KEY: the ciphertext no longer opens.
|
||||
testDb
|
||||
.prepare("UPDATE app_settings SET value = 'enc:v1:bm90IGEgY2lwaGVydGV4dA' WHERE key = ?")
|
||||
.run(VAPID_PRIVATE_KEY_SETTING);
|
||||
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(rows(user.id)).toHaveLength(1);
|
||||
expect(rows(user.id)[0].vapid_public_key).toBe(publicKey);
|
||||
expect(rows(user.id)[0].failure_count).toBe(0);
|
||||
expect(push.isAvailable()).toBe(false);
|
||||
|
||||
// The test send says why instead of a bare failure, and sends nothing either.
|
||||
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WPUSH-012c: an install that only ran on VAPID_* generates nothing when that pair breaks, and keeps every device', async () => {
|
||||
const pair = generateVapidKeyPair();
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
subscribe(user.id, 'https://web.push.apple.com/y');
|
||||
// An existingSecret without the private half: the variable is simply gone.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', '');
|
||||
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
|
||||
|
||||
expect(push.isAvailable()).toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
expect(testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'web_push_vapid_%'").all()).toEqual([]);
|
||||
expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
|
||||
|
||||
// The Secret fixed: the same devices receive again, nobody turns push on again.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
safeFetchFollow.mockResolvedValue(reply(201));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
|
||||
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
|
||||
expect(rows(user.id)).toHaveLength(2);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('WPUSH-012d: a broken VAPID_* pair over a pair stored from before sends nothing, keeps every device and serves no key', async () => {
|
||||
// The first start without VAPID_* stored a pair; the operator then brought their own.
|
||||
const storedKey = keys.getPublicKey();
|
||||
const pair = generateVapidKeyPair();
|
||||
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
const controller = new PushController(keys, subscriptions);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
subscribe(user.id, 'https://web.push.apple.com/y');
|
||||
// An existingSecret rotated without the private half.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', '');
|
||||
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
|
||||
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
|
||||
expect(push.isAvailable()).toBe(false);
|
||||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||||
// Signing with the stored pair would have dropped both rows; they stay on the environment key.
|
||||
expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
|
||||
expect(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PUBLIC_KEY_SETTING)).toEqual({
|
||||
value: storedKey,
|
||||
});
|
||||
// Browsers are not handed the stored key to subscribe again with either.
|
||||
let refusal: unknown = null;
|
||||
try {
|
||||
controller.publicKey();
|
||||
} catch (err) {
|
||||
refusal = err;
|
||||
}
|
||||
expect(refusal).toBeInstanceOf(HttpException);
|
||||
expect((refusal as HttpException).getStatus()).toBe(503);
|
||||
|
||||
// The Secret fixed: the same rows receive again, and the key served is the one they hold.
|
||||
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
||||
safeFetchFollow.mockResolvedValue(reply(201));
|
||||
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
|
||||
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
|
||||
expect(rows(user.id)).toHaveLength(2);
|
||||
expect(controller.publicKey()).toEqual({ publicKey: pair.publicKey });
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('WPUSH-013: the test send reports success and failure like the other channels', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
|
||||
safeFetchFollow.mockResolvedValueOnce(reply(201));
|
||||
expect(push.isAvailable()).toBe(true);
|
||||
await expect(push.sendTest(user.id)).resolves.toEqual({ success: true });
|
||||
const sent = open(safeFetchFollow.mock.calls[0][1].body, device);
|
||||
expect(sent).toMatchObject({ title: 'Test Notification', url: '/settings?tab=notifications' });
|
||||
// Only chat collapses; a second test is a second notification.
|
||||
expect(sent).not.toHaveProperty('tag');
|
||||
safeFetchFollow.mockResolvedValueOnce(reply(500));
|
||||
await expect(push.sendTest(user.id)).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Failed to send push notification',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPushPayload', () => {
|
||||
const size = (p: unknown) => Buffer.byteLength(JSON.stringify(p), 'utf8');
|
||||
|
||||
it('WPUSH-PAY-001: passes a short message through; chat collapses per trip', () => {
|
||||
expect(buildPushPayload(MSG)).toEqual({
|
||||
title: MSG.title,
|
||||
body: MSG.body,
|
||||
url: '/trips/12',
|
||||
tag: 'collab_message:/trips/12',
|
||||
});
|
||||
// Without a trip to collapse on there is no tag at all, never one for every chat everywhere.
|
||||
expect(buildPushPayload({ ...MSG, navigateTarget: undefined })).toEqual({
|
||||
title: MSG.title,
|
||||
body: MSG.body,
|
||||
});
|
||||
});
|
||||
|
||||
it('WPUSH-PAY-001b: every other event has no tag, so two of them never replace each other', () => {
|
||||
const others = ALL_EVENT_TYPES.filter((event) => event !== 'collab_message');
|
||||
expect(others.length).toBeGreaterThan(5);
|
||||
for (const event of others) {
|
||||
const payload = buildPushPayload({ ...MSG, event });
|
||||
expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12' });
|
||||
expect(payload, event).not.toHaveProperty('tag');
|
||||
}
|
||||
});
|
||||
|
||||
it('WPUSH-PAY-002: cuts a long body at a character boundary until the payload fits', () => {
|
||||
const payload = buildPushPayload({ ...MSG, body: '🚄'.repeat(5000) });
|
||||
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
|
||||
expect(size(payload)).toBeGreaterThan(MAX_PUSH_PAYLOAD_BYTES - 8);
|
||||
expect(payload.body.endsWith('…')).toBe(true);
|
||||
expect(payload.body.slice(0, -1)).toMatch(/^(🚄)+$/u);
|
||||
});
|
||||
|
||||
it('WPUSH-PAY-003: characters JSON has to escape are counted as what they cost', () => {
|
||||
const payload = buildPushPayload({ ...MSG, title: '\u0001'.repeat(500), body: '"\u0002'.repeat(3000) });
|
||||
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
|
||||
expect(Array.from(payload.title)).toHaveLength(120);
|
||||
expect(payload.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('WPUSH-PAY-004: only same-origin paths reach the worker', () => {
|
||||
expect(samePagePath('/trips/5')).toBe('/trips/5');
|
||||
expect(samePagePath('/admin?tab=storage')).toBe('/admin?tab=storage');
|
||||
for (const target of [
|
||||
'https://evil.test/x',
|
||||
'//evil.test/x',
|
||||
'/\\evil.test',
|
||||
'trips/5',
|
||||
'/trips/5 x',
|
||||
`/${'a'.repeat(300)}`,
|
||||
'',
|
||||
undefined,
|
||||
]) {
|
||||
expect(samePagePath(target), String(target)).toBeUndefined();
|
||||
}
|
||||
expect(buildPushPayload({ ...MSG, navigateTarget: 'https://evil.test/' })).not.toHaveProperty('url');
|
||||
});
|
||||
});
|
||||
@@ -720,6 +720,9 @@ const admin: TranslationStrings = {
|
||||
'admin.notifications.ntfy': 'Ntfy', // en-fallback
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)', // en-fallback
|
||||
'admin.notifications.webhookPanel.title': 'Webhook', // en-fallback
|
||||
'admin.notifications.webPushPanel.title': 'إشعارات الويب الفورية',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'تتيح للمستخدمين تلقي الإشعارات على هواتفهم وحواسيبهم عبر المتصفح، حتى عندما يكون TREK مغلقًا. يتطلب HTTPS؛ وعلى iPhone وiPad يجب إضافة TREK إلى الشاشة الرئيسية.',
|
||||
'admin.notifications.inappPanel.title': 'In-App', // en-fallback
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', // en-fallback
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // en-fallback
|
||||
|
||||
@@ -960,11 +960,13 @@ const help: TranslationStrings = {
|
||||
'«وضع الجودة العالية» لتنعيم الحواف وإسقاط الكرة الأرضية؛ و«حفظ الخريطة» يثبّت الاختيار.',
|
||||
'help.ctx.settings-notifications.title': 'الإشعارات',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'أين يصلك TREK خارج التطبيق: موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.',
|
||||
'أين يصلك TREK خارج التطبيق: إشعارات فورية على هذا الجهاز، أو موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: الموضوع، وخادم خاص بك اختياري، ورمز وصول اختياري، مع «اختبار» لإرسال رسالة على الفور.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: عنوان URL واحد يستقبل كل حدث بصيغة JSON، مع «اختبار».',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'الإشعارات الفورية على هذا الجهاز: «تشغيل على هذا الجهاز» يشمل المتصفح الذي تستخدمه فقط، لذا كرّره على كل هاتف أو حاسوب. «إرسال اختبار» يصل إليها جميعًا.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'صفوف التفضيلات: لكل حدث، أي قناة مفعّلة. تعرض قنوات الإضافات «تهيئة» حتى يتم إعدادها.',
|
||||
'help.ctx.settings-integrations.title': 'التكاملات',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1266,7 +1268,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'القنوات التي تقدمها النسخة لمستخدميها، والقنوات التي تصلك أنت بصفتك المسؤول. يختار المستخدمون مواضيعهم وعناوين URL الخاصة بهم في «الإعدادات»؛ وأنت تقرر ما يوجد وتهيّئ البريد الإلكتروني.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.',
|
||||
'«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook» و«إشعارات الويب الفورية»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.',
|
||||
'help.ctx.admin-notifications.bullet.2': '«تذكيرات الرحلات»: هل يرسل الخادم التذكير قبل بدء الرحلة.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
'«Ntfy المسؤول» و«Webhook المسؤول»: وجهة أحداث المسؤول مثل فشل نسخة احتياطية أو صدور إصدار جديد، مع «اختبار».',
|
||||
@@ -1482,7 +1484,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'تحت «Email (SMTP)»، أدخل SMTP Host وSMTP Port وSMTP User وSMTP Password وFrom Address؛ و«إرسال بريد تجريبي» يرسل رسالة إليك.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'شغّل «Ntfy» و«Webhook» لتقديمهما؛ ثم يدخل المستخدمون موضوعهم أو عنوان URL الخاص بهم في «الإعدادات»، «الإشعارات».',
|
||||
'شغّل «إشعارات الويب الفورية» و«Ntfy» و«Webhook» لتقديمها؛ ثم يشغّل المستخدمون الإشعارات الفورية لكل جهاز، أو يدخلون موضوعهم أو عنوان URL الخاص بهم، في «الإعدادات»، «الإشعارات».',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'«تذكيرات الرحلات» تشغّل التذكير قبل بدء الرحلة؛ و«In-App» مفعّل دائمًا ويُشرح هنا فحسب.',
|
||||
'help.guide.channels-instance.result': 'يعرض تبويب «الإشعارات» لدى كل مستخدم القنوات التي شغّلتها.',
|
||||
|
||||
@@ -300,6 +300,18 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.testFailed': 'فشل الاختبار.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback
|
||||
'settings.notificationPreferences.push': 'إشعارات فورية',
|
||||
'settings.webPush.title': 'الإشعارات الفورية على هذا الجهاز',
|
||||
'settings.webPush.hint':
|
||||
'يعرض إشعارات TREK على هذا الجهاز حتى عندما يكون TREK مغلقًا. الأحداث التي تصل تتبع عمود «إشعارات فورية».',
|
||||
'settings.webPush.enable': 'تشغيل على هذا الجهاز',
|
||||
'settings.webPush.disable': 'إيقاف على هذا الجهاز',
|
||||
'settings.webPush.enabled': 'مُفعّل على هذا الجهاز',
|
||||
'settings.webPush.unsupported': 'لا يمكن لهذا المتصفح تلقي الإشعارات الفورية.',
|
||||
'settings.webPush.insecure': 'تتطلب الإشعارات الفورية فتح TREK عبر HTTPS.',
|
||||
'settings.webPush.iosInstall': 'على iPhone وiPad، أضف TREK أولًا إلى الشاشة الرئيسية ثم افتحه من هناك.',
|
||||
'settings.webPush.denied': 'الإشعارات محظورة لـ TREK في هذا المتصفح. اسمح بها في إعدادات المتصفح، ثم حاول مرة أخرى.',
|
||||
'settings.webPush.failed': 'تعذّر تشغيل الإشعارات الفورية على هذا الجهاز.',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback
|
||||
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', // en-fallback
|
||||
|
||||
@@ -19,6 +19,9 @@ const admin: TranslationStrings = {
|
||||
'admin.notifications.testNtfyFailed': 'Sınaq ntfy bildirişi uğursuz oldu',
|
||||
'admin.notifications.emailPanel.title': 'E-poçt (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'İstifadəçilərə TREK bağlı olanda belə brauzer vasitəsilə telefon və kompüterlərində bildiriş almağa imkan verir. HTTPS tələb olunur; iPhone və iPad-də TREK Əsas ekrana əlavə edilməlidir.',
|
||||
'admin.notifications.inappPanel.title': 'Tətbiqdaxili',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'Tətbiqdaxili bildirişlər həmişə aktivdir və ümumi şəkildə deaktiv edilə bilməz.',
|
||||
|
||||
@@ -1085,11 +1085,13 @@ const help: TranslationStrings = {
|
||||
'Hamarlaşdırma və qlobus proyeksiyası üçün Yüksək keyfiyyət rejimi; Xəritəni yadda saxla seçimi saxlayır.',
|
||||
'help.ctx.settings-notifications.title': 'Bildirişlər',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'TREK-in tətbiqdən kənarda sizə harada çatdığı: ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.',
|
||||
'TREK-in tətbiqdən kənarda sizə harada çatdığı: bu cihazda push bildirişləri, ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: mövzu, istəyə bağlı öz serveriniz və istəyə bağlı giriş tokeni, dərhal test göndərmək üçün Sına düyməsi ilə.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: hər hadisəni JSON kimi qəbul edən bir URL, Sına düyməsi ilə.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Bu cihazda push bildirişləri: Bu cihaz üçün aktiv et yalnız istifadə etdiyiniz brauzerə aiddir, ona görə də bunu hər telefonda və ya kompüterdə təkrarlayın. Test göndər hamısına çatır.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'Tənzimləmə sətirləri: hər hadisə üçün hansı kanalın aktiv olduğu. Plagin kanalları qurulana qədər Konfiqurasiya et göstərir.',
|
||||
'help.ctx.settings-integrations.title': 'İnteqrasiyalar',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1418,7 +1420,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'İnstansiyanın istifadəçilərə təklif etdiyi kanallar və sizə administrator kimi çatan kanallar. İstifadəçilər öz mövzularını və URL-lərini Tənzimləmələr bölməsində seçir; nəyin mövcud olacağını siz müəyyən edirsiniz və e-poçtu siz konfiqurasiya edirsiniz.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'Tətbiqdaxili, E-poçt (SMTP), Ntfy və Webhook: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.',
|
||||
'Tətbiqdaxili, E-poçt (SMTP), Ntfy, Webhook və Web Push: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.',
|
||||
'help.ctx.admin-notifications.bullet.2':
|
||||
'Səyahət xatırlatmaları: serverin səyahət başlamazdan əvvəl xatırlatma göndərib-göndərməməsi.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
@@ -1687,7 +1689,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'E-poçt (SMTP) bölməsində SMTP hostu, SMTP portu, SMTP istifadəçisi, SMTP parolu və Göndərən ünvanı daxil edin; Sınaq e-poçtu göndər sizə məktub göndərir.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində öz mövzularını və ya URL-lərini daxil edirlər.',
|
||||
'Web Push, Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində push-u hər cihaz üçün aktiv edir və ya öz mövzularını və ya URL-lərini daxil edirlər.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Səyahət xatırlatmaları səyahət başlamazdan əvvəlki xatırlatmanı aktiv və ya deaktiv edir; Tətbiqdaxili həmişə aktivdir və burada yalnız izah olunur.',
|
||||
'help.guide.channels-instance.result':
|
||||
|
||||
@@ -163,6 +163,19 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Konfiqurasiya edilib. Giriş məlumatlarını plaginin tənzimləmələr səhifəsindən idarə edin.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.webPush.title': 'Bu cihazda push bildirişləri',
|
||||
'settings.webPush.hint':
|
||||
'TREK bağlı olanda belə TREK bildirişlərini bu cihazda göstərir. Hansı hadisələrin gəldiyini Push sütunu müəyyən edir.',
|
||||
'settings.webPush.enable': 'Bu cihaz üçün aktiv et',
|
||||
'settings.webPush.disable': 'Bu cihaz üçün deaktiv et',
|
||||
'settings.webPush.enabled': 'Bu cihaz üçün aktivdir',
|
||||
'settings.webPush.unsupported': 'Bu brauzer push bildirişlərini qəbul edə bilmir.',
|
||||
'settings.webPush.insecure': 'Push üçün TREK HTTPS vasitəsilə açılmalıdır.',
|
||||
'settings.webPush.iosInstall': 'iPhone və iPad-də əvvəlcə TREK-i Əsas ekrana əlavə edin və onu oradan açın.',
|
||||
'settings.webPush.denied':
|
||||
'Bu brauzerdə TREK üçün bildirişlər bloklanıb. Onlara brauzer tənzimləmələrində icazə verin, sonra yenidən cəhd edin.',
|
||||
'settings.webPush.failed': 'Bu cihaz üçün push aktiv edilə bilmədi.',
|
||||
'settings.notificationPreferences.noChannels':
|
||||
'Heç bir bildiriş kanalı konfiqurasiya edilməyib. Administratordan e-poçt və ya webhook bildirişlərini quraşdırmasını istəyin.',
|
||||
|
||||
|
||||
@@ -714,6 +714,9 @@ const admin: TranslationStrings = {
|
||||
'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'Permite que os usuários recebam notificações no celular e no computador pelo navegador, mesmo com o TREK fechado. Requer HTTPS; no iPhone e no iPad, o TREK precisa ser adicionado à Tela de Início.',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
|
||||
|
||||
@@ -1051,11 +1051,13 @@ const help: TranslationStrings = {
|
||||
'Modo alta qualidade para antialiasing e a projeção de globo; Salvar mapa grava a escolha.',
|
||||
'help.ctx.settings-notifications.title': 'Notificações',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'Onde o TREK alcança você fora do app: um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.',
|
||||
'Onde o TREK alcança você fora do app: notificações push neste dispositivo, um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: o tópico, um servidor próprio opcional e um token de acesso opcional, com Testar para enviar um na hora.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: uma URL que recebe cada evento como JSON, com Testar.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Notificações push neste dispositivo: Ativar neste dispositivo vale só para o navegador que você está usando, então repita em cada celular ou computador. Enviar teste chega a todos eles.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'As linhas de preferências: por evento, qual canal está ligado. Canais de plugin mostram Configurar até serem configurados.',
|
||||
'help.ctx.settings-integrations.title': 'Integrações',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1377,7 +1379,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'Os canais que a instância oferece aos usuários, e os que chegam até você como admin. Os usuários escolhem seus próprios tópicos e URLs em Configurações; você decide o que existe e configura o e-mail.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'In-App, Email (SMTP), Ntfy e Webhook: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.',
|
||||
'In-App, Email (SMTP), Ntfy, Webhook e Web Push: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.',
|
||||
'help.ctx.admin-notifications.bullet.2':
|
||||
'Lembretes de viagem: se o servidor envia o lembrete antes de uma viagem começar.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
@@ -1620,7 +1622,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'Em Email (SMTP), informe SMTP Host, SMTP Port, SMTP User, SMTP Password e a From Address; Enviar e-mail de teste manda um e-mail para você.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Ligue Ntfy e Webhook para oferecê-los; os usuários então informam o próprio tópico ou URL em Configurações, Notificações.',
|
||||
'Ligue Web Push, Ntfy e Webhook para oferecê-los; os usuários então ativam o push em cada dispositivo, ou informam o próprio tópico ou URL, em Configurações, Notificações.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Lembretes de viagem controla o lembrete antes de uma viagem começar; In-App está sempre ligado e aqui só é explicado.',
|
||||
'help.guide.channels-instance.result': 'A aba Notificações de cada usuário mostra os canais que você ligou.',
|
||||
|
||||
@@ -322,6 +322,19 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.webPush.title': 'Notificações push neste dispositivo',
|
||||
'settings.webPush.hint':
|
||||
'Mostra as notificações do TREK neste dispositivo mesmo com o TREK fechado. Os eventos que chegam seguem a coluna Push.',
|
||||
'settings.webPush.enable': 'Ativar neste dispositivo',
|
||||
'settings.webPush.disable': 'Desativar neste dispositivo',
|
||||
'settings.webPush.enabled': 'Ativado neste dispositivo',
|
||||
'settings.webPush.unsupported': 'Este navegador não pode receber notificações push.',
|
||||
'settings.webPush.insecure': 'O push exige que o TREK seja aberto via HTTPS.',
|
||||
'settings.webPush.iosInstall': 'No iPhone e no iPad, adicione primeiro o TREK à Tela de Início e abra-o por lá.',
|
||||
'settings.webPush.denied':
|
||||
'As notificações do TREK estão bloqueadas neste navegador. Permita-as nas configurações do navegador e tente novamente.',
|
||||
'settings.webPush.failed': 'Não foi possível ativar o push neste dispositivo.',
|
||||
'settings.currency': 'Moeda de exibição',
|
||||
'settings.currencyHint':
|
||||
'Os valores em Custos são exibidos convertidos para esta moeda apenas para visualização — os valores originais permanecem inalterados.',
|
||||
|
||||
@@ -361,6 +361,9 @@ const admin: TranslationStrings = {
|
||||
'admin.group.maintenance': 'Manteniment',
|
||||
'admin.notifications.emailPanel.title': 'Correu (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
"Permet als usuaris rebre notificacions als telèfons i ordinadors a través del navegador, fins i tot amb TREK tancat. Cal HTTPS; a l'iPhone i l'iPad, TREK s'ha d'afegir a la pantalla d'inici.",
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'Les notificacions in-app sempre estan actives i no es poden desactivar globalment.',
|
||||
|
||||
@@ -1068,11 +1068,13 @@ const help: TranslationStrings = {
|
||||
"Mode d'alta qualitat per a l’antialiàsing i la projecció de globus; Desa el mapa escriu la tria.",
|
||||
'help.ctx.settings-notifications.title': 'Notificacions',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'On et troba TREK fora de l’app: un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.',
|
||||
'On et troba TREK fora de l’app: notificacions push en aquest dispositiu, un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: el tema, un servidor propi opcional i un token d’accés opcional, amb Prova per enviar-ne un a l’instant.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: una URL que rep cada esdeveniment com a JSON, amb Prova.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Notificacions push en aquest dispositiu: Activa en aquest dispositiu només afecta el navegador que fas servir, així que repeteix-ho a cada mòbil o ordinador. Envia una prova arriba a tots.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'Les files de preferències: per esdeveniment, quin canal està actiu. Els canals de connectors mostren Configura fins que estan configurats.',
|
||||
'help.ctx.settings-integrations.title': 'Integracions',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1401,7 +1403,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'Els canals que la instància ofereix als seus usuaris, i els que t’arriben a tu com a admin. Els usuaris trien els seus propis temes i URL a Configuració; tu decideixes què existeix i configures el correu.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'In-App, Correu (SMTP), Ntfy i Webhook: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.',
|
||||
'In-App, Correu (SMTP), Ntfy, Webhook i Web Push: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.',
|
||||
'help.ctx.admin-notifications.bullet.2':
|
||||
'Recordatoris de viatge: si el servidor envia el recordatori abans que comenci un viatge.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
@@ -1653,7 +1655,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'A Correu (SMTP), introdueix SMTP Host, SMTP Port, SMTP User, SMTP Password i la From Address; Enviar correu de prova t’envia un correu a tu.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Activa Ntfy i Webhook per oferir-los; els usuaris introdueixen llavors el seu propi tema o URL a Configuració, Notificacions.',
|
||||
'Activa Web Push, Ntfy i Webhook per oferir-los; els usuaris activen llavors el push per a cada dispositiu, o introdueixen el seu propi tema o URL, a Configuració, Notificacions.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Recordatoris de viatge controla el recordatori abans que comenci un viatge; In-App sempre està activat i aquí només s’explica.',
|
||||
'help.guide.channels-instance.result': 'La pestanya Notificacions de cada usuari mostra els canals que has activat.',
|
||||
|
||||
@@ -296,6 +296,19 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Correu electrònic',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.webPush.title': 'Notificacions push en aquest dispositiu',
|
||||
'settings.webPush.hint':
|
||||
'Mostra les notificacions de TREK en aquest dispositiu fins i tot quan TREK està tancat. Els esdeveniments que arriben segueixen la columna Push.',
|
||||
'settings.webPush.enable': 'Activa en aquest dispositiu',
|
||||
'settings.webPush.disable': 'Desactiva en aquest dispositiu',
|
||||
'settings.webPush.enabled': 'Activat en aquest dispositiu',
|
||||
'settings.webPush.unsupported': 'Aquest navegador no pot rebre notificacions push.',
|
||||
'settings.webPush.insecure': "Push necessita que TREK s'obri per HTTPS.",
|
||||
'settings.webPush.iosInstall': "A l'iPhone i l'iPad, afegeix primer TREK a la pantalla d'inici i obre'l des d'allà.",
|
||||
'settings.webPush.denied':
|
||||
'Les notificacions de TREK estan bloquejades en aquest navegador. Permet-les a la configuració del navegador i torna-ho a provar.',
|
||||
'settings.webPush.failed': "No s'ha pogut activar push en aquest dispositiu.",
|
||||
'settings.passkey.title': 'Passkeys',
|
||||
'settings.passkey.description':
|
||||
'Inicia la sessió més ràpidament i amb protecció contra el phishing utilitzant una passkey: la teva empremta, la teva cara, el teu PIN o una clau de seguretat física. La teva contrasenya continua disponible com a alternativa.',
|
||||
|
||||
@@ -709,6 +709,9 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.permissions': 'Oprávnění',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'Umožňuje uživatelům přijímat oznámení na telefonech a počítačích přes prohlížeč, i když je TREK zavřený. Vyžaduje HTTPS; na iPhonu a iPadu musí být TREK přidán na plochu.',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app oznámení jsou vždy aktivní a nelze je globálně vypnout.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
|
||||
|
||||
@@ -1021,12 +1021,14 @@ const help: TranslationStrings = {
|
||||
'Režim vysoké kvality pro vyhlazování a projekci glóbu; Uložit nastavení mapy volbu zapíše.',
|
||||
'help.ctx.settings-notifications.title': 'Oznámení',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'Kde vás TREK zastihne mimo aplikaci: téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.',
|
||||
'Kde vás TREK zastihne mimo aplikaci: push notifikace na tomto zařízení, téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: téma, volitelně vlastní server a volitelný přístupový token, s tlačítkem Otestovat, které hned jedno odešle.',
|
||||
'help.ctx.settings-notifications.bullet.2':
|
||||
'Webhook: jedna URL, která dostává každou událost jako JSON, s tlačítkem Otestovat.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Push notifikace na tomto zařízení: Zapnout pro toto zařízení platí jen pro prohlížeč, který právě používáte, takže to zopakujte na každém telefonu nebo počítači. Odeslat test dorazí na všechny.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'Řádky předvoleb: pro každou událost, který kanál je zapnutý. Kanály doplňků ukazují Nastavit, dokud nejsou nastavené.',
|
||||
'help.ctx.settings-integrations.title': 'Integrace',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1345,7 +1347,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'Kanály, které instance nabízí svým uživatelům, a ty, které zastihnou vás jako administrátora. Uživatelé si vybírají vlastní témata a URL pod Nastavení; vy rozhodujete, co existuje, a nastavujete e-mail.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'In-App, Email (SMTP), Ntfy a Webhook: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.',
|
||||
'In-App, Email (SMTP), Ntfy, Webhook a Web Push: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.',
|
||||
'help.ctx.admin-notifications.bullet.2': 'Připomínky výletů: zda server posílá připomínku před začátkem cesty.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
'Admin Ntfy a Admin webhook: kam jdou události pro administrátory, jako neúspěšná záloha nebo nové vydání, s tlačítkem pro test.',
|
||||
@@ -1580,7 +1582,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'Pod Email (SMTP) zadejte SMTP Host, SMTP Port, SMTP User, SMTP Password a From Address; Odeslat testovací e-mail vám pošle zprávu.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Zapněte Ntfy a Webhook, abyste je nabídli; uživatelé pak zadají vlastní téma nebo URL pod Nastavení, Oznámení.',
|
||||
'Zapněte Web Push, Ntfy a Webhook, abyste je nabídli; uživatelé pak pod Nastavení, Oznámení zapnou push pro každé zařízení, nebo zadají vlastní téma či URL.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Připomínky výletů přepínají připomínku před začátkem cesty; In-App je vždy zapnuté a tady je jen vysvětlené.',
|
||||
'help.guide.channels-instance.result': 'Karta Oznámení každého uživatele ukazuje kanály, které jste zapnuli.',
|
||||
|
||||
@@ -315,6 +315,19 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.webPush.title': 'Push notifikace na tomto zařízení',
|
||||
'settings.webPush.hint':
|
||||
'Zobrazuje oznámení TREK na tomto zařízení, i když je TREK zavřený. Které události dorazí, určuje sloupec Push.',
|
||||
'settings.webPush.enable': 'Zapnout pro toto zařízení',
|
||||
'settings.webPush.disable': 'Vypnout pro toto zařízení',
|
||||
'settings.webPush.enabled': 'Zapnuto pro toto zařízení',
|
||||
'settings.webPush.unsupported': 'Tento prohlížeč nemůže přijímat push notifikace.',
|
||||
'settings.webPush.insecure': 'Push vyžaduje, aby byl TREK otevřen přes HTTPS.',
|
||||
'settings.webPush.iosInstall': 'Na iPhonu a iPadu nejprve přidejte TREK na plochu a otevřete ho odtud.',
|
||||
'settings.webPush.denied':
|
||||
'Oznámení pro TREK jsou v tomto prohlížeči zablokovaná. Povolte je v nastavení prohlížeče a zkuste to znovu.',
|
||||
'settings.webPush.failed': 'Push se pro toto zařízení nepodařilo zapnout.',
|
||||
'settings.currency': 'Zobrazovaná měna',
|
||||
'settings.currencyHint':
|
||||
'Částky v Nákladech se do této měny přepočítávají pouze pro zobrazení – původní částky zůstávají beze změny.',
|
||||
|
||||
@@ -721,6 +721,9 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.permissions': 'Berechtigungen',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'Ermöglicht Benutzern, über den Browser Benachrichtigungen auf ihren Smartphones und Computern zu empfangen, auch wenn TREK geschlossen ist. Erfordert HTTPS; auf iPhone und iPad muss TREK zum Home-Bildschirm hinzugefügt werden.',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
|
||||
|
||||
@@ -1075,11 +1075,13 @@ const help: TranslationStrings = {
|
||||
'Hochqualitäts-Modus für Antialiasing und die Globus-Projektion; Karte speichern schreibt die Wahl.',
|
||||
'help.ctx.settings-notifications.title': 'Mitteilungen',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'Wo TREK dich außerhalb der App erreicht: ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.',
|
||||
'Wo TREK dich außerhalb der App erreicht: Push-Benachrichtigungen auf diesem Gerät, ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: das Thema, optional ein eigener Server und ein optionaler Zugriffstoken, mit Testen, um sofort eine Nachricht zu schicken.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: eine URL, die jedes Ereignis als JSON empfängt, mit Testen.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Push-Benachrichtigungen auf diesem Gerät: Für dieses Gerät einschalten gilt nur für den Browser, den du gerade nutzt, also wiederhole es auf jedem Handy oder Computer. Test senden erreicht alle.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'Die Zeilen mit den Präferenzen: pro Ereignis, welcher Kanal an ist. Plugin-Kanäle zeigen Einrichten, bis sie eingerichtet sind.',
|
||||
'help.ctx.settings-integrations.title': 'Integrationen',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1407,7 +1409,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'Die Kanäle, die die Instanz ihren Benutzern anbietet, und die, die dich als Admin erreichen. Benutzer wählen ihre eigenen Themen und URLs unter Einstellungen; du entscheidest, was es gibt, und richtest E-Mail ein.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'In-App, Email (SMTP), Ntfy und Webhook: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.',
|
||||
'In-App, Email (SMTP), Ntfy, Webhook und Web Push: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.',
|
||||
'help.ctx.admin-notifications.bullet.2': 'Reiseerinnerungen: ob der Server die Erinnerung vor Reisebeginn schickt.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
'Admin-Ntfy und Admin-Webhook: wohin Admin-Ereignisse wie ein fehlgeschlagenes Backup oder ein neues Release gehen, mit Test.',
|
||||
@@ -1662,7 +1664,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'Trag unter Email (SMTP) SMTP Host, SMTP Port, SMTP User, SMTP Password und die From Address ein; Test-E-Mail senden schickt eine Mail an dich.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Schalte Ntfy und Webhook an, um sie anzubieten; Benutzer tragen dann ihr eigenes Thema oder ihre URL unter Einstellungen, Mitteilungen ein.',
|
||||
'Schalte Web Push, Ntfy und Webhook an, um sie anzubieten; Benutzer schalten dann unter Einstellungen, Mitteilungen Push pro Gerät ein oder tragen ihr eigenes Thema oder ihre URL ein.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Reiseerinnerungen schaltet die Erinnerung vor Reisebeginn; In-App ist immer an und wird hier nur erklärt.',
|
||||
'help.guide.channels-instance.result':
|
||||
|
||||
@@ -323,6 +323,20 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.webPush.title': 'Push-Benachrichtigungen auf diesem Gerät',
|
||||
'settings.webPush.hint':
|
||||
'Zeigt TREK-Benachrichtigungen auf diesem Gerät an, auch wenn TREK geschlossen ist. Welche Ereignisse ankommen, richtet sich nach der Spalte Push.',
|
||||
'settings.webPush.enable': 'Für dieses Gerät einschalten',
|
||||
'settings.webPush.disable': 'Für dieses Gerät ausschalten',
|
||||
'settings.webPush.enabled': 'Für dieses Gerät eingeschaltet',
|
||||
'settings.webPush.unsupported': 'Dieser Browser kann keine Push-Benachrichtigungen empfangen.',
|
||||
'settings.webPush.insecure': 'Für Push muss TREK über HTTPS geöffnet werden.',
|
||||
'settings.webPush.iosInstall':
|
||||
'Füge TREK auf iPhone und iPad zuerst zum Home-Bildschirm hinzu und öffne es von dort.',
|
||||
'settings.webPush.denied':
|
||||
'Benachrichtigungen für TREK sind in diesem Browser blockiert. Erlaube sie in den Browsereinstellungen und versuche es dann erneut.',
|
||||
'settings.webPush.failed': 'Push konnte für dieses Gerät nicht eingeschaltet werden.',
|
||||
'settings.currency': 'Anzeigewährung',
|
||||
'settings.currencyHint':
|
||||
'Beträge unter Kosten werden nur zur Anzeige in diese Währung umgerechnet – die ursprünglichen Beträge bleiben unverändert.',
|
||||
|
||||
@@ -19,6 +19,9 @@ const admin: TranslationStrings = {
|
||||
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'Lets users receive notifications on their phones and computers through the browser, even while TREK is closed. Needs HTTPS; on iPhone and iPad TREK has to be added to the Home Screen.',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
|
||||
|
||||
@@ -1034,11 +1034,13 @@ const help: TranslationStrings = {
|
||||
'High Quality Mode for antialiasing and the globe projection; Save Map writes the choice.',
|
||||
'help.ctx.settings-notifications.title': 'Notifications',
|
||||
'help.ctx.settings-notifications.summary':
|
||||
'Where TREK reaches you outside the app: an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.',
|
||||
'Where TREK reaches you outside the app: push notifications on this device, an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.',
|
||||
'help.ctx.settings-notifications.bullet.1':
|
||||
'ntfy: the topic, an optional server of your own and an optional access token, with Test to send one right away.',
|
||||
'help.ctx.settings-notifications.bullet.2': 'Webhook: one URL that receives every event as JSON, with Test.',
|
||||
'help.ctx.settings-notifications.bullet.3':
|
||||
'Push notifications on this device: Turn on for this device covers only the browser you are using, so repeat it on each phone or computer. Send test reaches all of them.',
|
||||
'help.ctx.settings-notifications.bullet.4':
|
||||
'The preference rows: per event, which channel is on. Plugin channels show Configure until they are set up.',
|
||||
'help.ctx.settings-integrations.title': 'Integrations',
|
||||
'help.ctx.settings-integrations.summary':
|
||||
@@ -1350,7 +1352,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.admin-notifications.summary':
|
||||
'The channels the instance offers its users, and the ones that reach you as the admin. Users pick their own topics and URLs under Settings; you decide what exists and configure email.',
|
||||
'help.ctx.admin-notifications.bullet.1':
|
||||
'In-App, Email (SMTP), Ntfy and Webhook: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.',
|
||||
'In-App, Email (SMTP), Ntfy, Webhook and Web Push: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.',
|
||||
'help.ctx.admin-notifications.bullet.2':
|
||||
'Trip Reminders: whether the server sends the reminder before a trip starts.',
|
||||
'help.ctx.admin-notifications.bullet.3':
|
||||
@@ -1587,7 +1589,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.channels-instance.step.1':
|
||||
'Under Email (SMTP), enter SMTP Host, SMTP Port, SMTP User, SMTP Password and the From Address; Send test email sends a mail to you.',
|
||||
'help.guide.channels-instance.step.2':
|
||||
'Switch Ntfy and Webhook on to offer them; users then enter their own topic or URL under Settings, Notifications.',
|
||||
'Switch Web Push, Ntfy and Webhook on to offer them; users then turn push on per device, or enter their own topic or URL, under Settings, Notifications.',
|
||||
'help.guide.channels-instance.step.3':
|
||||
'Trip Reminders switches the reminder before a trip starts; In-App is always on and only explained here.',
|
||||
'help.guide.channels-instance.result': 'The Notifications tab of every user shows the channels you switched on.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user