421 lines
15 KiB
Go
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)
|
|
if err != nil || updated.RoleID != writer || updated.TokenVersion != 0 {
|
|
t.Fatalf("unexpected %+v, %v", updated, err)
|
|
}
|
|
if _, err := repo.UpdateRole(5, admin, owner); !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); !errors.Is(err, user.ErrUserNotFound) {
|
|
t.Errorf("expected ErrUserNotFound, got %v", err)
|
|
}
|
|
if _, err := repo.UpdateRole(5, writer, 999); !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); 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); 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 := 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)
|
|
}
|
|
})
|
|
}
|