* 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>
153 lines
4.4 KiB
Go
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 ¬ifications.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(¬ifications.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: ¬ifications.System{},
|
|
body: `{}`,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
system: ¬ifications.System{
|
|
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
|
|
},
|
|
body: `{`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid field",
|
|
system: ¬ifications.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: ¬ifications.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())
|
|
}
|
|
})
|
|
}
|
|
}
|