e2e/registration_test.go
255 lines · 8679 bytes
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 // Registering with an address already on an account is refused
127 // atomically (the username stays free for the real attempt).
128 inst.admin(t, "admin", "user", "create", "existing", "--key", inst.newKey(t, "existing")+".pub", "--email", "taken@example.test")
129 _, errOut2, code2 := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "taken@example.test")
130 if code2 != 2 || !strings.Contains(errOut2, "already belongs") {
131 t.Fatalf("open register with taken email: exit %d, %s", code2, errOut2)
132 }
133
134 // Register: account created pending, verification mail sent.
135 out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test")
136 if code != 0 {
137 t.Fatalf("register: exit %d, %s", code, errOut)
138 }
139 if !strings.Contains(out, "verification code was sent") {
140 t.Fatalf("register output: %s", out)
141 }
142 msg := smtp.waitMail(t, 0)
143 if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") {
144 t.Fatalf("mail headers:\n%s", msg)
145 }
146 if !strings.Contains(msg, "/login") {
147 t.Fatalf("mail missing web sign-in path:\n%s", msg)
148 }
149
150 // Pending: the key authenticates, whoami works, but everything else is
151 // gated — control commands and git alike.
152 if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" {
153 t.Fatalf("pending whoami: %d %q", code, out)
154 }
155 _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj")
156 if code != 4 || !strings.Contains(errOut, "not active yet") {
157 t.Fatalf("pending repo create: exit %d, %s", code, errOut)
158 }
159 cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything"))
160 if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") {
161 t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut)
162 }
163
164 // A wrong code fails; the mailed code activates the account.
165 if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 {
166 t.Fatalf("bad code: exit %d, want 2", code)
167 }
168 verifyCode := extractCode(t, msg)
169 out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode)
170 if code != 0 || !strings.Contains(out, "account is active") {
171 t.Fatalf("verify: exit %d, %s%s", code, out, errOut)
172 }
173 // Single use.
174 if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 {
175 t.Fatalf("code reuse: exit %d, want 2", code)
176 }
177
178 // Fully active: repo create works, and the verified email makes
179 // signature verification meaningful (verified_by = smtp).
180 if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 {
181 t.Fatalf("post-verify repo create: %s", errOut)
182 }
183
184 // Self-service email add on an existing account sends a second mail.
185 if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 {
186 t.Fatalf("email add: %s", errOut)
187 }
188 msg2 := smtp.waitMail(t, 1)
189 if !strings.Contains(msg2, "To: dana2@example.test") {
190 t.Fatalf("second mail:\n%s", msg2)
191 }
192 if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 {
193 t.Fatal("second verify failed")
194 }
195}
196
197func TestInviteRegistration(t *testing.T) {
198 smtp := startFakeSMTP(t)
199 inst := startInstanceWith(t, fmt.Sprintf(
200 "[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
201
202 // Registering without an invite is refused.
203 newKey := inst.newKey(t, "guest")
204 _, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test")
205 if code != 4 || !strings.Contains(errOut, "invite-only") {
206 t.Fatalf("uninvited register: exit %d, %s", code, errOut)
207 }
208
209 // Admin issues an invite; the code arrives by mail.
210 out := inst.admin(t, "admin", "invite", "--email", "erin@example.test")
211 if !strings.Contains(out, "invite emailed") {
212 t.Fatalf("invite output: %s", out)
213 }
214 inviteCode := extractCode(t, smtp.waitMail(t, 0))
215
216 // Redeeming it creates an ACTIVE account: code possession proves the
217 // mailbox, so the email is verified (by smtp) and nothing is pending.
218 out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode)
219 if code != 0 || !strings.Contains(out, "account is active") {
220 t.Fatalf("invite register: exit %d, %s%s", code, out, errOut)
221 }
222 if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 {
223 t.Fatalf("invited user repo create: %s", errOut)
224 }
225
226 // Invites are single-use.
227 otherKey := inst.newKey(t, "other")
228 _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode)
229 if code != 4 || !strings.Contains(errOut, "already used") {
230 t.Fatalf("invite reuse: exit %d, %s", code, errOut)
231 }
232
233 // Atomicity: a failed redemption (taken username) leaves the invite
234 // redeemable and no partial account.
235 inst.admin(t, "admin", "invite", "--email", "gray@example.test")
236 code2 := extractCode(t, smtp.waitMail(t, 1))
237 if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "erin", "--invite", code2); code != 2 || !strings.Contains(errOut, "taken") {
238 t.Fatalf("taken-name register: exit %d, %s", code, errOut)
239 }
240 if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "gray", "--invite", code2); code != 0 {
241 t.Fatalf("invite not redeemable after failed attempt: %s", errOut)
242 }
243
244 // Inviting an address that already has an account is refused.
245 out2 := inst.forgedAdminErr(t, "admin", "invite", "--email", "erin@example.test")
246 if !strings.Contains(out2, "already belongs") {
247 t.Fatalf("invite to existing address: %s", out2)
248 }
249
250 // A registered key running register gets a pointer, not confusion.
251 _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "again", "--invite", "x")
252 if code != 2 || !strings.Contains(errOut, "already belongs to gray") {
253 t.Fatalf("register with known key: exit %d, %s", code, errOut)
254 }
255}