e2e/registration_test.go

v1.43.0
gitbay/e2e/registration_test.go history · blame · raw

257 lines · 8707 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	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}