Extend the push pipeline to Android devices through the Silo push relay's /v1/fcm/send endpoint. push_devices gains platform-conditional FCM token columns (encrypted at rest with row AAD, hashed like APNs tokens), the generic POST /notifications/push/devices endpoint the Android client already calls registers FCM tokens, and fanout, operational dispatch, retries, and terminal UNREGISTERED device disabling all reuse the existing Apple machinery. Delivery is gated by a new notifications.android_push_delivery_enabled setting, advertised through the capability endpoint's android_push block, and testable via POST /admin/notifications/push/fcm/test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
240 lines
7.6 KiB
Go
240 lines
7.6 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
|
|
gotFCM notifications.FCMPushDeviceRegistration
|
|
calls int
|
|
device *notifications.PushDevice
|
|
err error
|
|
deleted []string
|
|
}
|
|
|
|
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 (f *handlerPushStore) UpsertFCM(ctx context.Context, registration notifications.FCMPushDeviceRegistration, cipher *secret.Cipher) (*notifications.PushDevice, error) {
|
|
f.calls++
|
|
f.gotFCM = registration
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if f.device != nil {
|
|
return f.device, nil
|
|
}
|
|
return ¬ifications.PushDevice{
|
|
ID: "push-row-android",
|
|
Platform: notifications.PushPlatformAndroid,
|
|
ServerDeviceID: "server-device-android",
|
|
Enabled: true,
|
|
PushMode: registration.PushMode,
|
|
}, nil
|
|
}
|
|
|
|
func (f *handlerPushStore) DeleteByProfileDevice(ctx context.Context, profileID, deviceID string) error {
|
|
f.deleted = append(f.deleted, profileID+"/"+deviceID)
|
|
return f.err
|
|
}
|
|
|
|
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 newPushDevicesRequest(method, target, body string) *http.Request {
|
|
req := httptest.NewRequest(method, target, 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 TestHandleRegisterPushDeviceAndroid(t *testing.T) {
|
|
store := &handlerPushStore{}
|
|
handler := NewNotificationsHandler(¬ifications.System{
|
|
PushDevices: notifications.NewPushDeviceService(store, handlerPushCipher(t)),
|
|
}, nil)
|
|
|
|
token := strings.Repeat("F", 140)
|
|
body := `{
|
|
"platform":"android",
|
|
"token":"` + token + `",
|
|
"device_id":"local-device",
|
|
"push_mode":"private_push"
|
|
}`
|
|
rr := httptest.NewRecorder()
|
|
handler.HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost, "/api/v1/notifications/push/devices", 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-android" || response.PushMode != notifications.PushModePrivatePush {
|
|
t.Fatalf("unexpected response: %+v", response)
|
|
}
|
|
if store.gotFCM.UserID != 42 || store.gotFCM.ProfileID != "profile-1" || store.gotFCM.FCMToken != token {
|
|
t.Fatalf("unexpected stored registration: %+v", store.gotFCM)
|
|
}
|
|
}
|
|
|
|
func TestHandleRegisterPushDeviceRejectsUnknownPlatformAndBadToken(t *testing.T) {
|
|
newHandler := func() *NotificationsHandler {
|
|
return NewNotificationsHandler(¬ifications.System{
|
|
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
|
|
}, nil)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
newHandler().HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost,
|
|
"/api/v1/notifications/push/devices",
|
|
`{"platform":"apple","token":"`+strings.Repeat("F", 140)+`","device_id":"local-device"}`))
|
|
if rr.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("apple platform status = %d, want 422", rr.Code)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
newHandler().HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost,
|
|
"/api/v1/notifications/push/devices",
|
|
`{"platform":"android","token":"short","device_id":"local-device"}`))
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad token status = %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
})
|
|
}
|
|
}
|