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