Files
bare/internal/push/push_test.go
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

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

146 lines
4.9 KiB
Go

package push
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"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)
}
}
}