e2e/registration_test.go

e9566eed86ebcd185c4b85f63d667b5e671fe787
gitbay/e2e/registration_test.go history · blame · raw

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}