| 1 | package apns |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ecdsa" |
| 5 | "crypto/elliptic" |
| 6 | "crypto/rand" |
| 7 | "crypto/x509" |
| 8 | "encoding/pem" |
| 9 | "errors" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | ) |
| 15 | |
| 16 | // testKeyPEM returns a throwaway P-256 key as PKCS8 PEM, the shape apns.New |
| 17 | // expects for a real .p8. |
| 18 | func testKeyPEM(t *testing.T) string { |
| 19 | t.Helper() |
| 20 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | der, err := x509.MarshalPKCS8PrivateKey(key) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) |
| 29 | } |
| 30 | |
| 31 | func newTestClient(t *testing.T, stubURL string) *Client { |
| 32 | t.Helper() |
| 33 | c, err := New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app") |
| 34 | if err != nil { |
| 35 | t.Fatalf("New: %v", err) |
| 36 | } |
| 37 | c.SetHosts(stubURL, stubURL) |
| 38 | return c |
| 39 | } |
| 40 | |
| 41 | func TestValidDeviceToken(t *testing.T) { |
| 42 | ok := []string{"a", "AABBCCDD", strings.Repeat("f", 200)} |
| 43 | for _, s := range ok { |
| 44 | if !ValidDeviceToken(s) { |
| 45 | t.Errorf("expected %q valid", s) |
| 46 | } |
| 47 | } |
| 48 | bad := []string{"", "xyz", "aa/bb", "aa..bb", "aa\r\nHost:", strings.Repeat("a", 201), "zz"} |
| 49 | for _, s := range bad { |
| 50 | if ValidDeviceToken(s) { |
| 51 | t.Errorf("expected %q invalid", s) |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // TestBuildPayloadStable pins the aps envelope byte-for-byte. The iOS |
| 57 | // Notification Service Extension mirrors these exact keys; if this changes, |
| 58 | // on-device decryption breaks — so a drift must be a deliberate, reviewed edit. |
| 59 | func TestBuildPayloadStable(t *testing.T) { |
| 60 | got := string(BuildPayload("t1", "ciphertext-blob", 0, 0)) |
| 61 | want := `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"blob":"ciphertext-blob","thread":"t1"}` |
| 62 | if got != want { |
| 63 | t.Errorf("envelope drift:\n got: %s\nwant: %s", got, want) |
| 64 | } |
| 65 | // With a message id/created_at the payload carries them (the NSE's local |
| 66 | // store ingest); id 0 (test pushes, old relays) must omit both. |
| 67 | got = string(BuildPayload("t1", "ciphertext-blob", 42, 1700000000)) |
| 68 | want = `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"at":1700000000,"blob":"ciphertext-blob","id":42,"thread":"t1"}` |
| 69 | if got != want { |
| 70 | t.Errorf("envelope drift (id/at):\n got: %s\nwant: %s", got, want) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestSendClassifiesResponses(t *testing.T) { |
| 75 | const ( |
| 76 | live = "aaaaaaaa" |
| 77 | dead = "deadbeef" |
| 78 | bad = "badd0000" |
| 79 | err5 = "cccccccc" |
| 80 | ) |
| 81 | stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 82 | switch { |
| 83 | case strings.Contains(r.URL.Path, dead): |
| 84 | w.WriteHeader(http.StatusGone) |
| 85 | case strings.Contains(r.URL.Path, bad): |
| 86 | w.WriteHeader(http.StatusBadRequest) |
| 87 | w.Write([]byte(`{"reason":"BadDeviceToken"}`)) |
| 88 | case strings.Contains(r.URL.Path, err5): |
| 89 | w.WriteHeader(http.StatusInternalServerError) |
| 90 | w.Write([]byte(`{"reason":"InternalServerError"}`)) |
| 91 | default: |
| 92 | w.WriteHeader(http.StatusOK) |
| 93 | } |
| 94 | })) |
| 95 | defer stub.Close() |
| 96 | c := newTestClient(t, stub.URL) |
| 97 | |
| 98 | if err := c.Send(live, false, "th", "blob", 1, 1); err != nil { |
| 99 | t.Errorf("live token: want nil, got %v", err) |
| 100 | } |
| 101 | if err := c.Send(dead, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) { |
| 102 | t.Errorf("410: want ErrGone, got %v", err) |
| 103 | } |
| 104 | if err := c.Send(bad, false, "th", "blob", 1, 1); !errors.Is(err, ErrGone) { |
| 105 | t.Errorf("BadDeviceToken: want ErrGone, got %v", err) |
| 106 | } |
| 107 | if err := c.Send(err5, false, "th", "blob", 1, 1); err == nil || errors.Is(err, ErrGone) { |
| 108 | t.Errorf("500: want a non-gone error, got %v", err) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // A non-hex token must be rejected before it can reach the URL path, and must |
| 113 | // never be classified as gone. |
| 114 | func TestSendRejectsBadToken(t *testing.T) { |
| 115 | reached := false |
| 116 | stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) |
| 117 | defer stub.Close() |
| 118 | c := newTestClient(t, stub.URL) |
| 119 | err := c.Send("aa/../bb", false, "th", "blob", 1, 1) |
| 120 | if err == nil || errors.Is(err, ErrGone) { |
| 121 | t.Errorf("bad token: want a non-gone error, got %v", err) |
| 122 | } |
| 123 | if reached { |
| 124 | t.Error("bad token reached the upstream server — validation is too late") |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestBuildAlertPayloadStable(t *testing.T) { |
| 129 | // The plaintext changelog banner (announce.go): no mutable-content (the NSE |
| 130 | // never runs — the words are already readable), a `changelog` marker for tap |
| 131 | // routing. Pinned like BuildPayload so the messenger and relay can't drift. |
| 132 | got := string(BuildAlertPayload("What's new in Test Space", "Updates take seconds.")) |
| 133 | want := `{"aps":{"alert":{"body":"Updates take seconds.","title":"What's new in Test Space"},"sound":"default"},"changelog":true}` |
| 134 | if got != want { |
| 135 | t.Errorf("alert envelope drift:\n got: %s\nwant: %s", got, want) |
| 136 | } |
| 137 | } |
| 138 | |