- TestOAuthProviders: old bytes without a source, order, per-request lookup - TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s, microsoft by default, source names and opts.Providers precedence - TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders, the handler waiting for the lock, concurrent handlers - TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
333 lines
12 KiB
Go
333 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|