Files
silo-server/internal/notifications/webpush_logic_test.go
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

166 lines
5.0 KiB
Go

package notifications
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
)
func TestBuildWebPushPayload(t *testing.T) {
t.Run("episode available", func(t *testing.T) {
raw, err := buildWebPushPayload(webhookTestRow(), "https://cdn.example.com/poster.jpg")
if err != nil {
t.Fatalf("build failed: %v", err)
}
var payload webPushPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload.Title != "The latest episode of Severance S02E01 just dropped!" {
t.Fatalf("unexpected title %q", payload.Title)
}
if payload.Body != "Hello, Ms. Cobel" {
t.Fatalf("unexpected body %q", payload.Body)
}
if payload.URL != "/item/episode-456" {
t.Fatalf("unexpected url %q", payload.URL)
}
if payload.Icon != "https://cdn.example.com/poster.jpg" || payload.DeliveryID != "01DELIVERY" {
t.Fatalf("unexpected payload: %+v", payload)
}
})
t.Run("request fulfilled deep-links to the matched item", func(t *testing.T) {
raw, err := buildWebPushPayload(requestFulfilledTestRow(), "https://cdn.example.com/poster.jpg")
if err != nil {
t.Fatal(err)
}
var payload webPushPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload.Title != "Dune is now available" {
t.Fatalf("unexpected title %q", payload.Title)
}
if payload.URL != "/item/movie-123" {
t.Fatalf("unexpected url %q", payload.URL)
}
if payload.Icon != "https://cdn.example.com/poster.jpg" {
t.Fatalf("unexpected icon %q", payload.Icon)
}
})
t.Run("webhook auto-disable routes to settings", func(t *testing.T) {
row := DeliveryRow{Delivery: Delivery{ID: "01X", Type: DeliveryTypeWebhookAutoDisabled}}
raw, err := buildWebPushPayload(row, "")
if err != nil {
t.Fatal(err)
}
var payload webPushPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload.URL != "/settings/notifications" {
t.Fatalf("unexpected url %q", payload.URL)
}
})
t.Run("unknown types render generically", func(t *testing.T) {
row := DeliveryRow{Delivery: Delivery{ID: "01Y", Type: "future.type"}}
raw, err := buildWebPushPayload(row, "")
if err != nil {
t.Fatal(err)
}
var payload webPushPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload.Title != "Silo notification" || payload.URL != "/notifications" {
t.Fatalf("unexpected payload: %+v", payload)
}
})
}
func TestBuildNotificationDisplay(t *testing.T) {
t.Run("episode available", func(t *testing.T) {
display := BuildNotificationDisplay(webhookTestRow())
if display.DeliveryID != "01DELIVERY" ||
display.Title != "The latest episode of Severance S02E01 just dropped!" ||
display.Body != "Hello, Ms. Cobel" ||
display.ThreadID != "series:series-123" ||
display.Category != "episode_available" ||
display.URL != "/item/episode-456" {
t.Fatalf("display = %+v", display)
}
})
t.Run("request declined", func(t *testing.T) {
row := requestDeclinedTestRow()
display := BuildNotificationDisplay(row)
if display.Title != "Dune was declined" ||
display.Body != "Reason: Already available in 4K" ||
display.ThreadID != "request:01REQ" ||
display.Category != "request_declined" ||
display.URL != "/notifications" {
t.Fatalf("display = %+v", display)
}
})
t.Run("unknown type", func(t *testing.T) {
display := BuildNotificationDisplay(DeliveryRow{Delivery: Delivery{ID: "01X", Type: "future.type"}})
if display.Title != genericNotificationTitle ||
display.Category != "future_type" ||
display.URL != "/notifications" {
t.Fatalf("display = %+v", display)
}
})
}
func TestWebPushRetrySchedule(t *testing.T) {
total := time.Duration(0)
for attempt := 1; attempt < webPushMaxAttempts; attempt++ {
delay, ok := webPushRetryDelay(attempt)
if !ok {
t.Fatalf("schedule ended early at attempt %d", attempt)
}
total += delay
}
if total != 30*time.Minute {
t.Fatalf("schedule must span 30m, got %v", total)
}
if _, ok := webPushRetryDelay(webPushMaxAttempts); ok {
t.Fatal("attempt 5 must exhaust the schedule")
}
}
func TestWebPushSubscribeValidation(t *testing.T) {
// Validation failures reject before any repository access, so a nil repo
// is safe here.
service := newWebPushService(nil, NewSettings(nil), nil)
ctx := context.Background()
cases := []struct {
name string
endpoint string
p256dh string
auth string
}{
{"missing endpoint", "", "k", "a"},
{"missing keys", "https://push.example.com/x", "", ""},
{"plain http", "http://push.example.com/x", "k", "a"},
{"loopback", "https://127.0.0.1/x", "k", "a"},
{"v4-mapped loopback", "https://[::ffff:127.0.0.1]/x", "k", "a"},
{"private network", "https://192.168.1.10/x", "k", "a"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := service.Subscribe(ctx, 1, "p1", tc.endpoint, tc.p256dh, tc.auth, "test")
if !errors.Is(err, ErrWebPushInvalid) {
t.Fatalf("Subscribe = %v, want ErrWebPushInvalid", err)
}
})
}
}