krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: e2e/registration_test.go · raw

  1package e2e
  2
  3import (
  4	"bufio"
  5	"fmt"
  6	"net"
  7	"regexp"
  8	"strings"
  9	"sync"
 10	"testing"
 11	"time"
 12)
 13
 14// fakeSMTP is a minimal SMTP server capturing delivered messages.
 15type fakeSMTP struct {
 16	addr string
 17	mu   sync.Mutex
 18	mail []string // raw DATA payloads
 19}
 20
 21func startFakeSMTP(t *testing.T) *fakeSMTP {
 22	t.Helper()
 23	ln, err := net.Listen("tcp", "127.0.0.1:0")
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27	t.Cleanup(func() { ln.Close() })
 28	f := &fakeSMTP{addr: ln.Addr().String()}
 29	go func() {
 30		for {
 31			conn, err := ln.Accept()
 32			if err != nil {
 33				return
 34			}
 35			go f.handle(conn)
 36		}
 37	}()
 38	return f
 39}
 40
 41func (f *fakeSMTP) handle(conn net.Conn) {
 42	defer conn.Close()
 43	r := bufio.NewReader(conn)
 44	say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) }
 45	say("220 fake ESMTP")
 46	var data strings.Builder
 47	inData := false
 48	for {
 49		line, err := r.ReadString('\n')
 50		if err != nil {
 51			return
 52		}
 53		line = strings.TrimRight(line, "\r\n")
 54		if inData {
 55			if line == "." {
 56				f.mu.Lock()
 57				f.mail = append(f.mail, data.String())
 58				f.mu.Unlock()
 59				data.Reset()
 60				inData = false
 61				say("250 ok")
 62				continue
 63			}
 64			data.WriteString(line + "\n")
 65			continue
 66		}
 67		switch {
 68		case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
 69			fmt.Fprintf(conn, "250-fake\r\n250 SIZE 1000000\r\n")
 70		case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
 71			say("250 ok")
 72		case line == "DATA":
 73			inData = true
 74			say("354 go")
 75		case line == "QUIT":
 76			say("221 bye")
 77			return
 78		default:
 79			say("250 ok")
 80		}
 81	}
 82}
 83
 84// waitMail returns the nth captured message.
 85func (f *fakeSMTP) waitMail(t *testing.T, n int) string {
 86	t.Helper()
 87	deadline := time.Now().Add(5 * time.Second)
 88	for time.Now().Before(deadline) {
 89		f.mu.Lock()
 90		if len(f.mail) > n {
 91			m := f.mail[n]
 92			f.mu.Unlock()
 93			return m
 94		}
 95		f.mu.Unlock()
 96		time.Sleep(50 * time.Millisecond)
 97	}
 98	t.Fatalf("mail %d never arrived", n)
 99	return ""
100}
101
102var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`)
103
104func extractCode(t *testing.T, mail string) string {
105	t.Helper()
106	m := codePat.FindStringSubmatch(mail)
107	if m == nil {
108		t.Fatalf("no code in mail:\n%s", mail)
109	}
110	return m[1]
111}
112
113func TestOpenRegistration(t *testing.T) {
114	smtp := startFakeSMTP(t)
115	inst := startInstanceWith(t, fmt.Sprintf(
116		"[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
117
118	// A stranger's key cannot run normal commands, and the denial explains
119	// how to register.
120	newKey := inst.newKey(t, "newcomer")
121	_, errOut, code := inst.ssh(t, newKey, "", "whoami")
122	if code != 4 || !strings.Contains(errOut, "register --username") {
123		t.Fatalf("stranger whoami: exit %d, %s", code, errOut)
124	}
125
126	// Register: account created pending, verification mail sent.
127	out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test")
128	if code != 0 {
129		t.Fatalf("register: exit %d, %s", code, errOut)
130	}
131	if !strings.Contains(out, "verification code was sent") {
132		t.Fatalf("register output: %s", out)
133	}
134	msg := smtp.waitMail(t, 0)
135	if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") {
136		t.Fatalf("mail headers:\n%s", msg)
137	}
138
139	// Pending: the key authenticates, whoami works, but everything else is
140	// gated — control commands and git alike.
141	if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" {
142		t.Fatalf("pending whoami: %d %q", code, out)
143	}
144	_, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj")
145	if code != 4 || !strings.Contains(errOut, "not active yet") {
146		t.Fatalf("pending repo create: exit %d, %s", code, errOut)
147	}
148	cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything"))
149	if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") {
150		t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut)
151	}
152
153	// A wrong code fails; the mailed code activates the account.
154	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 {
155		t.Fatalf("bad code: exit %d, want 2", code)
156	}
157	verifyCode := extractCode(t, msg)
158	out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode)
159	if code != 0 || !strings.Contains(out, "account is active") {
160		t.Fatalf("verify: exit %d, %s%s", code, out, errOut)
161	}
162	// Single use.
163	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 {
164		t.Fatalf("code reuse: exit %d, want 2", code)
165	}
166
167	// Fully active: repo create works, and the verified email makes
168	// signature verification meaningful (verified_by = smtp).
169	if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 {
170		t.Fatalf("post-verify repo create: %s", errOut)
171	}
172
173	// Self-service email add on an existing account sends a second mail.
174	if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 {
175		t.Fatalf("email add: %s", errOut)
176	}
177	msg2 := smtp.waitMail(t, 1)
178	if !strings.Contains(msg2, "To: dana2@example.test") {
179		t.Fatalf("second mail:\n%s", msg2)
180	}
181	if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 {
182		t.Fatal("second verify failed")
183	}
184}
185
186func TestInviteRegistration(t *testing.T) {
187	smtp := startFakeSMTP(t)
188	inst := startInstanceWith(t, fmt.Sprintf(
189		"[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
190
191	// Registering without an invite is refused.
192	newKey := inst.newKey(t, "guest")
193	_, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test")
194	if code != 4 || !strings.Contains(errOut, "invite-only") {
195		t.Fatalf("uninvited register: exit %d, %s", code, errOut)
196	}
197
198	// Admin issues an invite; the code arrives by mail.
199	out := inst.admin(t, "admin", "invite", "--email", "erin@example.test")
200	if !strings.Contains(out, "invite emailed") {
201		t.Fatalf("invite output: %s", out)
202	}
203	inviteCode := extractCode(t, smtp.waitMail(t, 0))
204
205	// Redeeming it creates an ACTIVE account: code possession proves the
206	// mailbox, so the email is verified (by smtp) and nothing is pending.
207	out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode)
208	if code != 0 || !strings.Contains(out, "account is active") {
209		t.Fatalf("invite register: exit %d, %s%s", code, out, errOut)
210	}
211	if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 {
212		t.Fatalf("invited user repo create: %s", errOut)
213	}
214
215	// Invites are single-use.
216	otherKey := inst.newKey(t, "other")
217	_, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode)
218	if code != 4 || !strings.Contains(errOut, "already used") {
219		t.Fatalf("invite reuse: exit %d, %s", code, errOut)
220	}
221}