Files
RenewCMS/test/users_api_hardening_test.go
T

312 lines
11 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"
"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")
}
})
}