test: cover user group codes and the small class helpers

- UserGroupCodes and HasGroupCode against Postgres: id order, code-less
  groups skipped, membership by code, a missing handle and a failing query
  reported as errors
- mail locale resolution, hook registration (a failing hook stops the
  chain), the code lifetimes and throttle settings carried on the context,
  constant-time code comparison, JWT secret loading and the principal
  lookup without an app or a gorm handle
This commit is contained in:
Jakub Zych
2026-10-02 16:19:06 +02:00
parent 86970074b7
commit c258e9fade

185
classes/user_groups_test.go Normal file
View File

@@ -0,0 +1,185 @@
package classes
import (
"context"
"errors"
"reflect"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// TestUserGroupCodesAndHasGroupCode reads group codes in user_groups.id
// order (groups without a code skipped), checks membership by code, and
// reports a missing handle or a failing query as an error.
func TestUserGroupCodesAndHasGroupCode(t *testing.T) {
ctx := t.Context()
db := throttleDB(t)
site := models.User{Email: "groups-site@example.test", IsActivated: true}
plain := models.User{Email: "groups-plain@example.test", IsActivated: true}
for _, u := range []*models.User{&site, &plain} {
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec(`INSERT INTO user_groups (name, code, created_at, updated_at) VALUES ('Admins', 'admin', NOW(), NOW()), ('Nameless', NULL, NOW(), NOW())`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) SELECT ?, id FROM user_groups WHERE code IN ('admin', 'registered') OR code IS NULL`, site.ID).Error; err != nil {
t.Fatal(err)
}
codes, err := UserGroupCodes(ctx, db, site.ID)
if err != nil || !reflect.DeepEqual(codes, []string{"registered", "admin"}) {
t.Fatalf("site codes %v %v", codes, err)
}
codes, err = UserGroupCodes(ctx, db, plain.ID)
if err != nil || codes == nil || len(codes) != 0 {
t.Fatalf("plain codes %#v %v", codes, err)
}
for _, c := range []struct {
user uint
code string
want bool
}{{site.ID, "admin", true}, {site.ID, "guest", false}, {plain.ID, "admin", false}, {site.ID, "", false}} {
got, err := HasGroupCode(ctx, db, c.user, c.code)
if err != nil || got != c.want {
t.Errorf("HasGroupCode(%d, %q) = %v %v", c.user, c.code, got, err)
}
}
if _, err := UserGroupCodes(ctx, nil, site.ID); err == nil {
t.Error("UserGroupCodes without a handle")
}
if _, err := HasGroupCode(ctx, nil, site.ID, "admin"); err == nil {
t.Error("HasGroupCode without a handle")
}
errRollback := errors.New("rollback")
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE users_groups RENAME TO users_groups_gone`).Error; err != nil {
return err
}
if _, err := UserGroupCodes(ctx, tx, site.ID); err == nil {
t.Error("UserGroupCodes swallowed a query error")
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatal(err)
}
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE users_groups RENAME TO users_groups_gone`).Error; err != nil {
return err
}
if _, err := HasGroupCode(ctx, tx, site.ID, "admin"); err == nil {
t.Error("HasGroupCode swallowed a query error")
}
return errRollback
})
if !errors.Is(err, errRollback) {
t.Fatal(err)
}
if _, err := LookupByEmail(ctx, nil, "x@example.test"); err == nil {
t.Error("LookupByEmail without a handle")
}
}
// TestUserClassHelpers covers the small helpers around them: mail locale
// resolution, hook registration, the code and throttle context settings,
// constant-time code comparison, JWT secret loading and the principal
// lookup's missing app or handle.
func TestUserClassHelpers(t *testing.T) {
pl, empty := "pl", ""
ctx := towel.WithLocale(context.Background(), "en")
if got := ResolveMailLocale(ctx, &models.User{PreferredLocale: &pl}); got != "pl" {
t.Errorf("saved locale: %q", got)
}
if got := ResolveMailLocale(ctx, &models.User{PreferredLocale: &empty}); got != "en" {
t.Errorf("request locale: %q", got)
}
if got := ResolveMailLocale(context.Background(), nil); got != "" {
t.Errorf("no locale: %q", got)
}
if MailTemplate("welcome", "en") != "welcome-en" || MailTemplate("welcome", "pl") != "welcome" {
t.Error("MailTemplate")
}
saved := hookRegistrars
t.Cleanup(func() { hookRegistrars = saved })
hookRegistrars = nil
calls := 0
RegisterHook(func(*gorm.DB) error { calls++; return nil })
if err := RegisterHooks(nil); err != nil || calls != 1 {
t.Fatalf("hooks: %v %d", err, calls)
}
boom := errors.New("boom")
RegisterHook(func(*gorm.DB) error { return boom })
RegisterHook(func(*gorm.DB) error { calls += 10; return nil })
if err := RegisterHooks(nil); !errors.Is(err, boom) || calls != 2 {
t.Fatalf("a failing hook stops the chain: %v %d", err, calls)
}
//nolint:staticcheck // a nil context is what the helpers guard against
c := ContextWithCodeTTL(nil, 5*time.Minute, 0)
if resetTTL(c) != 5*time.Minute || activationTTL(c) != 72*time.Hour || resetTTL(nil) != 60*time.Minute || activationTTL(nil) != 72*time.Hour {
t.Error("code lifetimes")
}
if codesEqual("abc", "abcd") || !codesEqual("abc", "abc") || codesEqual("abd", "abc") {
t.Error("codesEqual")
}
if code, err := newCode(); err != nil || len(code) != 64 {
t.Errorf("newCode %q %v", code, err)
}
//nolint:staticcheck // see above
if s := throttleFrom(withThrottle(nil, throttleSettings{use: false})); s.use {
t.Error("withThrottle on a nil context")
}
if s := throttleFrom(nil); !s.use || s.limit != 5 || s.minutes != 15 {
t.Errorf("default throttle %+v", s)
}
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "development", Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
for k, v := range map[string]any{"golem15.user.throttle.use_throttle": false, "golem15.user.throttle.attempt_limit": 3, "golem15.user.throttle.suspension_minutes": 9} {
if err := cfg.Set(k, v); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if s := throttleFrom(ContextWithThrottle(context.Background(), app)); s.use || s.limit != 3 || s.minutes != 9 {
t.Errorf("configured throttle %+v", s)
}
if s := throttleFrom(ContextWithThrottle(context.Background(), nil)); !s.use || s.limit != 5 {
t.Errorf("throttle without an app %+v", s)
}
if err := RejectIfThrottled(ContextWithThrottle(context.Background(), app), &gorm.DB{}, &models.User{ID: 1}, "127.0.0.1"); err != nil {
t.Errorf("throttling switched off: %v", err)
}
if err := RejectIfThrottled(context.Background(), nil, &models.User{ID: 1}, "127.0.0.1"); err != nil {
t.Errorf("no handle: %v", err)
}
if _, err := JWTSecret(nil); err == nil {
t.Error("JWTSecret without an app")
}
if _, err := JWTSecret(app); err == nil {
t.Error("JWTSecret with an empty secret")
}
if err := cfg.Set("golem15.user.jwt.secret", " s3cret "); err != nil {
t.Fatal(err)
}
if s, err := JWTSecret(app); err != nil || s != "s3cret" {
t.Errorf("JWTSecret = %q %v", s, err)
}
if _, err := (GormUsers{}).FindByID(context.Background(), 1); err == nil {
t.Error("FindByID without an app")
}
if _, err := (GormUsers{App: app}).FindByID(context.Background(), 1); err == nil {
t.Error("FindByID without a gorm handle")
}
}