diff --git a/deliberi/xrpc/account.go b/deliberi/xrpc/account.go --- a/deliberi/xrpc/account.go +++ b/deliberi/xrpc/account.go @@ -12,6 +12,7 @@ tangled "tangled.org/core/api/org_tangled" appviewemail "tangled.org/core/appview/email" db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/emailtemplate" xrpcerr "tangled.org/core/xrpc/errors" ) @@ -224,7 +225,12 @@ link := x.verifyLink(token) text := "Verify your email address on Tangled: " + link - html := "

Verify your email address on Tangled

\n

or open: " + link + "

" + body := `

Verify your email address on Tangled by clicking the link below:

+

+ Verify email address +

+

or open: ` + link + `

` + html := emailtemplate.Shell("Verify your email on Tangled", body, x.Config.BaseURL) if err := x.Sender.Send(addr, "Verify your email on Tangled", text, html); err != nil { l.Error("failed to send verification email", "err", err) writeError(w, errInternal, http.StatusInternalServerError) diff --git a/deliberi/xrpc/signup.go b/deliberi/xrpc/signup.go --- a/deliberi/xrpc/signup.go +++ b/deliberi/xrpc/signup.go @@ -2,6 +2,7 @@ import ( "bytes" + "database/sql" "encoding/json" "errors" "fmt" @@ -9,16 +10,30 @@ "net/http" "net/url" "regexp" + "strings" + "time" tangled "tangled.org/core/api/org_tangled" appviewemail "tangled.org/core/appview/email" db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/emailtemplate" "tangled.org/core/deliberi/models" ) var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) var errEmailAlreadyRegistered = errors.New("email already registered") + +// signupEmailCooldown caps scripted email spam while turnstile verification is unwired. +const signupEmailCooldown = time.Minute + +func withinSignupCooldown(lastSent string) bool { + t, err := time.Parse(time.RFC3339, lastSent) + if err != nil { + return false + } + return time.Since(t) < signupEmailCooldown +} func isValidSubdomain(name string) bool { return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) @@ -55,6 +70,24 @@ return } + if pending, err := db.GetInflightByEmail(x.DB, email); err == nil && withinSignupCooldown(pending.LastSent) { + writeError(w, xrpcErrorTag("TooManyRequests", "please wait a moment before requesting another email"), http.StatusTooManyRequests) + return + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + l.Error("failed to check pending signup", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + token, err := generateVerificationToken() + if err != nil { + l.Error("failed to generate verification token", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + // the invite code never leaves deliberi: the emailed link only carries the + // verification token, and completeSignup redeems the stored code internally code, err := x.pdsCreateInviteCode() if err != nil { l.Error("failed to create invite code", "err", err) @@ -62,21 +95,88 @@ return } - if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: email, InviteCode: code}); err != nil { + if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: email, InviteCode: code, VerificationToken: token}); err != nil { l.Error("failed to add inflight signup", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } - text := "Copy and paste this code below to verify your account on Tangled.\n" + code - html := "

Copy and paste this code below to verify your account on Tangled.

\n

" + code + "

" - if err := x.Sender.Send(email, "Verify your Tangled account", text, html); err != nil { - l.Error("failed to send verification email", "err", err) + link := x.signupLink(token) + text, html := signupLinkEmail(link, x.Config.BaseURL) + if err := x.Sender.Send(email, "Complete your Tangled signup", text, html); err != nil { + l.Error("failed to send signup email", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) +} + +// AccountResendSignup re-mails the same live link — same token, no new invite code. +func (x *Xrpc) AccountResendSignup(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountResendSignup") + + if !x.Config.SignupEnabled() { + writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) + return + } + + var input tangled.TempAccountResendSignup_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + email := canonicalEmail(input.Email) + if !appviewemail.IsValidEmail(email) { + writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) + return + } + + inflight, err := db.GetInflightByEmail(x.DB, email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + if serr := db.DeleteExpiredInflightSignups(x.DB); serr != nil { + l.Error("failed to sweep expired signups", "err", serr) + } + writeError(w, xrpcErrorTag("NoPendingSignup", "there is no pending signup for this email; start again from the signup page"), http.StatusNotFound) + return + } + l.Error("failed to get inflight signup", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if withinSignupCooldown(inflight.LastSent) { + writeError(w, xrpcErrorTag("TooManyRequests", "please wait a moment before requesting another email"), http.StatusTooManyRequests) + return + } + + link := x.signupLink(inflight.VerificationToken) + text, html := signupLinkEmail(link, x.Config.BaseURL) + if err := x.Sender.Send(email, "Complete your Tangled signup", text, html); err != nil { + l.Error("failed to resend signup email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if err := db.UpdateInflightLastSent(x.DB, email); err != nil { + l.Error("failed to record resend", "err", err) + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) signupLink(token string) string { + return strings.TrimSuffix(x.Config.BaseURL, "/") + "/signup/verify?token=" + token +} + +func signupLinkEmail(link, baseURL string) (text, html string) { + text = "Complete your Tangled signup: " + link + body := `

Complete your Tangled signup by clicking the link below:

+

+ Complete signup +

+

or open: ` + link + `

` + return text, emailtemplate.Shell("Complete your Tangled signup", body, baseURL) } func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { @@ -93,20 +193,33 @@ return } + token := strings.TrimSpace(input.Token) + if token == "" { + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest) + return + } + if !isValidSubdomain(input.Username) { writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest) return } - emailAddr, err := db.GetEmailForCode(x.DB, input.Code) + inflight, err := db.GetInflightByToken(x.DB, token) if err != nil { - l.Error("failed to get email for code", "err", err) - writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) + if errors.Is(err, sql.ErrNoRows) { + if serr := db.DeleteExpiredInflightSignups(x.DB); serr != nil { + l.Error("failed to sweep expired signups", "err", serr) + } + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest) + return + } + l.Error("failed to get inflight signup for token", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) return } - emailAddr = canonicalEmail(emailAddr) + emailAddr := canonicalEmail(inflight.Email) - did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) + did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, inflight.InviteCode) if err != nil { if errors.Is(err, errEmailAlreadyRegistered) { writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) @@ -148,14 +261,15 @@ did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code) if err != nil { - return "", "", err + return did, handle, err } if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { if db.IsUniqueConstraintErr(err) { - return "", "", errEmailAlreadyRegistered + // keep did/handle so the defer rolls the PDS account back + return did, handle, errEmailAlreadyRegistered } - return "", "", err + return did, handle, err } emailAdded = true diff --git a/deliberi/xrpc/signup_test.go b/deliberi/xrpc/signup_test.go new file mode 100644 --- /dev/null +++ b/deliberi/xrpc/signup_test.go @@ -0,0 +1,528 @@ +package xrpc + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + config "tangled.org/core/deliberi/config" + db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/mailer" + "tangled.org/core/deliberi/models" +) + +const ( + testInviteCode = "invite-code-abc123" + testNewDid = "did:plc:newuser" +) + +const testAdminSecret = "test-admin-secret" + +// pdsRecorder stubs the PDS endpoints the signup handlers call and records them. +type pdsRecorder struct { + mu sync.Mutex + inviteMints int + createAccount []map[string]any + adminDeletes []string +} + +func (p *pdsRecorder) InviteMints() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.inviteMints +} + +func (p *pdsRecorder) CreateAccounts() []map[string]any { + p.mu.Lock() + defer p.mu.Unlock() + return append([]map[string]any(nil), p.createAccount...) +} + +func (p *pdsRecorder) AdminDeletes() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.adminDeletes...) +} + +func newPdsStub(t *testing.T, rec *pdsRecorder) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/com.atproto.server.createInviteCode", func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "admin" || pass != testAdminSecret { + w.WriteHeader(http.StatusUnauthorized) + return + } + rec.mu.Lock() + rec.inviteMints++ + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"code":"`+testInviteCode+`"}`) + }) + mux.HandleFunc("/xrpc/com.atproto.server.createAccount", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + rec.mu.Lock() + rec.createAccount = append(rec.createAccount, body) + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"did":"`+testNewDid+`"}`) + }) + mux.HandleFunc("/xrpc/com.atproto.admin.deleteAccount", func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "admin" || pass != testAdminSecret { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + rec.mu.Lock() + rec.adminDeletes = append(rec.adminDeletes, body["did"]) + rec.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func newSignupXrpc(t *testing.T, baseURL ...string) (http.Handler, *db.DB, *kvRecorder, *pdsRecorder) { + t.Helper() + rec := &kvRecorder{} + pds := &pdsRecorder{} + srv := newPdsStub(t, pds) + base := "https://tangled.example" + if len(baseURL) > 0 && baseURL[0] != "" { + base = baseURL[0] + } + x, router, d, _ := newTestXrpcFull(t, func(x *Xrpc) { + x.Config.Pds.AdminSecret = testAdminSecret + x.Config.Pds.Host = srv.URL + x.Config.BaseURL = base + x.Sender = mailer.New(config.ResendConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + x.KV = rec + }) + _ = x + return router, d, rec, pds +} + +func TestAccountBeginSignupStoresTokenAndSendsLink(t *testing.T) { + router, d, kvRec, _ := newSignupXrpc(t) + + emitted := captureStdout(t, func() { + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":" Alice@Example.com ","turnstileToken":"test"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + }) + + if !strings.Contains(emitted, "to: alice@example.com") { + t.Fatalf("email not addressed to canonical alice@example.com in:\n%s", emitted) + } + + token := "" + invite := "" + rows, err := d.Query(`select verification_token, invite_code from signups_inflight where email = 'alice@example.com'`) + if err != nil { + t.Fatalf("query inflight: %v", err) + } + defer rows.Close() + if rows.Next() { + rows.Scan(&token, &invite) + } + if token == "" { + t.Fatal("inflight row missing verification_token") + } + assertVerificationToken(t, token) + if invite == "" { + t.Fatal("inflight row missing invite_code") + } + + if !strings.Contains(emitted, "/signup/verify?token="+token) { + t.Fatalf("email link missing verification token; got:\n%s", emitted) + } + if strings.Contains(emitted, invite) { + t.Fatalf("email leaked the invite code %q:\n%s", invite, emitted) + } + + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupRejectsRegisteredEmail(t *testing.T) { + router, d, kvRec, _ := newSignupXrpc(t) + + if err := db.AddEmail(d, models.Email{Did: testNewDid, Address: "alice@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"alice@example.com","turnstileToken":"test"}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + if !strings.Contains(rec.Body.String(), "EmailAlreadyRegistered") { + t.Fatalf("error body = %s, want EmailAlreadyRegistered", rec.Body.String()) + } + kvRec.assertIdle(t) +} + +func TestAccountCompleteSignupWithToken(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + + token, err := generateVerificationToken() + if err != nil { + t.Fatalf("generate token: %v", err) + } + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "bob@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":"`+token+`","username":"bobby","password":"password1234"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var out tangledTempAccountCompleteSignupOutput + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if out.Did != testNewDid { + t.Fatalf("did = %q, want %q", out.Did, testNewDid) + } + if out.Handle == "" || !strings.HasPrefix(out.Handle, "bobby.") { + t.Fatalf("handle = %q, want bobby.", out.Handle) + } + + accounts := pds.CreateAccounts() + if len(accounts) != 1 { + t.Fatalf("createAccount calls = %d, want 1", len(accounts)) + } + body := accounts[0] + if body["inviteCode"] != testInviteCode { + t.Fatalf("createAccount inviteCode = %v, want %q", body["inviteCode"], testInviteCode) + } + if body["email"] != "bob@example.com" { + t.Fatalf("createAccount email = %v, want bob@example.com", body["email"]) + } + if body["handle"] != out.Handle { + t.Fatalf("createAccount handle = %v, want %q", body["handle"], out.Handle) + } + + row := emailByAddress(t, d, testNewDid, "bob@example.com") + if !row.Verified || !row.Primary { + t.Fatalf("email row = verified:%v primary:%v, want verified+primary", row.Verified, row.Primary) + } + + emailDids, primaries, deletes := kvRec.snapshot() + if len(emailDids) != 1 || emailDids[0] != "bob@example.com|"+testNewDid { + t.Errorf("PutEmailDid calls = %v, want [bob@example.com|%s]", emailDids, testNewDid) + } + if len(primaries) != 1 || primaries[0] != testNewDid+"|bob@example.com" { + t.Errorf("SetPrimaryEmail calls = %v, want [%s|bob@example.com]", primaries, testNewDid) + } + if len(deletes) != 0 { + t.Errorf("unexpected KV deletes: %v", deletes) + } + + // deletion runs in a goroutine; poll briefly + deadline := time.Now().Add(2 * time.Second) + for { + _, err := db.GetInflightByToken(d, token) + if errors.Is(err, sql.ErrNoRows) { + break + } else if err == nil && time.Now().After(deadline) { + t.Fatal("inflight row still present after signup") + } else if err != nil { + t.Fatalf("GetInflightByToken: %v", err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestAccountCompleteSignupBadToken(t *testing.T) { + router, d, kvRec, _ := newSignupXrpc(t) + + token, _ := generateVerificationToken() + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "bob@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef","username":"bobby","password":"password1234"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "InvalidCode") { + t.Fatalf("error body = %s, want InvalidCode", rec.Body.String()) + } + + if _, err := db.GetInflightByToken(d, token); err != nil { + t.Fatalf("inflight row lost on bad token: %v", err) + } + kvRec.assertIdle(t) +} + +func TestAccountCompleteSignupInvalidUsername(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t) + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":"`+strings.Repeat("ab", 32)+`","username":"UPPER","password":"password1234"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "InvalidUsername") { + t.Fatalf("error body = %s, want InvalidUsername", rec.Body.String()) + } + kvRec.assertIdle(t) +} + +func TestAccountCompleteSignupMissingToken(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t) + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":" ","username":"bobby","password":"password1234"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "InvalidCode") { + t.Fatalf("error body = %s, want InvalidCode", rec.Body.String()) + } + kvRec.assertIdle(t) +} + +func inflightByEmail(t *testing.T, d *db.DB, email string) models.InflightSignup { + t.Helper() + row, err := db.GetInflightByEmail(d, email) + if err != nil { + t.Fatalf("GetInflightByEmail(%s): %v", email, err) + } + return row +} + +func expireInflight(t *testing.T, d *db.DB, email string) { + t.Helper() + if _, err := d.Exec(`update signups_inflight set expires_at = '2000-01-01T00:00:00Z' where email = ?`, email); err != nil { + t.Fatalf("expire inflight: %v", err) + } +} + +func backdateInflightSent(t *testing.T, d *db.DB, email string) { + t.Helper() + if _, err := d.Exec(`update signups_inflight set last_sent = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 minutes') where email = ?`, email); err != nil { + t.Fatalf("backdate last_sent: %v", err) + } +} + +func inflightCount(t *testing.T, d *db.DB, email string) int { + t.Helper() + var n int + if err := d.QueryRow(`select count(*) from signups_inflight where email = ?`, email).Scan(&n); err != nil { + t.Fatalf("count inflight: %v", err) + } + return n +} + +func TestAccountResendSignupResendsSameLink(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"Erin@Example.com","turnstileToken":"test"}`) + if rec.Code != http.StatusOK { + t.Fatalf("begin status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if mints := pds.InviteMints(); mints != 1 { + t.Fatalf("invite mints after begin = %d, want 1", mints) + } + row := inflightByEmail(t, d, "erin@example.com") + backdateInflightSent(t, d, "erin@example.com") + + emitted := captureStdout(t, func() { + res := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"erin@example.com"}`) + if res.Code != http.StatusOK { + t.Fatalf("resend status = %d, want 200; body=%s", res.Code, res.Body.String()) + } + }) + + if !strings.Contains(emitted, "to: erin@example.com") { + t.Fatalf("resend not addressed to erin@example.com in:\n%s", emitted) + } + if !strings.Contains(emitted, "/signup/verify?token="+row.VerificationToken) { + t.Fatalf("resend link differs from the stored token in:\n%s", emitted) + } + if mints := pds.InviteMints(); mints != 1 { + t.Fatalf("invite mints after resend = %d, want still 1 (no new code)", mints) + } + if after := inflightByEmail(t, d, "erin@example.com"); after.VerificationToken != row.VerificationToken { + t.Fatalf("resend rotated the token: %q -> %q", row.VerificationToken, after.VerificationToken) + } + kvRec.assertIdle(t) +} + +func TestAccountResendSignupNoPending(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t) + + rec := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"nobody@example.com"}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "NoPendingSignup") { + t.Fatalf("error body = %s, want NoPendingSignup", rec.Body.String()) + } + kvRec.assertIdle(t) +} + +func TestAccountResendSignupExpiredSweeps(t *testing.T) { + router, d, kvRec, _ := newSignupXrpc(t) + + token, _ := generateVerificationToken() + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "erin@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + expireInflight(t, d, "erin@example.com") + + rec := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"erin@example.com"}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "NoPendingSignup") { + t.Fatalf("error body = %s, want NoPendingSignup", rec.Body.String()) + } + if n := inflightCount(t, d, "erin@example.com"); n != 0 { + t.Fatalf("expired row still present after resend miss (count=%d)", n) + } + kvRec.assertIdle(t) +} + +func TestAccountCompleteSignupExpiredToken(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + + token, _ := generateVerificationToken() + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "erin@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + expireInflight(t, d, "erin@example.com") + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":"`+token+`","username":"erin","password":"password1234"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "InvalidCode") { + t.Fatalf("error body = %s, want InvalidCode", rec.Body.String()) + } + if n := inflightCount(t, d, "erin@example.com"); n != 0 { + t.Fatalf("expired row still present after completeSignup miss (count=%d)", n) + } + if accounts := pds.CreateAccounts(); len(accounts) != 0 { + t.Fatalf("createAccount called with an expired token: %v", accounts) + } + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupNormalizesTrailingSlashBaseURL(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t, "https://tangled.example/") + + emitted := captureStdout(t, func() { + rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"gina-signup@example.com","turnstileToken":"test"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + }) + + if strings.Contains(emitted, "//signup/verify") { + t.Fatalf("emailed link contains a double slash:\n%s", emitted) + } + if !strings.Contains(emitted, "https://tangled.example/signup/verify?token=") { + t.Fatalf("emailed link missing normalized base in:\n%s", emitted) + } + kvRec.assertIdle(t) +} + +func TestAccountBeginSignupCooldown(t *testing.T) { + router, _, kvRec, _ := newSignupXrpc(t) + + first := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"henry-signup@example.com","turnstileToken":"test"}`) + if first.Code != http.StatusOK { + t.Fatalf("first begin status = %d, want 200; body=%s", first.Code, first.Body.String()) + } + + second := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"henry-signup@example.com","turnstileToken":"test"}`) + if second.Code != http.StatusTooManyRequests { + t.Fatalf("immediate re-begin status = %d, want 429; body=%s", second.Code, second.Body.String()) + } + if !strings.Contains(second.Body.String(), "TooManyRequests") { + t.Fatalf("error body = %s, want TooManyRequests", second.Body.String()) + } + kvRec.assertIdle(t) +} + +func TestAccountResendSignupCooldown(t *testing.T) { + router, d, kvRec, _ := newSignupXrpc(t) + + token, _ := generateVerificationToken() + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "ivan-signup@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + backdateInflightSent(t, d, "ivan-signup@example.com") + + first := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"ivan-signup@example.com"}`) + if first.Code != http.StatusOK { + t.Fatalf("first resend status = %d, want 200; body=%s", first.Code, first.Body.String()) + } + + second := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"ivan-signup@example.com"}`) + if second.Code != http.StatusTooManyRequests { + t.Fatalf("immediate re-resend status = %d, want 429; body=%s", second.Code, second.Body.String()) + } + kvRec.assertIdle(t) +} + +func TestAccountCompleteSignupRollsBackOnAddEmailConflict(t *testing.T) { + router, d, kvRec, pds := newSignupXrpc(t) + + if err := db.AddEmail(d, models.Email{Did: "did:plc:other", Address: "judy@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("seed competing email: %v", err) + } + token, _ := generateVerificationToken() + if err := db.AddInflightSignup(d, models.InflightSignup{Email: "judy@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil { + t.Fatalf("seed inflight: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.completeSignup", + `{"token":"`+token+`","username":"judy","password":"password1234"}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "EmailAlreadyRegistered") { + t.Fatalf("error body = %s, want EmailAlreadyRegistered", rec.Body.String()) + } + + deletes := pds.AdminDeletes() + if len(deletes) != 1 || deletes[0] != testNewDid { + t.Fatalf("admin.deleteAccount calls = %v, want [%s]", deletes, testNewDid) + } + emails, err := db.GetAllEmails(d, testNewDid) + if err != nil { + t.Fatalf("GetAllEmails: %v", err) + } + if len(emails) != 0 { + t.Fatalf("email row leaked for rolled-back account: %v", emails) + } + if _, err := db.GetInflightByToken(d, token); err != nil { + t.Fatalf("inflight row lost on conflict: %v", err) + } + kvRec.assertIdle(t) +} + +// typed mirror of the generated output so the assertions don't depend on codegen +type tangledTempAccountCompleteSignupOutput struct { + Did string `json:"did"` + Handle string `json:"handle"` +} diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go --- a/deliberi/xrpc/xrpc.go +++ b/deliberi/xrpc/xrpc.go @@ -36,6 +36,7 @@ r.Get("/_health", x.health) r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup) + r.Post("/"+tangled.TempAccountResendSignupNSID, x.AccountResendSignup) r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup) r.Post("/"+tangled.TempAccountVerifyEmailNSID, x.AccountVerifyEmail)