aboutsummaryrefslogtreecommitdiff
path: root/mail_test.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /mail_test.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'mail_test.go')
-rw-r--r--mail_test.go331
1 files changed, 331 insertions, 0 deletions
diff --git a/mail_test.go b/mail_test.go
new file mode 100644
index 0000000..c9170f6
--- /dev/null
+++ b/mail_test.go
@@ -0,0 +1,331 @@
+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 <forge@example.com>", 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 <forge@example.com>", "to": "bob@example.com",
+ "subject": "hello", "text": "body",
+ "h:List-Unsubscribe": "<https://git.example.com/settings>",
+ } {
+ 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)
+ }
+}