Commit 49d601e534
Verified · cmc
e2e/push_test.go added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 24 | func 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. | |
| 48 | func 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] | |
| 72 | enabled = true | |
| 73 | key_file = "`+keyPath+`" | |
| 74 | key_id = "KEYID" | |
| 75 | team_id = "TEAMID" | |
| 76 | topic = "org.gitbay.gitbay" | |
| 77 | environment = "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 | } | |