Commit 49d601e534

49d601e534c73b3131093128859fda025860e469

parent: 1155b57e8a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 10:34 UTC

e2e: push delivery and device reaping

A fake APNs over HTTP/1.1; the real transport is h2 by ALPN, which is
stdlib behaviour and not ours to test.

Ref #89
e2e/push_test.go added +145
@@ -0,0 +1,145 @@
1package e2e
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 "os"
14 "path/filepath"
15 "strings"
16 "sync"
17 "testing"
18)
19
20// writeTestAPNSKey writes a P-256 PKCS#8 key PEM, as config validation
21// expects for [push] key_file. Modelled on writeP8 in
22// internal/config/config_test.go, which is in a different package and so
23// cannot be called directly.
24func writeTestAPNSKey(t *testing.T) string {
25 t.Helper()
26 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
27 if err != nil {
28 t.Fatal(err)
29 }
30 der, err := x509.MarshalPKCS8PrivateKey(key)
31 if err != nil {
32 t.Fatal(err)
33 }
34 p := filepath.Join(t.TempDir(), "apns.p8")
35 f, err := os.Create(p)
36 if err != nil {
37 t.Fatal(err)
38 }
39 defer f.Close()
40 if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil {
41 t.Fatal(err)
42 }
43 return p
44}
45
46// A push reaches a registered device with the same words the inbox row
47// carries, and a token Apple has retired takes its device with it.
48func TestPush(t *testing.T) {
49 var mu sync.Mutex
50 var got []map[string]any
51 var gone bool
52
53 apns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54 raw, _ := io.ReadAll(r.Body)
55 var payload map[string]any
56 json.Unmarshal(raw, &payload)
57 mu.Lock()
58 defer mu.Unlock()
59 if gone {
60 w.WriteHeader(410)
61 io.WriteString(w, `{"reason":"Unregistered"}`)
62 return
63 }
64 got = append(got, payload)
65 w.WriteHeader(200)
66 }))
67 defer apns.Close()
68
69 keyPath := writeTestAPNSKey(t)
70 t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(apns.URL, "http://"))
71 inst := startInstanceWith(t, `[push]
72enabled = true
73key_file = "`+keyPath+`"
74key_id = "KEYID"
75team_id = "TEAMID"
76topic = "org.gitbay.gitbay"
77environment = "production"
78`)
79
80 aliceKey := inst.newKey(t, "alice")
81 bobKey := inst.newKey(t, "bob")
82 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
83 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
84 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
85 t.Fatalf("repo create: %s", errOut)
86 }
87
88 // Bob watches alice's repository and registers a device.
89 if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "watch", "alice/app"); code != 0 {
90 t.Fatalf("watch: %s%s", out, errOut)
91 }
92 if out, errOut, code := inst.ssh(t, bobKey, "DEVTOKEN\n", "notifications", "device", "add", "--label", "iphone"); code != 0 {
93 t.Fatalf("device add: %s%s", out, errOut)
94 }
95 if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json"); !strings.Contains(out, `"label":"iphone"`) {
96 t.Fatalf("device not listed:\n%s", out)
97 } else if strings.Contains(out, "DEVTOKEN") {
98 t.Fatalf("device list printed the token in full:\n%s", out)
99 }
100
101 // Alice opens an issue. Bob hears about it.
102 // The server tokenizer splits the ssh command string on whitespace, so
103 // a multi-word flag value needs its own quoting (internal/protocol.Tokenize).
104 if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'a bug'", "--body", "x"); code != 0 {
105 t.Fatalf("issue create: %s%s", out, errOut)
106 }
107
108 waitFor(t, "a push to arrive", func() bool {
109 mu.Lock()
110 defer mu.Unlock()
111 return len(got) == 1
112 })
113
114 mu.Lock()
115 aps := got[0]["aps"].(map[string]any)
116 alert := aps["alert"].(map[string]any)
117 mu.Unlock()
118 if alert["title"] != "alice/app" {
119 t.Fatalf("title = %v", alert["title"])
120 }
121 // The same words the inbox row carries.
122 if body, _ := alert["body"].(string); !strings.Contains(body, "opened issue #1") {
123 t.Fatalf("body = %q", body)
124 }
125 if got[0]["path"] != "alice/app/issues/1" {
126 t.Fatalf("path = %v", got[0]["path"])
127 }
128
129 // Apple retires the token. The next push reaps the device.
130 mu.Lock()
131 gone = true
132 mu.Unlock()
133 if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'ping'"); code != 0 {
134 t.Fatalf("issue comment: %s%s", out, errOut)
135 }
136 waitFor(t, "the device to be reaped after a 410", func() bool {
137 out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json")
138 return !strings.Contains(out, "iphone")
139 })
140
141 // The inbox is untouched by any of it: push is a side channel.
142 if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "list", "--json"); !strings.Contains(out, "opened issue #1") {
143 t.Fatalf("inbox missing the notice:\n%s", out)
144 }
145}