Files
glchat/internal/push/rules_test.go
T
grendervill 7afd23d6d4 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, очистка мёртвых подписок), ручки и лимиты, отправка
при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на
чистой БД.
2026-09-26 16:14:20 +03:00

135 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}