krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: 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}