package main import ( "bytes" "io" "log" "net/http" "net/http/httptest" "net/url" "os" "strings" "sync" "testing" ) // recorder is a Mailer that remembers instead of sending. type recorder struct { mu sync.Mutex sent []Message fail bool } func (m *recorder) Send(msg Message) error { m.mu.Lock() defer m.mu.Unlock() if m.fail { return io.ErrUnexpectedEOF } m.sent = append(m.sent, msg) return nil } func (m *recorder) messages() []Message { m.mu.Lock() defer m.mu.Unlock() return append([]Message(nil), m.sent...) } func TestSpoolDrainsOldestFirst(t *testing.T) { dir := t.TempDir() driver := &recorder{} spool := &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)} for _, to := range []string{"one@example.com", "two@example.com", "three@example.com"} { spool.Enqueue(Message{To: to, Subject: "hello", Body: "body"}) } if entries, _ := os.ReadDir(dir); len(entries) != 3 { t.Fatalf("expected three spooled messages, got %d", len(entries)) } if spool.drainOnce() { t.Fatal("draining reported a failure") } sent := driver.messages() if len(sent) != 3 || sent[0].To != "one@example.com" || sent[2].To != "three@example.com" { t.Errorf("sent out of order: %v", sent) } if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Errorf("the spool still holds %d messages", len(entries)) } // A message with no recipient is not a message. spool.Enqueue(Message{Subject: "nobody"}) if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Error("a message with no recipient was spooled") } } // The line that says a message was dropped has to name it without carrying it: // the body of one of these is a capability. func TestSpoolGivesUpLoudly(t *testing.T) { dir := t.TempDir() spool := &Mail{dir: dir, driver: &recorder{fail: true}, wake: make(chan struct{}, 1)} spool.Enqueue(Message{To: "one@example.com", Subject: "set a new password", Body: "https://forge.example/set-password?name=bob&t=the-capability\n"}) var buf bytes.Buffer old := log.Writer() log.SetOutput(&buf) for i := 0; i < mailAttempts; i++ { if !spool.drainOnce() { log.SetOutput(old) t.Fatalf("attempt %d: a failing driver reported success", i) } } log.SetOutput(old) if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Errorf("a message that will never send is still queued after %d attempts", mailAttempts) } for _, want := range []string{"giving up", "set a new password", "one@example.com"} { if !strings.Contains(buf.String(), want) { t.Errorf("the log does not say %q:\n%s", want, buf.String()) } } if strings.Contains(buf.String(), "the-capability") { t.Errorf("a one time link reached the log:\n%s", buf.String()) } } // The none driver is off: the log names what was not sent and never carries // it, since the body of one of these is a capability. func TestNoneDriverNamesWhatItDrops(t *testing.T) { if _, ok := newMail(&Config{Mail: MailConfig{Driver: "log"}}, t.TempDir()).driver.(logMailer); !ok { t.Error("the log driver is not the one that logs") } if _, ok := newMail(defaultConfig(), t.TempDir()).driver.(dropMailer); !ok { t.Error("the default driver sends somewhere") } var buf bytes.Buffer old := log.Writer() log.SetOutput(&buf) err := dropMailer{}.Send(Message{To: "bob@example.com", Subject: "set a new password", Body: "https://forge.example/set-password?name=bob&t=the-capability\n"}) log.SetOutput(old) if err != nil { t.Fatal(err) } for _, want := range []string{"set a new password", "bob@example.com"} { if !strings.Contains(buf.String(), want) { t.Errorf("the log does not say %q:\n%s", want, buf.String()) } } if strings.Contains(buf.String(), "the-capability") { t.Errorf("a one time link reached the log:\n%s", buf.String()) } } func TestMailgunDriver(t *testing.T) { var got url.Values var user, key string var ok bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, key, ok = r.BasicAuth() r.ParseForm() got = r.PostForm if r.URL.Path != "/v3/mg.example.com/messages" { t.Errorf("posted to %s", r.URL.Path) } })) defer srv.Close() driver := &mailgunMailer{ from: "git-forge ", base: srv.URL + "/v3", domain: "mg.example.com", key: "secret", list: "https://git.example.com/settings", client: srv.Client(), } err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body", Unsubscribe: true}) if err != nil { t.Fatal(err) } if !ok || user != "api" || key != "secret" { t.Errorf("basic auth was %q %q %v", user, key, ok) } for field, want := range map[string]string{ "from": "git-forge ", "to": "bob@example.com", "subject": "hello", "text": "body", "h:List-Unsubscribe": "", } { if got.Get(field) != want { t.Errorf("%s was %q, wanted %q", field, got.Get(field), want) } } // A message that ignores the toggle carries no unsubscribe header. if err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body"}); err != nil { t.Fatal(err) } if got.Has("h:List-Unsubscribe") { t.Error("a notice about the account itself carried List-Unsubscribe") } } func TestIssueNotifiesGranteesOnly(t *testing.T) { f := testForge(t) driver := &recorder{} f.mail.driver = driver addUser(t, f, "alice", "hunter2hunter2") addUser(t, f, "bob", "hunter2hunter2") addUser(t, f, "carol", "hunter2hunter2") confirm(t, f, "alice", "alice@example.com", true) confirm(t, f, "bob", "bob@example.com", true) confirm(t, f, "carol", "carol@example.com", false) // notify off addRepo(t, f, "notes", Repo{ Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, Grants: map[string]string{"alice": grantWrite, "bob": grantRead, "carol": grantRead}, }) h := f.routes() session := signIn(t, f, h, "alice") form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"A bug"}, "body": {"Details."}} if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { t.Fatalf("opening an issue: status %d", code) } f.mail.drainOnce() sent := driver.messages() if len(sent) != 1 { t.Fatalf("expected one message, got %v", sent) } if sent[0].To != "bob@example.com" { t.Errorf("mailed %s: alice caused it and carol has notifications off", sent[0].To) } if !sent[0].Unsubscribe { t.Error("an activity message did not carry the unsubscribe flag") } if !strings.Contains(sent[0].Body, "/notes/issues/1") { t.Errorf("the message does not link to the issue: %q", sent[0].Body) } } // An address receives nothing until a link sent to it has been followed, and // changing it again unproves it and spends the link sent to the old one. func TestAddressIsProvedByALink(t *testing.T) { f := testForge(t) driver := &recorder{} f.mail.driver = driver addUser(t, f, "bob", "hunter2hunter2") h := f.routes() session := signIn(t, f, h, "bob") token := f.csrf(withCookies("/", session)) set := func(email string) { t.Helper() w := post(h, "/settings/email", url.Values{ "csrf": {token}, "email": {email}, "notify": {"yes"}}, session) if w.Code != http.StatusOK { t.Fatalf("setting %q: status %d", email, w.Code) } f.mail.drainOnce() } set("bob@example.com") if u, _ := f.store.User("bob"); u.EmailConfirmed { t.Fatal("an address was confirmed without the link being followed") } sent := driver.messages() if len(sent) != 1 || sent[0].To != "bob@example.com" { t.Fatalf("expected one message to the address, got %v", sent) } link := between(sent[0].Body, f.cfg.BaseURL, "\n") if !strings.HasPrefix(link, "/confirm-email?") { t.Fatalf("no confirmation link in %q", sent[0].Body) } if code := get(h, link, session).Code; code != http.StatusOK { t.Fatalf("following the link: status %d", code) } if u, _ := f.store.User("bob"); !u.EmailConfirmed { t.Error("the address is still unconfirmed after the link was followed") } set("other@example.com") if u, _ := f.store.User("bob"); u.EmailConfirmed { t.Error("a changed address kept the old one's confirmation") } if code := get(h, link, session).Code; code != http.StatusNotFound { t.Errorf("the link to the old address still works: status %d", code) } } // The one unauthenticated path that sends a capability. The answer is the same // either way, or the form is a list of which names are taken. func TestResetTellsNobodyWhichAccountsExist(t *testing.T) { f := testForge(t) driver := &recorder{} f.mail.driver = driver addUser(t, f, "bob", "hunter2hunter2") confirm(t, f, "bob", "bob@example.com", true) h := f.routes() ask := func(name string) string { t.Helper() w := post(h, "/reset", url.Values{ "csrf": {f.csrf(withCookies("/", nil))}, "name": {name}}, nil) if w.Code != http.StatusOK { t.Fatalf("asking for %q: status %d", name, w.Code) } f.mail.drainOnce() return w.Body.String() } const same = "if that account exists" if page := ask("bob"); !strings.Contains(page, same) { t.Error("the answer for an account that exists does not carry the notice") } sent := driver.messages() if len(sent) != 1 || sent[0].To != "bob@example.com" { t.Fatalf("no link went to the account: %v", sent) } if page := ask("nobody"); !strings.Contains(page, same) { t.Error("the answer for an account that does not exist reads differently") } if n := len(driver.messages()); n != 1 { t.Errorf("a reset for an account that does not exist sent mail: %d", n) } // And the link it did send sets a password once. link := between(sent[0].Body, f.cfg.BaseURL, "\n") page := get(h, link, nil) if page.Code != http.StatusOK { t.Fatalf("following the link: status %d", page.Code) } body := page.Body.String() form := url.Values{ "csrf": {f.csrf(withCookies("/", nil))}, "name": {between(body, `name="name" value="`, `"`)}, "expires": {between(body, `name="expires" value="`, `"`)}, "t": {between(body, `name="t" value="`, `"`)}, "new": {"opensesame1"}, "again": {"opensesame1"}, } if w := post(h, "/set-password", form, nil); w.Code != http.StatusSeeOther { t.Fatalf("setting the password: status %d", w.Code) } if u, _ := f.store.User("bob"); !passwordOK(u.Password, "opensesame1") { t.Error("the new password was not stored") } if w := post(h, "/set-password", form, nil); w.Code != http.StatusNotFound { t.Errorf("the link worked twice: status %d", w.Code) } } func confirm(t *testing.T, f *Forge, name, email string, notify bool) { t.Helper() err := f.store.UpdateUsers("users: address", f.store.forgeAuthor(), func(m map[string]User) error { u := m[name] u.Email, u.EmailConfirmed, u.Notify = email, true, notify m[name] = u return nil }) if err != nil { t.Fatal(err) } }