package httpapi import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/json" "encoding/pem" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "commilitia.net/cdrop/internal/apns" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/push" ) // apnsTestKeyPEM generates an ephemeral P-256 key and returns its PEM bytes so // tests can build an enabled apns.Sender without real Apple credentials. func apnsTestKeyPEM(t *testing.T) []byte { 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) } return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) } // newAPNsTestServer builds a minimal Server with a real in-memory SQLite DB // and an enabled apns.Sender (using an ephemeral test key). The apns.Sender // base is left at the default; the endpoint test only exercises DB writes, so // the Sender never fires a real APNs request. func newAPNsTestServer(t *testing.T) *Server { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "push_test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.Bootstrap(context.Background(), conn); err != nil { t.Fatalf("bootstrap: %v", err) } q := db.New(conn) apnsSender := apns.NewSender(q, apnsTestKeyPEM(t), "TESTKID", "TESTTEAM", "com.example.cdrop", "sandbox") if !apnsSender.Enabled() { t.Fatal("apns sender must be enabled for this test") } return &Server{ queries: q, hub: hub.New(q), apns: apnsSender, } } // TestHandleAPNsRegister_DisabledReturns503 verifies that the register endpoint // returns 503 when APNs is not configured (inert Sender). func TestHandleAPNsRegister_DisabledReturns503(t *testing.T) { conn, err := db.Open(filepath.Join(t.TempDir(), "push_test2.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.Bootstrap(context.Background(), conn); err != nil { t.Fatalf("bootstrap: %v", err) } q := db.New(conn) s := &Server{ queries: q, hub: hub.New(q), apns: apns.NewSender(nil, nil, "", "", "", ""), // inert } body := `{"device_token":"aabbccdd1234"}` r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"})) r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone")) w := httptest.NewRecorder() s.handleAPNsRegister(w, r) if w.Code != http.StatusServiceUnavailable { t.Errorf("disabled: got %d, want 503", w.Code) } } // TestHandleAPNsRegister_StoresRowIdempotently verifies that POST /api/push/apns/register // stores the subscription row and that a second call with the same token upserts // in place (idempotent: still exactly one row). func TestHandleAPNsRegister_StoresRowIdempotently(t *testing.T) { s := newAPNsTestServer(t) const deviceToken = "aabbccdd1234deadbeef" const locale = "en-US" callRegister := func() int { body := `{"device_token":"` + deviceToken + `","locale":"` + locale + `"}` r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"})) r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone")) w := httptest.NewRecorder() s.handleAPNsRegister(w, r) return w.Code } // First registration. if code := callRegister(); code != http.StatusNoContent { t.Fatalf("first register: got %d, want 204", code) } // Verify the row exists. subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "iPhone", }) if err != nil { t.Fatalf("list subs: %v", err) } if len(subs) != 1 { t.Fatalf("after first register: got %d rows, want 1", len(subs)) } sub := subs[0] if sub.Endpoint != deviceToken { t.Errorf("endpoint = %q, want %q", sub.Endpoint, deviceToken) } if sub.Locale != locale { t.Errorf("locale = %q, want %q", sub.Locale, locale) } if sub.Platform != "apns" { t.Errorf("platform = %q, want apns", sub.Platform) } if sub.UserID != "u" { t.Errorf("user_id = %q, want u", sub.UserID) } expectedID := push.SubscriptionID(deviceToken) if sub.ID != expectedID { t.Errorf("id = %q, want %q (SHA-256 of token)", sub.ID, expectedID) } // Second registration with same token: must upsert in place. if code := callRegister(); code != http.StatusNoContent { t.Fatalf("second register: got %d, want 204", code) } subs2, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "iPhone", }) if err != nil { t.Fatalf("list subs after second register: %v", err) } if len(subs2) != 1 { t.Errorf("after second register: got %d rows, want 1 (should upsert not duplicate)", len(subs2)) } } // TestHandleAPNsRegister_MissingToken verifies that a missing device_token // returns 400. func TestHandleAPNsRegister_MissingToken(t *testing.T) { s := newAPNsTestServer(t) r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(`{}`)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"})) r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone")) w := httptest.NewRecorder() s.handleAPNsRegister(w, r) if w.Code != http.StatusBadRequest { t.Errorf("missing token: got %d, want 400", w.Code) } var resp map[string]string if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil { if resp["error"] != "missing device_token" { t.Errorf("error msg = %q", resp["error"]) } } } // TestHandleAPNsRegister_UnknownLocaleFallsBack verifies that an unrecognized // locale falls back to the server default (zh-CN). func TestHandleAPNsRegister_UnknownLocaleFallsBack(t *testing.T) { s := newAPNsTestServer(t) body := `{"device_token":"tok123456","locale":"fr-FR"}` r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"})) r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone")) w := httptest.NewRecorder() s.handleAPNsRegister(w, r) if w.Code != http.StatusNoContent { t.Fatalf("register: got %d, want 204", w.Code) } subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: "u", DeviceName: "iPhone", }) if err != nil { t.Fatalf("list: %v", err) } if len(subs) != 1 { t.Fatalf("want 1 row, got %d", len(subs)) } if subs[0].Locale != "zh-CN" { t.Errorf("locale = %q, want zh-CN (fallback)", subs[0].Locale) } }