Files
silo-server/internal/api/handlers/notifications_push_devices_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

153 lines
4.4 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/secret"
)
type handlerPushStore struct {
got notifications.ApplePushDeviceRegistration
calls int
device *notifications.PushDevice
err error
}
func (f *handlerPushStore) UpsertApple(ctx context.Context, registration notifications.ApplePushDeviceRegistration, cipher *secret.Cipher) (*notifications.PushDevice, error) {
f.calls++
f.got = registration
if f.err != nil {
return nil, f.err
}
if f.device != nil {
return f.device, nil
}
return &notifications.PushDevice{
ID: "push-row",
ServerDeviceID: "server-device",
Enabled: true,
PushMode: registration.PushMode,
}, nil
}
func handlerPushCipher(t *testing.T) *secret.Cipher {
t.Helper()
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatalf("new cipher: %v", err)
}
return cipher
}
func newApplePushRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/devices/push/apple", strings.NewReader(body))
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 42, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func TestHandleRegisterApplePushDevice(t *testing.T) {
store := &handlerPushStore{}
handler := NewNotificationsHandler(&notifications.System{
PushDevices: notifications.NewPushDeviceService(store, handlerPushCipher(t)),
}, nil)
body := `{
"device_id":"local-device",
"apns_token":"` + strings.Repeat("a", 64) + `",
"apns_environment":"production",
"apns_topic":"org.siloserver.silo",
"push_mode":"private_push"
}`
rr := httptest.NewRecorder()
handler.HandleRegisterApplePushDevice(rr, newApplePushRequest(body))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var response applePushRegisterResponse
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ID != "push-row" || response.ServerDeviceID != "server-device" || !response.Enabled || response.PushMode != notifications.PushModePrivatePush {
t.Fatalf("unexpected response: %+v", response)
}
if store.calls != 1 {
t.Fatalf("store calls = %d, want 1", store.calls)
}
if store.got.UserID != 42 || store.got.ProfileID != "profile-1" || store.got.DeviceID != "local-device" {
t.Fatalf("unexpected stored registration: %+v", store.got)
}
}
func TestHandleRegisterApplePushDeviceErrors(t *testing.T) {
tests := []struct {
name string
system *notifications.System
body string
wantStatus int
}{
{
name: "service unavailable",
system: &notifications.System{},
body: `{}`,
wantStatus: http.StatusServiceUnavailable,
},
{
name: "invalid json",
system: &notifications.System{
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
},
body: `{`,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid field",
system: &notifications.System{
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
},
body: `{
"device_id":"local-device",
"apns_token":"abcd",
"apns_environment":"production",
"apns_topic":"org.siloserver.silo",
"push_mode":"private_push"
}`,
wantStatus: http.StatusBadRequest,
},
{
name: "unsupported topic",
system: &notifications.System{
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
},
body: `{
"device_id":"local-device",
"apns_token":"` + strings.Repeat("a", 64) + `",
"apns_environment":"production",
"apns_topic":"com.example.app",
"push_mode":"private_push"
}`,
wantStatus: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewNotificationsHandler(tt.system, nil)
rr := httptest.NewRecorder()
handler.HandleRegisterApplePushDevice(rr, newApplePushRequest(tt.body))
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body = %s", rr.Code, tt.wantStatus, rr.Body.String())
}
})
}
}