package apns import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/pem" "errors" "net/http" "net/http/httptest" "strings" "testing" ) // testKeyPEM returns a throwaway P-256 key as PKCS8 PEM, the shape apns.New // expects for a real .p8. func testKeyPEM(t *testing.T) string { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } der, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { t.Fatal(err) } return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) } func newTestClient(t *testing.T, stubURL string) *Client { t.Helper() c, err := New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app") if err != nil { t.Fatalf("New: %v", err) } c.SetHosts(stubURL, stubURL) return c } func TestValidDeviceToken(t *testing.T) { ok := []string{"a", "AABBCCDD", strings.Repeat("f", 200)} for _, s := range ok { if !ValidDeviceToken(s) { t.Errorf("expected %q valid", s) } } bad := []string{"", "xyz", "aa/bb", "aa..bb", "aa\r\nHost:", strings.Repeat("a", 201), "zz"} for _, s := range bad { if ValidDeviceToken(s) { t.Errorf("expected %q invalid", s) } } } // TestBuildPayloadStable pins the aps envelope byte-for-byte. The iOS // Notification Service Extension mirrors these exact keys; if this changes, // on-device decryption breaks — so a drift must be a deliberate, reviewed edit. func TestBuildPayloadStable(t *testing.T) { got := string(BuildPayload("t1", "ciphertext-blob", 0, 0)) want := `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"blob":"ciphertext-blob","thread":"t1"}` if got != want { t.Errorf("envelope drift:\n got: %s\nwant: %s", got, want) } // With a message id/created_at the payload carries them (the NSE's local // store ingest); id 0 (test pushes, old relays) must omit both. got = string(BuildPayload("t1", "ciphertext-blob", 42, 1700000000)) want = `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"at":1700000000,"blob":"ciphertext-blob","id":42,"thread":"t1"}` if got != want { t.Errorf("envelope drift (id/at):\n got: %s\nwant: %s", got, want) } } func TestSendClassifiesResponses(t *testing.T) { const ( live = "aaaaaaaa" dead = "deadbeef" bad = "badd0000" err5 = "cccccccc" ) stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, dead): w.WriteHeader(http.StatusGone) case strings.Contains(r.URL.Path, bad): w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"reason":"BadDeviceToken"}`)) case strings.Contains(r.URL.Path, err5): w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"reason":"InternalServerError"}`)) default: w.WriteHeader(http.StatusOK) } })) defer stub.Close() c := newTestClient(t, stub.URL) if err := c.Send(live, false, "th", "blob", 1, 1); err != nil { t.Errorf("live token: want nil, got %v", err) } if err := c.Send(dead, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) { t.Errorf("410: want ErrGone, got %v", err) } if err := c.Send(bad, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) { t.Errorf("BadDeviceToken: want ErrGone, got %v", err) } if err := c.Send(err5, false, "th", "blob", 1, 1); err == nil || errors.Is(err, ErrGone) { t.Errorf("500: want a non-gone error, got %v", err) } } // A non-hex token must be rejected before it can reach the URL path, and must // never be classified as gone. func TestSendRejectsBadToken(t *testing.T) { reached := false stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) defer stub.Close() c := newTestClient(t, stub.URL) err := c.Send("aa/../bb", false, "th", "blob", 1, 1) if err == nil || errors.Is(err, ErrGone) { t.Errorf("bad token: want a non-gone error, got %v", err) } if reached { t.Error("bad token reached the upstream server — validation is too late") } } func TestBuildAlertPayloadStable(t *testing.T) { // The plaintext changelog banner (announce.go): no mutable-content (the NSE // never runs — the words are already readable), a `changelog` marker for tap // routing. Pinned like BuildPayload so the messenger and relay can't drift. got := string(BuildAlertPayload("What's new in Test Space", "Updates take seconds.")) want := `{"aps":{"alert":{"body":"Updates take seconds.","title":"What's new in Test Space"},"sound":"default"},"changelog":true}` if got != want { t.Errorf("alert envelope drift:\n got: %s\nwant: %s", got, want) } }