Сервер: пакет 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
146 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|