From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge 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. --- mail_test.go | 331 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 mail_test.go (limited to 'mail_test.go') 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 ", 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) + } +} -- cgit v1.2.3