Пуши на iOS: sub уезжал как mailto:mailto:, Apple отвечал BadJwtToken
webpush-go в getVAPIDAuthorizationHeader приписывает "mailto:" всему, что не начинается с "https:". В /etc/bare/env лежит правильная по спецификации VAPID форма mailto:admin@xmatic.team, и в JWT уходил sub = mailto:mailto:admin@xmatic.team. Apple отвергал токен, пуши не работали ни разу за всё время. Проверено экспериментом на живом endpoint: mailto:<адрес> → 201, https://<хост> → 201, mailto:mailto:<адрес> → 403 BadJwtToken, голый адрес без схемы → 403. Значит убрать схему из конфига нельзя: адаптируется код, а не операционный файл. Субъект нормализуется один раз при создании отправщика, форма BARE_VAPID_SUBJECT проверяется при старте — негодное значение валит запуск с понятной ошибкой, а не выключает пуши молча. ADR-064: в журнал пишется код причины от push-сервиса. Без него отказ был неотличим от любого другого 403, и дефект прожил незамеченным весь этап 4 и всю закалку. Адрес подписки в журнал по-прежнему не попадает: наружу идёт только короткий код из латиницы и цифр. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ADR-047: Исходящий запрос к push-сервису
|
||||
|
||||
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
|
||||
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). Код причины из ответа push-сервиса в журнале — [ADR-064](064-push-failure-reason-in-log.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-064: Код причины отказа push-сервиса в журнале
|
||||
|
||||
Уточняет [ADR-047](047-push-endpoint.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-047 убрал из журнала адрес подписки: отказ отправки пишется классом, текст ошибки транспорта не печатается вовсе. Под то же правило попал и ответ push-сервиса — в журнал уходил один статус: `пуш: push-сервис ответил 403`.
|
||||
|
||||
Цена выяснилась на работающем сервере. APNs отвечал `403` на каждый пуш, потому что в VAPID-токен уезжал `sub = "mailto:mailto:admin@xmatic.team"`: webpush-go приписывает `mailto:` всему, что не начинается с `https:`, а в окружении субъект записан правильным URI. Сутки не уходило ни одного пуша, и по журналу это выглядело как «что-то с пушами»: просроченный ключ, чужой субъект, лимит вендора и мёртвая подписка дают один и тот же `403`. Причина лежала в теле ответа: `{"reason":"BadJwtToken"}`.
|
||||
|
||||
Код причины от push-сервиса — диагностика вендора, а не данные пользователя. Но тело ответа приходит снаружи, и класть его в журнал целиком нельзя: в нём может оказаться адрес подписки.
|
||||
|
||||
## Решение
|
||||
|
||||
- В журнал уходит статус и, если он разобран, короткий код причины: `пуш: push-сервис ответил 403 (BadJwtToken)`.
|
||||
- Читается не больше 200 байт тела; остаток дочитывается ради переиспользования соединения. Код берётся из полей JSON `reason`, `error`, `message` — в этом порядке — либо из тела целиком, если оно само короткая строка.
|
||||
- В журнал идёт только первая строка не длиннее 64 символов из латиницы, цифр, `_`, `-` и пробелов. Точка, `:`, `/` и `@` встречаются в адресах подписки и именах хостов, поэтому строка с ними отбрасывается целиком и остаётся один статус.
|
||||
- Правило ADR-047 про транспорт не меняется: ошибка соединения по-прежнему сводится к классу, её текст не печатается, адреса подписки в журнале нет.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Отказ push-сервиса читается по журналу и чинится по нему же.
|
||||
- Часть вендорских кодов не разберётся: FCM отвечает вложенным объектом, а коды с точкой в имени не проходят алфавит. Тогда в журнале остаётся статус, как раньше. Осознанно: молчание безопаснее догадок о чужом теле.
|
||||
- Ответы `404` и `410` пишутся не строкой, а снятием подписки — так было и остаётся (ADR-011).
|
||||
+3
-1
@@ -35,6 +35,8 @@ BARE_INVITE_CODE=<пусто или код>
|
||||
|
||||
`bare vapid` печатает пару ключей; выполняется локально один раз, результат вписывается в файл.
|
||||
|
||||
`BARE_VAPID_SUBJECT` — URI по RFC 8292: `mailto:<адрес>` или `https://<хост>`. Форма проверяется при старте: с пустым при заданных ключах или с голым адресом без схемы сервер не поднимается. Отдавать субъект библиотеке приходится без схемы `mailto:` — она приписывает её сама, и готовый URI превратился бы в `mailto:mailto:…`, который push-сервис отвергает; нормализация живёт в `internal/push`, запись в этом файле верна и не меняется.
|
||||
|
||||
`/etc/systemd/system/bare.service`:
|
||||
|
||||
```ini
|
||||
@@ -144,6 +146,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
|
||||
|
||||
## Логи
|
||||
|
||||
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
|
||||
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). Ответ push-сервиса пишется статусом и коротким кодом причины из тела: «пуш: push-сервис ответил 403 (BadJwtToken)». Код — диагностика вендора; всё, что на короткий код не похоже, отбрасывается целиком, и остаётся один статус (ADR-064). journald хранит по своим правилам.
|
||||
|
||||
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.
|
||||
|
||||
@@ -68,9 +68,47 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key)
|
||||
}
|
||||
}
|
||||
if err := checkVAPIDSubject(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// checkVAPIDSubject проверяет форму VAPID-субъекта при старте. Ошибка
|
||||
// в нём иначе не видна вовсе: сервер поднимается, подписки ставятся,
|
||||
// а push-сервис отвергает каждый токен — «пуши не приходят» без единой
|
||||
// строки о причине. Отказ на старте дешевле.
|
||||
//
|
||||
// Годится ровно то, что допускает RFC 8292: «mailto:<адрес>» или
|
||||
// «https://<хост>» (docs/deploy.md).
|
||||
func checkVAPIDSubject(c *Config) error {
|
||||
if c.VAPIDSubject == "" {
|
||||
// Без ключей пуши и так выключены — это рабочий локальный запуск.
|
||||
// А вот ключи без субъекта означают, что настроить пуши хотели
|
||||
// и не настроили.
|
||||
if c.VAPIDPublic == "" && c.VAPIDPrivate == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("BARE_VAPID_SUBJECT пуст при заданных VAPID-ключах: нужен mailto:<адрес> или https://<хост>")
|
||||
}
|
||||
bad := fmt.Errorf("BARE_VAPID_SUBJECT=%q не годится: нужен mailto:<адрес> или https://<хост>", c.VAPIDSubject)
|
||||
if addr, ok := strings.CutPrefix(c.VAPIDSubject, "mailto:"); ok {
|
||||
local, domain, at := strings.Cut(addr, "@")
|
||||
if !at || local == "" || domain == "" || strings.ContainsAny(addr, " \t") {
|
||||
return bad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(c.VAPIDSubject, "https://"); ok {
|
||||
host, _, _ := strings.Cut(rest, "/")
|
||||
if host == "" || strings.ContainsAny(rest, " \t") {
|
||||
return bad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return strings.TrimSpace(v)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Форма VAPID-субъекта проверяется на старте: с мусором в нём сервер
|
||||
// поднимается, подписки ставятся, а push-сервис отвергает каждый токен —
|
||||
// поломка без единой строки в журнале (docs/deploy.md).
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
subject string
|
||||
keys bool
|
||||
ok bool
|
||||
}{
|
||||
{"mailto:admin@xmatic.team", true, true},
|
||||
{"https://bare.xmatic.team", true, true},
|
||||
{"https://bare.xmatic.team/", true, true},
|
||||
// Голый адрес — не URI: RFC 8292 требует схему, и APNs отвергает
|
||||
// токен без неё.
|
||||
{"admin@xmatic.team", true, false},
|
||||
{"mailto:", true, false},
|
||||
{"mailto:admin", true, false},
|
||||
{"mailto:@xmatic.team", true, false},
|
||||
{"mailto:admin@xmatic.team, second@xmatic.team", true, false},
|
||||
{"https://", true, false},
|
||||
{"http://bare.xmatic.team", true, false},
|
||||
{"bare.xmatic.team", true, false},
|
||||
// Ключи заданы, субъекта нет: пуши настроить хотели и не настроили.
|
||||
{"", true, false},
|
||||
// Ни ключей, ни субъекта — локальный запуск без пушей.
|
||||
{"", false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("BARE_VAPID_SUBJECT", c.subject)
|
||||
if c.keys {
|
||||
t.Setenv("BARE_VAPID_PUBLIC", "public")
|
||||
t.Setenv("BARE_VAPID_PRIVATE", "private")
|
||||
} else {
|
||||
t.Setenv("BARE_VAPID_PUBLIC", "")
|
||||
t.Setenv("BARE_VAPID_PRIVATE", "")
|
||||
}
|
||||
_, err := Load()
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): %v", c.subject, c.keys, err)
|
||||
}
|
||||
if !c.ok {
|
||||
if err == nil {
|
||||
t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): принят", c.subject, c.keys)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "BARE_VAPID_SUBJECT") {
|
||||
t.Errorf("ошибка не называет переменную: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-7
@@ -15,6 +15,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -69,6 +70,16 @@ const (
|
||||
dropEvery = time.Minute
|
||||
)
|
||||
|
||||
// Чтение тела ответа push-сервиса ради кода причины (ADR-064).
|
||||
const (
|
||||
// maxReasonBody — сколько байт тела читаем. Код причины стоит в начале
|
||||
// ответа; остальное дочитывается в никуда, ради переиспользования
|
||||
// соединения.
|
||||
maxReasonBody = 200
|
||||
// maxReason — предел длины кода причины в журнале.
|
||||
maxReason = 64
|
||||
)
|
||||
|
||||
// Devices — что отправителю нужно от хранилища. Правило «одно молчащее
|
||||
// устройство — один пуш» держится на атомарном захвате (ADR-023).
|
||||
type Devices interface {
|
||||
@@ -110,9 +121,11 @@ type Sender struct {
|
||||
connected func(device string) bool
|
||||
public string
|
||||
private string
|
||||
subject string
|
||||
client *http.Client
|
||||
logw io.Writer
|
||||
// subject — VAPID-субъект в той форме, которую ждёт webpush-go:
|
||||
// у «mailto:» схема снята, см. vapidSubscriber.
|
||||
subject string
|
||||
client *http.Client
|
||||
logw io.Writer
|
||||
|
||||
jobs chan job
|
||||
done chan struct{}
|
||||
@@ -149,7 +162,7 @@ func New(cfg *config.Config, devices Devices, connected func(device string) bool
|
||||
connected: connected,
|
||||
public: cfg.VAPIDPublic,
|
||||
private: cfg.VAPIDPrivate,
|
||||
subject: cfg.VAPIDSubject,
|
||||
subject: vapidSubscriber(cfg.VAPIDSubject),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
// Push-сервисы редиректов не шлют. Следование за ними
|
||||
@@ -180,6 +193,35 @@ func (s *Sender) on() bool {
|
||||
return s.public != "" && s.private != "" && s.subject != ""
|
||||
}
|
||||
|
||||
// vapidSubscriber приводит BARE_VAPID_SUBJECT к форме, которую ждёт
|
||||
// webpush-go. Нормализация здесь не косметика: без неё пуши не уходят
|
||||
// вовсе, ни на одной платформе.
|
||||
//
|
||||
// По RFC 8292 поле sub в VAPID-токене — это URI: «mailto:<адрес>» или
|
||||
// «https://<хост>». Именно так субъект и записан в окружении
|
||||
// (docs/deploy.md), и менять запись нельзя — она верна. Но webpush-go
|
||||
// в getVAPIDAuthorizationHeader считает субъектом голый адрес и сам
|
||||
// приписывает схему всему, что не начинается с «https:»:
|
||||
//
|
||||
// if !strings.HasPrefix(subscriber, "https:") {
|
||||
// subscriber = "mailto:" + subscriber
|
||||
// }
|
||||
//
|
||||
// Готовый «mailto:admin@example.org» превращается в
|
||||
// «mailto:mailto:admin@example.org», и push-сервис отвергает токен:
|
||||
// APNs отвечает 403 BadJwtToken на каждый пуш. Обойти это настройкой
|
||||
// нельзя — голый адрес в sub тот же APNs тоже отвергает 403. Поэтому
|
||||
// схему снимаем ровно перед вызовом библиотеки: в токен она вернётся,
|
||||
// а конфигурация остаётся правильной по спецификации.
|
||||
//
|
||||
// «https:» отдаётся как есть: его библиотека узнаёт и не трогает.
|
||||
func vapidSubscriber(subject string) string {
|
||||
if strings.HasPrefix(subject, "https:") {
|
||||
return subject
|
||||
}
|
||||
return strings.TrimPrefix(subject, "mailto:")
|
||||
}
|
||||
|
||||
// Send ставит пуш каждому из устройств в очередь отправки и возвращается
|
||||
// сразу: конверт уже в очереди устройства, ответ на POST /api/messages
|
||||
// пуша не ждёт (ADR-023).
|
||||
@@ -289,8 +331,9 @@ func (s *Sender) deliver(j job) {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Тело ответа push-сервиса нам не нужно, но дочитать его стоит:
|
||||
// иначе соединение не переиспользуется.
|
||||
// Начало тела нужно ради кода причины (ADR-064), остаток дочитывается
|
||||
// в никуда: иначе соединение не переиспользуется.
|
||||
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxReasonBody))
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
switch {
|
||||
@@ -305,7 +348,7 @@ func (s *Sender) deliver(j job) {
|
||||
// Подписки больше нет — чистим мёртвую (ADR-011).
|
||||
s.drop(j.device)
|
||||
default:
|
||||
s.report("push-сервис ответил %d", resp.StatusCode)
|
||||
s.report("push-сервис ответил %s", status(resp.StatusCode, head))
|
||||
s.release(j.device)
|
||||
}
|
||||
}
|
||||
@@ -388,6 +431,59 @@ func (s *Sender) report(format string, args ...any) {
|
||||
fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// status — ответ push-сервиса для журнала: код и, если он разобран,
|
||||
// короткий код причины из тела (ADR-064).
|
||||
func status(code int, body []byte) string {
|
||||
if r := serviceReason(body); r != "" {
|
||||
return fmt.Sprintf("%d (%s)", code, r)
|
||||
}
|
||||
return fmt.Sprintf("%d", code)
|
||||
}
|
||||
|
||||
// serviceReason достаёт из тела ответа короткий код причины: APNs отвечает
|
||||
// {"reason":"BadJwtToken"}, Mozilla — {"errno":…,"error":"Not Found"}.
|
||||
// Код — диагностика вендора, а не данные пользователя, и без него отказ
|
||||
// не читается: голый «403» сутки выглядел как «что-то с пушами» (ADR-064).
|
||||
//
|
||||
// Тело всё же приходит снаружи, поэтому в журнал идёт не оно, а то, что
|
||||
// прошло safeReason.
|
||||
func serviceReason(body []byte) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &fields) == nil {
|
||||
for _, key := range []string{"reason", "error", "message"} {
|
||||
var s string
|
||||
if json.Unmarshal(fields[key], &s) == nil && s != "" {
|
||||
return safeReason(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeReason(string(body))
|
||||
}
|
||||
|
||||
// safeReason пропускает только то, что заведомо является кодом причины:
|
||||
// одна строка не длиннее maxReason из латиницы, цифр, «_», «-» и пробелов.
|
||||
// Точка, «:», «/» и «@» встречаются в адресах подписки и именах хостов,
|
||||
// поэтому строка с ними отбрасывается целиком — в журнале остаётся один
|
||||
// статус (docs/deploy.md, «Логи»).
|
||||
func safeReason(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
if s == "" || len(s) > maxReason {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == ' ', r == '_', r == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047).
|
||||
var errLocalAddress = errors.New("push: адрес не публичный")
|
||||
|
||||
|
||||
@@ -2,15 +2,22 @@ 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"
|
||||
)
|
||||
@@ -143,3 +150,246 @@ func TestReasonWithoutEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// В 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-064). Без кода 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-064, 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user