diff --git a/classes/user_groups_test.go b/classes/user_groups_test.go new file mode 100644 index 0000000..0c03d95 --- /dev/null +++ b/classes/user_groups_test.go @@ -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") + } +}