package push import ( "context" "crypto/ecdh" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "net/netip" "net/url" "strings" "sync" "testing" "time" "github.com/xmatic-squad/bare/internal/config" ) // sender без VAPID-ключей: отправщиков он не заводит, а клиент собирает — // именно клиент здесь и проверяется. func client(t *testing.T, local bool) *http.Client { t.Helper() return New(&config.Config{PushLocal: local}, nil, nil, nil).client } // Публичный адрес отличается от того, по которому push-сервиса не бывает // (ADR-047). func TestPublicAddress(t *testing.T) { cases := []struct { addr string public bool }{ {"93.184.216.34", true}, {"2606:2800:220:1:248:1893:25c8:1946", true}, {"127.0.0.1", false}, {"::1", false}, {"10.0.0.1", false}, {"172.16.5.4", false}, {"192.168.1.1", false}, {"169.254.169.254", false}, {"fe80::1", false}, {"fc00::1", false}, {"0.0.0.0", false}, {"::", false}, {"224.0.0.1", false}, {"::ffff:127.0.0.1", false}, {"::ffff:10.0.0.1", false}, } for _, c := range cases { ip, err := netip.ParseAddr(c.addr) if err != nil { t.Fatalf("%s: %v", c.addr, err) } if got := Public(ip); got != c.public { t.Errorf("Public(%s): получено %v, ожидалось %v", c.addr, got, c.public) } } } // Соединения с непубличным адресом не случается: проверка стоит на самом // dial, поэтому её не обойти ни именем, ни редиректом (ADR-047). func TestClientRefusesLocalAddress(t *testing.T) { got := make(chan struct{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got <- struct{}{} })) defer srv.Close() resp, err := client(t, false).Get(srv.URL) if err == nil { resp.Body.Close() t.Fatal("соединение с 127.0.0.1 состоялось") } if !errors.Is(err, errLocalAddress) { t.Errorf("ошибка: %v, ожидался отказ по адресу", err) } if reason(err) != "адрес подписки не публичный" { t.Errorf("класс отказа: %q", reason(err)) } select { case <-got: t.Error("внутренняя служба получила запрос") default: } } // Редиректы push-сервиса не выполняются: иначе один 307 уводил бы запрос // вместе с VAPID-заголовком куда угодно, и проверка «endpoint — https» // не значила бы ничего (ADR-047). func TestClientDoesNotFollowRedirect(t *testing.T) { inside := make(chan struct{}, 1) internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { inside <- struct{}{} w.WriteHeader(http.StatusGone) })) defer internal.Close() vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, internal.URL+"/latest/meta-data/", http.StatusTemporaryRedirect) })) defer vendor.Close() // local: сами тестовые серверы живут на 127.0.0.1, проверяется здесь // именно политика редиректов. resp, err := client(t, true).Get(vendor.URL + "/push") if err != nil { t.Fatalf("запрос: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusTemporaryRedirect { t.Errorf("статус: получено %d, ожидалось 307", resp.StatusCode) } select { case <-inside: t.Error("запрос ушёл по редиректу на внутренний адрес") default: } } // Отказ отправки сводится к классу: адреса подписки в журнале нет // (docs/deploy.md, «Логи»). func TestReasonWithoutEndpoint(t *testing.T) { const endpoint = "secret-host.push.example" cases := []struct { err error want string }{ {fmt.Errorf("dial: %w", errLocalAddress), "адрес подписки не публичный"}, {fmt.Errorf("post: %w", context.DeadlineExceeded), "таймаут"}, {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", Err: &net.DNSError{Err: "no such host", Name: endpoint}}, "имя не разрешилось"}, {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", Err: &net.OpError{Op: "read", Net: "tcp", Addr: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 443}, Err: errors.New("connection reset by peer")}}, "отправка не удалась"}, } for _, c := range cases { got := reason(c.err) if got != c.want { t.Errorf("reason(%v): получено %q, ожидалось %q", c.err, got, c.want) } if strings.Contains(got, endpoint) || strings.Contains(got, "10.1.2.3") { t.Errorf("адрес подписки попал в журнал: %q", got) } } } // В VAPID-токен уходит субъект ровно в той форме, которую требует // RFC 8292: у «mailto:» одна схема, а не две. webpush-go приписывает // «mailto:» всему, что не начинается с «https:», поэтому готовый URI // приходится отдавать ему без схемы — см. vapidSubscriber. Без этого // APNs отвечает 403 BadJwtToken на каждый пуш. func TestVAPIDSubjectInToken(t *testing.T) { cases := []struct{ subject, want string }{ {"mailto:admin@xmatic.team", "mailto:admin@xmatic.team"}, {"https://bare.xmatic.team", "https://bare.xmatic.team"}, {"admin@xmatic.team", "mailto:admin@xmatic.team"}, } for _, c := range cases { t.Run(c.subject, func(t *testing.T) { got := make(chan string, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) select { case got <- r.Header.Get("Authorization"): default: } w.WriteHeader(http.StatusCreated) })) defer srv.Close() s, _ := sender(t, c.subject, srv.URL, nil) defer s.Close() s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"}) var header string select { case header = <-got: case <-time.After(wait): t.Fatal("push-сервис не получил запроса") } if sub := subClaim(t, header); sub != c.want { t.Errorf("sub: получено %q, ожидалось %q", sub, c.want) } }) } } // Отказ push-сервиса читается по журналу: статус и код причины из тела // (ADR-073). Без кода 403 от APNs неотличим от любого другого отказа. func TestServiceStatusInLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusForbidden) io.WriteString(w, `{"reason":"BadJwtToken"}`) })) defer srv.Close() log := &logbuf{} s, d := sender(t, "mailto:admin@xmatic.team", srv.URL, log) defer s.Close() s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"}) const want = "пуш: push-сервис ответил 403 (BadJwtToken)" deadline := time.Now().Add(wait) for !strings.Contains(log.String(), want) && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if line := log.String(); !strings.Contains(line, want) { t.Errorf("журнал: %q, ожидалась строка %q", line, want) } if strings.Contains(log.String(), srv.URL) || strings.Contains(log.String(), "d1") { t.Errorf("в журнал попал адрес подписки или устройство: %q", log.String()) } if d.drops() != 0 { t.Error("подписка снята по ответу 403") } } // Код причины берётся из тела ответа, но телом распоряжается чужая // сторона: всё, что на короткий код не похоже, в журнал не идёт вовсе // (ADR-073, docs/deploy.md, «Логи»). func TestServiceReason(t *testing.T) { const endpoint = "web.push.apple.com" cases := []struct{ body, want string }{ {`{"reason":"BadJwtToken"}`, "BadJwtToken"}, {`{"reason":"TooManyRequests"}`, "TooManyRequests"}, {`{"code":404,"errno":103,"error":"Not Found"}`, "Not Found"}, {"Unauthorized registration", "Unauthorized registration"}, {`{"error":{"code":403,"status":"UNAUTHENTICATED"}}`, ""}, {`{"reason":"unknown push endpoint https://` + endpoint + `/QK"}`, ""}, {"gone: " + endpoint, ""}, {`{"message":"subscription ` + endpoint + ` expired"}`, ""}, {strings.Repeat("A", maxReason+1), ""}, {"", ""}, // Многострочное тело: в журнал идёт первая строка, остальное // отбрасывается вместе с адресом. {"BadJwtToken\nendpoint: " + endpoint, "BadJwtToken"}, } for _, c := range cases { got := serviceReason([]byte(c.body)) if got != c.want { t.Errorf("serviceReason(%q): получено %q, ожидалось %q", c.body, got, c.want) } if strings.Contains(got, endpoint) { t.Errorf("адрес подписки попал в журнал: %q", got) } } } // Статус без разобранного кода причины остаётся статусом. func TestStatusWithoutReason(t *testing.T) { if got := status(500, nil); got != "500" { t.Errorf("status: получено %q, ожидалось %q", got, "500") } if got := status(403, []byte(`{"reason":"BadJwtToken"}`)); got != "403 (BadJwtToken)" { t.Errorf("status: получено %q, ожидалось %q", got, "403 (BadJwtToken)") } } // wait — сколько ждём отправку. Всё локально, задержек быть не должно. const wait = 5 * time.Second // sender — отправитель с настоящей парой VAPID-ключей и одной подпиской // на подменный push-сервис. Без ключей отправщики не заводятся. func sender(t *testing.T, subject, endpoint string, logw io.Writer) (*Sender, *devices) { t.Helper() key, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { t.Fatalf("vapid: %v", err) } d := &devices{subscription: subscription(t, endpoint)} cfg := &config.Config{ VAPIDPublic: base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()), VAPIDPrivate: base64.RawURLEncoding.EncodeToString(key.Bytes()), VAPIDSubject: subject, // Подменный push-сервис живёт на 127.0.0.1; в работе отправщик // ходит только по публичным адресам (ADR-047). PushLocal: true, } return New(cfg, d, nil, logw), d } // subscription — подписка устройства с настоящими ключами: webpush-go // шифрует ими нагрузку, случайных байт ему мало. func subscription(t *testing.T, endpoint string) string { t.Helper() key, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { t.Fatalf("ключ подписки: %v", err) } auth := make([]byte, 16) if _, err := rand.Read(auth); err != nil { t.Fatalf("секрет подписки: %v", err) } raw, err := json.Marshal(map[string]any{ "endpoint": endpoint + "/push", "keys": map[string]string{ "p256dh": base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()), "auth": base64.RawURLEncoding.EncodeToString(auth), }, }) if err != nil { t.Fatalf("подписка: %v", err) } return string(raw) } // subClaim достаёт sub из VAPID-заголовка: «vapid t=, k=<ключ>». // JWT разбирается руками — библиотека здесь и проверяется. func subClaim(t *testing.T, header string) string { t.Helper() rest, ok := strings.CutPrefix(header, "vapid t=") if !ok { t.Fatalf("заголовок не vapid: %q", header) } token, _, ok := strings.Cut(rest, ",") if !ok { t.Fatalf("в заголовке нет ключа: %q", header) } parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("не JWT: %q", token) } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatalf("claims: %v", err) } var claims struct { Sub string `json:"sub"` } if err := json.Unmarshal(raw, &claims); err != nil { t.Fatalf("claims: %v", err) } return claims.Sub } // devices — хранилище устройств в тесте: право на пуш даётся всегда, // возвраты и снятия считаются. type devices struct { subscription string mu sync.Mutex released int dropcount int } func (d *devices) ClaimPush(context.Context, string) (string, bool, error) { return d.subscription, true, nil } func (d *devices) ReleasePush(context.Context, string) error { d.mu.Lock() defer d.mu.Unlock() d.released++ return nil } func (d *devices) DropPush(context.Context, string) error { d.mu.Lock() defer d.mu.Unlock() d.dropcount++ return nil } func (d *devices) drops() int { d.mu.Lock() defer d.mu.Unlock() return d.dropcount } // logbuf — журнал теста. Пишет в него отправщик, читает тест, поэтому // с замком. type logbuf struct { mu sync.Mutex b strings.Builder } func (l *logbuf) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() return l.b.Write(p) } func (l *logbuf) String() string { l.mu.Lock() defer l.mu.Unlock() return l.b.String() }