internal/push/apns_test.go

v1.41.0
gitbay/internal/push/apns_test.go history · blame · raw

207 lines · 7057 bytes

  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}