internal/control/notifications_test.go

v1.41.0
gitbay/internal/control/notifications_test.go history · blame · raw

403 lines · 13666 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"strings"
  7	"testing"
  8	"time"
  9
 10	"gitbay.org/gitbay/internal/config"
 11	"gitbay.org/gitbay/internal/mailreply"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/seal"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17// notifTestCtx opens an in-memory store, migrates it, and creates one user
 18// to act as. Modeled on the store setup in snippet_test.go; this package
 19// has no shared testCtx helper.
 20func notifTestCtx(t *testing.T, username string) *Ctx {
 21	t.Helper()
 22	st, err := store.Open(":memory:")
 23	if err != nil {
 24		t.Fatal(err)
 25	}
 26	t.Cleanup(func() { st.Close() })
 27	if err := st.MigrateUp(); err != nil {
 28		t.Fatal(err)
 29	}
 30	uid, err := st.CreateUser(username, false)
 31	if err != nil {
 32		t.Fatal(err)
 33	}
 34	var out bytes.Buffer
 35	return &Ctx{
 36		User:  store.User{ID: uid, Username: username},
 37		Scope: "full",
 38		Store: st,
 39		// Push enabled is the instance state the push tests assume; the
 40		// disabled case sets it back to false explicitly.
 41		Cfg:    config.Config{Push: config.Push{Enabled: true}},
 42		Stdin:  strings.NewReader(""),
 43		Stdout: &out,
 44		Stderr: &out,
 45	}
 46}
 47
 48// testRepoWithWatcher returns a Ctx acting as alice, a repository she
 49// owns with issue #1 open on it, and bob's user id with a watch row on
 50// it — the shared setup for notify's recipient-widening tests.
 51func testRepoWithWatcher(t *testing.T) (*Ctx, store.Repo, int64) {
 52	t.Helper()
 53	c := notifTestCtx(t, "alice")
 54	repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
 55	if err != nil {
 56		t.Fatal(err)
 57	}
 58	repo, err := c.Store.RepoByID(repoID)
 59	if err != nil {
 60		t.Fatal(err)
 61	}
 62	if _, err := c.Store.CreateIssue(repo.ID, c.User.ID, "title", "", "markdown"); err != nil {
 63		t.Fatal(err)
 64	}
 65	bob, err := c.Store.CreateUser("bob", false)
 66	if err != nil {
 67		t.Fatal(err)
 68	}
 69	if err := c.Store.SetRepoWatch(repo.ID, bob, "watching"); err != nil {
 70		t.Fatal(err)
 71	}
 72	return c, repo, bob
 73}
 74
 75func TestNotifyQueuesPush(t *testing.T) {
 76	c, repo, bob := testRepoWithWatcher(t) // alice acts, bob watches
 77	c.Store.AddPushDevice(bob, "tok-b", "iphone")
 78
 79	notify(c, []int64{bob}, notice{repo: repo, kind: "issue",
 80		subject: "[alice/app] #1: title",
 81		action:  "opened issue #1",
 82		path:    "alice/app/issues/1"})
 83
 84	due, err := c.Store.DuePush(20)
 85	if err != nil {
 86		t.Fatalf("DuePush: %v", err)
 87	}
 88	if len(due) != 1 {
 89		t.Fatalf("want one queued push, got %d", len(due))
 90	}
 91	// The push body is the inbox row's summary, so the two surfaces
 92	// cannot disagree about what happened.
 93	if due[0].Title != "alice/app" {
 94		t.Fatalf("title = %q", due[0].Title)
 95	}
 96	if due[0].Body != "alice opened issue #1" {
 97		t.Fatalf("body = %q", due[0].Body)
 98	}
 99	if due[0].Path != "alice/app/issues/1" {
100		t.Fatalf("path = %q", due[0].Path)
101	}
102}
103
104func TestNotifyQueuesNoPushForTheActor(t *testing.T) {
105	c, repo, _ := testRepoWithWatcher(t)
106	c.Store.AddPushDevice(c.User.ID, "tok-self", "iphone")
107
108	notify(c, []int64{c.User.ID}, notice{repo: repo, kind: "issue",
109		subject: "s", action: "opened issue #1", path: "alice/app/issues/1"})
110
111	// NotifyRecipients already drops the actor; push inherits that and
112	// must not find its own way around it.
113	if due, _ := c.Store.DuePush(20); len(due) != 0 {
114		t.Fatalf("queued a push to the actor")
115	}
116}
117
118// TestNotifyQueuesNoPushWhenDisabled: on an instance with [push]
119// enabled = false nothing drains the queue, and the retention sweep only
120// collects rows that were sent or dead-lettered, so a row written here is
121// never collected. The mail half already gates on the instance having
122// SMTP; push gates the same way.
123func TestNotifyQueuesNoPushWhenDisabled(t *testing.T) {
124	c, repo, bob := testRepoWithWatcher(t)
125	c.Cfg.Push.Enabled = false
126	c.Store.AddPushDevice(bob, "tok-b", "iphone")
127
128	notify(c, []int64{bob}, notice{repo: repo, kind: "issue",
129		subject: "s", action: "opened issue #1", path: "alice/app/issues/1"})
130
131	if due, _ := c.Store.DuePush(20); len(due) != 0 {
132		t.Fatalf("queued %d pushes on a push-disabled instance", len(due))
133	}
134	// The inbox row is still filed: push is the optional half, not the
135	// notice.
136	if n := c.Store.UnreadNotices(bob); n != 1 {
137		t.Fatalf("unread notices = %d, want 1", n)
138	}
139}
140
141// TestNotificationsDeviceAddRefusedWhenPushDisabled: registering a device
142// on an instance that cannot deliver would report success and then never
143// push, with notifications settings show still saying push is on.
144func TestNotificationsDeviceAddRefusedWhenPushDisabled(t *testing.T) {
145	c := notifTestCtx(t, "alice")
146	c.Cfg.Push.Enabled = false
147	c.Stdin = strings.NewReader("DEVTOKEN\n")
148	var out bytes.Buffer
149	c.Stdout, c.Stderr = &out, &out
150
151	if code := runNotificationsDeviceAdd(c, nil); code != protocol.ExitFailure {
152		t.Fatalf("exit %d, want %d", code, protocol.ExitFailure)
153	}
154	if devices, _ := c.Store.PushDevices(c.User.ID); len(devices) != 0 {
155		t.Fatalf("device registered anyway: %+v", devices)
156	}
157	if !strings.Contains(out.String(), "[push] enabled = false") {
158		t.Fatalf("message does not name the instance setting: %q", out.String())
159	}
160}
161
162func TestNotificationsDeviceAddReadsStdin(t *testing.T) {
163	c := notifTestCtx(t, "alice")
164	c.Stdin = strings.NewReader("DEVTOKEN\n")
165	if code := runNotificationsDeviceAdd(c, []string{"--label", "iphone"}); code != 0 {
166		t.Fatalf("exit %d", code)
167	}
168	devices, _ := c.Store.PushDevices(c.User.ID)
169	if len(devices) != 1 || devices[0].Token != "DEVTOKEN" {
170		t.Fatalf("got %+v", devices)
171	}
172	if devices[0].Label != "iphone" {
173		t.Fatalf("label = %q", devices[0].Label)
174	}
175}
176
177func TestNotificationsDeviceListTruncatesTheToken(t *testing.T) {
178	c := notifTestCtx(t, "alice")
179	long := strings.Repeat("a", 64)
180	c.Store.AddPushDevice(c.User.ID, long, "iphone")
181	var out bytes.Buffer
182	c.Stdout = &out
183	if code := runNotificationsDeviceList(c, nil); code != 0 {
184		t.Fatalf("exit %d", code)
185	}
186	if strings.Contains(out.String(), long) {
187		t.Fatal("the full token was printed")
188	}
189}
190
191// TestNotificationsDeviceListTruncatesTheTokenJSON is the JSON-path twin
192// of the above: the plain and JSON output share the same rows slice, but
193// nothing enforces that beyond reading the code, so both paths get their
194// own test of the guarantee.
195func TestNotificationsDeviceListTruncatesTheTokenJSON(t *testing.T) {
196	c := notifTestCtx(t, "alice")
197	long := strings.Repeat("a", 64)
198	c.Store.AddPushDevice(c.User.ID, long, "iphone")
199	var out bytes.Buffer
200	c.Stdout, c.JSON = &out, true
201	if code := runNotificationsDeviceList(c, nil); code != 0 {
202		t.Fatalf("exit %d", code)
203	}
204	if strings.Contains(out.String(), long) {
205		t.Fatal("the full token was printed")
206	}
207}
208
209// TestNotificationsDeviceListMasksAShortToken: a token at or under the
210// truncation cut length is not returned unchanged. ShortToken's short
211// path used to return the token verbatim, a full echo of anything eight
212// characters or fewer; runNotificationsDeviceAdd enforces no minimum
213// length, so a short token is a value the command will store.
214func TestNotificationsDeviceListMasksAShortToken(t *testing.T) {
215	c := notifTestCtx(t, "alice")
216	short := "abc123"
217	c.Store.AddPushDevice(c.User.ID, short, "iphone")
218	var out bytes.Buffer
219	c.Stdout = &out
220	if code := runNotificationsDeviceList(c, nil); code != 0 {
221		t.Fatalf("exit %d", code)
222	}
223	if strings.Contains(out.String(), short) {
224		t.Fatal("the short token was printed verbatim")
225	}
226}
227
228// device add returns the row id. Without it a client that wants to
229// deregister has to list devices and match its own token against the
230// truncated display value, which is identity by rendered string.
231func TestNotificationsDeviceAddReturnsTheID(t *testing.T) {
232	c := notifTestCtx(t, "alice")
233	c.Stdin = strings.NewReader("DEVTOKEN\n")
234	var out bytes.Buffer
235	c.Stdout, c.JSON = &out, true
236	if code := runNotificationsDeviceAdd(c, nil); code != 0 {
237		t.Fatalf("exit %d", code)
238	}
239	devices, _ := c.Store.PushDevices(c.User.ID)
240	if len(devices) != 1 {
241		t.Fatalf("want one device, got %d", len(devices))
242	}
243	want := fmt.Sprintf(`"id":%d`, devices[0].ID)
244	if !strings.Contains(out.String(), want) {
245		t.Fatalf("output %s does not carry %s", out.String(), want)
246	}
247}
248
249func TestNotificationsDeviceAddPlainSaysRegisteredDevice(t *testing.T) {
250	c := notifTestCtx(t, "alice")
251	c.Stdin = strings.NewReader("DEVTOKEN\n")
252	var out bytes.Buffer
253	c.Stdout, c.JSON = &out, false
254	if code := runNotificationsDeviceAdd(c, nil); code != 0 {
255		t.Fatalf("exit %d", code)
256	}
257	if got := out.String(); !strings.HasPrefix(got, "registered device ") {
258		t.Errorf("device add printed %q", got)
259	}
260}
261
262func TestNotificationsSettingsShowsPush(t *testing.T) {
263	c := notifTestCtx(t, "alice")
264	var out bytes.Buffer
265	c.Stdout, c.JSON = &out, true
266	if code := runNotificationsSettingsShow(c, nil); code != 0 {
267		t.Fatalf("exit %d", code)
268	}
269	if !strings.Contains(out.String(), `"push":true`) {
270		t.Fatalf("no push key: %s", out.String())
271	}
272}
273
274func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
275	c, repo, bob := testRepoWithWatcher(t)
276	// An inbox with nothing in it at all keeps the plain message.
277	var empty bytes.Buffer
278	alice := c.User
279	c.User = store.User{ID: bob, Username: "bob"}
280	c.Stdout, c.Stderr = &bytes.Buffer{}, &empty
281	if code := runNotificationsList(c, nil); code != protocol.ExitOK {
282		t.Fatalf("exit %d: %s", code, empty.String())
283	}
284	if got := empty.String(); got != "nothing to list\n" {
285		t.Errorf("empty inbox stderr = %q", got)
286	}
287	c.User = alice
288	// Give bob one notice (acting as alice, so bob isn't filtered out as
289	// the actor), then mark it read as bob, so his inbox has rows but no
290	// unread ones.
291	notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"})
292	c.User = store.User{ID: bob, Username: "bob"}
293	if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK {
294		t.Fatalf("mark read: exit %d", code)
295	}
296	var out, errOut bytes.Buffer
297	c.Stdout, c.Stderr = &out, &errOut
298	if code := runNotificationsList(c, nil); code != protocol.ExitOK {
299		t.Fatalf("exit %d: %s", code, errOut.String())
300	}
301	if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" {
302		t.Errorf("stderr = %q", got)
303	}
304	// --all sees it and stays the generic message when that too is empty.
305	out.Reset()
306	errOut.Reset()
307	if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK {
308		t.Fatalf("exit %d: %s", code, errOut.String())
309	}
310	// The inbox row's summary is the notice's action ("a"), not its mail
311	// subject ("s"); check the path instead, which is unique to this row.
312	if !strings.Contains(out.String(), "x") {
313		t.Errorf("--all did not show the read notice: %q", out.String())
314	}
315}
316
317// The Reply-To is on issue and merge request mail only when the instance
318// reads replies and the recipient turned them on (#295).
319func TestNotifyReplyTo(t *testing.T) {
320	key, err := seal.NewKey()
321	if err != nil {
322		t.Fatal(err)
323	}
324	keyFile := t.TempDir() + "/secret.key"
325	if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
326		t.Fatal(err)
327	}
328	ring, err := seal.Load(keyFile)
329	if err != nil {
330		t.Fatal(err)
331	}
332	for _, tc := range []struct {
333		name           string
334		instance, user bool
335		kind           string
336		number         int64
337		want           bool
338	}{
339		{"both", true, true, "issue", 1, true},
340		{"merge request", true, true, "mr", 1, true},
341		{"instance off", false, true, "issue", 1, false},
342		{"user off", true, false, "issue", 1, false},
343		{"build", true, true, "build", 0, false},
344	} {
345		t.Run(tc.name, func(t *testing.T) {
346			c, repo, bob := testRepoWithWatcher(t)
347			c.Store.SetKeyring(ring)
348			c.Cfg.Push.Enabled = false
349			c.Cfg.Mail.SMTPHost, c.Cfg.Mail.From = "mx.example", "gitbay@example.test"
350			c.Cfg.Mail.Inbound = config.MailInbound{Enabled: tc.instance, ReplyAddress: "reply@gitbay.example"}
351			if err := c.Store.AddEmail(bob, "bob@example.test", "admin", true); err != nil {
352				t.Fatal(err)
353			}
354			if err := c.Store.SetReplyEnabled(bob, tc.user); err != nil {
355				t.Fatal(err)
356			}
357			notify(c, []int64{bob}, notice{repo: repo, kind: tc.kind, number: tc.number,
358				subject: "s", action: "commented", path: "alice/app/issues/1"})
359			due, err := c.Store.DueMail(20)
360			if err != nil || len(due) != 1 {
361				t.Fatalf("DueMail = %v, %v", due, err)
362			}
363			got := due[0].ReplyTo
364			if !tc.want {
365				if got != "" || strings.Contains(due[0].Body, "Reply to this mail") {
366					t.Fatalf("Reply-To %q, body %q", got, due[0].Body)
367				}
368				return
369			}
370			tok, ok := mailreply.TokenFrom("reply@gitbay.example", got)
371			if !ok {
372				t.Fatalf("Reply-To %q", got)
373			}
374			secrets, _ := ring.Derive(mailreply.Purpose)
375			target, err := mailreply.Verify(secrets, tok, time.Now())
376			want := mailreply.Target{UserID: bob, RepoID: repo.ID, Kind: tc.kind, Number: 1, Expires: target.Expires}
377			if err != nil || target != want || time.Since(target.Issued()) > time.Minute {
378				t.Fatalf("token names %+v, %v; want %+v", target, err, want)
379			}
380		})
381	}
382}
383
384func TestNotificationsSettingsReply(t *testing.T) {
385	c := notifTestCtx(t, "alice")
386	if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitFailure {
387		t.Fatalf("on without [mail.inbound]: exit %d", code)
388	}
389	c.Cfg.Mail.Inbound.Enabled = true
390	if code := Dispatch(c, []string{"notifications", "settings", "reply", "on"}); code != protocol.ExitOK {
391		t.Fatalf("on: exit %d: %s", code, c.Stdout)
392	}
393	if on, _ := c.Store.ReplyEnabled(c.User.ID); !on {
394		t.Fatal("reply not on")
395	}
396	c.Cfg.Mail.Inbound.Enabled = false
397	if code := Dispatch(c, []string{"notifications", "settings", "reply", "off"}); code != protocol.ExitOK {
398		t.Fatalf("off: exit %d", code)
399	}
400	if on, _ := c.Store.ReplyEnabled(c.User.ID); on {
401		t.Fatal("reply still on")
402	}
403}