Этап 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
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user