* 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>
124 lines
4.5 KiB
Go
124 lines
4.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
)
|
|
|
|
func TestAdminApplePushHandlerUnavailableWithoutSystem(t *testing.T) {
|
|
h := NewAdminApplePushHandler(nil, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleTest(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/apple/test", strings.NewReader(`{}`)))
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("HandleTest without system = %d, want 503", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminApplePushHandlerRejectsInvalidJSON(t *testing.T) {
|
|
h := NewAdminApplePushHandler(¬ifications.System{}, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleTest(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/apple/test", strings.NewReader(`{`)))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("HandleTest invalid JSON = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminApplePushHandlerRegistersRelayAndStoresKey(t *testing.T) {
|
|
settings := &fakeServerSettingsStore{values: map[string]string{
|
|
notifications.SettingPushRelayDeploymentID: "01EXISTING",
|
|
}}
|
|
var relayReq map[string]string
|
|
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/deployments/register" {
|
|
t.Fatalf("relay path = %s", r.URL.Path)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&relayReq); err != nil {
|
|
t.Fatalf("decode relay request: %v", err)
|
|
}
|
|
writeJSON(w, http.StatusOK, pushRelayRegisterResponse{
|
|
RequestID: "relay-request",
|
|
DeploymentID: "01RETURNED",
|
|
APIKey: "rk_live_raw-key",
|
|
KeyPrefix: "rk_live_raw",
|
|
APNsTopics: []string{"org.siloserver.silo"},
|
|
})
|
|
}))
|
|
t.Cleanup(relay.Close)
|
|
|
|
h := NewAdminApplePushHandler(¬ifications.System{Settings: notifications.NewSettings(settings)}, settings)
|
|
h.client = relay.Client()
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
|
|
"relay_url":"`+relay.URL+`"
|
|
}`)))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("http relay URL status = %d, want 400 because HTTPS is required", rec.Code)
|
|
}
|
|
|
|
httpsRelay := httptest.NewTLSServer(relay.Config.Handler)
|
|
t.Cleanup(httpsRelay.Close)
|
|
h.client = httpsRelay.Client()
|
|
rec = httptest.NewRecorder()
|
|
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
|
|
"relay_url":"`+httpsRelay.URL+`"
|
|
}`)))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d (%s), want 200", rec.Code, rec.Body.String())
|
|
}
|
|
if relayReq["deployment_id"] != "01EXISTING" || len(relayReq) != 1 {
|
|
t.Fatalf("relay request = %+v", relayReq)
|
|
}
|
|
if settings.values[notifications.SettingPushRelayURL] != httpsRelay.URL {
|
|
t.Fatalf("stored relay URL = %q", settings.values[notifications.SettingPushRelayURL])
|
|
}
|
|
if settings.values[notifications.SettingPushRelayDeploymentID] != "01RETURNED" {
|
|
t.Fatalf("stored deployment id = %q", settings.values[notifications.SettingPushRelayDeploymentID])
|
|
}
|
|
if settings.values[notifications.SettingPushRelayAPIKey] != "rk_live_raw-key" {
|
|
t.Fatalf("stored api key = %q", settings.values[notifications.SettingPushRelayAPIKey])
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if _, ok := body["api_key"]; ok {
|
|
t.Fatal("response leaked raw api_key")
|
|
}
|
|
if body["api_key_configured"] != true || body["deployment_id"] != "01RETURNED" {
|
|
t.Fatalf("response = %+v", body)
|
|
}
|
|
}
|
|
|
|
func TestAdminApplePushHandlerMapsRelayRateLimit(t *testing.T) {
|
|
settings := &fakeServerSettingsStore{values: map[string]string{}}
|
|
relay := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
|
"error": map[string]string{"code": "rate_limited", "message": "too many deployment registrations from this network"},
|
|
})
|
|
}))
|
|
t.Cleanup(relay.Close)
|
|
|
|
h := NewAdminApplePushHandler(¬ifications.System{}, settings)
|
|
h.client = relay.Client()
|
|
rec := httptest.NewRecorder()
|
|
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
|
|
"relay_url":"`+relay.URL+`"
|
|
}`)))
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d (%s), want 429", rec.Code, rec.Body.String())
|
|
}
|
|
if settings.values[notifications.SettingPushRelayAPIKey] != "" {
|
|
t.Fatal("api key was stored after relay rejected registration")
|
|
}
|
|
}
|