package user import ( "fmt" "net/http" "net/http/httptest" "reflect" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/postcard" "gorm.io/gorm" ) // skipShortPostgres skips a Postgres-backed test under -short (the phase // gate runs these by name without -short on a Docker host). func skipShortPostgres(t *testing.T) { t.Helper() if testing.Short() { t.Skip("Postgres-backed; skipped under -short") } } // hookEmail is a unique address for one test. func hookEmail(prefix string) string { return fmt.Sprintf("%s-%d@example.test", prefix, time.Now().UnixNano()) } func registerFields(email string) map[string]any { return map[string]any{ "email": email, "name": "Social Person", "password": "random-password-never-shown", "password_confirmation": "random-password-never-shown", } } // TestRegisterUserPasswordLess pins RegisterOptions.PasswordLess (D-22): the // zero value keeps has_self_set_password true, true stores false; the handle // is assigned either way; validation errors are the same with and without // it and nothing is written. func TestRegisterUserPasswordLess(t *testing.T) { skipShortPostgres(t) app, gdb := sessionApp(t, "0s") ctx := t.Context() stored := func(t *testing.T, id uint) models.User { t.Helper() var u models.User if err := gdb.First(&u, id).Error; err != nil { t.Fatal(err) } return u } t.Run("password_less_false_keeps_self_set", func(t *testing.T) { u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-false")), controllers.RegisterOptions{Activate: true}) if err != nil || len(errs) > 0 { t.Fatalf("RegisterUser: %v %v", err, errs) } if got := stored(t, u.ID); !got.HasSelfSetPassword || !u.HasSelfSetPassword { t.Fatalf("has_self_set_password stored %v (returned %v), want true", got.HasSelfSetPassword, u.HasSelfSetPassword) } }) t.Run("password_less_true_stores_false", func(t *testing.T) { u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-true")), controllers.RegisterOptions{Activate: true, PasswordLess: true, IP: "198.18.7.1"}) if err != nil || len(errs) > 0 { t.Fatalf("RegisterUser: %v %v", err, errs) } got := stored(t, u.ID) if got.HasSelfSetPassword || u.HasSelfSetPassword { t.Fatalf("has_self_set_password stored %v (returned %v), want false", got.HasSelfSetPassword, u.HasSelfSetPassword) } if !got.IsActivated || got.ActivatedAt == nil { t.Fatalf("activation %v %v", got.IsActivated, got.ActivatedAt) } if got.CreatedIPAddress == nil || *got.CreatedIPAddress != "198.18.7.1" || got.LastIPAddress == nil || *got.LastIPAddress != "198.18.7.1" { t.Fatalf("ip columns %v %v", got.CreatedIPAddress, got.LastIPAddress) } }) t.Run("handle_assigned", func(t *testing.T) { for _, pl := range []bool{false, true} { u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail(fmt.Sprintf("pwless-handle-%v", pl))), controllers.RegisterOptions{PasswordLess: pl}) if err != nil || len(errs) > 0 { t.Fatalf("RegisterUser(PasswordLess=%v): %v %v", pl, err, errs) } if got := stored(t, u.ID); got.Handle == nil || *got.Handle == "" { t.Fatalf("PasswordLess=%v: no handle", pl) } } }) t.Run("validation_errors_unchanged", func(t *testing.T) { email := "not-an-email" fields := map[string]any{"email": email, "password": "short", "password_confirmation": "other"} _, plain, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{}) if err != nil || len(plain) == 0 { t.Fatalf("no validation errors without PasswordLess: %v %v", err, plain) } u, less, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{PasswordLess: true}) if err != nil || u != nil { t.Fatalf("PasswordLess registration with invalid fields: user %v err %v", u, err) } if !reflect.DeepEqual(plain, less) { t.Fatalf("validation errors differ:\n without %v\n with %v", plain, less) } if _, ok := less["email"]; !ok { t.Fatalf("no email error: %v", less) } var n int64 if err := gdb.Unscoped().Model(&models.User{}).Where("email = ?", email).Count(&n).Error; err != nil || n != 0 { t.Fatalf("an invalid registration wrote %d rows (%v)", n, err) } }) } // TestFinishLogin pins the social sign-in tail of a password login: a // trashed account is restored with exactly one reactivate mail, an active // one gets none; last_ip_address and last_seen are written, and an empty // ip leaves last_ip_address alone. func TestFinishLogin(t *testing.T) { skipShortPostgres(t) newApp := func(t *testing.T) (*backpack.App, *gorm.DB, *captureMail) { t.Helper() app, gdb := sessionApp(t, "0s") mail := &captureMail{} if err := app.Publish[postcard.Mailer](mail); err != nil { t.Fatal(err) } return app, gdb, mail } load := func(t *testing.T, gdb *gorm.DB, id uint) models.User { t.Helper() var u models.User if err := gdb.Unscoped().First(&u, id).Error; err != nil { t.Fatal(err) } return u } newUser := func(t *testing.T, gdb *gorm.DB, prefix string) *models.User { t.Helper() loc := "en" u := &models.User{Email: hookEmail(prefix), Password: "x", PreferredLocale: &loc, IsActivated: true} if err := gdb.Create(u).Error; err != nil { t.Fatal(err) } return u } t.Run("trashed_user_restored_with_one_mail", func(t *testing.T) { app, gdb, mail := newApp(t) u := newUser(t, gdb, "finish-trashed") if err := gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } trashed := load(t, gdb, u.ID) if !trashed.DeletedAt.Valid { t.Fatal("setup: not soft-deleted") } if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil { t.Fatal(err) } if after := load(t, gdb, u.ID); after.DeletedAt.Valid { t.Fatal("not restored") } if trashed.DeletedAt.Valid { t.Fatal("the passed user still reads as trashed") } if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { t.Fatalf("mails %#v, want one reactivate-en", mail.got) } // A second sign-in of the restored user sends no further mail. if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil { t.Fatal(err) } if len(mail.got) != 1 { t.Fatalf("mails after a second sign-in %#v", mail.got) } }) t.Run("active_user_no_mail", func(t *testing.T) { app, gdb, mail := newApp(t) u := newUser(t, gdb, "finish-active") if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.2"); err != nil { t.Fatal(err) } if len(mail.got) != 0 { t.Fatalf("mails %#v, want none", mail.got) } }) t.Run("ip_and_last_seen", func(t *testing.T) { app, gdb, _ := newApp(t) u := newUser(t, gdb, "finish-ip") before := time.Now().Add(-time.Second) if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.3"); err != nil { t.Fatal(err) } after := load(t, gdb, u.ID) if after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.3" { t.Fatalf("last_ip_address %v", after.LastIPAddress) } if u.LastIPAddress == nil || *u.LastIPAddress != "198.18.8.3" { t.Fatalf("the passed user's LastIPAddress %v", u.LastIPAddress) } if after.LastSeen == nil || after.LastSeen.Before(before) { t.Fatalf("last_seen %v, want at or after %v", after.LastSeen, before) } }) t.Run("empty_ip_keeps_last_ip", func(t *testing.T) { app, gdb, _ := newApp(t) u := newUser(t, gdb, "finish-noip") if err := gdb.Model(u).Update("last_ip_address", "198.18.8.4").Error; err != nil { t.Fatal(err) } if err := controllers.FinishLogin(t.Context(), app, gdb, u, ""); err != nil { t.Fatal(err) } if after := load(t, gdb, u.ID); after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.4" { t.Fatalf("last_ip_address %v, want the old 198.18.8.4", after.LastIPAddress) } }) t.Run("refuses_unsaved_user", func(t *testing.T) { app, gdb, _ := newApp(t) if err := controllers.FinishLogin(t.Context(), app, gdb, &models.User{}, "198.18.8.5"); err == nil { t.Fatal("FinishLogin accepted a user without an id") } if err := controllers.FinishLogin(t.Context(), app, nil, &models.User{ID: 1}, ""); err == nil { t.Fatal("FinishLogin accepted a nil database") } }) } // TestRegistrationExports pins the helpers golem15.user exports for a social // login plugin: RegistrationSettings (defaults and config), RegistrationsFromIP // (the last 60 minutes, that IP only), RefreshTTL (default 20160 minutes and // config) and RemoteIP (with and without a port). func TestRegistrationExports(t *testing.T) { t.Run("registration_settings", func(t *testing.T) { app := backpack.New(sessionConfig(t)) allow, throttle, mode, require := controllers.RegistrationSettings(app) if !allow || !throttle || mode != "auto" || !require { t.Fatalf("defaults = %v %v %q %v, want true true auto true", allow, throttle, mode, require) } for key, value := range map[string]any{ "golem15.user.registration.allow_registration": false, "golem15.user.registration.use_register_throttle": false, "golem15.user.activation.activate_mode": "admin", "golem15.user.activation.require_activation": false, } { if err := app.Config.Set(key, value); err != nil { t.Fatal(err) } } allow, throttle, mode, require = controllers.RegistrationSettings(app) if allow || throttle || mode != "admin" || require { t.Fatalf("configured = %v %v %q %v, want false false admin false", allow, throttle, mode, require) } if a, th, m, r := controllers.RegistrationSettings(nil); !a || !th || m != "auto" || !r { t.Fatalf("nil app = %v %v %q %v", a, th, m, r) } }) t.Run("registrations_from_ip", func(t *testing.T) { skipShortPostgres(t) _, gdb := sessionApp(t, "0s") ip := fmt.Sprintf("198.18.%d.%d", time.Now().UnixNano()%200+20, time.Now().UnixNano()%250+1) other := "198.19.0.1" mk := func(addr string, age time.Duration) { t.Helper() u := &models.User{Email: hookEmail("regip"), Password: "x", CreatedIPAddress: &addr} if err := gdb.Create(u).Error; err != nil { t.Fatal(err) } if age > 0 { if err := gdb.Exec(`UPDATE users SET created_at = ? WHERE id = ?`, time.Now().Add(-age), u.ID).Error; err != nil { t.Fatal(err) } } } before := controllers.RegistrationsFromIP(t.Context(), gdb, ip) mk(ip, 0) mk(ip, 30*time.Minute) mk(ip, 61*time.Minute) mk(ip, 3*time.Hour) mk(other, 0) if got := controllers.RegistrationsFromIP(t.Context(), gdb, ip); got-before != 2 { t.Fatalf("RegistrationsFromIP = %d (before %d), want 2 more: the last 60 minutes only", got, before) } if got := controllers.RegistrationsFromIP(t.Context(), gdb, "198.19.255.254"); got != 0 { t.Fatalf("an unused ip counts %d", got) } }) t.Run("refresh_ttl", func(t *testing.T) { app := backpack.New(sessionConfig(t)) if got := controllers.RefreshTTL(app); got != 20160*time.Minute { t.Fatalf("default RefreshTTL = %s, want 20160m", got) } if err := app.Config.Set("golem15.user.jwt.refresh_ttl", 90); err != nil { t.Fatal(err) } if got := controllers.RefreshTTL(app); got != 90*time.Minute { t.Fatalf("configured RefreshTTL = %s, want 90m", got) } if got := controllers.RefreshTTL(nil); got != 20160*time.Minute { t.Fatalf("nil app RefreshTTL = %s", got) } }) t.Run("remote_ip", func(t *testing.T) { for remote, want := range map[string]string{ "203.0.113.9:4242": "203.0.113.9", "203.0.113.9": "203.0.113.9", "[2001:db8::1]:443": "2001:db8::1", "": "", } { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = remote req.Header.Set("X-Forwarded-For", "192.0.2.200") if got := controllers.RemoteIP(req); got != want { t.Fatalf("RemoteIP(%q) = %q, want %q", remote, got, want) } } }) }