Этап 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:
+22
-6
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/hub"
|
||||
"github.com/xmatic-squad/bare/internal/push"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
@@ -34,27 +35,39 @@ type server struct {
|
||||
cfg *config.Config
|
||||
st *store.Store
|
||||
hub *hub.Hub
|
||||
push *push.Sender
|
||||
msgs *buckets
|
||||
logw io.Writer
|
||||
}
|
||||
|
||||
// Handler — обработчик всех маршрутов и живые SSE-потоки за ним.
|
||||
// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей
|
||||
// за ним.
|
||||
type Handler struct {
|
||||
http.Handler
|
||||
hub *hub.Hub
|
||||
hub *hub.Hub
|
||||
push *push.Sender
|
||||
}
|
||||
|
||||
// Close закрывает открытые потоки событий. Без него остановка сервера
|
||||
// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
|
||||
func (h *Handler) Close() { h.hub.CloseAll() }
|
||||
// Close закрывает открытые потоки событий и останавливает отправку
|
||||
// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами:
|
||||
// у потока нет конца (ADR-004).
|
||||
func (h *Handler) Close() {
|
||||
h.hub.CloseAll()
|
||||
h.push.Close()
|
||||
}
|
||||
|
||||
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
|
||||
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
|
||||
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler {
|
||||
// Отправитель пушей спрашивает у hub, подключено ли устройство:
|
||||
// решение «пуш только молчащему» принимается в момент захвата права
|
||||
// на него, а не при постановке в очередь (ADR-023).
|
||||
live := hub.New()
|
||||
s := &server{
|
||||
cfg: cfg,
|
||||
st: st,
|
||||
hub: hub.New(),
|
||||
hub: live,
|
||||
push: push.New(cfg, st, live.Connected, logw),
|
||||
msgs: newBuckets(messagesPerMinute, messagesBurst),
|
||||
logw: logw,
|
||||
}
|
||||
@@ -79,6 +92,8 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
||||
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
|
||||
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
|
||||
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
|
||||
mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush)))
|
||||
mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush)))
|
||||
|
||||
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
|
||||
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
|
||||
@@ -103,6 +118,7 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
||||
return &Handler{
|
||||
Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))),
|
||||
hub: s.hub,
|
||||
push: s.push,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ func newEnv(t *testing.T) *env { return invited(t, "") }
|
||||
|
||||
// invited — сервер на временной базе; непустой code включает инвайты.
|
||||
func invited(t *testing.T, code string) *env {
|
||||
t.Helper()
|
||||
return envWith(t, func(cfg *config.Config) { cfg.InviteCode = code })
|
||||
}
|
||||
|
||||
// envWith — сервер на временной базе; tweak правит конфигурацию до старта.
|
||||
func envWith(t *testing.T, tweak func(*config.Config)) *env {
|
||||
t.Helper()
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
@@ -74,10 +80,14 @@ func invited(t *testing.T, code string) *env {
|
||||
DB: "bare.db",
|
||||
Origin: origin,
|
||||
VAPIDPublic: "vapid",
|
||||
InviteCode: code,
|
||||
}
|
||||
tweak(cfg)
|
||||
e := &env{t: t, st: st, log: &syncLog{}}
|
||||
e.h = api.New(cfg, st, static, e.log)
|
||||
h := api.New(cfg, st, static, e.log)
|
||||
// Обработчик закрывается раньше базы: отправщики пушей дописывают
|
||||
// начатое, а база им ещё нужна.
|
||||
t.Cleanup(h.Close)
|
||||
e.h = h
|
||||
return e
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,6 @@ func TestDevices(t *testing.T) {
|
||||
if list[0].Current || !list[1].Current {
|
||||
t.Errorf("текущее устройство второй сессии: %+v", list)
|
||||
}
|
||||
|
||||
// Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает
|
||||
// 404 not_found (ADR-026).
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)),
|
||||
http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
// Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/hub"
|
||||
"github.com/xmatic-squad/bare/internal/push"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
@@ -76,22 +77,29 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sess, _ := auth.From(r)
|
||||
room := in.To.Room != ""
|
||||
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
|
||||
// и так знает, — из ников и имени комнаты (ADR-023).
|
||||
var signal push.Payload
|
||||
if room {
|
||||
member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
|
||||
access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
if !access.Member {
|
||||
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
|
||||
return
|
||||
}
|
||||
if !knownKey {
|
||||
if !access.KnownKey {
|
||||
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
|
||||
return
|
||||
}
|
||||
} else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
|
||||
return
|
||||
signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room}
|
||||
} else {
|
||||
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
|
||||
return
|
||||
}
|
||||
signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick}
|
||||
}
|
||||
|
||||
if wait, ok := s.msgs.take(sess.Nick, now); !ok {
|
||||
@@ -122,7 +130,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
Envelope: string(raw),
|
||||
Now: env.TS,
|
||||
}
|
||||
var devices []string
|
||||
var devices []store.Target
|
||||
if room {
|
||||
devices, err = s.st.DeliverRoom(r.Context(), delivery)
|
||||
} else {
|
||||
@@ -133,16 +141,39 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Очередь уже записана: подключённое устройство получает конверт
|
||||
// сразу, остальные — при подключении. Пуши — этап 4.
|
||||
for _, id := range devices {
|
||||
s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)})
|
||||
// сразу, остальные — при подключении.
|
||||
for _, target := range devices {
|
||||
s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)})
|
||||
}
|
||||
// Пуш — побочный эффект доставки, а не её часть: конверт уже
|
||||
// в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023).
|
||||
s.push.Send(s.silent(devices, env.From), signal)
|
||||
writeJSON(w, http.StatusAccepted, struct {
|
||||
ID string `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
}{env.ID, env.TS})
|
||||
}
|
||||
|
||||
// silent — устройства, которым нужен пуш: чужие (устройства отправителя
|
||||
// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат
|
||||
// поток событий (ADR-023).
|
||||
//
|
||||
// Устройство без подписки отсеивается здесь: отправить ему нечего,
|
||||
// а место в очереди отправки оно заняло бы (ADR-048). Проверка на
|
||||
// подключение — ранний отсев: решает её повтор в момент захвата права
|
||||
// на пуш, потому что между этой строкой и отправкой устройство успевает
|
||||
// подключиться (ADR-023).
|
||||
func (s *server) silent(targets []store.Target, from string) []push.Target {
|
||||
var out []push.Target
|
||||
for _, target := range targets {
|
||||
if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, push.Target{Device: target.ID, Owner: target.Nick})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер
|
||||
// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан,
|
||||
// если вернулось false.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/push"
|
||||
)
|
||||
|
||||
// Push-подписка принадлежит устройству (ADR-023): её ставит и снимает
|
||||
// само устройство. Сервер хранит подписку как непрозрачный JSON и лезет
|
||||
// в неё только при отправке.
|
||||
|
||||
// Длины ключей подписки (RFC 8291): p256dh — несжатая точка P-256,
|
||||
// auth — общий секрет.
|
||||
const (
|
||||
p256dhLen = 65
|
||||
authLen = 16
|
||||
// maxEndpoint — предел длины адреса подписки. Адреса вендоров —
|
||||
// две-три сотни символов; всё остальное push-сервисом не является,
|
||||
// а прочие поля протокола ограничены явно (docs/protocol.md).
|
||||
maxEndpoint = 2 << 10
|
||||
)
|
||||
|
||||
// subscriptionIn — объект PushSubscription.toJSON(). Поле expirationTime
|
||||
// браузеры кладут рядом; сервер его не читает и не хранит — хранится
|
||||
// ровно то, что нужно для отправки.
|
||||
type subscriptionIn struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
// PUT /api/devices/{id}/push — подписка устройства на пуши. Сбрасывает
|
||||
// неотработанный пуш: устройство снова готово его принять (ADR-023).
|
||||
//
|
||||
// X-Device на этом маршруте обязателен, и устройство в пути тоже обязано
|
||||
// быть своим: чужому устройству подписку не поставить (docs/protocol.md,
|
||||
// «Общие правила»).
|
||||
func (s *server) setPush(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Subscription subscriptionIn `json:"subscription"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
// Форма проверяется раньше прав (ADR-043).
|
||||
subscription, ok := checkSubscription(w, in.Subscription)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := s.device(w, r); !ok {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
set, err := s.st.SetPush(r.Context(), r.PathValue("id"), sess.Nick, subscription)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if !set {
|
||||
unknownDevice(w)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// DELETE /api/devices/{id}/push — снять подписку. Подписки не было —
|
||||
// тот же 204: снимать нечего. Чужое устройство — 403, как и на PUT.
|
||||
func (s *server) deletePush(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.device(w, r); !ok {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
cleared, err := s.st.ClearPush(r.Context(), r.PathValue("id"), sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if !cleared {
|
||||
unknownDevice(w)
|
||||
return
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// checkSubscription проверяет форму подписки и отдаёт её канонический
|
||||
// JSON: три поля и ничего больше. Ответ об ошибке уже написан, если
|
||||
// вернулось false.
|
||||
func checkSubscription(w http.ResponseWriter, in subscriptionIn) (string, bool) {
|
||||
if !validEndpoint(in.Endpoint) {
|
||||
Invalid(w, "subscription", "endpoint — не публичный https-url до 2 КиБ")
|
||||
return "", false
|
||||
}
|
||||
if !pushPoint(in.Keys.P256dh) {
|
||||
Invalid(w, "subscription", "keys.p256dh — не точка p-256 в 65 байтах base64url")
|
||||
return "", false
|
||||
}
|
||||
if _, ok := pushKey(in.Keys.Auth, authLen); !ok {
|
||||
Invalid(w, "subscription", "keys.auth — не 16 байт base64url")
|
||||
return "", false
|
||||
}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// validEndpoint — адрес push-сервиса. Выбирает его браузер, сервер знает
|
||||
// о нём только то, что это абсолютный https-url разумной длины: без TLS
|
||||
// пуш ушёл бы открытым текстом мимо всех обещаний.
|
||||
//
|
||||
// Литеральный непубличный адрес отвергается сразу: push-сервиса по нему
|
||||
// не бывает, а внутренняя служба бывает (ADR-047). Имя здесь не
|
||||
// разрешается — за именем всё равно может стоять внутренний адрес,
|
||||
// поэтому решающая проверка идёт при соединении, в отправщике.
|
||||
func validEndpoint(raw string) bool {
|
||||
if raw == "" || len(raw) > maxEndpoint {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
ip, err := netip.ParseAddr(u.Hostname())
|
||||
if err != nil {
|
||||
// Не литерал, а имя: его разберёт отправщик.
|
||||
return true
|
||||
}
|
||||
return push.Public(ip)
|
||||
}
|
||||
|
||||
// pushPoint — p256dh: несжатая точка кривой P-256. Одной длины мало:
|
||||
// случайные 65 байт точкой не являются, отправка на них падает при
|
||||
// каждом сообщении, а устройство остаётся с подпиской, которая никогда
|
||||
// не заработает (docs/protocol.md, «Устройства»).
|
||||
func pushPoint(s string) bool {
|
||||
raw, ok := pushKey(s, p256dhLen)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err := ecdh.P256().NewPublicKey(raw)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// pushKey — ключ подписки: ровно n байт base64url. Push API задаёт форму
|
||||
// без паддинга, но браузер, добавивший паддинг, не должен остаться без
|
||||
// уведомлений: webpush-go разбирает обе формы, и сервер принимает обе.
|
||||
func pushKey(s string, n int) ([]byte, bool) {
|
||||
if raw, err := b64.DecodeString(s); err == nil {
|
||||
return raw, len(raw) == n
|
||||
}
|
||||
raw, err := base64.URLEncoding.DecodeString(s)
|
||||
return raw, err == nil && len(raw) == n
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
// quiet — сколько ждём, чтобы убедиться, что пуша нет. Всё локально,
|
||||
// задержек быть не должно.
|
||||
const quiet = 300 * time.Millisecond
|
||||
|
||||
// pushEnv — сервер с настоящей парой VAPID-ключей: без неё пуши выключены
|
||||
// (docs/deploy.md).
|
||||
func pushEnv(t *testing.T) *env { return pushEnvWith(t, true) }
|
||||
|
||||
// pushEnvWith — то же; local разрешает отправку на 127.0.0.1, где живёт
|
||||
// подменный push-сервис. Настоящий сервер ходит только по публичным
|
||||
// адресам (ADR-047), и это проверяется отдельно.
|
||||
func pushEnvWith(t *testing.T, local bool) *env {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("vapid: %v", err)
|
||||
}
|
||||
return envWith(t, func(cfg *config.Config) {
|
||||
cfg.VAPIDPublic = raw64(key.PublicKey().Bytes())
|
||||
cfg.VAPIDPrivate = raw64(key.Bytes())
|
||||
cfg.VAPIDSubject = "mailto:bare@bare.test"
|
||||
// Push-сервис вендора подменён сервером на 127.0.0.1: в работе
|
||||
// отправщик ходит только по публичным адресам (ADR-047).
|
||||
cfg.PushLocal = local
|
||||
})
|
||||
}
|
||||
|
||||
func raw64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// padded64 — то же, что bytesOf, но с паддингом: браузер вправе прислать
|
||||
// ключи подписки и в такой форме.
|
||||
func padded64(n int, seed byte) string {
|
||||
raw := make([]byte, n)
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// point65 — p256dh настоящей подписки: несжатая точка P-256 в 65 байтах.
|
||||
// Случайные байты той же длины точкой не являются, и сервер их не примет
|
||||
// (docs/protocol.md, «Устройства»).
|
||||
func point65(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ключ подписки: %v", err)
|
||||
}
|
||||
return key.PublicKey().Bytes()
|
||||
}
|
||||
|
||||
// pushService — push-сервис вендора в тесте. Настоящий FCM тестам не нужен
|
||||
// и не годится: проверяется, что уходит и что сервер делает с ответом.
|
||||
type pushService struct {
|
||||
t *testing.T
|
||||
url string
|
||||
got chan delivered
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
// delivered — то, что увидел push-сервис.
|
||||
type delivered struct {
|
||||
device string // хвост endpoint: по нему видно, чей это пуш
|
||||
ttl string
|
||||
urgency string
|
||||
encoding string
|
||||
auth string
|
||||
record []byte
|
||||
}
|
||||
|
||||
func newPushService(t *testing.T) *pushService {
|
||||
t.Helper()
|
||||
open := make(chan struct{})
|
||||
close(open)
|
||||
return pushServiceWith(t, open)
|
||||
}
|
||||
|
||||
// newSlowPushService — push-сервис, который принимает запрос и молчит,
|
||||
// пока тест не отпустит его. Так видно, что делает сервер, пока отправка
|
||||
// ещё идёт. Отпускать обязательно: иначе остановка сервера ждёт таймаута.
|
||||
func newSlowPushService(t *testing.T) (*pushService, func()) {
|
||||
t.Helper()
|
||||
gate := make(chan struct{})
|
||||
var once sync.Once
|
||||
return pushServiceWith(t, gate), func() { once.Do(func() { close(gate) }) }
|
||||
}
|
||||
|
||||
// pushServiceWith — push-сервис, отвечающий не раньше, чем закроется gate.
|
||||
func pushServiceWith(t *testing.T, gate <-chan struct{}) *pushService {
|
||||
t.Helper()
|
||||
p := &pushService{t: t, got: make(chan delivered, 512)}
|
||||
p.status.Store(http.StatusCreated)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
record, _ := io.ReadAll(r.Body)
|
||||
got := delivered{
|
||||
device: strings.TrimPrefix(r.URL.Path, "/push/"),
|
||||
ttl: r.Header.Get("TTL"),
|
||||
urgency: r.Header.Get("Urgency"),
|
||||
encoding: r.Header.Get("Content-Encoding"),
|
||||
auth: r.Header.Get("Authorization"),
|
||||
record: record,
|
||||
}
|
||||
select {
|
||||
case p.got <- got:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-gate:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
w.WriteHeader(int(p.status.Load()))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p.url = srv.URL
|
||||
return p
|
||||
}
|
||||
|
||||
// next — следующий пуш; его отсутствие — ошибка теста.
|
||||
func (p *pushService) next() delivered {
|
||||
p.t.Helper()
|
||||
select {
|
||||
case got := <-p.got:
|
||||
return got
|
||||
case <-time.After(wait):
|
||||
p.t.Fatal("пуш не пришёл")
|
||||
}
|
||||
return delivered{}
|
||||
}
|
||||
|
||||
// silent требует, чтобы других пушей не было.
|
||||
func (p *pushService) silent() {
|
||||
p.t.Helper()
|
||||
select {
|
||||
case got := <-p.got:
|
||||
p.t.Fatalf("лишний пуш устройству %s", got.device)
|
||||
case <-time.After(quiet):
|
||||
}
|
||||
}
|
||||
|
||||
// subscriber — устройство с push-подпиской. Ключи настоящие: тест
|
||||
// расшифровывает пуш ровно так, как это сделал бы браузер (RFC 8291),
|
||||
// и потому видит, что в нём лежит.
|
||||
type subscriber struct {
|
||||
device string
|
||||
key *ecdh.PrivateKey
|
||||
auth []byte
|
||||
}
|
||||
|
||||
// subscribe кладёт подписку устройства прямо в базу. Через PUT её сюда
|
||||
// не поставить: тестовый push-сервис живёт на http, а эндпоинт принимает
|
||||
// только https. Форму подписки проверяют TestPushSubscription
|
||||
// и TestPushSubscriptionForm, правила отправки от неё не зависят.
|
||||
func (e *env) subscribe(nick, device string, p *pushService) *subscriber {
|
||||
e.t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
e.t.Fatalf("ключ подписки: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
e.t.Fatalf("секрет подписки: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"endpoint": p.url + "/push/" + device,
|
||||
"keys": map[string]string{
|
||||
"p256dh": raw64(key.PublicKey().Bytes()),
|
||||
"auth": raw64(auth),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
e.t.Fatalf("подписка: %v", err)
|
||||
}
|
||||
set, err := e.st.SetPush(context.Background(), device, nick, string(raw))
|
||||
if err != nil || !set {
|
||||
e.t.Fatalf("SetPush: %v (поставлена: %v)", err, set)
|
||||
}
|
||||
return &subscriber{device: device, key: key, auth: auth}
|
||||
}
|
||||
|
||||
// open расшифровывает пуш: aes128gcm по RFC 8291, как это делает браузер.
|
||||
// Без расшифровки нельзя утверждать, что в пуше нет ничего лишнего.
|
||||
func (s *subscriber) open(t *testing.T, record []byte) map[string]string {
|
||||
t.Helper()
|
||||
check := func(what string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
// Заголовок записи: соль, размер записи, длина открытого ключа.
|
||||
const header = 16 + 4 + 1
|
||||
if len(record) < header {
|
||||
t.Fatalf("запись короче заголовка: %d байт", len(record))
|
||||
}
|
||||
salt := record[:16]
|
||||
keyLen := int(record[20])
|
||||
if len(record) < header+keyLen {
|
||||
t.Fatalf("запись короче ключа отправителя: %d байт", len(record))
|
||||
}
|
||||
sender, ct := record[header:header+keyLen], record[header+keyLen:]
|
||||
|
||||
remote, err := ecdh.P256().NewPublicKey(sender)
|
||||
check("ключ отправителя", err)
|
||||
shared, err := s.key.ECDH(remote)
|
||||
check("ecdh", err)
|
||||
|
||||
info := append([]byte("WebPush: info\x00"), s.key.PublicKey().Bytes()...)
|
||||
info = append(info, sender...)
|
||||
ikm, err := hkdf.Key(sha256.New, shared, s.auth, string(info), 32)
|
||||
check("ikm", err)
|
||||
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
|
||||
check("ключ записи", err)
|
||||
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
|
||||
check("nonce", err)
|
||||
|
||||
block, err := aes.NewCipher(cek)
|
||||
check("aes", err)
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
check("gcm", err)
|
||||
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||
check("расшифровка", err)
|
||||
|
||||
// Хвост записи — набивка: нули после разделителя 0x02.
|
||||
plain = bytes.TrimSuffix(bytes.TrimRight(plain, "\x00"), []byte{2})
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(plain, &out); err != nil {
|
||||
t.Fatalf("нагрузка %q: %v", plain, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasPush — что о подписке устройства говорит GET /api/devices.
|
||||
func (e *env) hasPush(c *http.Cookie, device string) bool {
|
||||
e.t.Helper()
|
||||
rec := e.do(http.MethodGet, "/api/devices", nil, with(c))
|
||||
expect(e.t, rec, http.StatusOK, "")
|
||||
var list []struct {
|
||||
ID string `json:"id"`
|
||||
HasPush bool `json:"hasPush"`
|
||||
}
|
||||
decodeBody(e.t, rec, &list)
|
||||
for _, got := range list {
|
||||
if got.ID == device {
|
||||
return got.HasPush
|
||||
}
|
||||
}
|
||||
e.t.Fatalf("устройства %s нет в списке", device)
|
||||
return false
|
||||
}
|
||||
|
||||
// waitPushGone ждёт, пока подписка исчезнет: снимает её отправщик, уже
|
||||
// после того, как push-сервис ответил.
|
||||
func (e *env) waitPushGone(c *http.Cookie, device string) {
|
||||
e.t.Helper()
|
||||
for deadline := time.Now().Add(wait); time.Now().Before(deadline); {
|
||||
if !e.hasPush(c, device) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
e.t.Fatalf("подписка устройства %s не снята", device)
|
||||
}
|
||||
|
||||
// send — обычная отправка личного сообщения.
|
||||
func (e *env) send(c *http.Cookie, device, to string, seed byte) {
|
||||
e.t.Helper()
|
||||
expect(e.t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), seed), to),
|
||||
with(c), withDevice(device)), http.StatusAccepted, "")
|
||||
}
|
||||
|
||||
// pushEventually шлёт сообщения, пока не придёт пуш. И разрыв потока,
|
||||
// и возврат права на пуш случаются после ответа на запрос: момент их
|
||||
// наступления не назначить, поэтому попытка повторяется.
|
||||
func (e *env) pushEventually(p *pushService, c *http.Cookie, device, to string) delivered {
|
||||
e.t.Helper()
|
||||
for i := 0; i < 8; i++ {
|
||||
e.send(c, device, to, byte(50+i))
|
||||
select {
|
||||
case got := <-p.got:
|
||||
return got
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
e.t.Fatal("пуш так и не пришёл")
|
||||
return delivered{}
|
||||
}
|
||||
|
||||
// subscription — тело PUT /api/devices/{id}/push в форме
|
||||
// PushSubscription.toJSON().
|
||||
func subscription(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"endpoint": "https://push.example/one",
|
||||
"expirationTime": nil,
|
||||
"keys": map[string]string{"p256dh": raw64(point65(t)), "auth": bytesOf(16, 7)},
|
||||
}
|
||||
}
|
||||
|
||||
// Подписка ставится и снимается, hasPush честный, чужое устройство — 403
|
||||
// (docs/protocol.md, «Устройства», «Общие правила»).
|
||||
func TestPushSubscription(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
body := map[string]any{"subscription": subscription(t)}
|
||||
|
||||
if e.hasPush(marta, m1) {
|
||||
t.Error("hasPush до подписки: true")
|
||||
}
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, with(marta), withDevice(m1)),
|
||||
http.StatusNoContent, "")
|
||||
if !e.hasPush(marta, m1) {
|
||||
t.Error("hasPush после подписки: false")
|
||||
}
|
||||
// Подписка принадлежит устройству: у соседа её не появилось.
|
||||
if e.hasPush(petya, p1) {
|
||||
t.Error("подписка досталась чужому устройству")
|
||||
}
|
||||
|
||||
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
|
||||
http.StatusNoContent, "")
|
||||
if e.hasPush(marta, m1) {
|
||||
t.Error("hasPush после снятия: true")
|
||||
}
|
||||
// Снимать нечего — тот же 204.
|
||||
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
|
||||
http.StatusNoContent, "")
|
||||
|
||||
// Чужое устройство в пути — 403, и подписки у него не появилось.
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", body, with(marta), withDevice(m1)),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
expect(t, e.do(http.MethodDelete, "/api/devices/"+p1+"/push", nil, with(marta), withDevice(m1)),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
if e.hasPush(petya, p1) {
|
||||
t.Error("подписка поставлена чужому устройству")
|
||||
}
|
||||
|
||||
// X-Device обязателен и обязан быть своим.
|
||||
for _, opts := range [][]func(*http.Request){
|
||||
{with(marta)},
|
||||
{with(marta), withDevice(p1)},
|
||||
{with(marta), withDevice("мусор")},
|
||||
{with(marta), withDevice(deviceOf(9))},
|
||||
} {
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, opts...),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, opts...),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
}
|
||||
if e.hasPush(marta, m1) {
|
||||
t.Error("подписка появилась после отказа")
|
||||
}
|
||||
}
|
||||
|
||||
// Форма подписки: абсолютный https-адрес и два ключа нужной длины.
|
||||
func TestPushSubscriptionForm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
_, p1 := e.join("petya", 2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(map[string]any)
|
||||
}{
|
||||
{"нет endpoint", func(s map[string]any) { delete(s, "endpoint") }},
|
||||
{"endpoint без tls", func(s map[string]any) { s["endpoint"] = "http://push.example/one" }},
|
||||
{"endpoint без хоста", func(s map[string]any) { s["endpoint"] = "https:///one" }},
|
||||
{"endpoint не url", func(s map[string]any) { s["endpoint"] = "какой же это url" }},
|
||||
{"нет ключей", func(s map[string]any) { delete(s, "keys") }},
|
||||
{"endpoint на loopback", func(s map[string]any) { s["endpoint"] = "https://127.0.0.1:9/push" }},
|
||||
{"endpoint на link-local", func(s map[string]any) {
|
||||
s["endpoint"] = "https://169.254.169.254/latest/meta-data/"
|
||||
}},
|
||||
{"endpoint в приватной сети", func(s map[string]any) { s["endpoint"] = "https://10.0.0.1/push" }},
|
||||
{"endpoint на ::1", func(s map[string]any) { s["endpoint"] = "https://[::1]:8411/api/me" }},
|
||||
{"endpoint длиннее 2 КиБ", func(s map[string]any) {
|
||||
s["endpoint"] = "https://push.example/" + strings.Repeat("a", 2048)
|
||||
}},
|
||||
{"p256dh не 65 байт", func(s map[string]any) {
|
||||
s["keys"] = map[string]string{"p256dh": bytesOf(32, 5), "auth": bytesOf(16, 7)}
|
||||
}},
|
||||
{"p256dh не точка на кривой", func(s map[string]any) {
|
||||
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(16, 7)}
|
||||
}},
|
||||
{"auth не 16 байт", func(s map[string]any) {
|
||||
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(32, 7)}
|
||||
}},
|
||||
{"ключ не base64url", func(s map[string]any) {
|
||||
s["keys"] = map[string]string{"p256dh": strings.Repeat("!", 87), "auth": bytesOf(16, 7)}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
sub := subscription(t)
|
||||
c.change(sub)
|
||||
rec := e.do(http.MethodPut, "/api/devices/"+m1+"/push",
|
||||
map[string]any{"subscription": sub}, with(marta), withDevice(m1))
|
||||
expect(t, rec, http.StatusBadRequest, "invalid")
|
||||
var field struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
decodeBody(t, rec, &field)
|
||||
if field.Field != "subscription" {
|
||||
t.Errorf("field: получено %q, ожидалось \"subscription\"", field.Field)
|
||||
}
|
||||
})
|
||||
}
|
||||
if e.hasPush(marta, m1) {
|
||||
t.Error("подписка не по форме поставилась")
|
||||
}
|
||||
|
||||
// Паддинг в base64url тоже принимается: браузер вправе его добавить.
|
||||
padded := subscription(t)
|
||||
padded["keys"] = map[string]string{
|
||||
"p256dh": base64.URLEncoding.EncodeToString(point65(t)),
|
||||
"auth": padded64(16, 7),
|
||||
}
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push",
|
||||
map[string]any{"subscription": padded}, with(marta), withDevice(m1)), http.StatusNoContent, "")
|
||||
|
||||
// Форма проверяется раньше прав: на запрос к чужому устройству
|
||||
// приходит отказ по форме, а не по правам (ADR-043).
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", "не json", with(marta), withDevice(m1)),
|
||||
http.StatusBadRequest, "bad_json")
|
||||
broken := subscription(t)
|
||||
broken["endpoint"] = "http://push.example/one"
|
||||
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push",
|
||||
map[string]any{"subscription": broken}, with(marta), withDevice(m1)), http.StatusBadRequest, "invalid")
|
||||
}
|
||||
|
||||
// Пуш уходит только отключённому устройству с подпиской (ADR-023).
|
||||
// Он несёт заголовок, «новое сообщение» и адрес чата — и ничего больше.
|
||||
func TestPushToSilentDevice(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
p2 := e.addDevice(petya, deviceOf(3))
|
||||
e.subscribe("petya", p1, svc) // подключено по SSE
|
||||
silent := e.subscribe("petya", p2, svc) // молчит
|
||||
|
||||
stream := e.open(p1, petya)
|
||||
stream.untilReady()
|
||||
e.send(marta, m1, "petya", 4)
|
||||
|
||||
got := svc.next()
|
||||
if got.device != p2 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p2)
|
||||
}
|
||||
// TTL сутки, urgency normal (ADR-023).
|
||||
if got.ttl != "86400" {
|
||||
t.Errorf("TTL: получено %q, ожидалось \"86400\"", got.ttl)
|
||||
}
|
||||
if got.urgency != "normal" {
|
||||
t.Errorf("Urgency: получено %q, ожидалось \"normal\"", got.urgency)
|
||||
}
|
||||
if got.encoding != "aes128gcm" {
|
||||
t.Errorf("Content-Encoding: получено %q, ожидалось \"aes128gcm\"", got.encoding)
|
||||
}
|
||||
if !strings.HasPrefix(got.auth, "vapid t=") {
|
||||
t.Errorf("Authorization: получено %q, ожидался vapid", got.auth)
|
||||
}
|
||||
|
||||
payload := silent.open(t, got.record)
|
||||
if len(payload) != 3 {
|
||||
t.Errorf("поля нагрузки: %v", payload)
|
||||
}
|
||||
if payload["title"] != "@marta" {
|
||||
t.Errorf("title: получено %q, ожидалось \"@marta\"", payload["title"])
|
||||
}
|
||||
if payload["body"] != "новое сообщение" {
|
||||
t.Errorf("body: получено %q, ожидалось \"новое сообщение\"", payload["body"])
|
||||
}
|
||||
if payload["chat"] != "dm:marta" {
|
||||
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
|
||||
}
|
||||
// Шифротекста сообщения в пуше нет ни в каком виде: сервер его
|
||||
// не пересылает, а плейнтекста он и не знает (ADR-011).
|
||||
if bytes.Contains(got.record, []byte(bytesOf(48, 23))) {
|
||||
t.Error("шифротекст сообщения попал в пуш")
|
||||
}
|
||||
svc.silent()
|
||||
}
|
||||
|
||||
// Одно молчащее устройство получает один пуш, а не ленту (ADR-023).
|
||||
func TestPushOncePerSilentDevice(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
_, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
if got := svc.next(); got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
|
||||
}
|
||||
// Второе и третье сообщение подряд пуша не порождают.
|
||||
e.send(marta, m1, "petya", 4)
|
||||
e.send(marta, m1, "petya", 5)
|
||||
svc.silent()
|
||||
}
|
||||
|
||||
// Подключение по SSE сбрасывает неотработанный пуш: следующее сообщение
|
||||
// молчащему устройству снова даёт пуш (ADR-023).
|
||||
func TestPushAgainAfterStream(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
svc.next()
|
||||
e.send(marta, m1, "petya", 4)
|
||||
svc.silent()
|
||||
|
||||
stream := e.open(p1, petya)
|
||||
stream.untilReady()
|
||||
stream.close()
|
||||
|
||||
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
|
||||
}
|
||||
}
|
||||
|
||||
// 404 и 410 от push-сервиса означают, что подписки больше нет (ADR-011).
|
||||
func TestPushDeadSubscription(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
svc.status.Store(int32(status))
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
svc.next()
|
||||
e.waitPushGone(petya, p1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Прочие отказы push-сервиса подписку не трогают и доставку сообщения
|
||||
// не роняют. Право на пуш при этом возвращается: иначе одна ошибка
|
||||
// затыкала бы уведомления устройства до самого подключения.
|
||||
func TestPushServiceError(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
svc.status.Store(http.StatusInternalServerError)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
svc.next()
|
||||
if !e.hasPush(petya, p1) {
|
||||
t.Error("подписка снята по ответу 500")
|
||||
}
|
||||
|
||||
svc.status.Store(http.StatusCreated)
|
||||
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
|
||||
}
|
||||
if !e.hasPush(petya, p1) {
|
||||
t.Error("подписка снята после успешного пуша")
|
||||
}
|
||||
}
|
||||
|
||||
// Отправитель пуша о собственном сообщении не получает — ни на то
|
||||
// устройство, с которого писал, ни на остальные свои (ADR-045).
|
||||
func TestPushNotToSender(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
m2 := e.addDevice(marta, deviceOf(2))
|
||||
_, p1 := e.join("petya", 3)
|
||||
e.subscribe("marta", m1, svc)
|
||||
e.subscribe("marta", m2, svc)
|
||||
to := e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 4)
|
||||
|
||||
got := svc.next()
|
||||
if got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству отправителя %s", got.device)
|
||||
}
|
||||
svc.silent()
|
||||
if payload := to.open(t, got.record); payload["chat"] != "dm:marta" {
|
||||
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
|
||||
}
|
||||
}
|
||||
|
||||
// Пуш из комнаты: заголовок — имя комнаты, адрес чата — её идентификатор
|
||||
// (ADR-023).
|
||||
func TestPushFromRoom(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
_, p1 := e.join("petya", 2)
|
||||
room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1))
|
||||
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 41),
|
||||
http.StatusOK, "")
|
||||
member := e.subscribe("petya", p1, svc)
|
||||
|
||||
expect(t, e.do(http.MethodPost, "/api/messages",
|
||||
roomMessage(ulid(nowMillis(), 5), room.ID, keyID(41)), with(marta), withDevice(m1)),
|
||||
http.StatusAccepted, "")
|
||||
|
||||
got := svc.next()
|
||||
if got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
|
||||
}
|
||||
payload := member.open(t, got.record)
|
||||
if payload["title"] != "#общая" {
|
||||
t.Errorf("title: получено %q, ожидалось \"#общая\"", payload["title"])
|
||||
}
|
||||
if payload["chat"] != "room:"+room.ID {
|
||||
t.Errorf("chat: получено %q, ожидалось %q", payload["chat"], "room:"+room.ID)
|
||||
}
|
||||
if payload["body"] != "новое сообщение" {
|
||||
t.Errorf("body: получено %q", payload["body"])
|
||||
}
|
||||
svc.silent()
|
||||
}
|
||||
|
||||
// Без VAPID-ключей пуши выключены: подписка ставится, отправки нет.
|
||||
func TestPushOffWithoutKeys(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := newEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
_, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
svc.silent()
|
||||
}
|
||||
|
||||
// Аккаунт с молчащим push-сервисом не отбирает отправку у остальных:
|
||||
// доля одного аккаунта в отправщиках ограничена (ADR-048).
|
||||
func TestPushShareBetweenAccounts(t *testing.T) {
|
||||
e := pushEnv(t)
|
||||
stuck, release := newSlowPushService(t)
|
||||
defer release()
|
||||
live := newPushService(t)
|
||||
|
||||
marta, m1 := e.join("marta", 1)
|
||||
greedy, g1 := e.join("greedy", 2)
|
||||
e.subscribe("greedy", g1, stuck)
|
||||
for seed := byte(10); seed < 30; seed++ {
|
||||
e.subscribe("greedy", e.addDevice(greedy, deviceOf(seed)), stuck)
|
||||
}
|
||||
_, c1 := e.join("carol", 3)
|
||||
e.subscribe("carol", c1, live)
|
||||
|
||||
// Двадцать одно молчащее устройство одного аккаунта: часть заданий
|
||||
// отбрасывается сразу, остальные занимают не больше своей доли.
|
||||
e.send(marta, m1, "greedy", 40)
|
||||
e.send(marta, m1, "carol", 41)
|
||||
|
||||
select {
|
||||
case got := <-live.got:
|
||||
if got.device != c1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, c1)
|
||||
}
|
||||
case <-time.After(wait):
|
||||
t.Fatal("пуш постороннему аккаунту не ушёл: отправщики заняты чужим")
|
||||
}
|
||||
}
|
||||
|
||||
// Устройство, подключившееся по SSE во время отправки, не остаётся
|
||||
// с неотработанным пушем: право возвращается, и следующее сообщение
|
||||
// после ухода в офлайн снова даёт пуш (ADR-023).
|
||||
func TestPushReleasedWhenDeviceConnects(t *testing.T) {
|
||||
e := pushEnv(t)
|
||||
svc, release := newSlowPushService(t)
|
||||
defer release()
|
||||
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
// Отправка уже началась: push-сервис получил запрос и держит его.
|
||||
if got := svc.next(); got.device != p1 {
|
||||
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
|
||||
}
|
||||
// Пока пуш в пути, устройство подключилось: подключение сбрасывает
|
||||
// неотработанный пуш, а отправщик поставил его позже.
|
||||
stream := e.open(p1, petya)
|
||||
stream.untilReady()
|
||||
release()
|
||||
|
||||
// Право на пуш свободно: захват удаётся.
|
||||
for deadline := time.Now().Add(wait); ; {
|
||||
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPush: %v", err)
|
||||
}
|
||||
if ok {
|
||||
if claimed == "" {
|
||||
t.Error("подписка пуста")
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("неотработанный пуш остался висеть на подключённом устройстве")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Пуш на непубличный адрес не уходит вовсе: соединения не случается,
|
||||
// право на пуш возвращается, а адрес подписки в журнал не попадает
|
||||
// (ADR-047, docs/deploy.md, «Логи»).
|
||||
func TestPushSkipsLocalEndpoint(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnvWith(t, false)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
e.subscribe("petya", p1, svc)
|
||||
|
||||
e.send(marta, m1, "petya", 3)
|
||||
svc.silent()
|
||||
|
||||
if !e.hasPush(petya, p1) {
|
||||
t.Error("подписка снята, хотя push-сервис не отвечал")
|
||||
}
|
||||
// Право на пуш вернулось: следующее сообщение попробует снова.
|
||||
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPush: %v", err)
|
||||
}
|
||||
if !ok || claimed == "" {
|
||||
t.Error("право на пуш осталось захваченным")
|
||||
}
|
||||
log := e.log.String()
|
||||
if !strings.Contains(log, "адрес подписки не публичный") {
|
||||
t.Errorf("в журнале нет причины отказа: %q", log)
|
||||
}
|
||||
if strings.Contains(log, "127.0.0.1") || strings.Contains(log, strings.TrimPrefix(svc.url, "http://")) {
|
||||
t.Errorf("адрес подписки попал в журнал: %q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// Одно сообщение — несколько молчащих устройств: каждое получает свою
|
||||
// расшифровываемую нагрузку. Нагрузка на всех одна (ADR-045), но
|
||||
// шифруется она для каждой подписки отдельно.
|
||||
func TestPushPayloadPerDevice(t *testing.T) {
|
||||
svc := newPushService(t)
|
||||
e := pushEnv(t)
|
||||
marta, m1 := e.join("marta", 1)
|
||||
petya, p1 := e.join("petya", 2)
|
||||
p2 := e.addDevice(petya, deviceOf(3))
|
||||
p3 := e.addDevice(petya, deviceOf(4))
|
||||
subs := map[string]*subscriber{
|
||||
p1: e.subscribe("petya", p1, svc),
|
||||
p2: e.subscribe("petya", p2, svc),
|
||||
p3: e.subscribe("petya", p3, svc),
|
||||
}
|
||||
|
||||
e.send(marta, m1, "petya", 5)
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < len(subs); i++ {
|
||||
got := svc.next()
|
||||
to, ok := subs[got.device]
|
||||
if !ok {
|
||||
t.Fatalf("пуш ушёл неизвестному устройству %s", got.device)
|
||||
}
|
||||
if seen[got.device] {
|
||||
t.Fatalf("устройство %s получило второй пуш", got.device)
|
||||
}
|
||||
seen[got.device] = true
|
||||
payload := to.open(t, got.record)
|
||||
if payload["title"] != "@marta" || payload["chat"] != "dm:marta" || payload["body"] != "новое сообщение" {
|
||||
t.Errorf("нагрузка устройства %s: %v", got.device, payload)
|
||||
}
|
||||
}
|
||||
svc.silent()
|
||||
}
|
||||
+28
-2
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
@@ -379,7 +380,32 @@ func uniqueNicks(list []string) ([]string, bool) {
|
||||
return out, true
|
||||
}
|
||||
|
||||
// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021).
|
||||
// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих
|
||||
// символов, без переопределений направления письма и не из одних
|
||||
// пробелов (ADR-021).
|
||||
//
|
||||
// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит
|
||||
// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод
|
||||
// строки, ни разворот текста — на экране блокировки такое имя выглядит
|
||||
// не строкой списка, а сообщением от системы.
|
||||
func validRoomName(name string) bool {
|
||||
return name != "" && utf8.RuneCountInString(name) <= maxRoomName
|
||||
if name == "" || utf8.RuneCountInString(name) > maxRoomName {
|
||||
return false
|
||||
}
|
||||
blank := true
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) || bidi(r) {
|
||||
return false
|
||||
}
|
||||
if !unicode.IsSpace(r) {
|
||||
blank = false
|
||||
}
|
||||
}
|
||||
return !blank
|
||||
}
|
||||
|
||||
// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069.
|
||||
// Они переставляют текст на экране местами, оставаясь невидимыми.
|
||||
func bidi(r rune) bool {
|
||||
return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,17 @@ func TestCreateRoomRejects(t *testing.T) {
|
||||
{"имя длиннее 64", func(m map[string]any) {
|
||||
m["name"] = strings.Repeat("я", 65)
|
||||
}, http.StatusBadRequest, "invalid", "name"},
|
||||
// Имя уходит в заголовок системного уведомления (ADR-045):
|
||||
// ни перевода строки, ни разворота текста в нём быть не должно.
|
||||
{"имя с переводом строки", func(m map[string]any) {
|
||||
m["name"] = "общая\nсрочно: перезагрузите телефон"
|
||||
}, http.StatusBadRequest, "invalid", "name"},
|
||||
{"имя с bidi", func(m map[string]any) {
|
||||
m["name"] = "общая\u202eяандекс"
|
||||
}, http.StatusBadRequest, "invalid", "name"},
|
||||
{"имя из пробелов", func(m map[string]any) {
|
||||
m["name"] = " "
|
||||
}, http.StatusBadRequest, "invalid", "name"},
|
||||
{"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"},
|
||||
{"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""},
|
||||
{"ключ чужому", func(m map[string]any) {
|
||||
|
||||
Reference in New Issue
Block a user