Files
bare/internal/push/push_test.go
T
mayatnikovandClaude Opus 5 f9ac83bef8 Развести номера ADR: 064/065/066 этой ветки → 073/074/075
Ветка отведена от 522ba89, где последним был ADR-063, и заняла
064–072. Тем временем в main через #3 и #4 пришли свои 064
(строка ввода — div вместо form), 065 (стороны сообщений)
и 066 (серверного перца нет).

Переезжают три ADR этой ветки, 067–072 остаются на месте:
их номера свободны, а внутренние ссылки менять незачем.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR8uS3zGkybzWRW4GEY1oz
2026-08-24 11:23:12 +03:00

396 lines
14 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 (
"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=<jwt>, 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()
}