internal/control/notifications_test.go
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}