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,343 @@
|
||||
// Package push отправляет Web Push уведомления браузерам и телефонам
|
||||
// (AGENT.md 7.16, Фаза 7): VAPID-подпись, шифрование полезной нагрузки по
|
||||
// RFC 8291 и очередь доставки, чтобы HTTP-ручки не ждали push-сервисы.
|
||||
//
|
||||
// Решение «уведомлять или нет» принимает сервер (internal/push/rules.go) по
|
||||
// тем же правилам, что нативные уведомления desktop-обёртки
|
||||
// (web/src/lib/desktopNotifications.ts), а показывает уведомление service
|
||||
// worker клиента (web/public/sw.js).
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTTL — сколько push-сервис хранит недоставленное уведомление.
|
||||
DefaultTTL = 12 * time.Hour
|
||||
// MaxPayload — предел полезной нагрузки: запись RFC 8291 — 4096 байт,
|
||||
// оставляем запас на шифрование и служебные поля.
|
||||
MaxPayload = 3000
|
||||
// DeliverTimeout — сколько ждём push-сервис на одну доставку.
|
||||
DeliverTimeout = 10 * time.Second
|
||||
// MaxFailures — после стольких неудач подряд подписка удаляется.
|
||||
MaxFailures = 5
|
||||
// Воркеры и очередь: push не должен ни блокировать запросы, ни плодить
|
||||
// горутины на каждого получателя.
|
||||
workerCount = 4
|
||||
queueSize = 256
|
||||
)
|
||||
|
||||
// VAPIDSubject по умолчанию: push-сервисы требуют контакт администратора.
|
||||
const defaultSubjectPrefix = "mailto:admin@"
|
||||
|
||||
// Payload — то, что получает service worker (web/public/sw.js).
|
||||
type Payload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// Kind — "mention" или "direct": клиент по нему выбирает иконку и текст.
|
||||
Kind string `json:"kind"`
|
||||
GuildID string `json:"guild_id,omitempty"`
|
||||
GuildName string `json:"guild_name,omitempty"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name,omitempty"`
|
||||
AuthorID string `json:"author_id,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
// URL — путь, который открывает клик по уведомлению.
|
||||
URL string `json:"url"`
|
||||
// Tag — ключ схлопывания: уведомления одной комнаты заменяют друг друга.
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Service — отправитель Web Push. Нулевое значение безопасно: методы
|
||||
// проверяют nil, поэтому «push выключен» не требует ветвлений у вызывающего.
|
||||
type Service struct {
|
||||
logger *slog.Logger
|
||||
store *store.Store
|
||||
publicKey string
|
||||
privateKey string
|
||||
subject string
|
||||
ttl int
|
||||
client webpush.HTTPClient
|
||||
|
||||
startOnce sync.Once
|
||||
queue chan delivery
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type delivery struct {
|
||||
subscription store.PushSubscription
|
||||
payload Payload
|
||||
}
|
||||
|
||||
// New собирает сервис из конфига. Если VAPID-ключ не задан — возвращает nil
|
||||
// без ошибки: инстанс без push работает как обычно. Некорректный ключ —
|
||||
// ошибка: молча выключенный push выглядел бы как «уведомления не приходят».
|
||||
func New(cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
if strings.TrimSpace(cfg.VAPIDPrivateKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
privateKey, publicKey, err := ParseVAPIDKeys(cfg.VAPIDPrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse VAPID key: %w", err)
|
||||
}
|
||||
subject := strings.TrimSpace(cfg.VAPIDSubject)
|
||||
if subject == "" {
|
||||
subject = defaultSubjectPrefix + cfg.Domain
|
||||
}
|
||||
return &Service{
|
||||
logger: logger,
|
||||
store: st,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
subject: subject,
|
||||
ttl: int(DefaultTTL.Seconds()),
|
||||
client: httpx.SafeClient(DeliverTimeout),
|
||||
queue: make(chan delivery, queueSize),
|
||||
closed: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled сообщает, настроен ли Web Push на инстансе.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.privateKey != "" }
|
||||
|
||||
// PublicKey — публичный VAPID-ключ (base64url) для `applicationServerKey`.
|
||||
func (s *Service) PublicKey() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.publicKey
|
||||
}
|
||||
|
||||
// Enqueue ставит доставку в очередь. Возвращает false, если push выключен или
|
||||
// очередь переполнена: уведомление теряется осознанно, но запрос не ждёт.
|
||||
func (s *Service) Enqueue(subscription store.PushSubscription, payload Payload) bool {
|
||||
if !s.Enabled() {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-s.closed:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
s.start()
|
||||
select {
|
||||
case s.queue <- delivery{subscription: subscription, payload: payload}:
|
||||
return true
|
||||
default:
|
||||
s.logger.Warn("push queue is full, notification dropped",
|
||||
slog.String("user_id", formatID(subscription.UserID)))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Close останавливает воркеры: вызывается при завершении процесса и в тестах.
|
||||
func (s *Service) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.closeOnce.Do(func() { close(s.closed) })
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) start() {
|
||||
s.startOnce.Do(func() {
|
||||
for i := 0; i < workerCount; i++ {
|
||||
s.wg.Add(1)
|
||||
go s.work()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) work() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-s.closed:
|
||||
return
|
||||
case job := <-s.queue:
|
||||
s.deliver(context.Background(), job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deliver отправляет одно уведомление и обслуживает ответ push-сервиса:
|
||||
// успех отмечается, 404/410 удаляют подписку, остальные ошибки копят счётчик.
|
||||
func (s *Service) deliver(ctx context.Context, job delivery) {
|
||||
payload, err := json.Marshal(job.payload)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "push payload marshal failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
if len(payload) > MaxPayload {
|
||||
s.logger.WarnContext(ctx, "push payload is too large",
|
||||
slog.Int("bytes", len(payload)), slog.String("user_id", formatID(job.subscription.UserID)))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, DeliverTimeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
||||
Endpoint: job.subscription.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: job.subscription.P256dh,
|
||||
Auth: job.subscription.Auth,
|
||||
},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: s.client,
|
||||
Subscriber: s.subject,
|
||||
TTL: s.ttl,
|
||||
Urgency: webpush.UrgencyNormal,
|
||||
Topic: topicFor(job.payload),
|
||||
VAPIDPublicKey: s.publicKey,
|
||||
VAPIDPrivateKey: s.privateKey,
|
||||
})
|
||||
if err != nil {
|
||||
s.noteFailure(ctx, job.subscription, 0, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case response.StatusCode >= 200 && response.StatusCode < 300:
|
||||
if err := s.store.TouchPushSubscription(ctx, job.subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription touch failed", slog.Any("error", err))
|
||||
}
|
||||
case response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone:
|
||||
// Подписка отозвана браузером или push-сервисом — хранить нечего.
|
||||
if err := s.store.DeletePushSubscriptionByID(ctx, job.subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
|
||||
}
|
||||
default:
|
||||
s.noteFailure(ctx, job.subscription, response.StatusCode, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// noteFailure копит неудачи и убирает подписку, которая не работает подряд.
|
||||
func (s *Service) noteFailure(ctx context.Context, subscription store.PushSubscription, status int, cause error) {
|
||||
count, err := s.store.FailPushSubscription(ctx, subscription.ID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription failure note failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
attributes := []any{
|
||||
slog.String("user_id", formatID(subscription.UserID)),
|
||||
slog.Int("status", status),
|
||||
slog.Int("failures", count),
|
||||
}
|
||||
if cause != nil {
|
||||
attributes = append(attributes, slog.Any("error", cause))
|
||||
}
|
||||
s.logger.WarnContext(ctx, "web push delivery failed", attributes...)
|
||||
if count >= MaxFailures {
|
||||
if err := s.store.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
s.logger.InfoContext(ctx, "stale push subscription removed",
|
||||
slog.String("user_id", formatID(subscription.UserID)))
|
||||
}
|
||||
}
|
||||
|
||||
// topicFor схлопывает уведомления одной комнаты: свежее заменяет предыдущее,
|
||||
// иначе упоминания в активной переписке заваливают телефон.
|
||||
func topicFor(payload Payload) string {
|
||||
if payload.ChannelID == "" {
|
||||
return ""
|
||||
}
|
||||
return "channel-" + payload.ChannelID
|
||||
}
|
||||
|
||||
// ParseVAPIDKeys принимает приватный ключ в двух видах и возвращает пару в
|
||||
// формате, которого ждёт web-push (base64url без выравнивания):
|
||||
// - PKCS#8 DER в base64 — так ключ генерирует установщик (`openssl pkcs8`);
|
||||
// - «сырой» 32-байтовый скаляр в base64url — вывод GenerateVAPIDKeys().
|
||||
//
|
||||
// Публичный ключ всегда выводится из приватного: хранить его отдельно
|
||||
// незачем, а рассинхронизация пары ломала бы подписку молча.
|
||||
func ParseVAPIDKeys(encoded string) (privateKey, publicKey string, err error) {
|
||||
trimmed := strings.TrimSpace(encoded)
|
||||
raw, decodeErr := decodeBase64(trimmed)
|
||||
if decodeErr != nil {
|
||||
return "", "", fmt.Errorf("decode: %w", decodeErr)
|
||||
}
|
||||
var key *ecdsa.PrivateKey
|
||||
switch {
|
||||
case len(raw) == 32:
|
||||
// «Сырой» скаляр: так ключ печатает GenerateVAPIDKeys().
|
||||
parsed, parseErr := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse raw private key: %w", parseErr)
|
||||
}
|
||||
key = parsed
|
||||
default:
|
||||
parsed, parseErr := x509.ParsePKCS8PrivateKey(raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse PKCS#8: %w", parseErr)
|
||||
}
|
||||
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", "", errors.New("key is not an ECDSA private key")
|
||||
}
|
||||
if ecdsaKey.Curve != elliptic.P256() {
|
||||
return "", "", fmt.Errorf("curve %s is not P-256", ecdsaKey.Curve.Params().Name)
|
||||
}
|
||||
key = ecdsaKey
|
||||
}
|
||||
// Bytes() у ecdsa отдаёт фиксированную длину и несжатую точку (SEC 1):
|
||||
// именно в таком виде ключи ждёт web-push, и никаких big.Int снаружи.
|
||||
scalar, err := key.Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("encode private key: %w", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("encode public key: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(scalar),
|
||||
base64.RawURLEncoding.EncodeToString(point), nil
|
||||
}
|
||||
|
||||
// decodeBase64 принимает base64 (standard/raw/url) — установщик пишет
|
||||
// стандартный base64, а ключи из библиотек приходят в base64url.
|
||||
func decodeBase64(value string) ([]byte, error) {
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding,
|
||||
base64.URLEncoding, base64.RawURLEncoding,
|
||||
}
|
||||
var lastErr error
|
||||
for _, encoding := range encodings {
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func formatID(id uint64) string { return fmt.Sprintf("%d", id) }
|
||||
@@ -0,0 +1,369 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// newPushTestStore открывает временную БД с миграциями и создаёт пользователя:
|
||||
// подписки ссылаются на users по внешнему ключу.
|
||||
func newPushTestStore(t *testing.T) (*store.Store, uint64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("открыть тестовую БД: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("закрыть тестовую БД: %v", err)
|
||||
}
|
||||
})
|
||||
st := store.New(db)
|
||||
user, err := st.CreateUser(ctx, store.CreateUserParams{
|
||||
Username: "push_user",
|
||||
DisplayName: "Push",
|
||||
EmailEnc: "enc",
|
||||
EmailIndex: "idx",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать пользователя: %v", err)
|
||||
}
|
||||
return st, user.ID
|
||||
}
|
||||
|
||||
// testVAPIDKey возвращает приватный ключ в том же виде, в каком его пишет
|
||||
// установщик: PKCS#8 DER в base64 (одной строкой).
|
||||
func testVAPIDKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("сгенерировать ключ: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PKCS#8: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) config.Config {
|
||||
return config.Config{
|
||||
Domain: "gl.example.com",
|
||||
VAPIDPrivateKey: testVAPIDKey(t),
|
||||
VAPIDSubject: "mailto:admin@example.com",
|
||||
}
|
||||
}
|
||||
|
||||
// testSubscription сохраняет в БД подписку с указанным эндпоинтом. Ключи
|
||||
// настоящие (P-256 и 16 байт auth): иначе шифрование RFC 8291 не соберётся.
|
||||
func testSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("сгенерировать ключ подписки: %v", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ подписки: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: endpoint,
|
||||
P256dh: base64.RawURLEncoding.EncodeToString(point),
|
||||
Auth: base64.RawURLEncoding.EncodeToString(auth),
|
||||
UserAgent: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("сохранить подписку: %v", err)
|
||||
}
|
||||
return *subscription
|
||||
}
|
||||
|
||||
func TestNewDisabledWithoutKey(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
service, err := New(config.Config{Domain: "example.com"}, st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New без ключа вернул ошибку: %v", err)
|
||||
}
|
||||
if service != nil {
|
||||
t.Fatal("без VAPID-ключа сервис должен быть nil")
|
||||
}
|
||||
if service.Enabled() {
|
||||
t.Fatal("nil-сервис не может быть включён")
|
||||
}
|
||||
if service.PublicKey() != "" {
|
||||
t.Fatal("у выключенного сервиса нет публичного ключа")
|
||||
}
|
||||
if service.Enqueue(store.PushSubscription{}, Payload{}) {
|
||||
t.Fatal("выключенный сервис не должен принимать доставку")
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
func TestNewRejectsBrokenKey(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
if _, err := New(config.Config{VAPIDPrivateKey: "not-a-key"}, st, nil); err == nil {
|
||||
t.Fatal("некорректный ключ должен давать ошибку, а не тихо выключать push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVAPIDKeysAcceptsBothFormats(t *testing.T) {
|
||||
// Формат библиотеки: «сырой» скаляр в base64url.
|
||||
rawPrivate, rawPublic, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVAPIDKeys: %v", err)
|
||||
}
|
||||
privateKey, publicKey, err := ParseVAPIDKeys(rawPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVAPIDKeys(raw): %v", err)
|
||||
}
|
||||
if privateKey != rawPrivate || publicKey != rawPublic {
|
||||
t.Fatalf("пары не совпали: %s/%s против %s/%s", privateKey, publicKey, rawPrivate, rawPublic)
|
||||
}
|
||||
|
||||
// Формат установщика: PKCS#8 DER в base64.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
privateKey, publicKey, err = ParseVAPIDKeys(base64.StdEncoding.EncodeToString(der))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVAPIDKeys(pkcs8): %v", err)
|
||||
}
|
||||
decodedPublic, err := base64.RawURLEncoding.DecodeString(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ не base64url: %v", err)
|
||||
}
|
||||
expectedPublic, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ ключа: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decodedPublic, expectedPublic) {
|
||||
t.Fatalf("публичный ключ не совпал с приватным: %d байт", len(decodedPublic))
|
||||
}
|
||||
expectedPrivate, err := key.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("приватный ключ: %v", err)
|
||||
}
|
||||
decodedPrivate, err := base64.RawURLEncoding.DecodeString(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("приватный ключ не base64url: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decodedPrivate, expectedPrivate) {
|
||||
t.Fatal("приватный ключ не совпал с исходным")
|
||||
}
|
||||
|
||||
if _, _, err := ParseVAPIDKeys("!!!not base64!!!"); err == nil {
|
||||
t.Fatal("мусор должен отвергаться")
|
||||
}
|
||||
if _, _, err := ParseVAPIDKeys(base64.StdEncoding.EncodeToString([]byte("слишком короткий ключ"))); err == nil {
|
||||
t.Fatal("неверная длина ключа должна отвергаться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverSuccessMarksSubscription(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seen http.Header
|
||||
method string
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
seen = r.Header.Clone()
|
||||
method = r.Method
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// Эндпоинт тестовый (http + 127.0.0.1), поэтому клиент подменяем: в бою
|
||||
// адрес проверяет httpx.SafeClient.
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
service.deliver(context.Background(), delivery{
|
||||
subscription: subscription,
|
||||
payload: Payload{Title: "Упоминание", Body: "привет", ChannelID: "10", Tag: "channel-10"},
|
||||
})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if method != http.MethodPost {
|
||||
t.Fatalf("метод = %q, ожидался POST", method)
|
||||
}
|
||||
if seen.Get("Content-Encoding") != "aes128gcm" {
|
||||
t.Fatalf("Content-Encoding = %q", seen.Get("Content-Encoding"))
|
||||
}
|
||||
if seen.Get("TTL") != "43200" {
|
||||
t.Fatalf("TTL = %q, ожидалось 43200", seen.Get("TTL"))
|
||||
}
|
||||
if seen.Get("Topic") != "channel-10" {
|
||||
t.Fatalf("Topic = %q", seen.Get("Topic"))
|
||||
}
|
||||
if !strings.HasPrefix(seen.Get("Authorization"), "vapid t=") {
|
||||
t.Fatalf("нет VAPID-подписи: %q", seen.Get("Authorization"))
|
||||
}
|
||||
|
||||
updated, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("прочитать подписку: %v", err)
|
||||
}
|
||||
if updated.LastSuccessAt == nil {
|
||||
t.Fatal("успешная доставка не отмечена")
|
||||
}
|
||||
if updated.FailureCount != 0 {
|
||||
t.Fatalf("счётчик неудач = %d, ожидался 0", updated.FailureCount)
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
func TestDeliverRemovesRevokedSubscription(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||
st, userID := newPushTestStore(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
|
||||
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
|
||||
t.Fatalf("подписка с ответом %d должна быть удалена", status)
|
||||
}
|
||||
service.Close()
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverDropsSubscriptionAfterRepeatedFailures(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
|
||||
}
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
|
||||
t.Fatalf("после %d неудач подписка должна быть удалена", MaxFailures)
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Очередь не блокирует запрос: Enqueue возвращает управление сразу, а воркеры
|
||||
// доставляют уведомление в фоне.
|
||||
func TestEnqueueDeliversInBackground(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
delivered := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
select {
|
||||
case delivered <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
if !service.Enqueue(subscription, Payload{Title: "Упоминание", Body: "привет", ChannelID: "10"}) {
|
||||
t.Fatal("Enqueue отказал при пустой очереди")
|
||||
}
|
||||
select {
|
||||
case <-delivered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("уведомление не доставлено за 5 секунд")
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Публичный ключ из конфига годится для applicationServerKey браузера.
|
||||
func TestPublicKeyIsBase64URLPoint(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(service.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ не base64url: %v", err)
|
||||
}
|
||||
if len(decoded) != 65 {
|
||||
t.Fatalf("длина публичного ключа = %d, ожидалось 65", len(decoded))
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Полезная нагрузка сериализуется в JSON с полями, которые читает service
|
||||
// worker клиента (web/public/sw.js).
|
||||
func TestPayloadJSONShape(t *testing.T) {
|
||||
payload := Payload{
|
||||
Title: "Упоминание в #общий", Body: "привет", Kind: "mention",
|
||||
GuildID: "1", ChannelID: "10", MessageID: "99", Tag: "channel-10", URL: "/app/1/10",
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
for _, field := range []string{`"title"`, `"body"`, `"kind"`, `"channel_id"`, `"tag"`, `"url"`} {
|
||||
if !strings.Contains(string(encoded), field) {
|
||||
t.Errorf("в payload нет поля %s: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Правила уведомлений: сервер повторяет логику desktop-уведомлений клиента
|
||||
// (web/src/lib/desktopNotifications.ts, docs/client-tauri.md §5), потому что
|
||||
// push уходит на устройство, где приложение не запущено и решить не может.
|
||||
//
|
||||
// Отличия от клиента ровно два и оба вынужденные:
|
||||
// - «окно в фокусе и комната открыта» сервер не знает, поэтому вместо этого
|
||||
// проверяется наличие активного соединения шлюза: если оно есть,
|
||||
// уведомление покажет сам клиент по своим правилам;
|
||||
// - «уведомления включены» на устройстве — это наличие подписки: отписался,
|
||||
// значит выключил.
|
||||
|
||||
// previewLimit — сколько символов сообщения показываем в уведомлении.
|
||||
const previewLimit = 140
|
||||
|
||||
// NotificationInput — данные, от которых зависит решение об уведомлении.
|
||||
type NotificationInput struct {
|
||||
// MessageType — тип сообщения (AGENT.md 7.6).
|
||||
MessageType store.MessageType
|
||||
Content string
|
||||
Mentions []uint64
|
||||
// AuthorID — автор сообщения (0 у системных сообщений и вебхуков).
|
||||
AuthorID uint64
|
||||
// MeID — получатель: свои сообщения не уведомляют.
|
||||
MeID uint64
|
||||
// IsDirect — личная беседа (в т.ч. групповая).
|
||||
IsDirect bool
|
||||
// ChannelName — имя комнаты для заголовка (у беседы — имя или пусто).
|
||||
ChannelName string
|
||||
// ChannelID — комната сообщения: по ней строится ссылка и ключ схлопывания.
|
||||
ChannelID uint64
|
||||
// MessageID — сообщение: клик открывает комнату и подсвечивает его.
|
||||
MessageID uint64
|
||||
// GuildID — сервер комнаты (0 — личная беседа).
|
||||
GuildID uint64
|
||||
// GuildName — имя сервера для текста уведомления.
|
||||
GuildName string
|
||||
// AuthorName — отображаемое имя автора.
|
||||
AuthorName string
|
||||
// Online — у получателя есть активное соединение шлюза.
|
||||
Online bool
|
||||
}
|
||||
|
||||
// NotificationFor возвращает уведомление или nil, если показывать не нужно.
|
||||
// Чистая функция: её поведение проверяется тестами без сети.
|
||||
func NotificationFor(input NotificationInput) *Payload {
|
||||
// Системные сообщения (входы, баны) уведомлений не требуют.
|
||||
if input.MessageType == store.MessageSystem {
|
||||
return nil
|
||||
}
|
||||
if input.AuthorID != 0 && input.AuthorID == input.MeID {
|
||||
return nil
|
||||
}
|
||||
mentionsMe := false
|
||||
for _, mentioned := range input.Mentions {
|
||||
if mentioned == input.MeID {
|
||||
mentionsMe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mentionsMe && !input.IsDirect {
|
||||
return nil
|
||||
}
|
||||
// Получатель в приложении: уведомление покажет клиент (и только если окно
|
||||
// неактивно) — второй раз с телефона дёргать не нужно.
|
||||
if input.Online {
|
||||
return nil
|
||||
}
|
||||
body := previewText(input.Content)
|
||||
if body == "" && len(input.Mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
if body == "" {
|
||||
// Вложение без текста: так же, как в клиенте.
|
||||
body = "Вложение"
|
||||
}
|
||||
kind := "mention"
|
||||
title := "Упоминание"
|
||||
if input.IsDirect {
|
||||
kind = "direct"
|
||||
title = "Личное сообщение"
|
||||
if input.ChannelName != "" {
|
||||
title = "Личное сообщение — " + input.ChannelName
|
||||
}
|
||||
} else if input.ChannelName != "" {
|
||||
title = "Упоминание в #" + input.ChannelName
|
||||
}
|
||||
channel := formatOptionalID(input.ChannelID)
|
||||
tag := ""
|
||||
url := "/app"
|
||||
switch {
|
||||
case input.GuildID != 0 && input.ChannelID != 0:
|
||||
url = "/app/" + formatID(input.GuildID) + "/" + channel
|
||||
tag = "channel-" + channel
|
||||
case input.ChannelID != 0:
|
||||
// Личная беседа: раздел друзей выбирает беседу по идентификатору.
|
||||
url = "/app/friends/" + channel
|
||||
tag = "channel-" + channel
|
||||
}
|
||||
return &Payload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
Kind: kind,
|
||||
GuildID: formatOptionalID(input.GuildID),
|
||||
GuildName: input.GuildName,
|
||||
ChannelID: channel,
|
||||
ChannelName: input.ChannelName,
|
||||
AuthorID: formatOptionalID(input.AuthorID),
|
||||
AuthorName: input.AuthorName,
|
||||
MessageID: formatOptionalID(input.MessageID),
|
||||
URL: url,
|
||||
Tag: tag,
|
||||
}
|
||||
}
|
||||
|
||||
// previewText убирает Markdown-разметку и лишние переводы строк — та же
|
||||
// подготовка, что в web/src/lib/desktopNotifications.ts.
|
||||
func previewText(content string) string {
|
||||
plain := stripCodeBlocks(content)
|
||||
plain = stripInlineCode(plain)
|
||||
plain = stripLinks(plain)
|
||||
plain = strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune("*_~>#", r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, plain)
|
||||
plain = collapseSpaces(plain)
|
||||
runes := []rune(plain)
|
||||
if len(runes) <= previewLimit {
|
||||
return plain
|
||||
}
|
||||
return strings.TrimRight(string(runes[:previewLimit-1]), " ") + "…"
|
||||
}
|
||||
|
||||
// stripCodeBlocks убирает блоки ```…``` целиком, оставляя пометку.
|
||||
func stripCodeBlocks(content string) string {
|
||||
var builder strings.Builder
|
||||
rest := content
|
||||
for {
|
||||
start := strings.Index(rest, "```")
|
||||
if start < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(rest[:start])
|
||||
builder.WriteString(" [код] ")
|
||||
end := strings.Index(rest[start+3:], "```")
|
||||
if end < 0 {
|
||||
return builder.String()
|
||||
}
|
||||
rest = rest[start+3+end+3:]
|
||||
}
|
||||
}
|
||||
|
||||
// stripInlineCode убирает обратные кавычки, оставляя их содержимое.
|
||||
func stripInlineCode(content string) string {
|
||||
var builder strings.Builder
|
||||
for {
|
||||
start := strings.Index(content, "`")
|
||||
if start < 0 {
|
||||
builder.WriteString(content)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(content[:start])
|
||||
rest := content[start+1:]
|
||||
end := strings.Index(rest, "`")
|
||||
if end < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(rest[:end])
|
||||
content = rest[end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// stripLinks оставляет от ссылки её текст: [текст](url) → текст.
|
||||
func stripLinks(content string) string {
|
||||
var builder strings.Builder
|
||||
rest := content
|
||||
for {
|
||||
open := strings.Index(rest, "](")
|
||||
if open < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
start := strings.LastIndex(rest[:open], "[")
|
||||
if start < 0 {
|
||||
builder.WriteString(rest[:open+2])
|
||||
rest = rest[open+2:]
|
||||
continue
|
||||
}
|
||||
builder.WriteString(rest[:start])
|
||||
builder.WriteString(rest[start+1 : open])
|
||||
end := strings.Index(rest[open+2:], ")")
|
||||
if end < 0 {
|
||||
return builder.String()
|
||||
}
|
||||
rest = rest[open+2+end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// collapseSpaces схлопывает любые пробельные последовательности в один пробел.
|
||||
func collapseSpaces(value string) string {
|
||||
var builder strings.Builder
|
||||
space := false
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) {
|
||||
if !space && builder.Len() > 0 {
|
||||
builder.WriteRune(' ')
|
||||
}
|
||||
space = true
|
||||
continue
|
||||
}
|
||||
space = false
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
// formatOptionalID печатает идентификатор в десятичном виде, пусто для нуля.
|
||||
func formatOptionalID(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return formatID(id)
|
||||
}
|
||||
@@ -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