package apns import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/json" "encoding/pem" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/golang-jwt/jwt/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/push" ) // generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded // PKCS8 bytes alongside the typed private key (for verification). func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } der, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { t.Fatalf("marshal pkcs8: %v", err) } pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) return pemBytes, priv } // openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a // *db.Queries backed by that DB. func openTestDB(t *testing.T) *db.Queries { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db")) if err != nil { t.Fatalf("open test db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.Bootstrap(context.Background(), conn); err != nil { t.Fatalf("bootstrap: %v", err) } return db.New(conn) } // TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing // config field is inert: Enabled reports false and Notify is a safe no-op. func TestNewSenderInertWhenUnconfigured(t *testing.T) { keyPEM, _ := generateTestKey(t) cases := []struct { name string keyPEM []byte keyID, teamID, topic string }{ {"no key", nil, "KID", "TID", "topic"}, {"no keyID", keyPEM, "", "TID", "topic"}, {"no teamID", keyPEM, "KID", "", "topic"}, {"no topic", keyPEM, "KID", "TID", ""}, {"all empty", nil, "", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox") if s.Enabled() { t.Fatal("inert Sender should report Enabled=false") } // Notify must be a safe no-op with nil queries. s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage}) }) } } // TestNilSenderIsSafe verifies that a nil *Sender is safe to call. func TestNilSenderIsSafe(t *testing.T) { var s *Sender if s.Enabled() { t.Fatal("nil sender should report disabled") } s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic } // TestProviderJWT verifies the JWT structure and ES256 signature. func TestProviderJWT(t *testing.T) { const keyID = "TESTKEYID1" const teamID = "TEAMID1234" keyPEM, priv := generateTestKey(t) s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox") if !s.Enabled() { t.Fatal("sender should be enabled with a valid key") } jwtStr, err := s.providerJWT() if err != nil { t.Fatalf("providerJWT: %v", err) } if jwtStr == "" { t.Fatal("providerJWT returned empty string") } // Parse without verification first to inspect headers. tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) { return &priv.PublicKey, nil }) if err != nil { t.Fatalf("parse jwt: %v", err) } if !tok.Valid { t.Fatal("jwt should be valid") } // Header checks. alg, _ := tok.Header["alg"].(string) if alg != "ES256" { t.Errorf("header alg = %q, want ES256", alg) } kid, _ := tok.Header["kid"].(string) if kid != keyID { t.Errorf("header kid = %q, want %q", kid, keyID) } // Claims checks. claims, ok := tok.Claims.(jwt.MapClaims) if !ok { t.Fatal("claims not MapClaims") } iss, _ := claims["iss"].(string) if iss != teamID { t.Errorf("claim iss = %q, want %q", iss, teamID) } iat, ok := claims["iat"] if !ok { t.Error("claim iat missing") } // iat should be a recent unix timestamp (within the last minute). iatF, _ := iat.(float64) iatTime := time.Unix(int64(iatF), 0) if time.Since(iatTime) > time.Minute { t.Errorf("claim iat too old: %v", iatTime) } } // TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the // same token string, and that forcing the cache stale mints a new one. func TestJWTIsCached(t *testing.T) { keyPEM, _ := generateTestKey(t) s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox") first, err := s.providerJWT() if err != nil { t.Fatalf("first jwt: %v", err) } second, err := s.providerJWT() if err != nil { t.Fatalf("second jwt: %v", err) } if first != second { t.Error("providerJWT should return the cached token within refresh window") } // Expire the cache. s.mu.Lock() s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second)) s.mu.Unlock() third, err := s.providerJWT() if err != nil { t.Fatalf("third jwt: %v", err) } if third == first { t.Error("providerJWT should mint a new token after refresh window expires") } } // apnsServerRecorder is a minimal httptest server that records the last request // and responds with a configurable status. type apnsServerRecorder struct { srv *httptest.Server lastPath string lastAuth string lastTopic string lastPushType string lastBody []byte respondWith int } func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder { t.Helper() rec := &apnsServerRecorder{respondWith: respondWith} rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.lastPath = r.URL.Path rec.lastAuth = r.Header.Get("Authorization") rec.lastTopic = r.Header.Get("apns-topic") rec.lastPushType = r.Header.Get("apns-push-type") rec.lastBody, _ = io.ReadAll(r.Body) w.WriteHeader(rec.respondWith) })) t.Cleanup(rec.srv.Close) return rec } // TestSendRequest asserts that Notify fires a POST with the correct path and // headers against the APNs base URL, and that the JSON body has the expected shape. func TestSendRequest(t *testing.T) { const deviceToken = "abc123deadbeef" const topic = "com.example.cdrop" q := openTestDB(t) keyPEM, _ := generateTestKey(t) rec := newAPNsTestServer(t, http.StatusOK) s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox") if !s.Enabled() { t.Fatal("sender must be enabled") } // Point the sender at the test server instead of the real APNs endpoint. s.base = rec.srv.URL // Insert an APNs subscription row for user "u", device "d". now := time.Now().Unix() if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{ ID: push.SubscriptionID(deviceToken), UserID: "u", DeviceName: "d", Endpoint: deviceToken, Locale: "en-US", CreatedAt: now, LastUsedAt: now, }); err != nil { t.Fatalf("seed subscription: %v", err) } s.Notify(context.Background(), "u", "d", push.Notification{ Type: push.KindTransferIncoming, Params: map[string]string{"sender": "Mac", "filename": "a.pdf"}, Tag: "transfer:1", URL: "/transfers", }) // Path must be /3/device/{token}. wantPath := "/3/device/" + deviceToken if rec.lastPath != wantPath { t.Errorf("path = %q, want %q", rec.lastPath, wantPath) } // Authorization header must start with "bearer ". if !strings.HasPrefix(rec.lastAuth, "bearer ") { t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth) } // APNs headers. if rec.lastTopic != topic { t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic) } if rec.lastPushType != "alert" { t.Errorf("apns-push-type = %q, want alert", rec.lastPushType) } // JSON body shape. var body apsPayload if err := json.Unmarshal(rec.lastBody, &body); err != nil { t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody) } if body.Type != push.KindTransferIncoming { t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming) } if body.APS.Alert.Title != "Incoming file" { t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title) } if !strings.Contains(body.APS.Alert.Body, "a.pdf") { t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body) } if body.URL != "/transfers" { t.Errorf("body.url = %q, want /transfers", body.URL) } if body.APS.Sound != "default" { t.Errorf("body.aps.sound = %q, want default", body.APS.Sound) } } // TestSend410PrunesRow verifies that an HTTP 410 response from the APNs // endpoint causes the subscription row to be deleted from the database. func TestSend410PrunesRow(t *testing.T) { const deviceToken = "deadbeef410token" q := openTestDB(t) keyPEM, _ := generateTestKey(t) rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox") s.base = rec.srv.URL now := time.Now().Unix() subID := push.SubscriptionID(deviceToken) if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{ ID: subID, UserID: "u", DeviceName: "d", Endpoint: deviceToken, Locale: "zh-CN", CreatedAt: now, LastUsedAt: now, }); err != nil { t.Fatalf("seed subscription: %v", err) } s.Notify(context.Background(), "u", "d", push.Notification{ Type: push.KindMessage, Params: map[string]string{ "sender": "Phone", "text": "hello", }, }) // The row must be gone. subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "d", }) if err != nil { t.Fatalf("list subs: %v", err) } if len(subs) != 0 { t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs)) } } // TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are // invisible to ListAPNsSubscriptionsByUserDevice and vice versa. func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) { q := openTestDB(t) now := time.Now().Unix() // Insert a webpush row. const webEndpoint = "https://fcm.googleapis.com/fake" if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{ ID: push.SubscriptionID(webEndpoint), UserID: "u", DeviceName: "d", Endpoint: webEndpoint, P256dh: "p", Auth: "a", UserAgent: "", Locale: "en-US", CreatedAt: now, LastUsedAt: now, }); err != nil { t.Fatalf("upsert webpush: %v", err) } // APNs query must not see the webpush row. apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "d", }) if err != nil { t.Fatalf("list apns: %v", err) } if len(apnsSubs) != 0 { t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs)) } // WebPush query must not see apns rows. const apnsToken = "aabbccdd1234" if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{ ID: push.SubscriptionID(apnsToken), UserID: "u", DeviceName: "d", Endpoint: apnsToken, Locale: "zh-CN", CreatedAt: now, LastUsedAt: now, }); err != nil { t.Fatalf("upsert apns: %v", err) } webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "d", }) if err != nil { t.Fatalf("list webpush: %v", err) } if len(webSubs) != 1 { t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs)) } }