feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
@@ -0,0 +1,311 @@
|
||||
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"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUsersAPIOwnerVerificationUseCase(t *testing.T) {
|
||||
newRepo := func() *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
for _, id := range []uint32{1, 2} {
|
||||
repo.users[id] = user.User{ID: id, RoleID: ownerRoleID, VerificationCode: "hashed:code", VerificationExpiration: time.Now().Add(time.Hour)}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
t.Run("the second Owner cannot be verified while a verified Owner exists", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder)
|
||||
if _, err := verifier.Verify(testCtx, 1, "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(testCtx, 2, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) {
|
||||
t.Fatalf("expected ErrInvalidVerificationCode, got %v", err)
|
||||
}
|
||||
if repo.users[2].IsVerified || !repo.users[1].IsVerified {
|
||||
t.Errorf("exactly the first Owner must be verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a rival verified in between wins and the late one is refused", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder)
|
||||
repo.beforeClearVerification = func() {
|
||||
rival := repo.users[2]
|
||||
rival.IsVerified = true
|
||||
repo.users[2] = rival
|
||||
}
|
||||
if _, err := verifier.Verify(testCtx, 1, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) {
|
||||
t.Fatalf("expected ErrInvalidVerificationCode, got %v", err)
|
||||
}
|
||||
if repo.users[1].IsVerified {
|
||||
t.Errorf("the late verification must not stand")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIInviteRoleLockedUseCases(t *testing.T) {
|
||||
t.Run("accepting an invite that points at the Owner role is invalid", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = w.id("Owner")
|
||||
w.invites.invites[3] = pending
|
||||
users := len(w.users.users)
|
||||
_, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"})
|
||||
if !errors.Is(err, invite.ErrInviteInvalid) {
|
||||
t.Fatalf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
if len(w.users.users) != users || w.invites.invites[3].AcceptedAt != nil {
|
||||
t.Errorf("no user may be created and the invite stays open")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renewing an invite that points at the Owner role is refused", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = w.id("Owner")
|
||||
w.invites.invites[3] = pending
|
||||
_, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaOwner), ID: 3, RenewedBy: uaOwner})
|
||||
if !errors.Is(err, role.ErrRoleLocked) {
|
||||
t.Fatalf("expected ErrRoleLocked, got %v", err)
|
||||
}
|
||||
if w.invites.invites[3].HashedCode != pending.HashedCode || len(w.mail.SentEmails) != 0 {
|
||||
t.Errorf("nothing may be renewed or mailed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renewing an invite whose role was deleted is an unknown role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = 99
|
||||
w.invites.invites[3] = pending
|
||||
_, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaAdmin), ID: 3, RenewedBy: uaAdmin})
|
||||
if !errors.Is(err, role.ErrUnknownRole) {
|
||||
t.Fatalf("expected ErrUnknownRole, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIOwnerVerificationRepository(t *testing.T) {
|
||||
t.Run("the second Owner is refused and stays unverified", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
owner := storedRole(t, db, "Owner").ID
|
||||
uaPersistedUser(t, db, 1, &owner, false)
|
||||
uaPersistedUser(t, db, 2, &owner, false)
|
||||
if _, err := repo.ClearVerificationCode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.ClearVerificationCode(2); !errors.Is(err, user.ErrInvalidVerificationCode) {
|
||||
t.Fatalf("expected ErrInvalidVerificationCode, got %v", err)
|
||||
}
|
||||
if userRow(t, db, 2).IsVerified || !userRow(t, db, 1).IsVerified {
|
||||
t.Errorf("exactly one Owner must be verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other roles verify freely and a missing user is not found", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
admin := storedRole(t, db, "Admin").ID
|
||||
uaPersistedUser(t, db, 1, &admin, true)
|
||||
uaPersistedUser(t, db, 2, &admin, false)
|
||||
uaPersistedUser(t, db, 3, nil, false)
|
||||
for _, id := range []uint32{2, 3} {
|
||||
if _, err := repo.ClearVerificationCode(id); err != nil || !userRow(t, db, id).IsVerified {
|
||||
t.Fatalf("user %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := repo.ClearVerificationCode(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a verified Owner can be verified again", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
owner := storedRole(t, db, "Owner").ID
|
||||
uaPersistedUser(t, db, 1, &owner, true)
|
||||
if _, err := repo.ClearVerificationCode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two concurrent verifications leave one winner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
owner := storedRole(t, db, "Owner").ID
|
||||
uaPersistedUser(t, db, 1, &owner, false)
|
||||
uaPersistedUser(t, db, 2, &owner, false)
|
||||
var group sync.WaitGroup
|
||||
for _, id := range []uint32{1, 2} {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, _ = repo.ClearVerificationCode(id)
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
var verified int64
|
||||
if err := db.Model(&models.User{}).Where("role_id = ? AND is_verified = ?", owner, true).Count(&verified).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified != 1 {
|
||||
t.Errorf("expected exactly one verified Owner, got %d", verified)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a closed database fails", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if _, err := repo.ClearVerificationCode(1); err == nil {
|
||||
t.Errorf("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPITransferToSelf(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)
|
||||
if err := repo.TransferOwnership(3, 3, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) {
|
||||
t.Fatalf("expected ErrInvalidTransfer, got %v", err)
|
||||
}
|
||||
if roleIDOf(userRow(t, db, 3)) != owner {
|
||||
t.Errorf("the owner must stay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAPIMigrationRerun(t *testing.T) {
|
||||
t.Run("several legacy owners keep the lowest id as Owner and the rest become Admin", func(t *testing.T) {
|
||||
db := legacyUsersDB(t, map[uint32]string{4: "owner", 2: "owner", 9: "owner", 3: "admin"})
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expect := map[uint32]string{2: "Owner", 4: "Admin", 9: "Admin", 3: "Admin"}
|
||||
for id, name := range expect {
|
||||
if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID {
|
||||
t.Errorf("user %d: expected %s", id, name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a legacy owner is not displaced by a lower empty-string user", func(t *testing.T) {
|
||||
db := legacyUsersDB(t, map[uint32]string{1: "", 5: "owner"})
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if roleIDOf(userRow(t, db, 5)) != storedRole(t, db, "Owner").ID || roleIDOf(userRow(t, db, 1)) != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("exactly one Owner is expected, user 5")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users and invites created after the upgrade are never promoted by a later Migrate", func(t *testing.T) {
|
||||
db := legacyUsersDB(t, map[uint32]string{2: "owner"})
|
||||
pending := uaPersistedInvite(t, db, "legacy@x.example", nil, false, false)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uaPersistedUser(t, db, 20, nil, true)
|
||||
late := uaPersistedInvite(t, db, "late@x.example", nil, false, false)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if userRow(t, db, 20).RoleID != nil {
|
||||
t.Errorf("the post-upgrade user must stay unassigned")
|
||||
}
|
||||
var stored models.Invite
|
||||
if err := db.First(&stored, late.ID).Error; err != nil || stored.RoleID != nil {
|
||||
t.Errorf("the post-upgrade invite must stay unassigned, got %v %v", stored.RoleID, err)
|
||||
}
|
||||
var legacy models.Invite
|
||||
if err := db.First(&legacy, pending.ID).Error; err != nil || legacy.RoleID == nil || *legacy.RoleID != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("the legacy invite must be Admin, got %v %v", legacy.RoleID, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without the legacy column nothing is assigned", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
uaPersistedUser(t, db, 20, nil, true)
|
||||
uaPersistedInvite(t, db, "late@x.example", nil, false, false)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if userRow(t, db, 20).RoleID != nil {
|
||||
t.Errorf("the user must stay unassigned")
|
||||
}
|
||||
var stored models.Invite
|
||||
_ = db.Where("email = ?", "late@x.example").First(&stored).Error
|
||||
if stored.RoleID != nil {
|
||||
t.Errorf("the invite must stay unassigned")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type usersAPIGetAllHookRepo struct {
|
||||
*mockUserRepo
|
||||
hook func()
|
||||
}
|
||||
|
||||
func (r *usersAPIGetAllHookRepo) GetAll() []user.User {
|
||||
if r.hook != nil {
|
||||
hook := r.hook
|
||||
r.hook = nil
|
||||
hook()
|
||||
}
|
||||
return r.mockUserRepo.GetAll()
|
||||
}
|
||||
|
||||
func TestUsersAPIAcceptInviteRace(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
w.users.users[200] = user.User{ID: 200, Username: "taken", Email: pending.Email, IsVerified: true, RoleID: writerRoleID}
|
||||
hooked := &usersAPIGetAllHookRepo{mockUserRepo: w.users}
|
||||
hooked.hook = func() {
|
||||
accepted := time.Now()
|
||||
raced := w.invites.invites[3]
|
||||
raced.AcceptedAt = &accepted
|
||||
w.invites.invites[3] = raced
|
||||
}
|
||||
accept := useCases.NewAcceptInviteUseCase(hooked, w.invites, w.roles, &stubHasher{}, w.recorder)
|
||||
_, err := accept.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"})
|
||||
if !errors.Is(err, invite.ErrInviteInvalid) {
|
||||
t.Fatalf("a concurrently accepted invite must surface its own refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAPIRepositoryFailuresUseCases(t *testing.T) {
|
||||
t.Run("a failing successor lookup is not an invalid transfer", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.fail.failNext("Get")
|
||||
_, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: uaAdmin, ConfirmEmail: "jonas@renew.example"})
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failing content type listing stops a role change", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.types.fail.failNext("List")
|
||||
_, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaWriter, RoleID: w.id("Contributor")})
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository failure, got %v", err)
|
||||
}
|
||||
if w.users.users[uaWriter].RoleID != writerRoleID {
|
||||
t.Errorf("the role must not change")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user