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) } } }