package apns import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "math/big" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" ) func testKey(t *testing.T) (*ecdsa.PrivateKey, []byte) { 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 key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) } func client(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { t.Helper() key, _ := testKey(t) server := httptest.NewServer(handler) t.Cleanup(server.Close) c, err := New(Config{ KeyID: "ABC123DEFG", TeamID: "TEAM123456", Key: key, Topics: map[string]string{"ios": "works.oficina.calls", "macos": "works.oficina.calls.mac"}, Host: server.URL, HTTP: server.Client(), }) if err != nil { t.Fatal(err) } return c, server } func TestParseKeyAcceptsAP8AndRefusesAnythingElse(t *testing.T) { _, pemBytes := testKey(t) if _, err := ParseKey(pemBytes); err != nil { t.Fatal(err) } for name, input := range map[string]string{ "not pem": "hello", "empty": "", "pem but not a key": "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", } { if _, err := ParseKey([]byte(input)); err == nil { t.Errorf("accepted %s", name) } } } // The provider token is the part Apple rejects most opaquely — a wrong // signature encoding comes back as "InvalidProviderToken" and nothing // else — so it is verified here against the public key rather than // merely produced. func TestTheProviderTokenIsAValidES256JWTApplesWay(t *testing.T) { var seen atomic.Value c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { seen.Store(r.Header.Get("authorization")) w.WriteHeader(http.StatusOK) }) if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { t.Fatal(err) } header, ok := seen.Load().(string) if !ok || !strings.HasPrefix(header, "bearer ") { t.Fatal("no bearer token", header) } parts := strings.Split(strings.TrimPrefix(header, "bearer "), ".") if len(parts) != 3 { t.Fatal("not three JWT segments") } var head map[string]any raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil || json.Unmarshal(raw, &head) != nil { t.Fatal("header is not base64url JSON") } if head["alg"] != "ES256" || head["kid"] != "ABC123DEFG" { t.Fatal("wrong header", head) } var claims map[string]any raw, err = base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || json.Unmarshal(raw, &claims) != nil { t.Fatal("claims are not base64url JSON") } if claims["iss"] != "TEAM123456" { t.Fatal("wrong issuer", claims) } // The signature must be the raw r||s pair, each padded to 32 bytes. // ASN.1 — what SignASN1 produces — is the mistake this asserts against. signature, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil || len(signature) != 64 { t.Fatalf("signature is %d bytes, want 64 raw r||s", len(signature)) } digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) r := new(big.Int).SetBytes(signature[:32]) s := new(big.Int).SetBytes(signature[32:]) if !ecdsa.Verify(&c.cfg.Key.PublicKey, digest[:], r, s) { t.Fatal("signature does not verify against the key that made it") } } func TestOneTokenIsReusedAndThenReminted(t *testing.T) { var tokens sync.Map var count atomic.Int32 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { tokens.Store(r.Header.Get("authorization"), true) count.Add(1) w.WriteHeader(http.StatusOK) }) now := time.Now() c.cfg.Now = func() time.Time { return now } for range 3 { if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { t.Fatal(err) } } distinct := 0 tokens.Range(func(_, _ any) bool { distinct++; return true }) if distinct != 1 { t.Fatalf("minted %d tokens for three pushes; Apple rate-limits that", distinct) } // Past the life, a new one. Apple refuses a token older than an hour. now = now.Add(providerTokenLife + time.Minute) if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { t.Fatal(err) } distinct = 0 tokens.Range(func(_, _ any) bool { distinct++; return true }) if distinct != 2 { t.Fatal("an expired provider token was reused") } } func TestTheRequestCarriesWhatApplesRequires(t *testing.T) { var path, topic, pushType, priority atomic.Value var payload atomic.Value c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { path.Store(r.URL.Path) topic.Store(r.Header.Get("apns-topic")) pushType.Store(r.Header.Get("apns-push-type")) priority.Store(r.Header.Get("apns-priority")) var body map[string]any json.NewDecoder(r.Body).Decode(&body) payload.Store(body) w.WriteHeader(http.StatusOK) }) if _, err := c.Push(t.Context(), "devicetoken123", "macos", "Bea O'Neill"); err != nil { t.Fatal(err) } if path.Load() != "/3/device/devicetoken123" { t.Fatal("wrong path", path.Load()) } // The topic follows the platform: the two apps have different bundle // identifiers, and a token sent under the wrong one is refused with // DeviceTokenNotForTopic. if topic.Load() != "works.oficina.calls.mac" { t.Fatal("wrong topic for macos", topic.Load()) } if pushType.Load() != "alert" || priority.Load() != "10" { t.Fatal("wrong push type or priority", pushType.Load(), priority.Load()) } body := payload.Load().(map[string]any) aps := body["aps"].(map[string]any) alert := aps["alert"].(map[string]any) // The name is someone else's text and rides as a JSON value, never // built into one. if alert["body"] != "Bea O'Neill" { t.Fatal("name not carried verbatim", alert["body"]) } } func TestAnUnknownPlatformIsRefusedWithoutCallingApple(t *testing.T) { var called atomic.Int32 c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { called.Add(1) }) verdict, err := c.Push(t.Context(), "abc", "android", "Bea") if err == nil || verdict != Retry { t.Fatal("accepted an unknown platform") } if called.Load() != 0 { t.Fatal("called Apple for a platform with no topic") } } // The table that decides whether a token lives or dies. Pure, so every // reason Apple documents can be covered without a fake acting each out. func TestApplesRefusalsAreSortedIntoKeepAndDelete(t *testing.T) { for _, c := range []struct { status int reason string want Verdict }{ {410, "Unregistered", Gone}, {400, "BadDeviceToken", Gone}, {400, "DeviceTokenNotForTopic", Gone}, {410, "", Gone}, {403, "ExpiredProviderToken", Retry}, {403, "InvalidProviderToken", Retry}, {429, "TooManyRequests", Retry}, {503, "", Retry}, {500, "InternalServerError", Retry}, } { got, err := classify(c.status, c.reason) if got != c.want { t.Errorf("status %d reason %q: got %v, want %v", c.status, c.reason, got, c.want) } if err == nil { t.Errorf("status %d reason %q: refusal with no error", c.status, c.reason) } } } func TestApplesGoneVerdictReachesTheCaller(t *testing.T) { c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusGone) w.Write([]byte(`{"reason":"Unregistered"}`)) }) verdict, err := c.Push(t.Context(), "dead", "ios", "Bea") if verdict != Gone || err == nil { t.Fatal("a dead token must be reported as Gone", verdict, err) } } func TestAConfigMissingItsCredentialsIsRefusedAtConstruction(t *testing.T) { key, _ := testKey(t) full := Config{KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}} if _, err := New(full); err != nil { t.Fatal(err) } for name, broken := range map[string]Config{ "no key id": {TeamID: "T", Key: key, Topics: full.Topics}, "no team": {KeyID: "K", Key: key, Topics: full.Topics}, "no key": {KeyID: "K", TeamID: "T", Topics: full.Topics}, "no topics": {KeyID: "K", TeamID: "T", Key: key}, } { if _, err := New(broken); err == nil { t.Errorf("accepted a config with %s", name) } } } func TestUnreachableAppleIsRetryAndNotGone(t *testing.T) { // The distinction that matters: a network that is down must never be // read as "this device is gone", or an outage deletes every token. key, _ := testKey(t) c, err := New(Config{ KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}, Host: "http://127.0.0.1:1", }) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) defer cancel() verdict, err := c.Push(ctx, "abc", "ios", "Bea") if verdict != Retry || err == nil { t.Fatal("an unreachable Apple must be Retry", verdict, err) } }