e2e/registration_test.go

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

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