test: email_verified_at writers, non-writers and the migration

- 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
This commit is contained in:
Jakub Zych
2026-10-21 14:01:52 +02:00
parent a99cb467b7
commit f5d2fa1dfa
2 changed files with 520 additions and 0 deletions

377
email_verified_test.go Normal file
View File

@@ -0,0 +1,377 @@
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)
})
}

View File

@@ -0,0 +1,143 @@
package updates
import (
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const emailVerifiedAtID = "202610210001_add_users_email_verified_at"
// emailVerifiedDB opens a dedicated database and a migrator over every
// registered migration.
func emailVerifiedDB(t *testing.T, name string) (*gorm.DB, *gormigrate.Gormigrate) {
t.Helper()
gdb, err := lagoon.Use(t.Context(), dedicatedDB(t, name))
if err != nil {
t.Fatal(err)
}
return gdb, gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_golem15_user",
UseTransaction: true,
}, All())
}
// TestEmailVerifiedAtMigration pins D-27: users.email_verified_at is a
// nullable TIMESTAMPTZ added only when missing, the migration writes no
// rows (no backfill, D-26), a column that already exists keeps its values
// and the rollback leaves it in place.
func TestEmailVerifiedAtMigration(t *testing.T) {
t.Run("column_shape", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_shape")
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var row struct {
DataType string
IsNullable string
}
if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'email_verified_at'`).Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.DataType != "timestamp with time zone" || row.IsNullable != "YES" {
t.Fatalf("email_verified_at = %+v", row)
}
})
t.Run("no_backfill", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_backfill")
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasColumn("users", "email_verified_at") {
t.Fatal("setup: the column exists before the migration")
}
for _, email := range []string{"legacy-a@example.test", "legacy-b@example.test"} {
if err := gdb.Exec(`INSERT INTO users (email, password, is_activated, has_self_set_password, activated_at)
VALUES (?, 'hash', TRUE, TRUE, NOW())`, email).Error; err != nil {
t.Fatal(err)
}
}
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var total, proven int64
if err := gdb.Raw(`SELECT COUNT(*), COUNT(email_verified_at) FROM users`).Row().Scan(&total, &proven); err != nil {
t.Fatal(err)
}
if total != 2 || proven != 0 {
t.Fatalf("users %d, with proof %d: the migration must write no rows", total, proven)
}
})
t.Run("existing_column_kept", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_existing")
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
t.Fatal(err)
}
// A Laravel-derived schema already carries the column and its values.
if err := gdb.Exec(`ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ NULL`).Error; err != nil {
t.Fatal(err)
}
want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
if err := gdb.Exec(`INSERT INTO users (email, email_verified_at) VALUES ('imported@example.test', ?)`, want).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO users (email) VALUES ('imported-unproven@example.test')`).Error; err != nil {
t.Fatal(err)
}
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var ran int64
if err := gdb.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, emailVerifiedAtID).Scan(&ran).Error; err != nil {
t.Fatal(err)
}
if ran != 1 {
t.Fatal("the migration did not run over the existing column")
}
var got *time.Time
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE email = 'imported@example.test'`).Row().Scan(&got); err != nil {
t.Fatal(err)
}
if got == nil || !got.Equal(want) {
t.Fatalf("existing value = %v, want %v", got, want)
}
var proven int64
if err := gdb.Raw(`SELECT COUNT(email_verified_at) FROM users`).Row().Scan(&proven); err != nil {
t.Fatal(err)
}
if proven != 1 {
t.Fatalf("rows with proof = %d, want only the imported one", proven)
}
})
t.Run("rollback_keeps_column", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_rollback")
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
if err := m.RollbackMigration(usersEmailVerifiedAtMigrations[0]); err != nil {
t.Fatal(err)
}
if !gdb.Migrator().HasColumn("users", "email_verified_at") {
t.Fatal("the rollback dropped the shared users column")
}
})
t.Run("history_once", func(t *testing.T) {
n := 0
for _, mig := range All() {
if mig.ID == emailVerifiedAtID {
n++
}
}
if n != 1 || len(usersEmailVerifiedAtMigrations) != 1 || usersEmailVerifiedAtMigrations[0].ID != emailVerifiedAtID {
t.Fatalf("%s registered %d times", emailVerifiedAtID, n)
}
})
}