370 lines
12 KiB
Go
370 lines
12 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|