Files
silo-server/internal/notifications/push_sender_test.go
T
cf0db385f3 Add Apple push notifications support (#255)
* 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>
2026-07-01 17:25:16 -04:00

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)
}
}