internal/push/apns_test.go
207 lines · 7057 bytes
6 symbols in this file
1package push
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12 "unicode/utf8"
13
14 "gitbay.org/gitbay/internal/config"
15)
16
17// fakeAPNs stands in for Apple. It speaks HTTP/1.1; the real transport is
18// h2 by ALPN, which is stdlib behaviour and not this repository's to test.
19func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
20 t.Helper()
21 srv := httptest.NewServer(h)
22 t.Cleanup(srv.Close)
23 t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(srv.URL, "http://"))
24 c, err := NewClient(config.Push{
25 Enabled: true, KeyID: "K", TeamID: "T",
26 Topic: "org.gitbay.gitbay", Environment: "production",
27 }, "https://gitbay.example")
28 if err != nil {
29 t.Fatal(err)
30 }
31 c.key = testKey(t)
32 c.tokens = newTokenSource(c.key, "K", "T")
33 c.scheme = "http"
34 return c, srv
35}
36
37func TestSendShapesTheRequest(t *testing.T) {
38 var gotPath, gotTopic, gotType, gotAuth, gotCollapse string
39 var payload map[string]any
40 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
41 gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic")
42 gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization")
43 gotCollapse = r.Header.Get("apns-collapse-id")
44 raw, _ := io.ReadAll(r.Body)
45 json.Unmarshal(raw, &payload)
46 w.WriteHeader(200)
47 })
48 res, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 3, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
49 if err != nil || res != resultSent {
50 t.Fatalf("res = %v, err = %v", res, err)
51 }
52 if gotPath != "/3/device/DEVTOKEN" {
53 t.Fatalf("path = %q", gotPath)
54 }
55 if gotTopic != "org.gitbay.gitbay" || gotType != "alert" {
56 t.Fatalf("topic = %q, push-type = %q", gotTopic, gotType)
57 }
58 if !strings.HasPrefix(gotAuth, "bearer ") {
59 t.Fatalf("authorization = %q", gotAuth)
60 }
61 aps := payload["aps"].(map[string]any)
62 alert := aps["alert"].(map[string]any)
63 if alert["title"] != "krz/gitbay" || alert["body"] != "cmc opened issue #12" {
64 t.Fatalf("alert = %v", alert)
65 }
66 if aps["thread-id"] != "krz/gitbay" {
67 t.Fatalf("thread-id = %v", aps["thread-id"])
68 }
69 if payload["path"] != "krz/gitbay/issues/12" {
70 t.Fatalf("path = %v", payload["path"])
71 }
72 // Collapsing is wrong here: two comments are two notices. This is an
73 // APNs HTTP header, not a body field, so it must be checked on the
74 // request the handler received, not on the decoded JSON payload.
75 if gotCollapse != "" {
76 t.Fatalf("apns-collapse-id = %q, want unset", gotCollapse)
77 }
78}
79
80func TestSendMapsResponses(t *testing.T) {
81 cases := []struct {
82 name string
83 status int
84 body string
85 retryAfter string
86 want result
87 wantAfter time.Duration
88 }{
89 {"ok", 200, "", "", resultSent, 0},
90 {"gone", 410, `{"reason":"Unregistered"}`, "", resultReap, 0},
91 {"bad token", 400, `{"reason":"BadDeviceToken"}`, "", resultReap, 0},
92 {"other 400 is permanent", 400, `{"reason":"PayloadTooLarge"}`, "", resultDead, 0},
93 {"forbidden is permanent", 403, `{"reason":"InvalidProviderToken"}`, "", resultDead, 0},
94 {"too many requests retries", 429, `{"reason":"TooManyRequests"}`, "7", resultRetry, 7 * time.Second},
95 {"server error retries", 503, `{"reason":"ServiceUnavailable"}`, "", resultRetry, 0},
96 }
97 for _, tc := range cases {
98 t.Run(tc.name, func(t *testing.T) {
99 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
100 if tc.retryAfter != "" {
101 w.Header().Set("Retry-After", tc.retryAfter)
102 }
103 w.WriteHeader(tc.status)
104 io.WriteString(w, tc.body)
105 })
106 res, after, err := c.Send(context.Background(), "T", "u", 0, "t", "b", "p")
107 // Only a delivered push has no error. Every other result
108 // carries the status and reason, which is what the drainer
109 // records on the queue row.
110 if tc.want == resultSent && err != nil {
111 t.Fatalf("err = %v", err)
112 }
113 if tc.want != resultSent && err == nil {
114 t.Fatalf("want an error explaining %v, got nil", tc.want)
115 }
116 if res != tc.want {
117 t.Fatalf("res = %v, want %v", res, tc.want)
118 }
119 if after != tc.wantAfter {
120 t.Fatalf("retryAfter = %v, want %v", after, tc.wantAfter)
121 }
122 })
123 }
124}
125
126func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
127 var payload map[string]any
128 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
129 raw, _ := io.ReadAll(r.Body)
130 json.Unmarshal(raw, &payload)
131 w.WriteHeader(200)
132 })
133 // A leading ASCII byte shifts every following two-byte rune off an
134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on
135 // the second byte of one of them rather than a rune boundary.
136 long := "x" + strings.Repeat("é", 2000)
137 res, _, err := c.Send(context.Background(), "T", "u", 0, "t", long, "p")
138 if err != nil || res != resultSent {
139 t.Fatalf("res = %v, err = %v", res, err)
140 }
141 aps := payload["aps"].(map[string]any)
142 alert := aps["alert"].(map[string]any)
143 body := alert["body"].(string)
144 if !utf8.ValidString(body) {
145 t.Fatalf("body is not valid UTF-8: %q", body)
146 }
147 if len(body) > maxBodyBytes {
148 t.Fatalf("body is %d bytes, want <= %d", len(body), maxBodyBytes)
149 }
150}
151
152// The override drops to plain HTTP only for a host on this machine. The
153// provider token is a bearer credential valid for an hour that can push
154// to any device under the topic, so a GITBAY_APNS_HOST aimed anywhere
155// else keeps HTTPS rather than putting it on the wire in cleartext.
156func TestAPNSSchemeDowngradesOnlyOnLoopback(t *testing.T) {
157 for _, tc := range []struct{ host, want string }{
158 {"", "https"},
159 {"127.0.0.1:8080", "http"},
160 {"127.0.0.53:2197", "http"},
161 {"localhost:1234", "http"},
162 {"[::1]:1234", "http"},
163 {"::1", "http"},
164 {"10.0.0.5:2197", "https"},
165 {"apns.example.com", "https"},
166 {"api.push.apple.com:443", "https"},
167 {"not a host", "https"},
168 } {
169 t.Setenv("GITBAY_APNS_HOST", tc.host)
170 if got := apnsScheme(); got != tc.want {
171 t.Errorf("apnsScheme() with host %q = %q, want %q", tc.host, got, tc.want)
172 }
173 }
174}
175
176// The alert names the account it belongs to. One device token is one
177// install, and an install registers against every account signed in on
178// it, so `path` alone cannot say which instance a notice came from — two
179// instances can hold the same owner/name.
180func TestSendNamesTheAccount(t *testing.T) {
181 var payload map[string]any
182 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
183 raw, _ := io.ReadAll(r.Body)
184 json.Unmarshal(raw, &payload)
185 w.WriteHeader(200)
186 })
187 c.siteURL = "https://gitbay.org"
188
189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 2,
190 "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
191 t.Fatalf("Send: %v", err)
192 }
193 if payload["instance"] != "https://gitbay.org" {
194 t.Fatalf("instance = %v", payload["instance"])
195 }
196 if payload["user"] != "cmc" {
197 t.Fatalf("user = %v", payload["user"])
198 }
199 // Still carries what it always did.
200 if payload["path"] != "krz/gitbay/issues/12" {
201 t.Fatalf("path = %v", payload["path"])
202 }
203 aps := payload["aps"].(map[string]any)
204 if aps["badge"] != float64(2) {
205 t.Fatalf("badge = %v", aps["badge"])
206 }
207}