feat(push): Web Push — VAPID, подписки устройств и отправка (AGENT.md 7.16)
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда клиент закрыт. Сервер: - миграция 00021: `push_subscriptions` (эндпоинт уникален, ключи, счётчик неудач, время последней доставки); - `internal/push` — VAPID-ключи (приватный PKCS#8 из конфига, публичный выводится из него), правила уведомлений повторяют `web/src/lib/desktopNotifications.ts` (упоминания и личные беседы, без своих и системных сообщений), очередь доставки, TTL 12 часов, Topic по комнате; - 404/410 от push-сервиса удаляют подписку сразу, 5 неудач подряд — тоже, иначе копились бы мёртвые эндпоинты; retention чистит «молчащие» подписки; - `internal/httpx/safeurl.go` — общий запрет внутренних адресов с проверкой адреса в момент подключения (DNS rebinding): эндпоинт подписки приходит от клиента, и без проверки сервер сам себе организует SSRF; - `internal/gateway/presence.go` — `IsUserOnline`: если получатель в клиенте, уведомление покажет клиент, дублировать на телефон не нужно; - ручки `GET /push/config`, `GET|POST|DELETE /push/subscriptions`, лимиты 10 подписок и 20 уведомлений в минуту; ключ p256dh проверяется как настоящая точка P-256; - установщик генерирует `VAPID_PRIVATE_KEY` (openssl, PKCS#8 DER в base64) и `VAPID_SUBJECT`, ключ переиспользуется при переустановке; для ручной установки есть `glchat vapid-keys`; `features.web_push_enabled` виден в `/meta`. Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование, заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// input — сообщение по умолчанию: обычное сообщение в комнате сервера.
|
||||
// Набор проверок повторяет web/tests/desktopNotifications.test.ts: правила
|
||||
// push и нативных уведомлений обязаны совпадать.
|
||||
func input(overrides func(*NotificationInput)) NotificationInput {
|
||||
value := NotificationInput{
|
||||
MessageType: store.MessageDefault,
|
||||
Content: "привет",
|
||||
Mentions: nil,
|
||||
AuthorID: 2,
|
||||
MeID: 1,
|
||||
IsDirect: false,
|
||||
ChannelName: "общий",
|
||||
ChannelID: 10,
|
||||
MessageID: 99,
|
||||
GuildID: 1,
|
||||
GuildName: "Сервер",
|
||||
AuthorName: "Аня",
|
||||
}
|
||||
if overrides != nil {
|
||||
overrides(&value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestNotificationForMention(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) { in.Mentions = []uint64{1} }))
|
||||
if payload == nil {
|
||||
t.Fatal("упоминание не дало уведомления")
|
||||
}
|
||||
if payload.Title != "Упоминание в #общий" || payload.Body != "привет" {
|
||||
t.Fatalf("неожиданный текст: %+v", payload)
|
||||
}
|
||||
if payload.Kind != "mention" || payload.ChannelID != "10" || payload.Tag != "channel-10" {
|
||||
t.Fatalf("неожиданные поля: %+v", payload)
|
||||
}
|
||||
if payload.URL != "/app/1/10" {
|
||||
t.Fatalf("ссылка = %q, ожидалось /app/1/10", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationForDirect(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.GuildID = 0
|
||||
in.GuildName = ""
|
||||
in.ChannelName = "Аня"
|
||||
}))
|
||||
if payload == nil {
|
||||
t.Fatal("личное сообщение не дало уведомления")
|
||||
}
|
||||
if payload.Title != "Личное сообщение — Аня" || payload.Kind != "direct" {
|
||||
t.Fatalf("неожиданный текст: %+v", payload)
|
||||
}
|
||||
if payload.URL != "/app/friends/10" {
|
||||
t.Fatalf("ссылка = %q, ожидалось /app/friends/10", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationForSilentCases(t *testing.T) {
|
||||
cases := map[string]NotificationInput{
|
||||
"обычное сообщение в комнате": input(nil),
|
||||
"своё сообщение": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.AuthorID = in.MeID
|
||||
}),
|
||||
"системное сообщение": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.MessageType = store.MessageSystem
|
||||
}),
|
||||
"получатель в клиенте": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Mentions = []uint64{1}
|
||||
in.Online = true
|
||||
}),
|
||||
"упоминание другого пользователя": input(func(in *NotificationInput) {
|
||||
in.Mentions = []uint64{777}
|
||||
}),
|
||||
"пустое сообщение без упоминаний": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Content = ""
|
||||
}),
|
||||
}
|
||||
for name, value := range cases {
|
||||
if payload := NotificationFor(value); payload != nil {
|
||||
t.Errorf("%s: уведомление не должно отправляться (%+v)", name, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Упоминание с пустым текстом (одно вложение) — как в клиенте: «Вложение».
|
||||
func TestNotificationForAttachmentOnly(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Content = ""
|
||||
in.Mentions = []uint64{1}
|
||||
}))
|
||||
if payload == nil || payload.Body != "Вложение" {
|
||||
t.Fatalf("ожидалось «Вложение», получили %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewText(t *testing.T) {
|
||||
if got := previewText("**жирный**\n\n[ссылка](https://example.com) `код`"); got != "жирный ссылка код" {
|
||||
t.Fatalf("previewText = %q", got)
|
||||
}
|
||||
if got := previewText("```go\nfunc main() {}\n``` после"); got != "[код] после" {
|
||||
t.Fatalf("блок кода = %q", got)
|
||||
}
|
||||
long := previewText(strings.Repeat("а", 400))
|
||||
if len([]rune(long)) > previewLimit {
|
||||
t.Fatalf("длина превью %d превышает лимит %d", len([]rune(long)), previewLimit)
|
||||
}
|
||||
if !strings.HasSuffix(long, "…") {
|
||||
t.Fatalf("длинный текст не обрезан: %q", long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOptionalID(t *testing.T) {
|
||||
if got := formatOptionalID(0); got != "" {
|
||||
t.Fatalf("нулевой идентификатор = %q, ожидалась пустая строка", got)
|
||||
}
|
||||
if got := formatOptionalID(42); got != "42" {
|
||||
t.Fatalf("идентификатор = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user