Files
RenewCMS/test/users_api_persistence_test.go
T

421 lines
15 KiB
Go

package test
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
"time"
"gorm.io/gorm"
)
func uaPersistedUser(t *testing.T, db *gorm.DB, id uint32, roleID *uint32, verified bool) {
t.Helper()
row := models.User{ID: id, Username: "member" + string(rune('a'+id)), Email: "member" + string(rune('a'+id)) + "@x.example", Password: "x", IsVerified: verified, RoleID: roleID}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("user %d: %v", id, err)
}
}
func uaPersistedInvite(t *testing.T, db *gorm.DB, email string, roleID *uint32, accepted bool, expired bool) models.Invite {
t.Helper()
now := time.Now()
row := models.Invite{Email: email, HashedCode: "h", InvitedBy: 1, RoleID: roleID, ExpiresAt: now.Add(time.Hour), CreatedAt: now}
if expired {
row.ExpiresAt = now.Add(-time.Hour)
}
if accepted {
row.AcceptedAt = &now
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("invite %s: %v", email, err)
}
return row
}
func uaRoleIDPtr(id uint32) *uint32 {
return &id
}
func TestUsersAPIUserRepository(t *testing.T) {
t.Run("Create writes role_id and NULL for zero", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
owner := storedRole(t, db, "Owner").ID
created, err := repo.Create(user.User{Username: "a", Email: "a@x.example", Password: "x", RoleID: owner})
if err != nil || created.RoleID != owner || roleIDOf(userRow(t, db, created.ID)) != owner {
t.Fatalf("unexpected %+v, %v", created, err)
}
bare, err := repo.Create(user.User{Username: "b", Email: "b@x.example", Password: "x"})
if err != nil || userRow(t, db, bare.ID).RoleID != nil {
t.Fatalf("expected a NULL role_id, got %+v, %v", userRow(t, db, bare.ID).RoleID, err)
}
})
t.Run("UpdateRole is a compare-and-swap", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
admin, writer, owner := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID, storedRole(t, db, "Owner").ID
uaPersistedUser(t, db, 5, &admin, true)
updated, err := repo.UpdateRole(5, admin, writer, 0)
if err != nil || updated.RoleID != writer || updated.TokenVersion != 0 {
t.Fatalf("unexpected %+v, %v", updated, err)
}
if _, err := repo.UpdateRole(5, admin, owner, 0); !errors.Is(err, user.ErrRoleChanged) {
t.Fatalf("the second call with the same from must lose, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != writer {
t.Errorf("the winner must stand")
}
if _, err := repo.UpdateRole(999, admin, writer, 0); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
if _, err := repo.UpdateRole(5, writer, 999, 0); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != writer {
t.Errorf("a missing role must not be stored")
}
})
t.Run("UpdateRole swaps a NULL role_id when from is zero", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
writer := storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, nil, true)
if _, err := repo.UpdateRole(5, 0, writer, 0); err != nil || roleIDOf(userRow(t, db, 5)) != writer {
t.Fatalf("unexpected %v", err)
}
})
t.Run("UpdateRole fails when the database is closed", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if _, err := repo.UpdateRole(5, 1, 2, 0); err == nil {
t.Errorf("expected an error")
}
})
t.Run("TransferOwnership updates both rows", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
uaPersistedUser(t, db, 3, &owner, true)
uaPersistedUser(t, db, 5, &admin, true)
if err := repo.TransferOwnership(3, 5, owner, admin); err != nil {
t.Fatal(err)
}
if roleIDOf(userRow(t, db, 3)) != admin || roleIDOf(userRow(t, db, 5)) != owner {
t.Errorf("roles not swapped")
}
if err := repo.TransferOwnership(3, 5, owner, admin); !errors.Is(err, user.ErrRoleChanged) {
t.Errorf("the second transfer from the same owner must lose, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != owner {
t.Errorf("exactly one owner must remain")
}
})
t.Run("TransferOwnership rolls back for an unverified or missing target", func(t *testing.T) {
for name, target := range map[string]uint32{"unverified": 12, "missing": 99} {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
uaPersistedUser(t, db, 3, &owner, true)
uaPersistedUser(t, db, 12, &admin, false)
if err := repo.TransferOwnership(3, target, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) {
t.Fatalf("%s: expected ErrInvalidTransfer, got %v", name, err)
}
if roleIDOf(userRow(t, db, 3)) != owner {
t.Errorf("%s: the owner must stay", name)
}
}
})
t.Run("TransferOwnership fails when the database is closed", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if err := repo.TransferOwnership(3, 5, 1, 2); err == nil {
t.Errorf("expected an error")
}
})
}
func TestUsersAPIInviteRepository(t *testing.T) {
t.Run("invites have a role_id column and keep it through Create and Renew", func(t *testing.T) {
db := openMigratedRolesDB(t)
if !db.Migrator().HasColumn(&models.Invite{}, "role_id") {
t.Fatal("invites.role_id is missing")
}
repo := persistence.NewInviteRepository(db)
writer := storedRole(t, db, "Writer").ID
now := time.Now()
created, err := repo.Create(invite.Invite{Email: "a@x.example", HashedCode: "h", InvitedBy: 1, RoleID: writer, ExpiresAt: now.Add(time.Hour), CreatedAt: now})
if err != nil || created.RoleID != writer {
t.Fatalf("unexpected %+v, %v", created, err)
}
renewed, err := repo.Renew(created.ID, "h2", now.Add(2*time.Hour), now)
if err != nil || renewed.RoleID != writer {
t.Fatalf("unexpected %+v, %v", renewed, err)
}
if all := allOf(repo.GetAll()); len(all) != 1 || all[0].RoleID != writer {
t.Errorf("unexpected %+v", all)
}
})
t.Run("AcceptWithUser stores the user's role_id and marks the invite accepted", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewInviteRepository(db)
contributor := storedRole(t, db, "Admin").ID
row := uaPersistedInvite(t, db, "kim@x.example", &contributor, false, false)
created, err := repo.AcceptWithUser(row.ID, time.Now(), user.User{Username: "kim", Email: "kim@x.example", Password: "x", IsVerified: true, RoleID: contributor})
if err != nil || created.RoleID != contributor || roleIDOf(userRow(t, db, created.ID)) != contributor {
t.Fatalf("unexpected %+v, %v", created, err)
}
var stored models.Invite
_ = db.First(&stored, row.ID).Error
if stored.AcceptedAt == nil {
t.Errorf("the invite must be accepted")
}
})
}
type statementCounter struct {
statements int
}
func countStatements(db *gorm.DB) *statementCounter {
counter := &statementCounter{}
increment := func(*gorm.DB) { counter.statements++ }
_ = db.Callback().Query().Before("gorm:query").Register("users_api:count_query", increment)
_ = db.Callback().Row().Before("gorm:row").Register("users_api:count_row", increment)
_ = db.Callback().Raw().Before("gorm:raw").Register("users_api:count_raw", increment)
return counter
}
func TestUsersAPIRoleRepositoryMembers(t *testing.T) {
t.Run("MemberCounts counts users and open invites in two statements", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer, admin := storedRole(t, db, "Writer").ID, storedRole(t, db, "Admin").ID
uaPersistedUser(t, db, 1, &admin, true)
uaPersistedUser(t, db, 2, &admin, false)
uaPersistedUser(t, db, 3, &writer, true)
uaPersistedUser(t, db, 4, nil, true)
uaPersistedInvite(t, db, "p@x.example", &writer, false, false)
uaPersistedInvite(t, db, "e@x.example", &writer, false, true)
uaPersistedInvite(t, db, "a@x.example", &writer, true, false)
uaPersistedInvite(t, db, "n@x.example", nil, false, false)
counter := countStatements(db)
counts, err := repo.MemberCounts()
if err != nil {
t.Fatal(err)
}
if counts[admin] != 2 || counts[writer] != 3 || counts[0] != 0 || len(counts) != 2 {
t.Errorf("unexpected counts %v", counts)
}
if counter.statements > 2 {
t.Errorf("expected at most 2 statements, got %d", counter.statements)
}
})
t.Run("MemberCounts fails when the database is closed", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if _, err := repo.MemberCounts(); err == nil {
t.Errorf("expected an error")
}
})
t.Run("Delete refuses a role with a user or an open invite and accepts an accepted one", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
withUser, _ := repo.Create(role.Role{Name: "With user", Version: 1})
withInvite, _ := repo.Create(role.Role{Name: "With invite", Version: 1})
withHistory, _ := repo.Create(role.Role{Name: "With history", Version: 1})
uaPersistedUser(t, db, 1, &withUser.ID, true)
uaPersistedInvite(t, db, "p@x.example", &withInvite.ID, false, true)
uaPersistedInvite(t, db, "a@x.example", &withHistory.ID, true, false)
if err := repo.Delete(withUser.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Errorf("a user must block, got %v", err)
}
if err := repo.Delete(withInvite.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Errorf("an open invite must block, got %v", err)
}
if err := repo.Delete(withHistory.ID); err != nil {
t.Errorf("an accepted invite must not block, got %v", err)
}
})
t.Run("ReassignMembers moves users and open invites and returns the sum", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source, _ := repo.Create(role.Role{Name: "Source", Version: 1})
target, _ := repo.Create(role.Role{Name: "Target", Version: 1})
uaPersistedUser(t, db, 1, &source.ID, true)
uaPersistedUser(t, db, 2, &source.ID, true)
open := uaPersistedInvite(t, db, "p@x.example", &source.ID, false, false)
done := uaPersistedInvite(t, db, "a@x.example", &source.ID, true, false)
moved, err := repo.ReassignMembers(source.ID, target.ID)
if err != nil || moved != 3 {
t.Fatalf("expected 3, got %d, %v", moved, err)
}
var movedInvite, keptInvite models.Invite
_ = db.First(&movedInvite, open.ID).Error
_ = db.First(&keptInvite, done.ID).Error
if *movedInvite.RoleID != target.ID || *keptInvite.RoleID != source.ID {
t.Errorf("only the open invite moves")
}
if _, err := repo.ReassignMembers(source.ID, 999); !errors.Is(err, role.ErrInvalidReassign) {
t.Errorf("expected ErrInvalidReassign, got %v", err)
}
})
}
func addLegacyRoleColumn(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''").Error; err != nil {
t.Fatal(err)
}
}
func setLegacyRole(t *testing.T, db *gorm.DB, id uint32, legacy string) {
t.Helper()
if err := db.Exec("UPDATE users SET role = ? WHERE id = ?", legacy, id).Error; err != nil {
t.Fatal(err)
}
}
func legacyUsersDB(t *testing.T, rows map[uint32]string) *gorm.DB {
t.Helper()
db := openMigratedRolesDB(t)
addLegacyRoleColumn(t, db)
for id, legacy := range rows {
uaPersistedUser(t, db, id, nil, true)
setLegacyRole(t, db, id, legacy)
}
return db
}
func TestUsersAPIMigration(t *testing.T) {
rows := []struct {
name string
users map[uint32]string
expect map[uint32]string
}{
{"owner", map[uint32]string{3: "owner"}, map[uint32]string{3: "Owner"}},
{"admin", map[uint32]string{5: "admin"}, map[uint32]string{5: "Admin"}},
{"empty string, lowest id and any other id", map[uint32]string{2: "", 4: ""}, map[uint32]string{2: "Owner", 4: "Admin"}},
{"something else", map[uint32]string{7: "something-else"}, map[uint32]string{7: "Writer"}},
}
for _, row := range rows {
t.Run("legacy "+row.name, func(t *testing.T) {
db := legacyUsersDB(t, row.users)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
for id, name := range row.expect {
if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID {
t.Errorf("user %d: expected %s, got %d", id, name, roleIDOf(userRow(t, db, id)))
}
}
if !db.Migrator().HasColumn("users", "role") {
t.Errorf("the legacy column must stay")
}
before := map[uint32]uint32{}
for id := range row.users {
before[id] = roleIDOf(userRow(t, db, id))
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
for id, was := range before {
if roleIDOf(userRow(t, db, id)) != was {
t.Errorf("user %d changed on the second Migrate", id)
}
}
})
}
t.Run("a user that already has a role_id keeps it", func(t *testing.T) {
db := legacyUsersDB(t, nil)
custom, _ := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1})
uaPersistedUser(t, db, 5, &custom.ID, true)
setLegacyRole(t, db, 5, "admin")
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
if roleIDOf(userRow(t, db, 5)) != custom.ID {
t.Errorf("the role_id was replaced")
}
})
t.Run("existing invites without a role get Admin, accepted ones included", func(t *testing.T) {
db := openMigratedRolesDB(t)
addLegacyRoleColumn(t, db)
pending := uaPersistedInvite(t, db, "p@x.example", nil, false, false)
done := uaPersistedInvite(t, db, "a@x.example", nil, true, false)
writer := storedRole(t, db, "Writer").ID
kept := uaPersistedInvite(t, db, "k@x.example", &writer, false, false)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
admin := storedRole(t, db, "Admin").ID
for _, id := range []uint32{pending.ID, done.ID} {
var stored models.Invite
_ = db.First(&stored, id).Error
if stored.RoleID == nil || *stored.RoleID != admin {
t.Errorf("invite %d must be for Admin", id)
}
}
var stored models.Invite
_ = db.First(&stored, kept.ID).Error
if *stored.RoleID != writer {
t.Errorf("an invite with a role keeps it")
}
})
t.Run("created_by is attributed to the lowest user holding the Owner role", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
uaPersistedUser(t, db, 2, &admin, true)
uaPersistedUser(t, db, 6, &owner, true)
uaPersistedUser(t, db, 8, &owner, true)
createArticleRow(t, db, 10, 0)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "articles", 10) != 6 {
t.Errorf("expected user 6, got %d", createdByOf(t, db, "articles", 10))
}
})
t.Run("a fresh database never has a users.role column and Migrate succeeds with no users", func(t *testing.T) {
db := openMigratedRolesDB(t)
if db.Migrator().HasColumn("users", "role") {
t.Fatal("a fresh database must not have users.role")
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
})
t.Run("a legacy column with no users migrates", func(t *testing.T) {
db := legacyUsersDB(t, nil)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
})
}