feat(auth): confirm the e-mail address of new accounts by link
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s

- Setting / REGISTRATION_VERIFY_EMAIL; only with SMTP and BASE_URL; invites skip it
- Hashed one-time tokens (48 h), resend with cooldown, unconfirmed accounts purged
- Approval modes notify admins only after the address is confirmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 14:01:41 +01:00
1 parent 1406650b7c
commit ab97638361
25 files changed
+621 -56

No files matched your search

+124
View File
@@ -3,8 +3,11 @@ package api
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
@@ -155,3 +158,124 @@ func TestRegistrationFixedByEnvironment(t *testing.T) {
t.Fatalf("the locked value must not be overwritten in the database")
}
}
// verificationServer: mails can go out (SMTP + BASE_URL); returns the captured mails
func verificationServer(t *testing.T) (*Server, *storage.Store, string, func() []sentNote) {
t.Helper()
srv, store, adminToken := setupTestServer(t)
srv.cfg.BaseURL, srv.cfg.BaseURLSet = "https://mindsift.example", true
sys, _ := store.GetSystemSettings()
sys.SMTPHost, sys.RegistrationVerifyEmail = "smtp.example.com", true
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var sent []sentNote
srv.notifyEmailHook = func(to, subject, text, html string) error {
mu.Lock()
defer mu.Unlock()
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
return nil
}
mails := func() []sentNote {
mu.Lock()
defer mu.Unlock()
return append([]sentNote(nil), sent...)
}
return srv, store, adminToken, mails
}
// waitMail waits for a mail to `to` (mails go out in the background)
func waitMail(t *testing.T, mails func() []sentNote, to string) sentNote {
t.Helper()
for i := 0; i < 100; i++ {
for _, m := range mails() {
if m.to == to {
return m
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("no mail to %s, got %+v", to, mails())
return sentNote{}
}
func TestEmailVerification(t *testing.T) {
srv, store, _, mails := verificationServer(t)
router := srv.Router()
setRegistration(t, store, storage.RegistrationOpen, "")
sys, _ := store.GetSystemSettings()
sys.RegistrationVerifyEmail = true
_ = store.UpdateSystemSettings(sys)
code, out := register(srv, "neu@example.com", "")
if code != http.StatusAccepted || out["verify"] != true || out["token"] != nil {
t.Fatalf("register: want 202 with verify and no session, got %d %v", code, out)
}
mail := waitMail(t, mails, "neu@example.com")
m := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)
if m == nil || !strings.HasPrefix(mail.text[strings.Index(mail.text, "https://"):], "https://mindsift.example/") {
t.Fatalf("mail must carry a link built from BASE_URL: %s", mail.text)
}
login := func() int {
return sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "").Code
}
if c := login(); c != http.StatusForbidden {
t.Fatalf("login before confirming: want 403, got %d", c)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+strings.Repeat("0", 64)+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("wrong token: want 400, got %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":false`) {
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
}
if c := login(); c != http.StatusOK {
t.Fatalf("login after confirming: want 200, got %d", c)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("a link works only once, got %d", rec.Code)
}
// Invites need no confirmation
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
if code, out := register(srv, "eingeladen@example.com", inv.Code); code != http.StatusCreated || out["token"] == nil {
t.Fatalf("invite: want 201 with session, got %d %v", code, out)
}
}
func TestEmailVerificationBeforeApproval(t *testing.T) {
srv, store, _, mails := verificationServer(t)
router := srv.Router()
setRegistration(t, store, storage.RegistrationApproval, "")
sys, _ := store.GetSystemSettings()
sys.RegistrationVerifyEmail = true
_ = store.UpdateSystemSettings(sys)
admin, err := store.GetFirstUser()
if err != nil || admin == nil || admin.Role != "admin" {
t.Fatalf("test server needs its admin, got %+v %v", admin, err)
}
adminEmail := admin.Email
if code, _ := register(srv, "wartet@example.com", ""); code != http.StatusAccepted {
t.Fatalf("register: want 202, got %d", code)
}
mail := waitMail(t, mails, "wartet@example.com")
time.Sleep(100 * time.Millisecond)
for _, m := range mails() {
if m.to != "wartet@example.com" {
t.Fatalf("admins must not hear about it before the address is confirmed, got mail to %s", m.to)
}
}
token := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)[1]
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+token+`"}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":true`) {
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
}
if m := waitMail(t, mails, adminEmail); !strings.Contains(m.subject, "Freigabe") {
t.Fatalf("admin mail after confirming expected, got %q", m.subject)
}
if c := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"wartet@example.com","password":"password123"}`, "").Code; c != http.StatusForbidden {
t.Fatalf("still waiting for approval: want 403, got %d", c)
}
}