package user import ( "net/http" "net/url" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/postcard" "gorm.io/gorm" ) // proofOf reads users.email_verified_at of the user id. func proofOf(t *testing.T, gdb *gorm.DB, id uint) *time.Time { t.Helper() var got *time.Time if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE id = ?`, id).Row().Scan(&got); err != nil { t.Fatal(err) } return got } // userIDByEmail returns the id of the user with email. func userIDByEmail(t *testing.T, gdb *gorm.DB, email string) uint { t.Helper() var u models.User if err := gdb.Unscoped().Where("lower(email) = lower(?)", email).Take(&u).Error; err != nil { t.Fatal(err) } return u.ID } // setProof stores a proof time on the user id and returns it. func setProof(t *testing.T, gdb *gorm.DB, id uint) time.Time { t.Helper() at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) if err := gdb.Exec(`UPDATE users SET email_verified_at = ? WHERE id = ?`, at, id).Error; err != nil { t.Fatal(err) } return at } // assertStamped requires got to be set between before and after (with a // millisecond of slack for the database's microsecond precision). func assertStamped(t *testing.T, got *time.Time, before, after time.Time) { t.Helper() if got == nil { t.Fatal("email_verified_at is NULL, want the time of the proof") } if got.Before(before.Add(-time.Millisecond)) || got.After(after.Add(time.Millisecond)) { t.Fatalf("email_verified_at = %v, want between %v and %v", got, before, after) } } // assertNoProofKey requires a response body without the proof column. func assertNoProofKey(t *testing.T, body string) { t.Helper() if strings.Contains(body, "email_verified_at") { t.Fatalf("the payload carries email_verified_at: %s", body) } } // TestEmailVerifiedAt pins the user API side of D-26 and D-27: only the // mailed activation code and a completed reset-code password reset record // the proof of the address, an email change clears it, and the column is // never mass-assigned or part of a payload. func TestEmailVerifiedAt(t *testing.T) { app, gdb := sessionApp(t, "0s") disableRegisterThrottle(t, app) if err := app.Publish[postcard.Mailer](&captureMail{}); err != nil { t.Fatal(err) } stamp := adminStamp() email := func(label string) string { return "proof-" + label + "-" + stamp + "@example.test" } mode := func(t *testing.T, m string) { t.Helper() if err := app.Config.Set("golem15.user.activation.activate_mode", m); err != nil { t.Fatal(err) } } register := func(t *testing.T, body string) string { t.Helper() rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body) if rec.Code != http.StatusOK { t.Fatalf("register %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) return rec.Body.String() } t.Run("register_auto_no_proof", func(t *testing.T) { mode(t, "auto") addr := email("register-auto") body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`) if !strings.Contains(body, `"token":`) { t.Fatalf("auto-mode register answered %s", body) } id := userIDByEmail(t, gdb, addr) var activated bool if err := gdb.Raw(`SELECT is_activated FROM users WHERE id = ?`, id).Row().Scan(&activated); err != nil { t.Fatal(err) } if !activated || proofOf(t, gdb, id) != nil { t.Fatalf("auto-mode account: activated=%v proof=%v", activated, proofOf(t, gdb, id)) } }) t.Run("register_admin_mode_no_proof", func(t *testing.T) { mode(t, "admin") t.Cleanup(func() { mode(t, "auto") }) addr := email("register-admin") if body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`); body != `{}` { t.Fatalf("admin-mode register answered %s", body) } if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil { t.Fatalf("admin-mode account has proof %v", got) } }) t.Run("register_ignores_submitted_proof", func(t *testing.T) { mode(t, "auto") addr := email("register-submitted") register(t, `{"email":"`+addr+`","password":"correct-horse","email_verified_at":"2020-01-01T00:00:00Z"}`) if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil { t.Fatalf("a submitted email_verified_at was stored: %v", got) } }) t.Run("activate_by_code_stamps", func(t *testing.T) { user := insertUser(t, gdb, email("activate-by-code"), "correct-horse") code, err := classes.IssueActivationCode(t.Context(), gdb, user) if err != nil { t.Fatal(err) } before := time.Now() rec := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code", `{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`"}`) after := time.Now() if rec.Code != http.StatusOK { t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) assertStamped(t, proofOf(t, gdb, user.ID), before, after) }) t.Run("activate_stamps", func(t *testing.T) { addr := email("activate") token := loginToken(t, app, gdb, addr) user := &models.User{ID: userIDByEmail(t, gdb, addr)} code, err := classes.IssueActivationCode(t.Context(), gdb, user) if err != nil { t.Fatal(err) } before := time.Now() rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"`+code+`"}`) after := time.Now() if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":true`) { t.Fatalf("activate %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) assertStamped(t, proofOf(t, gdb, user.ID), before, after) }) t.Run("activate_wrong_code_no_proof", func(t *testing.T) { addr := email("activate-wrong") token := loginToken(t, app, gdb, addr) user := &models.User{ID: userIDByEmail(t, gdb, addr)} if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil { t.Fatal(err) } rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) { t.Fatalf("activate %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) if got := proofOf(t, gdb, user.ID); got != nil { t.Fatalf("a wrong activation code recorded proof %v", got) } }) t.Run("reset_password_stamps", func(t *testing.T) { user := insertUser(t, gdb, email("reset"), "correct-horse") code, err := classes.IssueResetCode(t.Context(), gdb, user) if err != nil { t.Fatal(err) } before := time.Now() rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password", `{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`","password":"new-password","password_confirmation":"new-password"}`) after := time.Now() if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` { t.Fatalf("reset %d %s", rec.Code, rec.Body.String()) } assertStamped(t, proofOf(t, gdb, user.ID), before, after) }) t.Run("reset_wrong_code_no_proof", func(t *testing.T) { user := insertUser(t, gdb, email("reset-wrong"), "correct-horse") if _, err := classes.IssueResetCode(t.Context(), gdb, user); err != nil { t.Fatal(err) } for _, code := range []string{strconv.FormatUint(uint64(user.ID), 10) + "!nope", "not-a-code"} { rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password", `{"code":"`+code+`","password":"new-password","password_confirmation":"new-password"}`) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("reset with %q: %d %s", code, rec.Code, rec.Body.String()) } } if got := proofOf(t, gdb, user.ID); got != nil { t.Fatalf("a refused reset recorded proof %v", got) } }) t.Run("change_password_no_proof", func(t *testing.T) { addr := email("change-password") token := loginToken(t, app, gdb, addr) // change-password replaces a self-set password only. if err := gdb.Exec(`UPDATE users SET has_self_set_password = TRUE WHERE id = ?`, userIDByEmail(t, gdb, addr)).Error; err != nil { t.Fatal(err) } rec := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`) if rec.Code != http.StatusOK { t.Fatalf("change-password %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil { t.Fatalf("change-password recorded proof %v", got) } }) update := func(t *testing.T, token, body string) string { t.Helper() rec := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body) if rec.Code != http.StatusOK { t.Fatalf("update %d %s", rec.Code, rec.Body.String()) } assertNoProofKey(t, rec.Body.String()) return rec.Body.String() } t.Run("update_email_change_clears", func(t *testing.T) { addr := email("update-change") token := loginToken(t, app, gdb, addr) id := userIDByEmail(t, gdb, addr) setProof(t, gdb, id) moved := email("update-changed-to") update(t, token, `{"name":"Ann","surname":"Owner","email":"`+moved+`"}`) if got := proofOf(t, gdb, id); got != nil { t.Fatalf("an email change kept the proof %v", got) } }) t.Run("update_same_email_keeps", func(t *testing.T) { addr := email("update-same") token := loginToken(t, app, gdb, addr) id := userIDByEmail(t, gdb, addr) want := setProof(t, gdb, id) update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`"}`) if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) { t.Fatalf("the same email changed the proof to %v", got) } update(t, token, `{"name":"Ann","surname":"Owner","email":"`+strings.ToUpper(addr)+`"}`) if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) { t.Fatalf("a case-only change dropped the proof: %v", got) } }) t.Run("update_ignores_submitted_proof", func(t *testing.T) { addr := email("update-submitted") token := loginToken(t, app, gdb, addr) update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`","email_verified_at":"2020-01-01T00:00:00Z"}`) if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil { t.Fatalf("a submitted email_verified_at was stored: %v", got) } }) } // TestAdminEmailVerifiedAt pins the admin side of D-26 and D-27: an // administrator's create and Activate action record no proof, the // invitation link (the mailed activation code) does, an email change on the // form clears it, and the admin JSON never carries it. func TestAdminEmailVerifiedAt(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stamp := adminStamp() const password = "proof-admin-password-1" create := func(t *testing.T, email string, extra map[string]any) uint { t.Helper() body := map[string]any{"name": "Proof", "email": email, "password": password, "password_confirmation": password} for k, v := range extra { body[k] = v } return createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body)) } proven := func(t *testing.T, label string) *models.User { t.Helper() at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) return env.seedUser(t, label, func(u *models.User) { u.EmailVerifiedAt = &at }) } t.Run("admin_create_no_proof", func(t *testing.T) { id := create(t, "admin-proof-create-"+stamp+"@example.test", nil) if got := env.reload(t, id); got == nil || got.EmailVerifiedAt != nil { t.Fatalf("admin-created user = %+v", got) } }) t.Run("admin_activate_action_no_proof", func(t *testing.T) { user := env.seedUser(t, "admin-proof-activate", func(u *models.User) { u.IsActivated = false }) if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK { t.Fatalf("activate action %d %s", rec.Code, rec.Body.String()) } got := env.reload(t, user.ID) if !got.IsActivated || got.EmailVerifiedAt != nil { t.Fatalf("after the activate action: activated=%v proof=%v", got.IsActivated, got.EmailVerifiedAt) } }) t.Run("admin_invite_link_stamps", func(t *testing.T) { invited := "admin-proof-invite-" + stamp + "@example.test" id := create(t, invited, map[string]any{"send_invite": true}) if got := env.reload(t, id); got.EmailVerifiedAt != nil { t.Fatalf("the invitation recorded proof before the link: %v", got.EmailVerifiedAt) } mails := env.mails(invited) if len(mails) != 1 { t.Fatalf("invitations sent = %d", len(mails)) } start := strings.Index(mails[0].Text, "https://app.example.test/activate?code=") if start < 0 { t.Fatalf("no activation link in %q", mails[0].Text) } link := mails[0].Text[start:] link = link[:strings.IndexAny(link, ")\n ")] parsed, err := url.Parse(link) if err != nil { t.Fatal(err) } before := time.Now() rec := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": parsed.Query().Get("code")}) after := time.Now() if rec.Code != http.StatusOK { t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String()) } got := env.reload(t, id) if !got.IsActivated { t.Fatal("the invitation link did not activate") } assertStamped(t, got.EmailVerifiedAt, before, after) }) t.Run("admin_email_change_clears", func(t *testing.T) { user := proven(t, "admin-proof-change") moved := "admin-proof-moved-" + stamp + "@example.test" env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Moved", "email": moved}) got := env.reload(t, user.ID) if got.Email != moved || got.EmailVerifiedAt != nil { t.Fatalf("after the email change: email=%s proof=%v", got.Email, got.EmailVerifiedAt) } }) t.Run("admin_save_keeps_proof", func(t *testing.T) { user := proven(t, "admin-proof-keep") want := *user.EmailVerifiedAt env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": user.Email}) if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) { t.Fatalf("a save with the same email changed the proof to %v", got.EmailVerifiedAt) } env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": strings.ToUpper(user.Email)}) if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) { t.Fatalf("a case-only change dropped the proof: %v", got.EmailVerifiedAt) } }) t.Run("admin_json_hides_proof", func(t *testing.T) { user := proven(t, "admin-proof-json") show := env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil) assertNoProofKey(t, show.Body.String()) form := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil) assertNoProofKey(t, form.Body.String()) list := env.users(t, token, "search="+url.QueryEscape(user.Email)) if !list.has(user.ID) { t.Fatal("the proven user is not in the list") } assertNoProofKey(t, list.Raw) }) }