package classes import ( "context" "errors" "reflect" "strconv" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gocloud.dev/blob/memblob" "gorm.io/gorm" ) // actionUser stores an active user for the admin action tests. func actionUser(t *testing.T, db *gorm.DB, label string, mutate func(*models.User)) *models.User { t.Helper() user := &models.User{Email: "action-" + label + "@example.test", IsActivated: true} if mutate != nil { mutate(user) } if err := db.Omit("Groups").Create(user).Error; err != nil { t.Fatal(err) } return user } // actionReload reads a user row, deactivated or not. func actionReload(t *testing.T, db *gorm.DB, id uint) models.User { t.Helper() var user models.User if err := db.Unscoped().Take(&user, id).Error; err != nil { t.Fatal(err) } return user } // actionThrottle returns the user's throttle rows ordered by id. func actionThrottle(t *testing.T, db *gorm.DB, id uint) []models.Throttle { t.Helper() var rows []models.Throttle if err := db.Where("user_id = ?", id).Order("id").Find(&rows).Error; err != nil { t.Fatal(err) } return rows } // actionPivot is the whole users_groups table, for "nothing changed" checks. func actionPivot(t *testing.T, db *gorm.DB) []models.UsersGroup { t.Helper() var rows []models.UsersGroup if err := db.Order("user_id, user_group_id").Find(&rows).Error; err != nil { t.Fatal(err) } return rows } // TestAdminActions covers the admin screen's user actions against a real // Postgres: the changed-row count of each, its idempotency, and that none of // them writes users_groups (D-13, D-14, D-29). func TestAdminActions(t *testing.T) { ctx := t.Context() db := throttleDB(t) group := models.UserGroup{Name: "Action group"} if err := db.Create(&group).Error; err != nil { t.Fatal(err) } join := func(user *models.User) { t.Helper() if err := db.Create(&models.UsersGroup{UserID: user.ID, UserGroupID: group.ID}).Error; err != nil { t.Fatal(err) } } t.Run("a missing handle is an error", func(t *testing.T) { users := []*models.User{{ID: 1}} if _, err := ActivateUsers(ctx, nil, users); err == nil { t.Error("ActivateUsers") } if _, err := DeactivateUsers(ctx, nil, users); err == nil { t.Error("DeactivateUsers") } if _, err := RestoreUsers(ctx, nil, users); err == nil { t.Error("RestoreUsers") } if _, err := BanUsers(ctx, nil, users); err == nil { t.Error("BanUsers") } if _, err := UnbanUsers(ctx, nil, users); err == nil { t.Error("UnbanUsers") } if err := UnsuspendUser(ctx, nil, 1); err == nil { t.Error("UnsuspendUser") } if _, _, err := ThrottleStates(ctx, nil, []uint{1}); err == nil { t.Error("ThrottleStates") } if err := ForceDeleteCleanup(ctx, nil, nil, &models.User{ID: 1}); err == nil { t.Error("ForceDeleteCleanup") } }) t.Run("activate, deactivate and restore", func(t *testing.T) { code := "pending" issued := time.Now() idle := actionUser(t, db, "idle", func(u *models.User) { u.IsActivated, u.ActivationCode, u.ActivationCodeIssuedAt = false, &code, &issued }) gone := actionUser(t, db, "gone-idle", func(u *models.User) { u.IsActivated = false u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true} }) live := actionUser(t, db, "live", nil) join(idle) join(live) before := actionPivot(t, db) everyone := []*models.User{idle, nil, gone, live, {}} n, err := ActivateUsers(ctx, db, everyone) if err != nil || n != 2 { t.Fatalf("ActivateUsers = %d %v, want the two users that were not active", n, err) } for _, id := range []uint{idle.ID, gone.ID} { got := actionReload(t, db, id) if !got.IsActivated || got.ActivatedAt == nil || got.ActivationCode != nil || got.ActivationCodeIssuedAt != nil || got.DeletedAt.Valid { t.Fatalf("user %d after activate = %+v", id, got) } } // The passed values are updated too, so the caller's next step sees them. if !idle.IsActivated || idle.ActivationCode != nil || gone.DeletedAt.Valid { t.Fatalf("the passed users were not updated: %+v %+v", idle, gone) } if n, err = ActivateUsers(ctx, db, everyone); err != nil || n != 0 { t.Fatalf("second ActivateUsers = %d %v, want 0", n, err) } if n, err = DeactivateUsers(ctx, db, everyone); err != nil || n != 3 { t.Fatalf("DeactivateUsers = %d %v, want 3", n, err) } for _, user := range []*models.User{idle, gone, live} { if got := actionReload(t, db, user.ID); !got.DeletedAt.Valid || !got.IsActivated { t.Fatalf("user %d after deactivate = %+v", user.ID, got) } } if n, err = DeactivateUsers(ctx, db, everyone); err != nil || n != 0 { t.Fatalf("second DeactivateUsers = %d %v, want 0", n, err) } if n, err = RestoreUsers(ctx, db, []*models.User{idle, live}); err != nil || n != 2 { t.Fatalf("RestoreUsers = %d %v, want 2", n, err) } if got := actionReload(t, db, idle.ID); got.DeletedAt.Valid { t.Fatal("restore left deleted_at") } if got := actionReload(t, db, gone.ID); !got.DeletedAt.Valid { t.Fatal("restore touched a user outside its selection") } if n, err = RestoreUsers(ctx, db, []*models.User{idle, live}); err != nil || n != 0 { t.Fatalf("second RestoreUsers = %d %v, want 0", n, err) } if !reflect.DeepEqual(actionPivot(t, db), before) { t.Fatal("an action changed users_groups") } // Conditions on the caller's handle never narrow an action: each one // starts from an empty statement. scoped := db.Where("1 = 0") late := actionUser(t, db, "scoped", func(u *models.User) { u.IsActivated = false }) if n, err = ActivateUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || !actionReload(t, db, late.ID).IsActivated { t.Fatalf("ActivateUsers through a scoped handle = %d %v", n, err) } if n, err = DeactivateUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || !actionReload(t, db, late.ID).DeletedAt.Valid { t.Fatalf("DeactivateUsers through a scoped handle = %d %v", n, err) } if n, err = BanUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || len(actionThrottle(t, db, late.ID)) != 1 { t.Fatalf("BanUsers through a scoped handle = %d %v", n, err) } if banned, _, err := ThrottleStates(ctx, scoped, []uint{late.ID}); err != nil || !banned[late.ID] { t.Fatalf("ThrottleStates through a scoped handle = %v %v", banned, err) } }) t.Run("ban and unban", func(t *testing.T) { several := actionUser(t, db, "several", nil) none := actionUser(t, db, "none", nil) anyRow := actionUser(t, db, "any-row", nil) first, second := "203.0.113.1", "203.0.113.2" for _, row := range []models.Throttle{ {UserID: several.ID, IPAddress: &first, Attempts: 2}, {UserID: several.ID, IPAddress: &second}, {UserID: anyRow.ID}, } { if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } join(several) before := actionPivot(t, db) everyone := []*models.User{several, none, anyRow, nil} n, err := BanUsers(ctx, db, everyone) if err != nil || n != 3 { t.Fatalf("BanUsers = %d %v, want 3", n, err) } // Every row is banned, and each user has exactly one row without an // address: created for the two who had none, kept for the third. for user, want := range map[*models.User]int{several: 3, none: 1, anyRow: 1} { rows := actionThrottle(t, db, user.ID) nullRows := 0 for _, row := range rows { if !row.IsBanned { t.Fatalf("user %d has a row that is not banned: %+v", user.ID, row) } if row.IPAddress == nil { nullRows++ } } if len(rows) != want || nullRows != 1 { t.Fatalf("user %d has %d rows (%d without an address), want %d and 1", user.ID, len(rows), nullRows, want) } } if rows := actionThrottle(t, db, several.ID); rows[0].Attempts != 2 { t.Fatalf("a ban changed the attempt counter: %+v", rows[0]) } if n, err = BanUsers(ctx, db, everyone); err != nil || n != 0 { t.Fatalf("second BanUsers = %d %v, want 0", n, err) } if rows := actionThrottle(t, db, none.ID); len(rows) != 1 { t.Fatalf("a second ban created another row: %d", len(rows)) } banned, suspended, err := ThrottleStates(ctx, db, []uint{several.ID, none.ID, anyRow.ID, 987654}) if err != nil || len(banned) != 3 || len(suspended) != 0 { t.Fatalf("ThrottleStates = %v %v %v", banned, suspended, err) } // A mixed selection counts only the users who were banned. fresh := actionUser(t, db, "never-banned", nil) if n, err = UnbanUsers(ctx, db, []*models.User{several, fresh, nil}); err != nil || n != 1 { t.Fatalf("UnbanUsers = %d %v, want 1", n, err) } for _, row := range actionThrottle(t, db, several.ID) { if row.IsBanned { t.Fatalf("a row stayed banned: %+v", row) } } if rows := actionThrottle(t, db, fresh.ID); len(rows) != 0 { t.Fatalf("unban created a row for a user who was not banned: %+v", rows) } if rows := actionThrottle(t, db, none.ID); !rows[0].IsBanned { t.Fatal("unban touched a user outside its selection") } if n, err = UnbanUsers(ctx, db, []*models.User{several}); err != nil || n != 0 { t.Fatalf("second UnbanUsers = %d %v, want 0", n, err) } if !reflect.DeepEqual(actionPivot(t, db), before) { t.Fatal("ban or unban changed users_groups") } }) t.Run("the suspension window is a pure read", func(t *testing.T) { inside := actionUser(t, db, "inside", nil) expired := actionUser(t, db, "expired", nil) flagged := actionUser(t, db, "flagged", nil) recent, longAgo := time.Now().Add(-14*time.Minute), time.Now().Add(-16*time.Minute) ip := "203.0.113.9" for _, row := range []models.Throttle{ {UserID: inside.ID, IPAddress: &ip, IsSuspended: true, SuspendedAt: &recent, Attempts: 5}, {UserID: expired.ID, IPAddress: &ip, IsSuspended: true, SuspendedAt: &longAgo, Attempts: 5}, // Suspended without a time: not inside any window. {UserID: flagged.ID, IPAddress: &ip, IsSuspended: true, Attempts: 5}, } { if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } ids := []uint{inside.ID, expired.ID, flagged.ID} _, suspended, err := ThrottleStates(ctx, db, ids) if err != nil || !reflect.DeepEqual(suspended, map[uint]bool{inside.ID: true}) { t.Fatalf("suspended = %v %v, want only the user inside the 15 minute window", suspended, err) } // A shorter window from the context moves the boundary. short := withThrottle(ctx, throttleSettings{use: true, limit: 5, minutes: 10}) if _, suspended, err = ThrottleStates(short, db, ids); err != nil || len(suspended) != 0 { t.Fatalf("suspended under a 10 minute window = %v %v", suspended, err) } long := withThrottle(ctx, throttleSettings{use: true, limit: 5, minutes: 30}) if _, suspended, err = ThrottleStates(long, db, ids); err != nil || len(suspended) != 2 { t.Fatalf("suspended under a 30 minute window = %v %v", suspended, err) } // A window of zero falls back to the default. zero := withThrottle(ctx, throttleSettings{use: true}) if _, suspended, err = ThrottleStates(zero, db, ids); err != nil || len(suspended) != 1 { t.Fatalf("suspended under a zero window = %v %v", suspended, err) } // Reading changed nothing: the expired suspension is still flagged. for _, id := range ids { if rows := actionThrottle(t, db, id); len(rows) != 1 || !rows[0].IsSuspended || rows[0].Attempts != 5 { t.Fatalf("reading the state changed the row of %d: %+v", id, rows) } } banned, suspended, err := ThrottleStates(ctx, db, nil) if err != nil || banned == nil || suspended == nil || len(banned)+len(suspended) != 0 { t.Fatalf("ThrottleStates without ids = %v %v %v", banned, suspended, err) } if err := UnsuspendUser(ctx, db, inside.ID); err != nil { t.Fatal(err) } if rows := actionThrottle(t, db, inside.ID); rows[0].IsSuspended || rows[0].SuspendedAt != nil || rows[0].Attempts != 0 { t.Fatalf("row after unsuspend = %+v", rows[0]) } if rows := actionThrottle(t, db, expired.ID); !rows[0].IsSuspended { t.Fatal("unsuspend touched another user") } // Idempotent, and a user without rows is no error. if err := UnsuspendUser(ctx, db, inside.ID); err != nil { t.Fatal(err) } if err := UnsuspendUser(ctx, db, 987654); err != nil { t.Fatal(err) } }) t.Run("the cleanup of a permanent delete", func(t *testing.T) { bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) // The class harness runs the plugin's migrations only; system_files is // the framework's table. if err := db.AutoMigrate(&attach.File{}); err != nil { t.Fatal(err) } loaded := func(label string) (*models.User, string) { t.Helper() user := actionUser(t, db, label, nil) join(user) ip := "203.0.113.20" for _, row := range []models.Throttle{{UserID: user.ID, IPAddress: &ip}, {UserID: user.ID, IsBanned: true}} { if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } disk := "cleanup-" + label + ".jpg" key := attach.BlobKey(disk) if err := bucket.WriteAll(ctx, key, []byte("avatar"), nil); err != nil { t.Fatal(err) } file := attach.File{DiskName: disk, FileName: "a.jpg", FileSize: 6, ContentType: "image/jpeg", Field: models.AvatarField, AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName()} if err := db.Create(&file).Error; err != nil { t.Fatal(err) } return user, key } left := func(user *models.User) [3]int64 { t.Helper() var out [3]int64 for i, query := range []string{ `SELECT COUNT(*) FROM user_throttle WHERE user_id = ?`, `SELECT COUNT(*) FROM users_groups WHERE user_id = ?`, } { if err := db.Raw(query, user.ID).Scan(&out[i]).Error; err != nil { t.Fatal(err) } } err := db.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ?`, user.MorphName(), strconv.FormatUint(uint64(user.ID), 10)).Scan(&out[2]).Error if err != nil { t.Fatal(err) } return out } exists := func(key string) bool { t.Helper() ok, err := bucket.Exists(ctx, key) if err != nil { t.Fatal(err) } return ok } // A transaction that rolls back keeps the rows and the blob. kept, keptKey := loaded("kept") bystander, bystanderKey := loaded("bystander") rollback := errors.New("rollback") err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { if err := ForceDeleteCleanup(ctx, tx, bucket, kept); err != nil { return err } return rollback }) if !errors.Is(err, rollback) || left(kept) != [3]int64{2, 1, 1} || !exists(keptKey) { t.Fatalf("after a rollback: err=%v rows=%v blob=%v", err, left(kept), exists(keptKey)) } // A committed one removes the rows, and the blob after the commit. err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { if err := ForceDeleteCleanup(ctx, tx, bucket, kept); err != nil { return err } if !exists(keptKey) { t.Error("the blob was removed before the commit") } return nil }) if err != nil || left(kept) != [3]int64{} || exists(keptKey) { t.Fatalf("after the commit: err=%v rows=%v blob=%v", err, left(kept), exists(keptKey)) } // The user row itself is the caller's to delete. if got := actionReload(t, db, kept.ID); got.ID != kept.ID { t.Fatal("the cleanup deleted the user row") } if left(bystander) != [3]int64{2, 1, 1} || !exists(bystanderKey) { t.Fatal("the cleanup touched another user") } // Idempotent: nothing left to remove is no error. if err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { return ForceDeleteCleanup(ctx, tx, bucket, kept) }); err != nil { t.Fatal(err) } // Without a bucket the rows still go; the blob is left for the host. err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { return ForceDeleteCleanup(ctx, tx, nil, bystander) }) if err != nil || left(bystander) != [3]int64{} || !exists(bystanderKey) { t.Fatalf("without a bucket: err=%v rows=%v blob=%v", err, left(bystander), exists(bystanderKey)) } for _, user := range []*models.User{nil, {}} { if err := ForceDeleteCleanup(ctx, db, bucket, user); err == nil { t.Fatalf("cleanup of %+v was accepted", user) } } // Only the group itself is left of the pivot's side. var groups int64 if err := db.Model(&models.UserGroup{}).Where("id = ?", group.ID).Count(&groups).Error; err != nil || groups != 1 { t.Fatalf("the cleanup removed the group: %d %v", groups, err) } }) }