e2e/push_test.go

main
gitbay/e2e/push_test.go history · blame · raw

161 lines · 5157 bytes

2 symbols in this file
  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	// The account the notice is for. A device signed in to several
129	// accounts cannot tell from the path alone which one this is.
130	if got[0]["user"] != "bob" {
131		t.Fatalf("user = %v", got[0]["user"])
132	}
133	if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") {
134		t.Fatalf("instance = %v, want this instance's site_url", inst)
135	}
136	// Bob's one unread notice, for the app icon badge.
137	if aps["badge"] != float64(1) {
138		t.Fatalf("badge = %v", aps["badge"])
139	}
140
141	// Apple retires the token. The next push reaps the device.
142	mu.Lock()
143	gone = true
144	mu.Unlock()
145	if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'ping'"); code != 0 {
146		t.Fatalf("issue comment: %s%s", out, errOut)
147	}
148	waitFor(t, "the device to be reaped after a 410", func() bool {
149		// This is an absence check, unlike every other waitFor in the
150		// suite: a transient ssh failure returns empty stdout, which
151		// would otherwise read as a false "reaped". The exit code rules
152		// that out.
153		out, _, code := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json")
154		return code == 0 && !strings.Contains(out, "iphone")
155	})
156
157	// The inbox is untouched by any of it: push is a side channel.
158	if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "list", "--json"); !strings.Contains(out, "opened issue #1") {
159		t.Fatalf("inbox missing the notice:\n%s", out)
160	}
161}