diff --git a/email_verified_test.go b/email_verified_test.go new file mode 100644 index 0000000..cb6fe21 --- /dev/null +++ b/email_verified_test.go @@ -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) + }) +} diff --git a/updates/email_verified_at_test.go b/updates/email_verified_at_test.go new file mode 100644 index 0000000..fb53048 --- /dev/null +++ b/updates/email_verified_at_test.go @@ -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) + } + }) +}