eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/x509"
8 "encoding/json"
9 "encoding/pem"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "lchat/internal/apns"
18)
19
20const testAdmin = "test-admin-token"
21
22// device tokens the stub Apple server treats specially (all valid hex).
23const (
24 liveDevice = "aaaaaaaa"
25 deadDevice = "deadbeef"
26)
27
28func testKeyPEM(t *testing.T) string {
29 t.Helper()
30 key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31 der, _ := x509.MarshalPKCS8PrivateKey(key)
32 return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
33}
34
35// newTestRelay wires the real router (s.handler) over a temp DB, with the APNs
36// client pointed at a stub standing in for Apple.
37func newTestRelay(t *testing.T) *httptest.Server {
38 t.Helper()
39 stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 if strings.Contains(r.URL.Path, deadDevice) {
41 w.WriteHeader(http.StatusGone)
42 return
43 }
44 w.WriteHeader(http.StatusOK)
45 }))
46 c, err := apns.New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app")
47 if err != nil {
48 t.Fatal(err)
49 }
50 c.SetHosts(stub.URL, stub.URL)
51
52 db, err := openDB(filepath.Join(t.TempDir(), "push.db"))
53 if err != nil {
54 t.Fatal(err)
55 }
56 s := &Server{
57 db: db, apns: c, adminHash: hashToken(testAdmin),
58 perInstance: newRateLimiter(1000, 500),
59 global: newRateLimiter(4000, 2000),
60 }
61 srv := httptest.NewServer(s.handler())
62 t.Cleanup(func() { srv.Close(); stub.Close(); db.sql.Close() })
63 return srv
64}
65
66func do(t *testing.T, method, url, bearer, body string) *http.Response {
67 t.Helper()
68 var r io.Reader
69 if body != "" {
70 r = strings.NewReader(body)
71 }
72 rq, _ := http.NewRequest(method, url, r)
73 if bearer != "" {
74 rq.Header.Set("Authorization", "Bearer "+bearer)
75 }
76 resp, err := http.DefaultClient.Do(rq)
77 if err != nil {
78 t.Fatal(err)
79 }
80 return resp
81}
82
83// mint creates an instance token via the admin API and returns (id, token).
84func mint(t *testing.T, base, label string) (string, string) {
85 t.Helper()
86 resp := do(t, "POST", base+"/api/admin/tokens", testAdmin, `{"label":"`+label+`"}`)
87 defer resp.Body.Close()
88 if resp.StatusCode != http.StatusOK {
89 t.Fatalf("mint: status %d", resp.StatusCode)
90 }
91 var out struct{ ID, Token string }
92 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
93 t.Fatal(err)
94 }
95 if out.Token == "" || out.ID == "" {
96 t.Fatal("mint returned empty id/token")
97 }
98 return out.ID, out.Token
99}
100
101func push(t *testing.T, base, token, deviceToken, extra string) int {
102 t.Helper()
103 body := `{"device_token":"` + deviceToken + `","thread":"th","blob":"x"` + extra + `}`
104 resp := do(t, "POST", base+"/api/push", token, body)
105 resp.Body.Close()
106 return resp.StatusCode
107}
108
109func TestPushAuth(t *testing.T) {
110 base := newTestRelay(t).URL
111 if got := push(t, base, "", liveDevice, ""); got != http.StatusUnauthorized {
112 t.Errorf("no token: want 401, got %d", got)
113 }
114 if got := push(t, base, "bogus-token", liveDevice, ""); got != http.StatusUnauthorized {
115 t.Errorf("bad token: want 401, got %d", got)
116 }
117 _, tok := mint(t, base, "family.example.com")
118 if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK {
119 t.Errorf("minted token: want 200, got %d", got)
120 }
121}
122
123func TestPushRevoke(t *testing.T) {
124 base := newTestRelay(t).URL
125 id, tok := mint(t, base, "test.example.com")
126 if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK {
127 t.Fatalf("pre-revoke: want 200, got %d", got)
128 }
129 resp := do(t, "DELETE", base+"/api/admin/tokens/"+id, testAdmin, "")
130 resp.Body.Close()
131 if resp.StatusCode != http.StatusOK {
132 t.Fatalf("revoke: want 200, got %d", resp.StatusCode)
133 }
134 if got := push(t, base, tok, liveDevice, ""); got != http.StatusUnauthorized {
135 t.Errorf("post-revoke: want 401, got %d", got)
136 }
137}
138
139func TestPushGonePassthrough(t *testing.T) {
140 base := newTestRelay(t).URL
141 _, tok := mint(t, base, "test.example.com")
142 // Apple says the token is dead → relay must return a bare 410 so the
143 // instance prunes it.
144 if got := push(t, base, tok, deadDevice, ""); got != http.StatusGone {
145 t.Errorf("dead device: want 410, got %d", got)
146 }
147}
148
149func TestPushBadDeviceToken(t *testing.T) {
150 base := newTestRelay(t).URL
151 _, tok := mint(t, base, "test.example.com")
152 if got := push(t, base, tok, "zznothex", ""); got != http.StatusBadRequest {
153 t.Errorf("non-hex device token: want 400, got %d", got)
154 }
155}
156
157func TestPushOversized(t *testing.T) {
158 base := newTestRelay(t).URL
159 _, tok := mint(t, base, "test.example.com")
160 // A blob well over maxPushBytes must 413, never 410 (a false prune).
161 big := `,"pad":"` + strings.Repeat("a", 9000) + `"`
162 if got := push(t, base, tok, liveDevice, big); got != http.StatusRequestEntityTooLarge {
163 t.Errorf("oversized: want 413, got %d", got)
164 }
165}
166
167func TestAdminRequiresToken(t *testing.T) {
168 base := newTestRelay(t).URL
169 for _, tc := range []struct{ method, path string }{
170 {"POST", "/api/admin/tokens"},
171 {"GET", "/api/admin/tokens"},
172 {"DELETE", "/api/admin/tokens/x"},
173 } {
174 resp := do(t, tc.method, base+tc.path, "wrong-admin", `{"label":"x"}`)
175 resp.Body.Close()
176 if resp.StatusCode != http.StatusUnauthorized {
177 t.Errorf("%s %s without admin: want 401, got %d", tc.method, tc.path, resp.StatusCode)
178 }
179 }
180}
181
182func TestHealthz(t *testing.T) {
183 base := newTestRelay(t).URL
184 resp := do(t, "GET", base+"/healthz", "", "")
185 defer resp.Body.Close()
186 if resp.StatusCode != http.StatusOK {
187 t.Errorf("healthz: want 200, got %d", resp.StatusCode)
188 }
189}
190
191// A changelog announcement: plaintext title/body instead of a ciphertext blob
192// (the one non-blind payload — branding-class release notes). Same 200/410
193// contract as message pushes.
194func TestPushChangelogAlert(t *testing.T) {
195 base := newTestRelay(t).URL
196 _, tok := mint(t, base, "inst")
197 body := `{"device_token":"` + liveDevice + `","changelog":true,"title":"What's new","body":"Notes."}`
198 if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusOK {
199 t.Fatalf("changelog push: status %d", resp.StatusCode)
200 }
201 // Dead tokens prune through the changelog path too.
202 body = `{"device_token":"` + deadDevice + `","changelog":true,"title":"What's new","body":"Notes."}`
203 if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusGone {
204 t.Fatalf("changelog push to dead device: status %d, want 410", resp.StatusCode)
205 }
206}
207