- TestEmailVerifiedAt: register (auto, admin), activate, activate-by-code, reset-password, change-password, update - TestAdminEmailVerifiedAt: admin create, activate action, invitation link, email change, JSON - TestEmailVerifiedAtMigration: column shape, no backfill, existing column kept, rollback keeps column
378 lines
15 KiB
Go
378 lines
15 KiB
Go
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)
|
|
})
|
|
}
|