* Add push notifications support * fix(notifications): address push notification review findings - Gate the capability endpoint's apple_push availability on the admin delivery toggle, matching web push: Available now means setup will actually deliver. - Reject direct admin writes to push_relay_deployment_id/api_key; the relay issues them as a pair during registration and a lone write desyncs them (and poisons the next rotation request). - Purge a device's registrations under other profiles when it re-registers, so a profile switch on a shared device stops the old profile's pushes (attempts cascade); adds a DB-backed test. - Extract the shared channelDispatcher core + retry sweep and rebuild the webhook/web push/Apple push dispatchers on it instead of keeping three copies of the worker-pool/retry loop. - Deduplicate relay URL validation (admin setting + register flow) and the push outbox attempt-building loops behind shared helpers. - Cap free-text decline reasons in notification display bodies. - Fix TestHandleApplePushDisplayDB expectations to match the shared display copy (test previously failed under SILO_TEST_DATABASE_URL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(notifications): route push relay URL writes through registration only Direct writes to notifications.push_relay_url via the admin settings endpoint bypassed the relay registration flow, letting the stored URL drift out of sync with the deployment id / API key pair the relay minted for it. Reject the URL alongside the deployment id and API key in the settings handler; POST /admin/notifications/push/relay/register remains the only path that persists all three together. The admin UI's Relay URL field now edits local draft state and is applied by the Register/Rotate action instead of the settings save. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestApplePushDeliverySettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
settings := NewSettings(nil)
|
|
if settings.ApplePushDeliveryEnabled(ctx) {
|
|
t.Fatal("ApplePushDeliveryEnabled must default to false")
|
|
}
|
|
if got := settings.PushRelayURL(ctx); got != DefaultPushRelayURL {
|
|
t.Fatalf("PushRelayURL default = %q, want %q", got, DefaultPushRelayURL)
|
|
}
|
|
|
|
settings = NewSettings(mapSettingReader{
|
|
SettingApplePushDeliveryEnabled: "true",
|
|
SettingPushRelayURL: "https://push.example.test/",
|
|
SettingPushRelayAPIKey: " relay-key ",
|
|
})
|
|
if !settings.ApplePushDeliveryEnabled(ctx) {
|
|
t.Fatal("ApplePushDeliveryEnabled = false with setting on")
|
|
}
|
|
if got := settings.PushRelayURL(ctx); got != "https://push.example.test" {
|
|
t.Fatalf("PushRelayURL = %q", got)
|
|
}
|
|
if got := settings.PushRelayAPIKey(ctx); got != "relay-key" {
|
|
t.Fatalf("PushRelayAPIKey was not trimmed")
|
|
}
|
|
}
|
|
|
|
func TestPushSenderSendBuildsRelayRequest(t *testing.T) {
|
|
token := strings.Repeat("a", 64)
|
|
var got struct {
|
|
auth string
|
|
idempotencyKey string
|
|
body pushRelayAppleRequest
|
|
}
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.auth = r.Header.Get("Authorization")
|
|
got.idempotencyKey = r.Header.Get("Idempotency-Key")
|
|
if r.URL.Path != relayAppleSendPath {
|
|
t.Fatalf("path = %q, want %q", r.URL.Path, relayAppleSendPath)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&got.body); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(pushRelayAppleResponse{
|
|
RequestID: "relay-request-1",
|
|
APNsID: "apns-1",
|
|
Status: "accepted",
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
settings := NewSettings(mapSettingReader{
|
|
SettingPushRelayURL: server.URL,
|
|
SettingPushRelayAPIKey: "relay-key",
|
|
})
|
|
sender := newPushSender(nil, nil, nil, settings)
|
|
sender.client = server.Client()
|
|
|
|
deliveryID := "delivery-1"
|
|
result := sender.send(context.Background(), PushDeliveryAttempt{
|
|
ID: "attempt-1",
|
|
NotificationDeliveryID: &deliveryID,
|
|
AttemptNumber: 1,
|
|
}, &PushDevice{
|
|
APNsEnvironment: APNsEnvironmentSandbox,
|
|
APNsTopic: ApplePushTopicSilo,
|
|
ServerDeviceID: "server-device-1",
|
|
}, token)
|
|
|
|
if !result.OK || result.RelayRequestID != "relay-request-1" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if got.auth != "Bearer relay-key" {
|
|
t.Fatalf("Authorization = %q", got.auth)
|
|
}
|
|
if got.idempotencyKey != "attempt-1:2" {
|
|
t.Fatalf("Idempotency-Key = %q", got.idempotencyKey)
|
|
}
|
|
if got.body.Token != token || got.body.Mode != "private_alert" || got.body.DeliveryID != deliveryID {
|
|
t.Fatalf("relay body = %+v", got.body)
|
|
}
|
|
if got.body.CollapseID == nil || *got.body.CollapseID != deliveryID {
|
|
t.Fatalf("collapse_id = %+v, want delivery id", got.body.CollapseID)
|
|
}
|
|
}
|
|
|
|
func TestPushSenderSendMapsRelayTerminalAPNsRejection(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = json.NewEncoder(w).Encode(pushRelayErrorResponse{
|
|
Error: struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
}{
|
|
Code: "apns_rejected",
|
|
Message: "APNs rejected the notification: BadDeviceToken",
|
|
RequestID: "relay-request-2",
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
settings := NewSettings(mapSettingReader{
|
|
SettingPushRelayURL: server.URL,
|
|
SettingPushRelayAPIKey: "relay-key",
|
|
})
|
|
sender := newPushSender(nil, nil, nil, settings)
|
|
sender.client = server.Client()
|
|
|
|
result := sender.send(context.Background(), PushDeliveryAttempt{ID: "attempt-1"}, &PushDevice{
|
|
APNsEnvironment: APNsEnvironmentSandbox,
|
|
APNsTopic: ApplePushTopicSilo,
|
|
ServerDeviceID: "server-device-1",
|
|
}, strings.Repeat("a", 64))
|
|
|
|
if result.OK || !result.TerminalDevice || result.HTTPStatus != http.StatusUnprocessableEntity {
|
|
t.Fatalf("terminal result = %+v", result)
|
|
}
|
|
if result.UpstreamReason != "apns_rejected" || result.RelayRequestID != "relay-request-2" {
|
|
t.Fatalf("terminal diagnostic = %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestPushSenderSendMapsRelayRetryAfter(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "30")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_ = json.NewEncoder(w).Encode(pushRelayErrorResponse{
|
|
Error: struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
}{
|
|
Code: "upstream_rate_limited",
|
|
Message: "APNs upstream rate limited the request",
|
|
RequestID: "relay-request-3",
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
settings := NewSettings(mapSettingReader{
|
|
SettingPushRelayURL: server.URL,
|
|
SettingPushRelayAPIKey: "relay-key",
|
|
})
|
|
sender := newPushSender(nil, nil, nil, settings)
|
|
sender.client = server.Client()
|
|
|
|
result := sender.send(context.Background(), PushDeliveryAttempt{ID: "attempt-1"}, &PushDevice{
|
|
APNsEnvironment: APNsEnvironmentSandbox,
|
|
APNsTopic: ApplePushTopicSilo,
|
|
ServerDeviceID: "server-device-1",
|
|
}, strings.Repeat("a", 64))
|
|
|
|
if result.OK || result.TerminalDevice || result.RetryAfter == 0 {
|
|
t.Fatalf("retryable result = %+v", result)
|
|
}
|
|
}
|