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
@@ -116,7 +116,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
|
||||
hasher := atomicPlainHasher{}
|
||||
handler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder),
|
||||
useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder),
|
||||
authtoken.New([]byte("test-secret")),
|
||||
true,
|
||||
)
|
||||
@@ -143,6 +143,7 @@ func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite {
|
||||
HashedCode: "hashed:" + acceptCode,
|
||||
ExpiresAt: time.Now().Add(48 * time.Hour),
|
||||
InvitedBy: 1,
|
||||
RoleID: uaRoleIDPtr(writerRoleID),
|
||||
}
|
||||
if err := db.Create(&pending).Error; err != nil {
|
||||
t.Fatalf("seed invite: %v", err)
|
||||
|
||||
@@ -55,20 +55,20 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
listHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
nil,
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
||||
)
|
||||
registerHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -497,7 +497,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
||||
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
|
||||
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||
invites.users = users
|
||||
return useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), users, invites, hasher
|
||||
return useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), hasher, discardRecorder), users, invites, hasher
|
||||
}
|
||||
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
|
||||
|
||||
@@ -571,7 +571,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
||||
invites.invites[seeded.ID] = seeded
|
||||
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
|
||||
}}
|
||||
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher, discardRecorder)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, racing, newSeededMockRoleRepo(), hasher, discardRecorder)
|
||||
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
|
||||
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
|
||||
@@ -64,10 +64,10 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
generator: hashing.NewSHA256KeyGenerator(),
|
||||
}
|
||||
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner}
|
||||
e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin}
|
||||
e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor", RoleID: writerRoleID}
|
||||
e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner}
|
||||
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: ownerRoleID}
|
||||
e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: adminRoleID}
|
||||
e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, RoleID: writerRoleID}
|
||||
e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: ownerRoleID}
|
||||
e.users.nextID = 4
|
||||
|
||||
hasher := &stubHasher{}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (p *producersEnv) seedBanner(id uint32, filename string, hash string) {
|
||||
}
|
||||
|
||||
func (p *producersEnv) seedPendingInvite(email string) invite.Invite {
|
||||
return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()})
|
||||
return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()})
|
||||
}
|
||||
|
||||
func paramsJSON(t *testing.T, event audit.Event) string {
|
||||
@@ -770,7 +770,7 @@ func TestActivityLogProducersUsers(t *testing.T) {
|
||||
|
||||
func (p *producersEnv) seedAcceptedInvite(email string) {
|
||||
accepted := time.Now().Add(-time.Hour)
|
||||
p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)})
|
||||
p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)})
|
||||
}
|
||||
|
||||
func TestActivityLogProducersSystem(t *testing.T) {
|
||||
|
||||
@@ -422,7 +422,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
|
||||
|
||||
t.Run("Registering is recorded with the new account as actor", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
uc := useCases.NewCreateUserUseCase(newUsers(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder)
|
||||
uc := useCases.NewCreateUserUseCase(newUsers(), newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder)
|
||||
|
||||
created, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
|
||||
|
||||
@@ -454,7 +454,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
|
||||
users := newUsers()
|
||||
mail := newMockMailRepo()
|
||||
example.setup(users, mail)
|
||||
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder)
|
||||
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder)
|
||||
|
||||
if _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}); err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
@@ -478,7 +478,7 @@ func TestActivityLogProducersAccounts(t *testing.T) {
|
||||
mail.fail.failNext("Send")
|
||||
cleanupFailure := errors.New("cleanup failure")
|
||||
users.fail.failNextWith("Delete", cleanupFailure)
|
||||
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder)
|
||||
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mail, "https://cms.example.com", h.recorder)
|
||||
|
||||
_, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
|
||||
|
||||
@@ -539,8 +539,8 @@ func TestActivityLogProducersAccounts(t *testing.T) {
|
||||
users := newUsers()
|
||||
invites := newInviteFakeRepo()
|
||||
invites.users = users
|
||||
seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder)
|
||||
seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(time.Hour)})
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder)
|
||||
|
||||
created, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: "the-code", Username: "sam", Password: password})
|
||||
|
||||
@@ -578,8 +578,8 @@ func TestActivityLogProducersAccounts(t *testing.T) {
|
||||
}
|
||||
invites := newInviteFakeRepo()
|
||||
invites.users = users
|
||||
invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(example.expiresIn)})
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder)
|
||||
invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", RoleID: writerRoleID, ExpiresAt: time.Now().Add(example.expiresIn)})
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, h.recorder)
|
||||
|
||||
if _, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: example.code, Username: "sam", Password: password}); err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
|
||||
@@ -38,9 +38,9 @@ func newActivityWorld(t *testing.T) *activityWorld {
|
||||
keys: newMockAPIKeyRepo(),
|
||||
}
|
||||
w.probe.maxReads = 50
|
||||
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
|
||||
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
|
||||
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
|
||||
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, RoleID: ownerRoleID}
|
||||
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, RoleID: adminRoleID}
|
||||
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true}
|
||||
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites(), plainAuthors())
|
||||
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
|
||||
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
|
||||
|
||||
@@ -79,10 +79,12 @@ var routesNotYetInTheCatalogue = []string{
|
||||
"POST /v1/role",
|
||||
"POST /v1/role/{id}/reassign",
|
||||
"POST /v1/role/{id}/reset",
|
||||
"POST /v1/site/transfer-ownership",
|
||||
"PUT /v1/content-type/{key}",
|
||||
"PUT /v1/content-type/{key}/entry/{id}",
|
||||
"PUT /v1/image-variants/{name}",
|
||||
"PUT /v1/role/{id}",
|
||||
"PUT /v1/user/{id}/role",
|
||||
}
|
||||
|
||||
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
||||
|
||||
+11
-11
@@ -93,10 +93,10 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
|
||||
}}
|
||||
|
||||
env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, Role: user.RoleAdmin, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)}
|
||||
env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleAdmin}
|
||||
env.users.users[apiKeyOwnerId] = user.User{ID: apiKeyOwnerId, Username: "marta", Email: "marta@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: ownerRoleID}
|
||||
env.users.users[apiKeyDevId] = user.User{ID: apiKeyDevId, Username: "dev", Email: "dev@example.com", Password: "hashed:oldpassword1", IsVerified: true, RoleID: adminRoleID, PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: env.now.Add(time.Hour)}
|
||||
env.users.users[apiKeyPatId] = user.User{ID: apiKeyPatId, Username: "pat", Email: "pat@example.com", IsVerified: true, RoleID: adminRoleID}
|
||||
env.users.users[apiKeyQuinnId] = user.User{ID: apiKeyQuinnId, Username: "quinn", Email: "quinn@example.com", IsVerified: false, RoleID: adminRoleID}
|
||||
env.users.nextID = 4
|
||||
|
||||
clock := func() time.Time { return env.now }
|
||||
@@ -119,19 +119,19 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, newSeededMockRoleRepo(), hasher, mail, "https://cms.example.com", recorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder),
|
||||
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
|
||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
|
||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, "https://cms.example.com", recorder),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites, newSeededMockRoleRepo()),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, newSeededMockRoleRepo(), hasher, recorder),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
@@ -575,7 +575,7 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
|
||||
t.Run("A writer's key can not invite", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.addUser(5, "", writerRoleID, true)
|
||||
env.addUser(5, writerRoleID, true)
|
||||
raw, _ := env.issueKey(5, "ci")
|
||||
|
||||
rec := env.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`, raw)
|
||||
@@ -657,7 +657,7 @@ func TestAPIKeysHttp(t *testing.T) {
|
||||
|
||||
t.Run("A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.addUser(5, "", writerRoleID, true)
|
||||
env.addUser(5, writerRoleID, true)
|
||||
raw, _ := env.issueKey(5, "ci")
|
||||
|
||||
invite := env.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", body: `{"email": "new@example.com"}`, bearer: raw, cookieUser: apiKeyOwnerId})
|
||||
|
||||
@@ -181,10 +181,10 @@ var (
|
||||
|
||||
func apiKeyUnitUsers() *mockUserRepo {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3}
|
||||
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin}
|
||||
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
|
||||
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, RoleID: adminRoleID, TokenVersion: 3}
|
||||
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, RoleID: adminRoleID}
|
||||
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
|
||||
return users
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,6 @@ var _ author.Repository = &mockAuthorRepo{}
|
||||
const (
|
||||
authorEditorID = 5
|
||||
authorOtherEditorID = 6
|
||||
authorEditorRole = user.Role("editor")
|
||||
)
|
||||
|
||||
type authorEnv struct {
|
||||
@@ -351,8 +350,8 @@ type authorEnv struct {
|
||||
func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
t.Helper()
|
||||
p := newProducersEnv(t)
|
||||
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, RoleID: writerRoleID}
|
||||
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, RoleID: writerRoleID}
|
||||
p.users.nextID = authorOtherEditorID
|
||||
authors := newMockAuthorRepo(p.articles)
|
||||
sites := plainSites()
|
||||
@@ -437,9 +436,9 @@ func joinNames(names []string) string {
|
||||
}
|
||||
|
||||
func userWithEmail(id uint32, username string, email string) user.User {
|
||||
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
return user.User{ID: id, Username: username, Email: email, IsVerified: true, RoleID: writerRoleID}
|
||||
}
|
||||
|
||||
func userNamed(id uint32, username string, displayName string) user.User {
|
||||
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
|
||||
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, RoleID: writerRoleID}
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func TestAuthorsBackendCurrentUser(t *testing.T) {
|
||||
t.Run("The existing fields are unchanged", func(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", ""))
|
||||
for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} {
|
||||
for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "access"} {
|
||||
if _, present := body[key]; !present {
|
||||
t.Errorf("missing %s", key)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -40,8 +40,8 @@ var supportFieldKeys = []string{"default", "key", "label", "max", "min", "option
|
||||
func newSupportEnv(t *testing.T) *contentTypeEnv {
|
||||
t.Helper()
|
||||
env := newContentTypeEnv(t)
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID}
|
||||
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, RoleID: adminRoleID}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
{"site", "site_name", "text", false, nil, float64(100), []any{}},
|
||||
{"site", "title_template", "text", false, nil, float64(200), []any{}},
|
||||
{"site", "default_description", "text", false, nil, float64(320), []any{}},
|
||||
{"user", "role", "select", true, nil, nil, []any{"owner", "admin"}},
|
||||
{"user", "role", "text", false, nil, nil, []any{}},
|
||||
{"user", "email", "text", true, nil, nil, []any{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -412,7 +412,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
t.Run("Reading the built-in types never touches data", func(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
users := newMockUserRepo()
|
||||
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
now := func() time.Time { return time.Now() }
|
||||
generator := hashing.NewSHA256KeyGenerator()
|
||||
@@ -445,6 +445,8 @@ type panickingContentTypeRepo struct{ contenttype.Repository }
|
||||
|
||||
type panickingEntryRepo struct{ entry.Repository }
|
||||
|
||||
var catalogueFieldsServedAsReferences = map[string]struct{}{"user.role": {}}
|
||||
|
||||
func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
|
||||
entities := map[string]any{
|
||||
builtintype.KeyArticle: article.Article{},
|
||||
@@ -466,6 +468,9 @@ func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range found.Fields {
|
||||
if _, derived := catalogueFieldsServedAsReferences[key+"."+field.Key]; derived {
|
||||
continue
|
||||
}
|
||||
if _, exists := object[field.Key]; !exists {
|
||||
t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key)
|
||||
}
|
||||
|
||||
@@ -257,11 +257,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
|
||||
}
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
|
||||
env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
|
||||
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true}
|
||||
env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||
env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
|
||||
env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
|
||||
env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1}
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
@@ -273,7 +273,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo(), newSeededMockRoleRepo()),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users),
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
userRepo.users[7] = user.User{
|
||||
ID: 7,
|
||||
Username: "alice",
|
||||
Role: user.RoleOwner,
|
||||
RoleID: ownerRoleID,
|
||||
Email: "alice@example.com",
|
||||
IsVerified: true,
|
||||
TokenVersion: 0,
|
||||
@@ -98,7 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
userRepo.users[8] = user.User{
|
||||
ID: 8,
|
||||
Username: "bob",
|
||||
Role: user.RoleOwner,
|
||||
RoleID: ownerRoleID,
|
||||
Email: "bob@example.com",
|
||||
IsVerified: false,
|
||||
TokenVersion: 0,
|
||||
@@ -126,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil)
|
||||
@@ -139,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 1}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: false, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", RoleID: adminRoleID, IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
|
||||
+4
-4
@@ -34,7 +34,7 @@ func (s *spyHasher) Compare(hashed string, value string) error {
|
||||
func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
|
||||
t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
|
||||
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -59,7 +59,7 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
|
||||
|
||||
t.Run("a hashing failure persists nothing", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -177,7 +177,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -195,7 +195,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -88,11 +88,11 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
|
||||
usage: newMockVariantUsageRepo(),
|
||||
}
|
||||
env.tracker = usage.NewTracker(env.usage, func() time.Time { return env.now })
|
||||
env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, RoleID: ownerRoleID}
|
||||
env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, RoleID: adminRoleID}
|
||||
env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true}
|
||||
env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||
env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
|
||||
env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, RoleID: adminRoleID}
|
||||
env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, RoleID: adminRoleID, TokenVersion: 1}
|
||||
|
||||
container := env.container(limits)
|
||||
admin := chi.NewRouter()
|
||||
|
||||
+19
-18
@@ -111,7 +111,7 @@ func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.Us
|
||||
var _ invite.Repository = &inviteFakeRepo{}
|
||||
|
||||
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
|
||||
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt}
|
||||
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: createdAt, ExpiresAt: expiresAt}
|
||||
}
|
||||
|
||||
func pendingInvite(email string) invite.Invite {
|
||||
@@ -126,7 +126,7 @@ func expiredInvite(email string) invite.Invite {
|
||||
|
||||
func inviteInviterRepo(displayName string) *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner}
|
||||
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID}
|
||||
repo.nextID = 7
|
||||
return repo
|
||||
}
|
||||
@@ -152,10 +152,10 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
||||
}
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
handler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
nil,
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
||||
)
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/user", func(r chi.Router) {
|
||||
@@ -165,6 +165,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Get("/", handler.ListUsers)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequirePermission(getUser, useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()), "users.invite"))
|
||||
r.Use(middleware.TrustedProxyRealIP(nil))
|
||||
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
|
||||
r.Post("/invite", handler.CreateInvite)
|
||||
@@ -238,7 +239,7 @@ func TestInvitationsHttp(t *testing.T) {
|
||||
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at")
|
||||
assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at")
|
||||
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" {
|
||||
t.Errorf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func TestInvitationsHttp(t *testing.T) {
|
||||
|
||||
t.Run("The email names the inviter by display name when there is one", func(t *testing.T) {
|
||||
f := newInviteFixture(t, 100)
|
||||
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true}
|
||||
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true, RoleID: ownerRoleID}
|
||||
|
||||
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
|
||||
|
||||
@@ -493,7 +494,7 @@ func TestInvitationsHttp(t *testing.T) {
|
||||
t.Errorf("item %d unexpected: %v", index, item)
|
||||
}
|
||||
if want.kind == "invite" {
|
||||
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
|
||||
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
|
||||
} else {
|
||||
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
}
|
||||
@@ -536,7 +537,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
|
||||
hasher := &stubHasher{}
|
||||
handler := auth.NewLoginHandler(
|
||||
authtoken.New([]byte("test-secret")),
|
||||
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), hasher, mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -601,12 +602,12 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
|
||||
func TestCreateInviteUseCase(t *testing.T) {
|
||||
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
|
||||
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
|
||||
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
|
||||
return useCases.NewCreateInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
|
||||
}
|
||||
|
||||
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
|
||||
uc, _, _, _, _ := newUseCase()
|
||||
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
|
||||
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "A@B.Example", InvitedBy: 7})
|
||||
if err != nil || got.Email != "a@b.example" || got.ID == 0 {
|
||||
t.Fatalf("unexpected result %+v, %v", got, err)
|
||||
}
|
||||
@@ -614,7 +615,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
|
||||
t.Run("unknown inviter", func(t *testing.T) {
|
||||
uc, _, invites, _, _ := newUseCase()
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 99})
|
||||
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
@@ -623,7 +624,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
t.Run("existing account is refused case-insensitively", func(t *testing.T) {
|
||||
uc, users, _, _, _ := newUseCase()
|
||||
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "jonas@renew.example", InvitedBy: 7})
|
||||
if !errors.Is(err, invite.ErrEmailHasAccount) {
|
||||
t.Errorf("expected ErrEmailHasAccount, got %v", err)
|
||||
}
|
||||
@@ -632,7 +633,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
t.Run("pending invite is refused", func(t *testing.T) {
|
||||
uc, _, invites, _, _ := newUseCase()
|
||||
invites.seed(pendingInvite("a@b.example"))
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
|
||||
if !errors.Is(err, invite.ErrPendingInvite) {
|
||||
t.Errorf("expected ErrPendingInvite, got %v", err)
|
||||
}
|
||||
@@ -645,7 +646,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
accepted.AcceptedAt = &acceptedAt
|
||||
invites.seed(accepted)
|
||||
invites.seed(pendingInvite("other@b.example"))
|
||||
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
|
||||
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7}); err != nil {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
if len(invites.invites) != 3 {
|
||||
@@ -656,7 +657,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
t.Run("hash failure stores and sends nothing", func(t *testing.T) {
|
||||
uc, _, invites, mail, hasher := newUseCase()
|
||||
hasher.hashErr = errors.New("hash boom")
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
|
||||
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
|
||||
t.Errorf("expected failure without side effects, got %v", err)
|
||||
}
|
||||
@@ -666,7 +667,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
uc, _, invites, mail, _ := newUseCase()
|
||||
invites.seed(expiredInvite("a@b.example"))
|
||||
invites.fail.failNext("Delete")
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
|
||||
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
|
||||
t.Errorf("expected repository failure, got %v", err)
|
||||
}
|
||||
@@ -676,7 +677,7 @@ func TestCreateInviteUseCase(t *testing.T) {
|
||||
uc, _, invites, mail, _ := newUseCase()
|
||||
mail.fail.failNextWith("Send", errors.New("smtp down"))
|
||||
invites.fail.failNextWith("Delete", errors.New("cleanup down"))
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
|
||||
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: ownerCaller(7), Email: "a@b.example", InvitedBy: 7})
|
||||
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
@@ -742,7 +743,7 @@ func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) {
|
||||
|
||||
func TestListUsersAndInvitesUseCase(t *testing.T) {
|
||||
t.Run("empty repositories give an empty non-nil list", func(t *testing.T) {
|
||||
got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites()
|
||||
got, _ := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo(), newSeededMockRoleRepo()).ListUsersAndInvites()
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("expected empty non-nil slice, got %#v", got)
|
||||
}
|
||||
|
||||
@@ -48,21 +48,21 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, RoleID: writerRoleID}
|
||||
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin}
|
||||
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
|
||||
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: writerRoleID}
|
||||
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, RoleID: adminRoleID}
|
||||
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2}
|
||||
f.users.nextID = 12
|
||||
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
handler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCancelInviteUseCase(f.invites, discardRecorder),
|
||||
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
useCases.NewRenewInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
||||
)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), f.hasher, discardRecorder),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
@@ -145,7 +145,7 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
|
||||
t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) {
|
||||
f := newInviteManagementFixture(t, 100)
|
||||
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
|
||||
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
|
||||
|
||||
rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil)
|
||||
@@ -223,7 +223,7 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} {
|
||||
f.invites.seed(pendingInvite(filler))
|
||||
}
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, RoleID: writerRoleID, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
|
||||
|
||||
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew")
|
||||
|
||||
@@ -231,7 +231,7 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at")
|
||||
assertExactKeys(t, decoded, "id", "kind", "email", "role", "status", "created_at", "expires_at")
|
||||
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
@@ -268,8 +268,8 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
|
||||
t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) {
|
||||
f := newInviteManagementFixture(t, 100)
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
|
||||
|
||||
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
|
||||
|
||||
@@ -288,8 +288,8 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
|
||||
t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) {
|
||||
f := newInviteManagementFixture(t, 100)
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
|
||||
f.asOwner(t, http.MethodDelete, "/v1/user/invite/2")
|
||||
usersBefore := len(f.users.users)
|
||||
@@ -363,7 +363,7 @@ func TestInviteManagementHttp(t *testing.T) {
|
||||
|
||||
t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) {
|
||||
f := newInviteManagementFixture(t, 100)
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
|
||||
|
||||
if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK {
|
||||
t.Fatalf("control expected 200, got %d", before.Code)
|
||||
@@ -480,9 +480,9 @@ func TestManageInviteUseCases(t *testing.T) {
|
||||
mail := newMockMailRepo()
|
||||
seeded := invites.seed(pendingInvite("a@renew.example"))
|
||||
mail.fail.failNextWith("Send", errors.New("smtp down"))
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
|
||||
|
||||
if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded {
|
||||
t.Errorf("unexpected result %v", err)
|
||||
@@ -495,9 +495,9 @@ func TestManageInviteUseCases(t *testing.T) {
|
||||
mail := newMockMailRepo()
|
||||
invites.seed(pendingInvite("a@renew.example"))
|
||||
mail.fail.failNextWith("Send", errors.New("smtp down"))
|
||||
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
|
||||
|
||||
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("unexpected result %v", err)
|
||||
@@ -530,9 +530,9 @@ func TestManageInviteUseCases(t *testing.T) {
|
||||
mail := newMockMailRepo()
|
||||
seeded := invites.seed(pendingInvite("a@renew.example"))
|
||||
invites.fail.failNextWith("Renew", errRepositoryFailure)
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
|
||||
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7})
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded {
|
||||
t.Errorf("unexpected result %v", err)
|
||||
@@ -544,9 +544,9 @@ func TestManageInviteUseCases(t *testing.T) {
|
||||
invites := newInviteFakeRepo()
|
||||
mail := newMockMailRepo()
|
||||
invites.seed(pendingInvite("a@renew.example"))
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
renew := useCases.NewRenewInviteUseCase(users, invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), &stubHasher{}, mail, invitePublicURL, discardRecorder)
|
||||
|
||||
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil {
|
||||
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: ownerCaller(7), ID: 1, RenewedBy: 7}); err != nil {
|
||||
t.Fatalf("unexpected %v", err)
|
||||
}
|
||||
if inviteMailData(t, mail).InvitedBy != "Marta L." {
|
||||
@@ -565,7 +565,7 @@ func TestInviteRepositoryRenew(t *testing.T) {
|
||||
}
|
||||
repo := persistence.NewInviteRepository(db)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
|
||||
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, RoleID: writerRoleID, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ const mediaGetEditorID = 9
|
||||
func newMediaGetEnv(t *testing.T) *siteEnv {
|
||||
t.Helper()
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID}
|
||||
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, RoleID: writerRoleID}
|
||||
e.seedImageRecord(5)
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv {
|
||||
users: newMockUserRepo(),
|
||||
token: authtoken.New([]byte("test-secret")),
|
||||
}
|
||||
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, RoleID: adminRoleID}
|
||||
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
|
||||
env.storage = newMergeRecordingStorage(env.transactor)
|
||||
env.variants = newMergeRecordingVariantFiles(env.transactor)
|
||||
|
||||
@@ -96,11 +96,11 @@ func purgeTime(value string) *time.Time {
|
||||
func newPurgeEnv(t *testing.T) *mediaEnv {
|
||||
t.Helper()
|
||||
env := newMediaEnv(t)
|
||||
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")}
|
||||
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, RoleID: ownerRoleID}
|
||||
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, RoleID: adminRoleID}
|
||||
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true}
|
||||
env.users.users[purgeNoRoleId] = user.User{ID: purgeNoRoleId, Username: "norole", IsVerified: true}
|
||||
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner}
|
||||
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, RoleID: ownerRoleID}
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,19 @@ import (
|
||||
)
|
||||
|
||||
func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) {
|
||||
for _, role := range []string{"OWNER", "Admin", "superuser"} {
|
||||
for _, unusable := range []struct {
|
||||
name string
|
||||
roleID uint32
|
||||
}{{"no role", 0}, {"missing role", 99}} {
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/v1/media/purge-settings"},
|
||||
{http.MethodPatch, "/v1/media/purge-settings"},
|
||||
{http.MethodGet, "/v1/media/purge-preview"},
|
||||
{http.MethodPost, "/v1/media/purge"},
|
||||
} {
|
||||
t.Run(role+" "+route.method+" "+route.path, func(t *testing.T) {
|
||||
t.Run(unusable.name+" "+route.method+" "+route.path, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, Role: user.Role(role)}
|
||||
env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, RoleID: unusable.roleID}
|
||||
|
||||
rec := env.send(t, route.method, route.path, "", 20)
|
||||
|
||||
|
||||
@@ -72,8 +72,9 @@ func TestMySQLMigration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
||||
t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
addLegacyRoleColumn(t, db)
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example")
|
||||
if insert.Error != nil {
|
||||
@@ -85,15 +86,18 @@ func TestMySQLMigration(t *testing.T) {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
roles := map[string]string{}
|
||||
roles := map[string]uint32{}
|
||||
var users []models.User
|
||||
db.Find(&users)
|
||||
for _, user := range users {
|
||||
roles[user.Username] = user.Role
|
||||
for _, stored := range users {
|
||||
roles[stored.Username] = roleIDOf(stored)
|
||||
}
|
||||
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
||||
if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
if !db.Migrator().HasColumn("users", "role") {
|
||||
t.Errorf("the legacy column must stay")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -184,8 +184,9 @@ func TestPostgresMigration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
||||
t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
addLegacyRoleColumn(t, db)
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
|
||||
if insert.Error != nil {
|
||||
@@ -197,15 +198,18 @@ func TestPostgresMigration(t *testing.T) {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
roles := map[string]string{}
|
||||
roles := map[string]uint32{}
|
||||
var users []models.User
|
||||
db.Find(&users)
|
||||
for _, user := range users {
|
||||
roles[user.Username] = user.Role
|
||||
for _, stored := range users {
|
||||
roles[stored.Username] = roleIDOf(stored)
|
||||
}
|
||||
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
||||
if roles["first"] != storedRole(t, db, "Owner").ID || roles["second"] != storedRole(t, db, "Admin").ID || roles["third"] != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
if !db.Migrator().HasColumn("users", "role") {
|
||||
t.Errorf("the legacy column must stay")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, publicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -218,7 +218,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
userRepo.nextID = 1
|
||||
userRepo.fail.failNext("Create")
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "bob",
|
||||
@@ -240,7 +240,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false}
|
||||
userRepo.nextID = 2
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "carol",
|
||||
@@ -268,7 +268,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -291,7 +291,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -312,7 +312,7 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
userRepo.fail.failNext("Delete")
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
hasher := &stubHasher{}
|
||||
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
createUseCase := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -162,7 +162,7 @@ func TestRegistrationStatusEndpointHttp(t *testing.T) {
|
||||
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
|
||||
userRepo.nextID = 1
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
if uc.IsRegistrationOpen() {
|
||||
t.Fatalf("expected IsRegistrationOpen to return false")
|
||||
|
||||
@@ -129,7 +129,7 @@ func newContractRepositories(db *gorm.DB) contractRepositories {
|
||||
}
|
||||
|
||||
func contractUser(username string, email string) domainUser.User {
|
||||
return domainUser.User{Username: username, Password: "hashed", Email: email, Role: domainUser.RoleAdmin, IsVerified: true}
|
||||
return domainUser.User{Username: username, Password: "hashed", Email: email, RoleID: adminRoleID, IsVerified: true}
|
||||
}
|
||||
|
||||
func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite {
|
||||
|
||||
@@ -281,7 +281,7 @@ func TestGrantSetTypedDecisions(t *testing.T) {
|
||||
|
||||
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 3, ownerIDPtr(t, db))
|
||||
createArticleRow(t, db, 10, 0)
|
||||
if err := db.Delete(&models.Article{}, 10).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -119,16 +119,21 @@ func TestRoleRepositoryFailures(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) {
|
||||
func TestBackfillWithoutBuiltInRoles(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}, &models.Invite{}, &models.MigrationMarker{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createUserRow(t, db, 1, "owner", nil)
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
addLegacyRoleColumn(t, db)
|
||||
createUserRow(t, db, 1, nil)
|
||||
setLegacyRole(t, db, 1, "owner")
|
||||
if err := database.BackfillLegacyUserRoles(db); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
if userRow(t, db, 1).RoleID != nil {
|
||||
t.Errorf("role_id must stay unset when no built-in role exists")
|
||||
}
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
t.Errorf("created_by backfill without roles must do nothing, got %v", err)
|
||||
}
|
||||
}
|
||||
+52
-14
@@ -16,6 +16,7 @@ type mockRoleRepo struct {
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
users *mockUserRepo
|
||||
invites *inviteFakeRepo
|
||||
calls map[string]int
|
||||
beforeSave func()
|
||||
}
|
||||
@@ -127,18 +128,47 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) memberCount(roleID uint32) int {
|
||||
if m.users == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, member := range m.users.users {
|
||||
if member.RoleID == roleID {
|
||||
count++
|
||||
if m.users != nil {
|
||||
for _, member := range m.users.users {
|
||||
if member.RoleID == roleID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.invites != nil {
|
||||
for _, pending := range m.invites.invites {
|
||||
if pending.RoleID == roleID && pending.AcceptedAt == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) MemberCounts() (map[uint32]int, error) {
|
||||
m.calls["MemberCounts"]++
|
||||
if err := m.fail.trigger("MemberCounts"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := map[uint32]int{}
|
||||
if m.users != nil {
|
||||
for _, member := range m.users.users {
|
||||
if member.RoleID != 0 {
|
||||
counts[member.RoleID]++
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.invites != nil {
|
||||
for _, pending := range m.invites.invites {
|
||||
if pending.RoleID != 0 && pending.AcceptedAt == nil {
|
||||
counts[pending.RoleID]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (m *mockRoleRepo) Delete(id uint32) error {
|
||||
m.calls["Delete"]++
|
||||
if err := m.fail.trigger("Delete"); err != nil {
|
||||
@@ -159,15 +189,23 @@ func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
|
||||
if err := m.fail.trigger("ReassignMembers"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if m.users == nil {
|
||||
return 0, nil
|
||||
}
|
||||
moved := 0
|
||||
for id, member := range m.users.users {
|
||||
if member.RoleID == from {
|
||||
member.RoleID = to
|
||||
m.users.users[id] = member
|
||||
moved++
|
||||
if m.users != nil {
|
||||
for id, member := range m.users.users {
|
||||
if member.RoleID == from {
|
||||
member.RoleID = to
|
||||
m.users.users[id] = member
|
||||
moved++
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.invites != nil {
|
||||
for id, pending := range m.invites.invites {
|
||||
if pending.RoleID == from && pending.AcceptedAt == nil {
|
||||
pending.RoleID = to
|
||||
m.invites.invites[id] = pending
|
||||
moved++
|
||||
}
|
||||
}
|
||||
}
|
||||
return moved, nil
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
createUserRow(t, db, 1, "", &source.ID)
|
||||
createUserRow(t, db, 1, &source.ID)
|
||||
moved, err := repo.ReassignMembers(source.ID, 9999)
|
||||
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
|
||||
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
|
||||
@@ -81,7 +81,7 @@ func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
|
||||
createUserRow(t, db, 1, "", &kept.ID)
|
||||
createUserRow(t, db, 1, &kept.ID)
|
||||
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
||||
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestRolesAPIHardeningUseCases(t *testing.T) {
|
||||
|
||||
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
|
||||
world := newRolesAPIWorld(t)
|
||||
world.roles.fail.failNext("GetByName")
|
||||
world.roles.fail.failNext("MemberCounts")
|
||||
err := world.remove(raAdmin, "Intern")
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository failure, got %v", err)
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestRoleRepositoryRolesAPI(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
createUserRow(t, db, 1, "", &created.ID)
|
||||
createUserRow(t, db, 1, &created.ID)
|
||||
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
|
||||
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||
}
|
||||
@@ -165,9 +165,9 @@ func TestRoleRepositoryRolesAPI(t *testing.T) {
|
||||
repo := persistence.NewRoleRepository(db)
|
||||
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
|
||||
writer := storedRole(t, db, "Writer")
|
||||
createUserRow(t, db, 1, "", &source.ID)
|
||||
createUserRow(t, db, 2, "", &source.ID)
|
||||
createUserRow(t, db, 3, "", &writer.ID)
|
||||
createUserRow(t, db, 1, &source.ID)
|
||||
createUserRow(t, db, 2, &source.ID)
|
||||
createUserRow(t, db, 3, &writer.ID)
|
||||
before := userRow(t, db, 1)
|
||||
moved, err := repo.ReassignMembers(source.ID, writer.ID)
|
||||
if err != nil || moved != 2 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package test
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -878,7 +877,7 @@ func TestGetMyAccessTypedGrants(t *testing.T) {
|
||||
t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) {
|
||||
world := newRolesAPIWorld(t)
|
||||
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
|
||||
world.addUser(60, user.RoleAdmin, hand.ID, true)
|
||||
world.addUser(60, hand.ID, true)
|
||||
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
|
||||
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
|
||||
t.Fatalf("unexpected permissions %v", access.Permissions)
|
||||
|
||||
@@ -73,11 +73,11 @@ func newRolesAPIWorld(t *testing.T) *rolesAPIWorld {
|
||||
|
||||
func (w *rolesAPIWorld) seedBuiltIns() {
|
||||
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
|
||||
w.addUser(raOwner, user.RoleOwner, ownerRoleID, true)
|
||||
w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true)
|
||||
w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true)
|
||||
w.addUser(raWriter, "", writerRoleID, true)
|
||||
w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false)
|
||||
w.addUser(raOwner, ownerRoleID, true)
|
||||
w.addUser(raAdmin, adminRoleID, true)
|
||||
w.addUser(raLegacyAdmin, adminRoleID, true)
|
||||
w.addUser(raWriter, writerRoleID, true)
|
||||
w.addUser(raUnverified, adminRoleID, false)
|
||||
}
|
||||
|
||||
func (w *rolesAPIWorld) seedRoles() {
|
||||
@@ -85,7 +85,7 @@ func (w *rolesAPIWorld) seedRoles() {
|
||||
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
|
||||
w.ids[spec.name] = created.ID
|
||||
for _, member := range spec.members {
|
||||
w.addUser(member, "", created.ID, true)
|
||||
w.addUser(member, created.ID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,12 +138,7 @@ func (w *rolesAPIWorld) path(name string) string {
|
||||
}
|
||||
|
||||
func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller {
|
||||
account := w.users.users[userID]
|
||||
roleID := account.RoleID
|
||||
if roleID == 0 {
|
||||
roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]]
|
||||
}
|
||||
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()}
|
||||
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[w.users.users[userID].RoleID].GrantSet()}
|
||||
}
|
||||
|
||||
func (w *rolesAPIWorld) members(name string) []uint32 {
|
||||
|
||||
@@ -601,14 +601,17 @@ func TestContentScopeRequestLoads(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) {
|
||||
t.Run("A user with role_id 0 is denied without any role load", func(t *testing.T) {
|
||||
world := newContentScopeWorld(t)
|
||||
legacy := world.users.users[csOwner]
|
||||
legacy.RoleID = 0
|
||||
world.users.users[csOwner] = legacy
|
||||
bare := world.users.users[csOwner]
|
||||
bare.RoleID = 0
|
||||
world.users.users[csOwner] = bare
|
||||
world.resetCounters()
|
||||
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
|
||||
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 {
|
||||
rec := world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != "missing_permission\n" {
|
||||
t.Errorf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if world.roleRepo.touched() != 0 {
|
||||
t.Errorf("unexpected role loads %+v", world.roleRepo)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -91,11 +91,11 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) {
|
||||
t.Errorf("unexpected loads %+v", counting)
|
||||
}
|
||||
counting.gets = 0
|
||||
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
|
||||
if _, err := check.Require(user.User{ID: 4, RoleID: adminRoleID}, "content.read"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 {
|
||||
t.Errorf("unexpected legacy loads %+v", counting)
|
||||
if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 {
|
||||
t.Errorf("unexpected loads %+v", counting)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -179,15 +179,15 @@ func newContentScopeWorld(t *testing.T) *contentScopeWorld {
|
||||
}
|
||||
|
||||
func (w *contentScopeWorld) seedUsers() {
|
||||
w.addUser(csOwner, user.RoleOwner, ownerRoleID, true)
|
||||
w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true)
|
||||
w.addUser(csWriter, "", writerRoleID, true)
|
||||
w.addUser(csUnverified, user.RoleAdmin, adminRoleID, false)
|
||||
w.addUser(csOrphan, "", 99, true)
|
||||
w.addUser(csOwner, ownerRoleID, true)
|
||||
w.addUser(csAdmin, adminRoleID, true)
|
||||
w.addUser(csWriter, writerRoleID, true)
|
||||
w.addUser(csUnverified, adminRoleID, false)
|
||||
w.addUser(csOrphan, 99, true)
|
||||
for _, spec := range contentScopeCustomRoles {
|
||||
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
|
||||
w.roleIDs[spec.user] = created.ID
|
||||
w.addUser(spec.user, "", created.ID, true)
|
||||
w.addUser(spec.user, created.ID, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ package test
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -32,17 +32,17 @@ func (g gateOutcome) blocked() bool {
|
||||
func newEnforcementEnv(t *testing.T) *apiKeyEnv {
|
||||
t.Helper()
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true)
|
||||
e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true)
|
||||
e.addUser(enforcementWriterID, "", writerRoleID, true)
|
||||
e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false)
|
||||
e.addUser(enforcementOrphanID, "", 99, true)
|
||||
e.addUser(enforcementOwnerID, ownerRoleID, true)
|
||||
e.addUser(enforcementAdminID, adminRoleID, true)
|
||||
e.addUser(enforcementWriterID, writerRoleID, true)
|
||||
e.addUser(enforcementUnverified, adminRoleID, false)
|
||||
e.addUser(enforcementOrphanID, 99, true)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role {
|
||||
custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants})
|
||||
e.addUser(id, "", custom.ID, true)
|
||||
e.addUser(id, custom.ID, true)
|
||||
return custom
|
||||
}
|
||||
|
||||
@@ -370,26 +370,25 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
|
||||
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) {
|
||||
t.Run("Only role_id decides authorization, and role_id 0 is denied", func(t *testing.T) {
|
||||
rows := []struct {
|
||||
id uint32
|
||||
legacy user.Role
|
||||
roleID uint32
|
||||
method string
|
||||
path string
|
||||
key string
|
||||
}{
|
||||
{14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"},
|
||||
{15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"},
|
||||
{16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""},
|
||||
{17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""},
|
||||
{18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""},
|
||||
{19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"},
|
||||
{14, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"},
|
||||
{15, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"},
|
||||
{16, adminRoleID, http.MethodPost, "/v1/content-type", ""},
|
||||
{17, 0, http.MethodPost, "/v1/user/invite", "users.invite"},
|
||||
{18, 0, http.MethodPost, "/v1/content-type", "schema.edit"},
|
||||
{19, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
|
||||
t.Run(strconv.Itoa(int(row.roleID))+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
|
||||
e := newEnforcementEnv(t)
|
||||
e.addUser(row.id, row.legacy, row.roleID, true)
|
||||
e.addUser(row.id, row.roleID, true)
|
||||
assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,10 +93,8 @@ func TestCheckPermissionUseCase(t *testing.T) {
|
||||
t.Run("An unresolvable role is refused as a missing permission", func(t *testing.T) {
|
||||
check, _ := newCheck()
|
||||
for name, account := range map[string]user.User{
|
||||
"unknown role_id": {ID: 13, RoleID: 99},
|
||||
"empty legacy and no id": {ID: 14, Role: ""},
|
||||
"editor legacy and no id": {ID: 15, Role: "editor"},
|
||||
"legacy owner string ignored when role_id names Writer": {ID: 16, Role: user.RoleOwner, RoleID: writerRoleID},
|
||||
"unknown role_id": {ID: 13, RoleID: 99},
|
||||
"role id zero": {ID: 14},
|
||||
} {
|
||||
_, err := check.Require(account, "users.invite")
|
||||
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != "users.invite" {
|
||||
@@ -107,10 +105,10 @@ func TestCheckPermissionUseCase(t *testing.T) {
|
||||
|
||||
t.Run("The legacy string resolves only owner and admin when there is no role_id", func(t *testing.T) {
|
||||
check, _ := newCheck()
|
||||
if _, err := check.Require(user.User{ID: 17, Role: user.RoleOwner}, "system.apply_updates"); err != nil {
|
||||
if _, err := check.Require(user.User{ID: 17, RoleID: ownerRoleID}, "system.apply_updates"); err != nil {
|
||||
t.Errorf("legacy owner: %v", err)
|
||||
}
|
||||
if _, err := check.Require(user.User{ID: 18, Role: user.RoleAdmin}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) {
|
||||
if _, err := check.Require(user.User{ID: 18, RoleID: adminRoleID}, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) {
|
||||
t.Errorf("legacy admin should lack owner-only keys, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,13 +25,12 @@ type meBody struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Role string `json:"role"`
|
||||
Author any `json:"author"`
|
||||
Access accessBody `json:"access"`
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) {
|
||||
e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID}
|
||||
func (e *apiKeyEnv) addUser(id uint32, roleID uint32, verified bool) {
|
||||
e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, RoleID: roleID}
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) {
|
||||
@@ -57,12 +56,12 @@ func permissionKeys(grants []role.Grant) []string {
|
||||
func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
e.addUser(7, ownerRoleID, true)
|
||||
rec, body := e.me(t, 7)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if body.ID != 7 || body.Role != "owner" || !body.IsVerified {
|
||||
if body.ID != 7 || !body.IsVerified {
|
||||
t.Errorf("existing fields changed: %+v", body)
|
||||
}
|
||||
if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked {
|
||||
@@ -84,7 +83,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
|
||||
t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(8, user.RoleAdmin, adminRoleID, true)
|
||||
e.addUser(8, adminRoleID, true)
|
||||
_, body := e.me(t, 8)
|
||||
if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked {
|
||||
t.Errorf("role %+v", body.Access.Role)
|
||||
@@ -105,7 +104,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
|
||||
t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
e.addUser(9, writerRoleID, true)
|
||||
_, body := e.me(t, 9)
|
||||
want := []role.Grant{
|
||||
{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll},
|
||||
@@ -124,7 +123,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{
|
||||
{Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll},
|
||||
}})
|
||||
e.addUser(10, user.RoleAdmin, custom.ID, true)
|
||||
e.addUser(10, custom.ID, true)
|
||||
_, body := e.me(t, 10)
|
||||
want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}}
|
||||
if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) {
|
||||
@@ -132,20 +131,16 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
|
||||
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(11, legacy, 0, true)
|
||||
_, body := e.me(t, 11)
|
||||
if body.Access.Role.Name != name {
|
||||
t.Errorf("%s: got %q", legacy, body.Access.Role.Name)
|
||||
}
|
||||
}
|
||||
t.Run("A user with role_id 0 is answered 500 on /me and never promoted", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(11, 0, true)
|
||||
rec, _ := e.me(t, 11)
|
||||
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("The permissions follow the role live", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
e.addUser(9, writerRoleID, true)
|
||||
writer := e.roles.byName("Writer")
|
||||
writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn})
|
||||
_, _ = e.roles.Save(writer)
|
||||
@@ -157,7 +152,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
|
||||
t.Run("An unverified user still gets /me, with access", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(12, user.RoleAdmin, writerRoleID, false)
|
||||
e.addUser(12, writerRoleID, false)
|
||||
rec, body := e.me(t, 12)
|
||||
if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" {
|
||||
t.Errorf("status %d body %+v", rec.Code, body)
|
||||
@@ -166,7 +161,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
|
||||
t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(13, "", 99, true)
|
||||
e.addUser(13, 99, true)
|
||||
rec, _ := e.me(t, 13)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
|
||||
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
|
||||
@@ -178,7 +173,7 @@ func TestRolesPermissionsFoundationHTTP(t *testing.T) {
|
||||
if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no cookie: %d", rec.Code)
|
||||
}
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
e.addUser(7, ownerRoleID, true)
|
||||
stale := e.users.users[7]
|
||||
stale.TokenVersion = 1
|
||||
e.users.users[7] = stale
|
||||
@@ -210,7 +205,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) {
|
||||
|
||||
t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
e.addUser(9, writerRoleID, true)
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
@@ -251,8 +246,8 @@ func TestPermissionCatalogueHTTP(t *testing.T) {
|
||||
|
||||
t.Run("The catalogue is the same for every role", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(9, user.RoleAdmin, writerRoleID, true)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
e.addUser(9, writerRoleID, true)
|
||||
e.addUser(7, ownerRoleID, true)
|
||||
writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
|
||||
owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
|
||||
if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 {
|
||||
@@ -277,7 +272,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) {
|
||||
|
||||
t.Run("A stale token is refused", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(7, user.RoleOwner, ownerRoleID, true)
|
||||
e.addUser(7, ownerRoleID, true)
|
||||
stored := e.users.users[7]
|
||||
stored.TokenVersion = 1
|
||||
e.users.users[7] = stored
|
||||
@@ -289,7 +284,7 @@ func TestPermissionCatalogueHTTP(t *testing.T) {
|
||||
|
||||
t.Run("An unverified user is refused", func(t *testing.T) {
|
||||
e := newAPIKeyEnv(t)
|
||||
e.addUser(12, user.RoleAdmin, writerRoleID, false)
|
||||
e.addUser(12, writerRoleID, false)
|
||||
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
|
||||
|
||||
@@ -229,9 +229,9 @@ func TestSeedBuiltInRoles(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) {
|
||||
func createUserRow(t *testing.T, db *gorm.DB, id uint32, roleID *uint32) {
|
||||
t.Helper()
|
||||
row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID}
|
||||
row := models.User{ID: id, Username: "u" + string(rune('a'+id)), Email: string(rune('a'+id)) + "@x.example", Password: "x", RoleID: roleID}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("user %d: %v", id, err)
|
||||
}
|
||||
@@ -253,62 +253,10 @@ func roleIDOf(row models.User) uint32 {
|
||||
return *row.RoleID
|
||||
}
|
||||
|
||||
func TestBackfillUserRoleIDs(t *testing.T) {
|
||||
t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 5, "admin", nil)
|
||||
createUserRow(t, db, 9, "admin", nil)
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
|
||||
if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID {
|
||||
t.Errorf("role ids not backfilled")
|
||||
}
|
||||
if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" {
|
||||
t.Errorf("legacy role changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 2, "", nil)
|
||||
createUserRow(t, db, 4, "", nil)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, four := userRow(t, db, 2), userRow(t, db, 4)
|
||||
if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID {
|
||||
t.Errorf("user 2: %+v", two)
|
||||
}
|
||||
if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID {
|
||||
t.Errorf("user 4: %+v", four)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createUserRow(t, db, 5, "admin", &custom.ID)
|
||||
for range 2 {
|
||||
if err := database.BackfillUserRoleIDs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if roleIDOf(userRow(t, db, 5)) != custom.ID {
|
||||
t.Errorf("custom role replaced")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A database with no users is left alone", func(t *testing.T) {
|
||||
if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
func ownerIDPtr(t *testing.T, db *gorm.DB) *uint32 {
|
||||
t.Helper()
|
||||
id := storedRole(t, db, "Owner").ID
|
||||
return &id
|
||||
}
|
||||
|
||||
func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
|
||||
@@ -337,8 +285,8 @@ func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
|
||||
func TestBackfillCreatedBy(t *testing.T) {
|
||||
t.Run("Existing articles are attributed to the first owner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 4, "owner", nil)
|
||||
createUserRow(t, db, 3, ownerIDPtr(t, db))
|
||||
createUserRow(t, db, 4, ownerIDPtr(t, db))
|
||||
createArticleRow(t, db, 10, 0)
|
||||
createArticleRow(t, db, 11, 0)
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
@@ -351,7 +299,7 @@ func TestBackfillCreatedBy(t *testing.T) {
|
||||
|
||||
t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 3, ownerIDPtr(t, db))
|
||||
createEntryRow(t, db, 20, 0)
|
||||
createEntryRow(t, db, 21, 7)
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
@@ -364,7 +312,7 @@ func TestBackfillCreatedBy(t *testing.T) {
|
||||
|
||||
t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
createUserRow(t, db, 3, "owner", nil)
|
||||
createUserRow(t, db, 3, ownerIDPtr(t, db))
|
||||
createArticleRow(t, db, 10, 7)
|
||||
for range 2 {
|
||||
if err := database.BackfillCreatedBy(db); err != nil {
|
||||
@@ -403,13 +351,10 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetMyAccessUseCase(t *testing.T) {
|
||||
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
|
||||
t.Run("A user with role_id 0 gets an error and is never promoted", func(t *testing.T) {
|
||||
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
|
||||
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
|
||||
access, err := uc.Get(user.User{ID: 11, Role: legacy})
|
||||
if err != nil || access.Role.Name != name {
|
||||
t.Errorf("%s: %v %v", legacy, access.Role.Name, err)
|
||||
}
|
||||
if _, err := uc.Get(user.User{ID: 11}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -422,12 +367,4 @@ func TestGetMyAccessUseCase(t *testing.T) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) {
|
||||
repo := newMockRoleRepo()
|
||||
_, _ = repo.Create(role.Role{Name: "Owner"})
|
||||
if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock
|
||||
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
isProduction,
|
||||
)
|
||||
@@ -139,7 +139,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
|
||||
|
||||
@@ -41,7 +41,7 @@ type seoEnv struct {
|
||||
func newSeoEnv(t *testing.T) *seoEnv {
|
||||
t.Helper()
|
||||
p := newProducersEnv(t)
|
||||
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, Role: user.Role("editor"), RoleID: writerRoleID}
|
||||
p.users.users[seoEditorID] = user.User{ID: seoEditorID, Username: "edith", Email: "edith@example.com", IsVerified: true, RoleID: writerRoleID}
|
||||
p.users.nextID = seoEditorID
|
||||
authors := newMockAuthorRepo(p.articles)
|
||||
sites := newMockSiteRepo(p.articles)
|
||||
|
||||
@@ -170,7 +170,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, Role: domainUser.RoleOwner}
|
||||
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, RoleID: ownerRoleID}
|
||||
sessions := newMemorySessionRepo()
|
||||
container := &api.UseCases{
|
||||
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
|
||||
@@ -178,7 +178,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
}
|
||||
settings := config.Settings{
|
||||
|
||||
@@ -641,7 +641,7 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) {
|
||||
for _, request := range requests {
|
||||
t.Run(request.method+" "+request.path+" with a verified writer's jwt cookie", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.addUser(siteWriterID, "", writerRoleID, true)
|
||||
e.addUser(siteWriterID, writerRoleID, true)
|
||||
before := e.sites.snapshot()
|
||||
rec := e.as(t, siteWriterID, request.method, request.path, request.body)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
|
||||
@@ -664,7 +664,7 @@ func TestSitesOnlyTheOwnerWrites(t *testing.T) {
|
||||
|
||||
t.Run("The role is checked before the body", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.addUser(siteWriterID, "", writerRoleID, true)
|
||||
e.addUser(siteWriterID, writerRoleID, true)
|
||||
|
||||
rec := e.as(t, siteWriterID, http.MethodPost, "/v1/site/", "{not json")
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestSitesActivityLog(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.addUser(siteWriterID, "", writerRoleID, true)
|
||||
e.addUser(siteWriterID, writerRoleID, true)
|
||||
e.as(t, c.as, c.method, c.path, c.body)
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
|
||||
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
|
||||
}
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id"} {
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} {
|
||||
delete(before.schema, name)
|
||||
delete(after.schema, name)
|
||||
}
|
||||
@@ -193,6 +193,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
for _, row := range after.rows["users"] {
|
||||
delete(row, "role_id")
|
||||
}
|
||||
for _, row := range after.rows["invites"] {
|
||||
if row["role_id"] == nil {
|
||||
t.Errorf("a legacy invite must receive a role_id: %v", row)
|
||||
}
|
||||
delete(row, "role_id")
|
||||
}
|
||||
if !reflect.DeepEqual(before.rows, after.rows) {
|
||||
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
|
||||
}
|
||||
@@ -219,7 +225,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "role", "idx_role"} {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -155,7 +155,7 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *authtoken.Au
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -39,11 +39,11 @@ type updateHarness struct {
|
||||
|
||||
func newUpdateUserRepo() *mockUserRepo {
|
||||
users := newMockUserRepo()
|
||||
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
|
||||
users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin}
|
||||
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin}
|
||||
users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner}
|
||||
users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner}
|
||||
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, RoleID: ownerRoleID, TokenVersion: 2}
|
||||
users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, RoleID: adminRoleID}
|
||||
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, RoleID: adminRoleID}
|
||||
users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, RoleID: ownerRoleID}
|
||||
users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, RoleID: ownerRoleID}
|
||||
return users
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
|
||||
func TestCreateUserAssignsVerificationCode(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
|
||||
before := time.Now()
|
||||
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
@@ -459,7 +459,7 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) {
|
||||
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
|
||||
func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
@@ -30,7 +30,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -91,7 +91,7 @@ func seededDisplayNameRepo(displayName string) *mockUserRepo {
|
||||
Email: "marta@renew.example",
|
||||
Password: "hashed:correct-horse",
|
||||
IsVerified: true,
|
||||
Role: user.RoleOwner,
|
||||
RoleID: ownerRoleID,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
return repo
|
||||
@@ -393,7 +393,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
|
||||
t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, RoleID: adminRoleID}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -421,7 +421,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
|
||||
|
||||
t.Run("An unverified user is refused", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, RoleID: adminRoleID}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
|
||||
@@ -21,7 +21,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
||||
hasher := &stubHasher{}
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
@@ -125,7 +125,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
if len(decoded) != len(expectedKeys) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded)
|
||||
}
|
||||
|
||||
+61
-131
@@ -7,20 +7,16 @@ import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,22 +46,22 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateUserUseCase(f.users, newSeededMockRoleRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher, discardRecorder),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), hasher, discardRecorder),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
|
||||
nil,
|
||||
nil,
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites, newSeededMockRoleRepo()),
|
||||
)
|
||||
|
||||
f.router = chi.NewRouter()
|
||||
@@ -95,13 +91,13 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role, verified bool) {
|
||||
func (f *userRolesFixture) seedUser(id uint32, username string, roleID uint32, verified bool) {
|
||||
f.users.users[id] = user.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: username + "@renew.example",
|
||||
IsVerified: verified,
|
||||
Role: role,
|
||||
RoleID: roleID,
|
||||
}
|
||||
if id > f.users.nextID {
|
||||
f.users.nextID = id
|
||||
@@ -109,10 +105,7 @@ func (f *userRolesFixture) seedUser(id uint32, username string, role user.Role,
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) seedWriter(id uint32, username string) {
|
||||
f.seedUser(id, username, "", true)
|
||||
stored := f.users.users[id]
|
||||
stored.RoleID = writerRoleID
|
||||
f.users.users[id] = stored
|
||||
f.seedUser(id, username, writerRoleID, true)
|
||||
}
|
||||
|
||||
func (f *userRolesFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
@@ -155,17 +148,18 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
t.Fatalf("expected one user, got %d", len(f.users.users))
|
||||
}
|
||||
for _, stored := range f.users.users {
|
||||
if stored.Username != "marta" || stored.Role != user.RoleOwner {
|
||||
if stored.Username != "marta" || stored.RoleID != ownerRoleID {
|
||||
t.Errorf("unexpected user %+v", stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user created by accepting an invite is an admin", func(t *testing.T) {
|
||||
t.Run("A user created by accepting an invite takes the role of the invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
pending.RoleID = adminRoleID
|
||||
f.invites.seed(pending)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), nil)
|
||||
@@ -173,23 +167,24 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
roles := map[string]user.Role{}
|
||||
roles := map[string]uint32{}
|
||||
for _, stored := range f.users.users {
|
||||
roles[stored.Username] = stored.Role
|
||||
roles[stored.Username] = stored.RoleID
|
||||
}
|
||||
if roles["lea"] != user.RoleAdmin {
|
||||
t.Errorf("expected lea admin, got %q", roles["lea"])
|
||||
if roles["lea"] != adminRoleID {
|
||||
t.Errorf("expected lea admin, got %d", roles["lea"])
|
||||
}
|
||||
if roles["marta"] != user.RoleOwner {
|
||||
t.Errorf("expected marta still owner, got %q", roles["marta"])
|
||||
if roles["marta"] != ownerRoleID {
|
||||
t.Errorf("expected marta still owner, got %d", roles["marta"])
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
username string
|
||||
role user.Role
|
||||
}{{"marta", user.RoleOwner}, {"lea", user.RoleAdmin}} {
|
||||
t.Run("GET /v1/auth/me returns the role of the session user: "+tc.username, func(t *testing.T) {
|
||||
role uint32
|
||||
name string
|
||||
}{{"marta", ownerRoleID, "Owner"}, {"lea", adminRoleID, "Admin"}} {
|
||||
t.Run("GET /v1/auth/me returns the role of the session user only in access.role: "+tc.username, func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(4, tc.username, tc.role, true)
|
||||
|
||||
@@ -198,15 +193,24 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "role"); got != string(tc.role) {
|
||||
t.Errorf("expected role %q, got %q", tc.role, got)
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
if _, present := decoded["role"]; present {
|
||||
t.Errorf("no top-level role expected: %s", rec.Body.String())
|
||||
}
|
||||
var access struct {
|
||||
Role struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded["access"], &access); err != nil || access.Role.Name != tc.name {
|
||||
t.Errorf("expected access.role %q, got %q (%v)", tc.name, access.Role.Name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An unverified owner sees their role on /auth/me", func(t *testing.T) {
|
||||
t.Run("An unverified owner can read /auth/me", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(5, "bob", user.RoleOwner, false)
|
||||
f.seedUser(5, "bob", ownerRoleID, false)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/auth/me", "", f.as(t, 5))
|
||||
|
||||
@@ -214,7 +218,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "role") != "owner" || string(decoded["is_verified"]) != "false" {
|
||||
if string(decoded["is_verified"]) != "false" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
@@ -222,9 +226,9 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
t.Run("The users list carries email and role on accepted users", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
day := func(d int) time.Time { return time.Date(2026, 3, d, 9, 0, 0, 0, time.UTC) }
|
||||
f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, Role: user.RoleOwner, CreatedAt: day(1)}
|
||||
f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(2)}
|
||||
f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, Role: user.RoleAdmin, CreatedAt: day(3)}
|
||||
f.users.users[1] = user.User{ID: 1, Username: "marta", Email: "marta@renew.example", IsVerified: true, RoleID: ownerRoleID, CreatedAt: day(1)}
|
||||
f.users.users[2] = user.User{ID: 2, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(2)}
|
||||
f.users.users[3] = user.User{ID: 3, Username: "ines", DisplayName: "Inès Duarte", Email: "ines.duarte@renew.example", IsVerified: true, RoleID: adminRoleID, CreatedAt: day(3)}
|
||||
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
@@ -237,18 +241,21 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
t.Fatalf("expected 4 items, got %d", len(items))
|
||||
}
|
||||
wantRoles := map[string][2]string{
|
||||
"marta": {"marta@renew.example", "owner"},
|
||||
"jonas": {"jonas@renew.example", "admin"},
|
||||
"ines": {"ines.duarte@renew.example", "admin"},
|
||||
"marta": {"marta@renew.example", "Owner"},
|
||||
"jonas": {"jonas@renew.example", "Admin"},
|
||||
"ines": {"ines.duarte@renew.example", "Admin"},
|
||||
}
|
||||
for _, item := range items {
|
||||
if decodedString(t, item, "kind") == "invite" {
|
||||
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
|
||||
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
|
||||
continue
|
||||
}
|
||||
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
want := wantRoles[decodedString(t, item, "username")]
|
||||
if decodedString(t, item, "email") != want[0] || decodedString(t, item, "role") != want[1] {
|
||||
var ref struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(item["role"], &ref); err != nil || decodedString(t, item, "email") != want[0] || ref.Name != want[1] {
|
||||
t.Errorf("unexpected user item %v", item)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +270,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("The owner can invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 1))
|
||||
|
||||
@@ -277,7 +284,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("An admin can invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
f.seedUser(2, "jonas", adminRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/v1/user/invite", rolesInviteBody, f.as(t, 2))
|
||||
|
||||
@@ -329,7 +336,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("The role is read on every request, not from the token", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleOwner, true)
|
||||
f.seedUser(2, "jonas", ownerRoleID, true)
|
||||
cookie := f.as(t, 2)
|
||||
stored := f.users.users[2]
|
||||
stored.RoleID = writerRoleID
|
||||
@@ -344,7 +351,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("An admin can still list users", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(2, "jonas", user.RoleAdmin, true)
|
||||
f.seedUser(2, "jonas", adminRoleID, true)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/v1/user/", "", f.as(t, 2))
|
||||
|
||||
@@ -355,7 +362,7 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
|
||||
t.Run("A jwt for a since-deleted account is refused with 401 on invite", func(t *testing.T) {
|
||||
f := newUserRolesFixture(t, 100)
|
||||
f.seedUser(1, "marta", user.RoleOwner, true)
|
||||
f.seedUser(1, "marta", ownerRoleID, true)
|
||||
cookie := f.as(t, 1)
|
||||
delete(f.users.users, 1)
|
||||
|
||||
@@ -370,34 +377,35 @@ func TestUserRolesHttp(t *testing.T) {
|
||||
func TestUserRolesUseCases(t *testing.T) {
|
||||
t.Run("CreateUser returns an owner", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
|
||||
|
||||
created, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if created.Role != user.RoleOwner {
|
||||
t.Errorf("expected owner, got %q", created.Role)
|
||||
if created.RoleID != ownerRoleID {
|
||||
t.Errorf("expected owner, got %d", created.RoleID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AcceptInvite returns an admin", func(t *testing.T) {
|
||||
t.Run("AcceptInvite returns a user with the role of the invite", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
invites := newInviteFakeRepo()
|
||||
invites.users = users
|
||||
pending := pendingInvite(acceptEmail)
|
||||
pending.HashedCode = "hashed:" + acceptCode
|
||||
pending.RoleID = adminRoleID
|
||||
invites.seed(pending)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, discardRecorder)
|
||||
uc := useCases.NewAcceptInviteUseCase(users, invites, newSeededMockRoleRepo(), &stubHasher{}, discardRecorder)
|
||||
|
||||
created, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if created.Role != user.RoleAdmin {
|
||||
t.Errorf("expected admin, got %q", created.Role)
|
||||
if created.RoleID != adminRoleID {
|
||||
t.Errorf("expected admin, got %d", created.RoleID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -406,7 +414,7 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) {
|
||||
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
repo := newMockUserRepo()
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, RoleID: ownerRoleID}
|
||||
r := chi.NewRouter()
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(authtoken.Authenticator)
|
||||
@@ -424,81 +432,3 @@ func TestRequirePermissionRepositoryFailure(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackfillUserRoles(t *testing.T) {
|
||||
openDB := func(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:roles?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
rolesByID := func(t *testing.T, db *gorm.DB) map[uint32]string {
|
||||
t.Helper()
|
||||
var rows []entity.User
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
roles := map[uint32]string{}
|
||||
for _, row := range rows {
|
||||
roles[row.ID] = row.Role
|
||||
}
|
||||
return roles
|
||||
}
|
||||
seed := func(t *testing.T, db *gorm.DB, id uint32, role string) {
|
||||
t.Helper()
|
||||
suffix := string(rune('a' + id))
|
||||
row := entity.User{ID: id, Username: "u" + suffix, Password: "x", Email: "e" + suffix, Role: role}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("The lowest id becomes owner and every other user admin", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seed(t, db, 3, "")
|
||||
seed(t, db, 5, "")
|
||||
seed(t, db, 9, "")
|
||||
|
||||
if err := database.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
roles := rolesByID(t, db)
|
||||
if roles[3] != "owner" || roles[5] != "admin" || roles[9] != "admin" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A later start changes nothing", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seed(t, db, 2, "admin")
|
||||
seed(t, db, 4, "owner")
|
||||
|
||||
if err := database.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
roles := rolesByID(t, db)
|
||||
if roles[2] != "admin" || roles[4] != "owner" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty database is left alone", func(t *testing.T) {
|
||||
db := openDB(t)
|
||||
|
||||
if err := database.BackfillUserRoles(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+80
-1
@@ -1,6 +1,7 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
@@ -47,6 +48,13 @@ type mockUserRepo struct {
|
||||
lastResetPasswordHash string
|
||||
updateDisplayNameCalls int
|
||||
lastUpdateDisplayName string
|
||||
getAllCalls int
|
||||
updateRoleCalls int
|
||||
transferCalls int
|
||||
beforeUpdateRole func()
|
||||
beforeTransfer func()
|
||||
beforeClearVerification func()
|
||||
knownRoles *mockRoleRepo
|
||||
}
|
||||
|
||||
func newMockUserRepo() *mockUserRepo {
|
||||
@@ -106,6 +114,7 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetAll() []user.User {
|
||||
m.getAllCalls++
|
||||
result := make([]user.User, 0)
|
||||
for _, id := range m.sortedIds() {
|
||||
result = append(result, m.users[id])
|
||||
@@ -198,10 +207,22 @@ func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) {
|
||||
if err := m.fail.trigger("ClearVerificationCode"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
if m.beforeClearVerification != nil {
|
||||
hook := m.beforeClearVerification
|
||||
m.beforeClearVerification = nil
|
||||
hook()
|
||||
}
|
||||
u, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
if u.RoleID == ownerRoleID {
|
||||
for id, other := range m.users {
|
||||
if id != userId && other.RoleID == ownerRoleID && other.IsVerified {
|
||||
return user.User{}, user.ErrInvalidVerificationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
u.IsVerified = true
|
||||
u.VerificationCode = ""
|
||||
m.users[userId] = u
|
||||
@@ -242,11 +263,69 @@ func (m *mockUserRepo) UpdateDisplayName(userId uint32, displayName string) (use
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) roleExists(id uint32) bool {
|
||||
if m.knownRoles == nil {
|
||||
return true
|
||||
}
|
||||
_, present := m.knownRoles.roles[id]
|
||||
return present
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User, error) {
|
||||
m.updateRoleCalls++
|
||||
if err := m.fail.trigger("UpdateRole"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
if m.beforeUpdateRole != nil {
|
||||
hook := m.beforeUpdateRole
|
||||
m.beforeUpdateRole = nil
|
||||
hook()
|
||||
}
|
||||
stored, ok := m.users[id]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
if !m.roleExists(to) {
|
||||
return user.User{}, role.ErrRoleNotFound
|
||||
}
|
||||
if stored.RoleID != from {
|
||||
return user.User{}, user.ErrRoleChanged
|
||||
}
|
||||
stored.RoleID = to
|
||||
m.users[id] = stored
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error {
|
||||
m.transferCalls++
|
||||
if err := m.fail.trigger("TransferOwnership"); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.beforeTransfer != nil {
|
||||
hook := m.beforeTransfer
|
||||
m.beforeTransfer = nil
|
||||
hook()
|
||||
}
|
||||
former, formerFound := m.users[from]
|
||||
if !formerFound || former.RoleID != ownerRoleID {
|
||||
return user.ErrRoleChanged
|
||||
}
|
||||
successor, successorFound := m.users[to]
|
||||
if !successorFound || !successor.IsVerified {
|
||||
return user.ErrInvalidTransfer
|
||||
}
|
||||
former.RoleID = formerRoleID
|
||||
successor.RoleID = ownerRoleID
|
||||
m.users[from] = former
|
||||
m.users[to] = successor
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ user.Repository = &mockUserRepo{}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
uc := useCases.NewCreateUserUseCase(repo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
|
||||
cmd := useCases.CreateUserCommand{
|
||||
Username: "testuser",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUsersAPIDomain(t *testing.T) {
|
||||
t.Run("user.User has no role string and invite.Invite carries RoleID", func(t *testing.T) {
|
||||
if _, present := reflect.TypeOf(user.User{}).FieldByName("Role"); present {
|
||||
t.Errorf("user.User.Role must not exist")
|
||||
}
|
||||
if field, present := reflect.TypeOf(invite.Invite{}).FieldByName("RoleID"); !present || field.Type.Kind() != reflect.Uint32 {
|
||||
t.Errorf("invite.Invite.RoleID is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the legacy role vocabulary is gone from the source tree", func(t *testing.T) {
|
||||
for _, path := range nonTestGoFiles(t, "../internal", "../api", "../cmd") {
|
||||
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
|
||||
switch found := node.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if owner, ok := found.X.(*ast.Ident); ok && (owner.Name == "user" || owner.Name == "domainUser") && (found.Sel.Name == "Role" || found.Sel.Name == "RoleOwner" || found.Sel.Name == "RoleAdmin") {
|
||||
t.Errorf("%s reads %s.%s", path, owner.Name, found.Sel.Name)
|
||||
}
|
||||
case *ast.Ident:
|
||||
if found.Name == "legacyBuiltInNames" || found.Name == "legacyRoleNames" || found.Name == "BackfillUserRoleIDs" {
|
||||
t.Errorf("%s names %s", path, found.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the new sentinels map with errors.Is", func(t *testing.T) {
|
||||
wrapped := errors.Join(errors.New("context"), user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrConfirmationMismatch, role.ErrUnknownRole)
|
||||
for _, sentinel := range []error{user.ErrRoleChanged, user.ErrInvalidTransfer, user.ErrConfirmationMismatch, role.ErrUnknownRole} {
|
||||
if !errors.Is(wrapped, sentinel) {
|
||||
t.Errorf("%v is not matched", sentinel)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user and invite import the standard library and domain packages only", func(t *testing.T) {
|
||||
for _, pattern := range []string{"../internal/domain/user/*.go", "../internal/domain/invite/*.go"} {
|
||||
files, _ := filepath.Glob(pattern)
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no files for %s", pattern)
|
||||
}
|
||||
for _, file := range files {
|
||||
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
|
||||
if !isStandardLibrary(imported) && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("%s imports %s", file, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GrantSet.Covers rows used by the slice", func(t *testing.T) {
|
||||
manager := unlockedGrantSet("content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all")
|
||||
cases := []struct {
|
||||
name string
|
||||
grants []string
|
||||
key string
|
||||
}{
|
||||
{"Writer", []string{"content.read=all", "content.create=all", "content.update=all"}, "content.update"},
|
||||
{"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all"}, "content.update"},
|
||||
{"Intern", []string{"content.read=own"}, ""},
|
||||
{"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, ""},
|
||||
}
|
||||
for _, row := range cases {
|
||||
err := manager.Covers(unlockedGrantSet(row.grants...), nil)
|
||||
if row.key == "" && err != nil {
|
||||
t.Errorf("%s: unexpected %v", row.name, err)
|
||||
}
|
||||
var escalation role.EscalationError
|
||||
if row.key != "" && (!errors.As(err, &escalation) || escalation.Key != row.key) {
|
||||
t.Errorf("%s: expected an escalation on %s, got %v", row.name, row.key, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the builtin user type keeps an optional plain text role field", func(t *testing.T) {
|
||||
found, err := builtintype.Find(builtintype.KeyUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range found.Fields {
|
||||
if field.Key != "role" {
|
||||
continue
|
||||
}
|
||||
if field.Type != contenttype.TypeText || field.Required || len(field.Options) != 0 {
|
||||
t.Errorf("unexpected role field %+v", field)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Errorf("the user type has no role field")
|
||||
})
|
||||
|
||||
t.Run("api/router.go uses the key constants for the new routes", func(t *testing.T) {
|
||||
wanted := map[string]bool{"KeyUsersChangeRole": false, "KeySystemTransferOwnership": false}
|
||||
ast.Inspect(parsedGoFile(t, "../api/router.go"), func(node ast.Node) bool {
|
||||
if selector, ok := node.(*ast.SelectorExpr); ok {
|
||||
if _, tracked := wanted[selector.Sel.Name]; tracked {
|
||||
wanted[selector.Sel.Name] = true
|
||||
}
|
||||
}
|
||||
if literal, ok := node.(*ast.BasicLit); ok && literal.Kind == token.STRING {
|
||||
if value, err := strconv.Unquote(literal.Value); err == nil && (value == "users.change_role" || value == "system.transfer_ownership") {
|
||||
t.Errorf("router holds the literal %q", value)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for name, seen := range wanted {
|
||||
if !seen {
|
||||
t.Errorf("router does not use role.%s", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handlers of the slice import no persistence, mailer, models or gorm", func(t *testing.T) {
|
||||
for _, pattern := range []string{"../api/handlers/user/*.go", "../api/handlers/ownership/*.go"} {
|
||||
files, _ := filepath.Glob(pattern)
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no files for %s", pattern)
|
||||
}
|
||||
for _, file := range files {
|
||||
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
|
||||
for _, forbidden := range []string{"persistence", "mailer", "models", "gorm"} {
|
||||
if strings.Contains(imported, forbidden) {
|
||||
t.Errorf("%s imports %s", file, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func usersAPIAssertMissing(t *testing.T, rec *httptest.ResponseRecorder, key string) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" || rec.Header().Get(apperr.MissingPermissionHeader) != key {
|
||||
t.Fatalf("expected 403 missing_permission for %s, got %d %q (%q)", key, rec.Code, rec.Body.String(), rec.Header().Get(apperr.MissingPermissionHeader))
|
||||
}
|
||||
}
|
||||
|
||||
func usersAPIAssertPlain(t *testing.T, rec *httptest.ResponseRecorder, status int, message string) {
|
||||
t.Helper()
|
||||
if rec.Code != status || rec.Body.String() != message+"\n" {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, message, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) transferBody(target uint32, email string) string {
|
||||
return `{"user_id": ` + strconv.FormatUint(uint64(target), 10) + `, "confirm_email": ` + strconv.Quote(email) + `}`
|
||||
}
|
||||
|
||||
func TestUsersAPIGateHTTP(t *testing.T) {
|
||||
t.Run("the gate decides who changes roles, invites and transfers", func(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
run func(w *usersAPIWorld) *httptest.ResponseRecorder
|
||||
assert func(t *testing.T, w *usersAPIWorld, rec *httptest.ResponseRecorder)
|
||||
}{
|
||||
{"Owner changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.putRole(t, uaOwner, "41", w.idText("Intern"))
|
||||
}, usersAPIWantStatus(http.StatusOK)},
|
||||
{"Admin changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.putRole(t, uaAdmin, "41", w.idText("Intern"))
|
||||
}, usersAPIWantStatus(http.StatusOK)},
|
||||
{"People manager changes a role", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.putRole(t, uaPeople, "41", w.idText("Intern"))
|
||||
}, usersAPIWantStatus(http.StatusOK)},
|
||||
{"Writer is refused", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.putRole(t, uaWriter, "41", w.idText("Intern"))
|
||||
}, usersAPIWantMissing("users.change_role")},
|
||||
{"Writer is refused before the id is read", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaWriter, "abc", "1") }, usersAPIWantMissing("users.change_role")},
|
||||
{"Viewer is refused before any lookup", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaViewer, "999", "1") }, usersAPIWantMissing("users.change_role")},
|
||||
{"Admin with a bad id", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaAdmin, "abc", "1") }, usersAPIWantPlain(http.StatusBadRequest, apperr.IdUint32ErrorMessage)},
|
||||
{"API key of an Admin", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
raw, _ := w.issueKey(uaAdmin, "ci")
|
||||
return w.withKey(t, http.MethodPut, "/v1/user/41/role", `{"role_id": `+w.idText("Intern")+`}`, raw)
|
||||
}, usersAPIWantPlain(http.StatusForbidden, apperr.SessionRequiredErrorMessage)},
|
||||
{"no cookie", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.anonymous(t, http.MethodPut, "/v1/user/41/role", `{"role_id": 1}`)
|
||||
}, usersAPIWantPlain(http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)},
|
||||
{"unverified Admin", func(w *usersAPIWorld) *httptest.ResponseRecorder { return w.putRole(t, uaUnverified, "41", "1") }, usersAPIWantPlain(http.StatusForbidden, apperr.VerificationRequiredErrorMessage)},
|
||||
{"Owner transfers", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"))
|
||||
}, usersAPIWantStatus(http.StatusOK)},
|
||||
{"Admin cannot transfer", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.do(t, uaAdmin, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaOtherAdmin, "lea@renew.example"))
|
||||
}, usersAPIWantMissing("system.transfer_ownership")},
|
||||
{"Writer is refused before the body", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.do(t, uaWriter, http.MethodPost, "/v1/site/transfer-ownership", "")
|
||||
}, usersAPIWantMissing("system.transfer_ownership")},
|
||||
{"API key of the Owner on the transfer", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
raw, _ := w.issueKey(uaOwner, "ci")
|
||||
return w.withKey(t, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"), raw)
|
||||
}, usersAPIWantPlain(http.StatusForbidden, apperr.SessionRequiredErrorMessage)},
|
||||
{"Viewer cannot invite", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.do(t, uaViewer, http.MethodPost, "/v1/user/invite", `{"email": "x@renew.example"}`)
|
||||
}, usersAPIWantMissing("users.invite")},
|
||||
{"Inviter cannot list", func(w *usersAPIWorld) *httptest.ResponseRecorder {
|
||||
return w.do(t, uaInviter, http.MethodGet, "/v1/user", "")
|
||||
}, usersAPIWantMissing("users.view")},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := row.run(w)
|
||||
row.assert(t, w, rec)
|
||||
if rec.Code >= 400 {
|
||||
if w.storedRoleID(41) != w.id("Contributor") || w.storedRoleID(uaOwner) != ownerRoleID || len(w.recorder.events) != 0 {
|
||||
t.Errorf("a refused call must store and record nothing")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the gate follows the role live", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
usersAPIAssertMissing(t, w.putRole(t, uaViewer, "41", w.idText("Intern")), "users.change_role")
|
||||
viewer := w.roles.roles[w.id("Viewer")]
|
||||
viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyUsersChangeRole, Scope: role.ScopeAll}, role.Grant{Key: role.KeyContentRead, Scope: role.ScopeAll})
|
||||
w.roles.roles[viewer.ID] = viewer
|
||||
rec := w.putRole(t, uaViewer, "41", w.idText("Intern"))
|
||||
if rec.Header().Get(apperr.MissingPermissionHeader) != "" {
|
||||
t.Fatalf("expected the same cookie to pass the gate, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertEscalation(t, rec, "content.create")
|
||||
})
|
||||
}
|
||||
|
||||
func usersAPIWantStatus(status int) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) {
|
||||
return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func usersAPIWantMissing(key string) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) {
|
||||
return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
usersAPIAssertMissing(t, rec, key)
|
||||
}
|
||||
}
|
||||
|
||||
func usersAPIWantPlain(status int, message string) func(*testing.T, *usersAPIWorld, *httptest.ResponseRecorder) {
|
||||
return func(t *testing.T, _ *usersAPIWorld, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
usersAPIAssertPlain(t, rec, status, message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAPIChangeRoleHTTP(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target string
|
||||
role string
|
||||
status int
|
||||
code string
|
||||
key string
|
||||
}{
|
||||
{"Admin gives Writer", uaAdmin, "41", "Writer", 200, "", ""},
|
||||
{"Admin gives Admin", uaAdmin, "41", "Admin", 200, "", ""},
|
||||
{"Admin demotes another Admin", uaAdmin, "10", "Contributor", 200, "", ""},
|
||||
{"Owner gives Content lead", uaOwner, "5", "Content lead", 200, "", ""},
|
||||
{"People manager gives Intern", uaPeople, "41", "Intern", 200, "", ""},
|
||||
{"People manager gives Writer", uaPeople, "41", "Writer", 403, "escalation", "content.update"},
|
||||
{"People manager gives Content lead", uaPeople, "41", "Content lead", 403, "escalation", "content.update"},
|
||||
{"People manager touches a Content lead", uaPeople, "45", "Intern", 403, "escalation", "content.update"},
|
||||
{"People manager touches an Admin", uaPeople, "5", "Intern", 403, "escalation", "content.update"},
|
||||
{"Admin gives Owner", uaAdmin, "41", "Owner", 403, "role_locked", ""},
|
||||
{"Owner gives Owner", uaOwner, "41", "Owner", 403, "role_locked", ""},
|
||||
{"Admin changes the owner", uaAdmin, "3", "Admin", 403, "role_locked", ""},
|
||||
{"Owner changes the owner", uaOwner, "3", "Admin", 403, "role_locked", ""},
|
||||
{"Admin changes itself", uaAdmin, "5", "Writer", 403, "own_role", ""},
|
||||
{"People manager changes itself", uaPeople, "31", "Intern", 403, "own_role", ""},
|
||||
{"unknown user", uaAdmin, "999", "Writer", 404, apperr.UserNotFoundErrorMessage, ""},
|
||||
{"unknown role", uaAdmin, "41", "unknown", 400, "invalid_role", ""},
|
||||
{"unverified user", uaAdmin, "12", "Writer", 200, "", ""},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
before := w.storedRoleID(41)
|
||||
rec := w.putRole(t, row.caller, row.target, strconv.FormatUint(uint64(w.roleIDFor(row.role)), 10))
|
||||
if row.status == 200 {
|
||||
item := usersAPIDecode(t, rec)
|
||||
if rec.Code != 200 || item["kind"] != "user" || item["status"] != "accepted" {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
if ref := usersAPIRoleRef(t, item); ref["name"] != row.role || ref["id"] != float64(w.ids[row.role]) {
|
||||
t.Errorf("unexpected role %v", ref)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("expected no-store")
|
||||
}
|
||||
return
|
||||
}
|
||||
if row.code == "escalation" {
|
||||
assertEscalation(t, rec, row.key)
|
||||
} else {
|
||||
assertRefusal(t, rec, row.status, row.code)
|
||||
}
|
||||
if w.storedRoleID(41) != before || w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 || w.users.users[41].TokenVersion != 0 {
|
||||
t.Errorf("a refused call changes nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing and zero role ids are validation errors", func(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `{"role_id": 0}`} {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, uaAdmin, http.MethodPut, "/v1/user/41/role", body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "RoleID") {
|
||||
t.Errorf("%s: expected a validator 400, got %d %q", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an undecodable body is a body error", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
usersAPIAssertPlain(t, w.do(t, uaAdmin, http.MethodPut, "/v1/user/41/role", `{"role_id": "x"`), http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("the same role again is a no-op with the unchanged item", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.putRole(t, uaAdmin, "41", w.idText("Contributor"))
|
||||
if rec.Code != 200 || usersAPIRoleRef(t, usersAPIDecode(t, rec))["name"] != "Contributor" || w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a role change takes effect at once and keeps sessions and keys", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
raw, _ := w.issueKey(41, "mine")
|
||||
if rec := w.do(t, 41, http.MethodGet, "/v1/auth/me", ""); usersAPIRoleName(t, rec) != "Contributor" {
|
||||
t.Fatalf("unexpected %q", rec.Body.String())
|
||||
}
|
||||
if rec := w.putRole(t, uaAdmin, "41", w.idText("Writer")); rec.Code != 200 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if w.users.users[41].TokenVersion != 0 {
|
||||
t.Errorf("token_version must not change")
|
||||
}
|
||||
rec := w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
|
||||
if usersAPIRoleName(t, rec) != "Writer" {
|
||||
t.Errorf("unexpected %q", rec.Body.String())
|
||||
}
|
||||
if rec := w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw); rec.Code != 200 || usersAPIRoleName(t, rec) != "Writer" {
|
||||
t.Errorf("the key must still authenticate with the new role, got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := w.do(t, 41, http.MethodGet, "/v1/media", ""); rec.Code != 200 {
|
||||
t.Errorf("Writer may view media, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two concurrent changes give one winner", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.beforeUpdateRole = func() {
|
||||
if rec := w.putRole(t, uaOwner, "41", w.idText("Intern")); rec.Code != 200 {
|
||||
t.Errorf("the rival must win, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
rec := w.putRole(t, uaAdmin, "41", w.idText("Writer"))
|
||||
usersAPIAssertPlain(t, rec, http.StatusConflict, "user_role_changed")
|
||||
if w.storedRoleID(41) != w.id("Intern") {
|
||||
t.Errorf("the winner's role must stay")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a deleted role is an invalid role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
temp := w.createTemp("content.read=own")
|
||||
w.users.beforeUpdateRole = func() { delete(w.roles.roles, temp) }
|
||||
usersAPIAssertPlain(t, w.putRole(t, uaAdmin, "41", strconv.FormatUint(uint64(temp), 10)), http.StatusBadRequest, "invalid_role")
|
||||
if w.storedRoleID(41) != w.id("Contributor") {
|
||||
t.Errorf("the user keeps Contributor")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a repository failure is a 500", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.fail.failNext("UpdateRole")
|
||||
usersAPIAssertPlain(t, w.putRole(t, uaAdmin, "41", w.idText("Writer")), http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("a gated request loads the user at most once and the role once", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.roles.calls = map[string]int{}
|
||||
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/user", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if w.roles.calls["MemberCounts"] != 0 || w.roles.calls["Get"] > 1 {
|
||||
t.Errorf("unexpected role calls %v", w.roles.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func usersAPIRoleName(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
body := usersAPIDecode(t, rec)
|
||||
access, ok := body["access"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no access in %q", rec.Body.String())
|
||||
}
|
||||
return access["role"].(map[string]any)["name"].(string)
|
||||
}
|
||||
|
||||
func TestUsersAPIInviteHTTP(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
body string
|
||||
status int
|
||||
code string
|
||||
key string
|
||||
role string
|
||||
}{
|
||||
{"default role", uaAdmin, `{"email": "a@renew.example"}`, 201, "", "", "Writer"},
|
||||
{"role id zero", uaAdmin, `{"email": "a@renew.example", "role_id": 0}`, 201, "", "", "Writer"},
|
||||
{"Contributor", uaAdmin, `{"email": "a@renew.example", "role_id": {Contributor}}`, 201, "", "", "Contributor"},
|
||||
{"Admin", uaAdmin, `{"email": "a@renew.example", "role_id": {Admin}}`, 201, "", "", "Admin"},
|
||||
{"Owner invites a Content lead", uaOwner, `{"email": "a@renew.example", "role_id": {Content lead}}`, 201, "", "", "Content lead"},
|
||||
{"Owner role", uaAdmin, `{"email": "a@renew.example", "role_id": {Owner}}`, 403, "role_locked", "", ""},
|
||||
{"unknown role", uaAdmin, `{"email": "a@renew.example", "role_id": 999}`, 400, "invalid_role", "", ""},
|
||||
{"People manager invites an Intern", uaPeople, `{"email": "a@renew.example", "role_id": {Intern}}`, 201, "", "", "Intern"},
|
||||
{"People manager default Writer", uaPeople, `{"email": "a@renew.example"}`, 403, "escalation", "content.update", ""},
|
||||
{"People manager Content lead", uaPeople, `{"email": "a@renew.example", "role_id": {Content lead}}`, 403, "escalation", "content.update", ""},
|
||||
{"Inviter default Writer", uaInviter, `{"email": "a@renew.example"}`, 403, "escalation", "content.read", ""},
|
||||
{"unknown role keeps the expired invite", uaAdmin, `{"email": "old2@renew.example", "role_id": 999}`, 400, "invalid_role", "", ""},
|
||||
{"expired invite replaced", uaAdmin, `{"email": "old2@renew.example", "role_id": {Writer}}`, 201, "", "", "Writer"},
|
||||
{"pending invite", uaAdmin, `{"email": "new3@renew.example"}`, 409, apperr.PendingInviteErrorMessage, "", ""},
|
||||
{"email with an account", uaAdmin, `{"email": "ines@renew.example"}`, 409, apperr.EmailHasAccountErrorMessage, "", ""},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
body := row.body
|
||||
for name, id := range w.ids {
|
||||
body = strings.ReplaceAll(body, "{"+name+"}", strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
rec := w.do(t, row.caller, http.MethodPost, "/v1/user/invite", body)
|
||||
if row.status == 201 {
|
||||
item := usersAPIDecode(t, rec)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
apiKeyAssertExactFields(t, item, "id", "kind", "email", "status", "role", "created_at", "expires_at")
|
||||
if usersAPIRoleRef(t, item)["name"] != row.role || len(w.mail.SentEmails) != 1 {
|
||||
t.Errorf("unexpected invite %v with %d mails", item, len(w.mail.SentEmails))
|
||||
}
|
||||
return
|
||||
}
|
||||
switch row.code {
|
||||
case "escalation":
|
||||
assertEscalation(t, rec, row.key)
|
||||
default:
|
||||
assertRefusal(t, rec, row.status, row.code)
|
||||
}
|
||||
if len(w.mail.SentEmails) != 0 || len(w.invites.invites) != 4 {
|
||||
t.Errorf("a refusal creates no invite and sends no mail")
|
||||
}
|
||||
if strings.Contains(row.name, "expired invite") {
|
||||
if _, present := w.invites.invites[2]; !present {
|
||||
t.Errorf("invite 2 must stay")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an invalid address is a validation error", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite", `{"email": "not-an-email"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an API key may still create invites with its creator's live role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
raw, _ := w.issueKey(uaPeople, "ci")
|
||||
rec := w.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "a@renew.example"}`, raw)
|
||||
assertEscalation(t, rec, "content.update")
|
||||
rec = w.withKey(t, http.MethodPost, "/v1/user/invite", `{"email": "a@renew.example", "role_id": `+w.idText("Intern")+`}`, raw)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIRenewHTTP(t *testing.T) {
|
||||
t.Run("renewing keeps the role and carries it in the response", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite/2/renew", "")
|
||||
item := usersAPIDecode(t, rec)
|
||||
if rec.Code != 200 || item["status"] != "pending" || usersAPIRoleRef(t, item)["name"] != "Contributor" || len(w.mail.SentEmails) != 1 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"People manager renews Contributor", uaPeople, http.MethodPost, "/v1/user/invite/3/renew", 200, ""},
|
||||
{"People manager renews Writer", uaPeople, http.MethodPost, "/v1/user/invite/1/renew", 403, "escalation"},
|
||||
{"People manager cancels Writer", uaPeople, http.MethodDelete, "/v1/user/invite/1", 204, ""},
|
||||
{"unknown invite", uaAdmin, http.MethodPost, "/v1/user/invite/999/renew", 404, apperr.InviteNotFoundErrorMessage},
|
||||
{"accepted invite", uaAdmin, http.MethodPost, "/v1/user/invite/4/renew", 409, apperr.InviteAlreadyAcceptedErrorMessage},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, row.caller, row.method, row.path, "")
|
||||
if row.status < 300 {
|
||||
if rec.Code != row.status {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if row.code == "escalation" {
|
||||
assertEscalation(t, rec, "content.update")
|
||||
if len(w.mail.SentEmails) != 0 {
|
||||
t.Errorf("no mail on a refusal")
|
||||
}
|
||||
return
|
||||
}
|
||||
usersAPIAssertPlain(t, rec, row.status, row.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAPIAcceptHTTP(t *testing.T) {
|
||||
for _, name := range []string{"Writer", "Contributor", "Admin"} {
|
||||
t.Run("accepting an invite for "+name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = w.ids[name]
|
||||
w.invites.invites[3] = pending
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"role"`) {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created uint32
|
||||
for id, member := range w.users.users {
|
||||
if member.Username == "kim" {
|
||||
created = id
|
||||
}
|
||||
}
|
||||
if w.storedRoleID(created) != w.ids[name] || !w.users.users[created].IsVerified {
|
||||
t.Errorf("unexpected stored user %+v", w.users.users[created])
|
||||
}
|
||||
if usersAPIRoleName(t, w.do(t, created, http.MethodGet, "/v1/auth/me", "")) != name {
|
||||
t.Errorf("/auth/me must show %s", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an invite without a usable role is invalid", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = 0
|
||||
w.invites.invites[3] = pending
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
|
||||
usersAPIAssertPlain(t, rec, http.StatusNotFound, apperr.InviteInvalidErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPITransferHTTP(t *testing.T) {
|
||||
t.Run("transferring ownership", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
raw5, _ := w.issueKey(uaAdmin, "five")
|
||||
raw3, _ := w.issueKey(uaOwner, "three")
|
||||
rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"))
|
||||
body := usersAPIDecode(t, rec)
|
||||
if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
apiKeyAssertExactFields(t, body, "owner", "former_owner")
|
||||
apiKeyAssertExactFields(t, body["owner"].(map[string]any), "id", "name")
|
||||
former := body["former_owner"].(map[string]any)
|
||||
apiKeyAssertExactFields(t, former, "id", "name", "role")
|
||||
if usersAPIRoleRef(t, former)["name"] != "Admin" || former["id"] != float64(uaOwner) {
|
||||
t.Errorf("unexpected former owner %v", former)
|
||||
}
|
||||
if w.users.users[uaAdmin].TokenVersion != 0 || w.users.users[uaOwner].TokenVersion != 0 {
|
||||
t.Errorf("token versions must not change")
|
||||
}
|
||||
if usersAPIRoleName(t, w.do(t, uaOwner, http.MethodGet, "/v1/auth/me", "")) != "Admin" || usersAPIRoleName(t, w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")) != "Owner" {
|
||||
t.Errorf("sessions must follow the new roles")
|
||||
}
|
||||
if usersAPIRoleName(t, w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw5)) != "Owner" || usersAPIRoleName(t, w.withKey(t, http.MethodGet, "/v1/auth/me", "", raw3)) != "Admin" {
|
||||
t.Errorf("keys must follow the new roles")
|
||||
}
|
||||
listed := w.do(t, uaAdmin, http.MethodGet, "/v1/role", "")
|
||||
if !strings.Contains(listed.Body.String(), `"name":"Owner"`) {
|
||||
t.Fatalf("unexpected %q", listed.Body.String())
|
||||
}
|
||||
usersAPIAssertMissing(t, w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example")), "system.transfer_ownership")
|
||||
if len(w.recorder.events) != 1 || w.recorder.events[0].Action != audit.ActionUserTransferOwnership {
|
||||
t.Errorf("expected one transfer event, got %v", w.recorder.actions())
|
||||
}
|
||||
})
|
||||
|
||||
refusals := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"empty object", `{}`, 400, ""},
|
||||
{"missing confirm_email", `{"user_id": 5}`, 400, ""},
|
||||
{"unknown user", `{"user_id": 999, "confirm_email": "x@renew.example"}`, 400, "invalid_transfer"},
|
||||
{"yourself", `{"user_id": 3, "confirm_email": "marta@renew.example"}`, 400, "invalid_transfer"},
|
||||
{"unverified", `{"user_id": 12, "confirm_email": "sam@renew.example"}`, 400, "invalid_transfer"},
|
||||
{"wrong case", `{"user_id": 5, "confirm_email": "JONAS@renew.example"}`, 400, "confirmation_mismatch"},
|
||||
{"empty email", `{"user_id": 5, "confirm_email": ""}`, 400, ""},
|
||||
}
|
||||
for _, row := range refusals {
|
||||
t.Run("refusal: "+row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", row.body)
|
||||
if row.code == "" {
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
} else {
|
||||
assertRefusal(t, rec, row.status, row.code)
|
||||
}
|
||||
if w.storedRoleID(uaOwner) != ownerRoleID || w.users.transferCalls != 0 || len(w.recorder.events) != 0 {
|
||||
t.Errorf("a refusal changes nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("two simultaneous transfers give one new owner", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.beforeTransfer = func() {
|
||||
if rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaOtherAdmin, "lea@renew.example")); rec.Code != 200 {
|
||||
t.Errorf("the rival must win, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
rec := w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"))
|
||||
usersAPIAssertPlain(t, rec, http.StatusConflict, "user_role_changed")
|
||||
owners := 0
|
||||
for _, member := range w.users.users {
|
||||
if member.RoleID == ownerRoleID && member.IsVerified {
|
||||
owners++
|
||||
}
|
||||
}
|
||||
if owners != 1 || w.storedRoleID(uaOtherAdmin) != ownerRoleID {
|
||||
t.Errorf("exactly the first successor must own the site")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIListHTTP(t *testing.T) {
|
||||
t.Run("the list shows roles and nothing secret", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.roles.calls = map[string]int{}
|
||||
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/user", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if w.roles.calls["List"] != 1 {
|
||||
t.Errorf("expected one role List, got %v", w.roles.calls)
|
||||
}
|
||||
for _, secret := range []string{"password", "verification", "token_version", "hashed_code", "invited_by", "accepted_at", `"code"`} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("the list leaks %q", secret)
|
||||
}
|
||||
}
|
||||
items := apiKeyDecodeList(t, rec)
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
kind := item["kind"].(string)
|
||||
key := kind + strconv.Itoa(int(item["id"].(float64)))
|
||||
seen[key] = true
|
||||
switch kind {
|
||||
case "user":
|
||||
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
|
||||
default:
|
||||
apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at")
|
||||
}
|
||||
if key == "user3" {
|
||||
ref := usersAPIRoleRef(t, item)
|
||||
if ref["name"] != "Owner" || ref["built_in"] != true || ref["locked"] != true || ref["id"] != float64(ownerRoleID) {
|
||||
t.Errorf("unexpected owner role %v", ref)
|
||||
}
|
||||
}
|
||||
if key == "user13" || key == "user14" {
|
||||
if item["role"] != nil {
|
||||
t.Errorf("%s must show role null, got %v", key, item["role"])
|
||||
}
|
||||
}
|
||||
if key == "invite2" && usersAPIRoleRef(t, item)["name"] != "Contributor" {
|
||||
t.Errorf("invite 2 must be for Contributor")
|
||||
}
|
||||
}
|
||||
if seen["invite4"] {
|
||||
t.Errorf("accepted invites are history")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a user without a usable role is denied and never promoted", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
for _, id := range []uint32{uaNoRole, uaGhostRole} {
|
||||
usersAPIAssertMissing(t, w.do(t, id, http.MethodGet, "/v1/user", ""), "users.view")
|
||||
usersAPIAssertPlain(t, w.do(t, id, http.MethodGet, "/v1/auth/me", ""), http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIPublicUserShape(t *testing.T) {
|
||||
t.Run("/auth/me keeps access.role and no top-level role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
rec := w.do(t, uaAdmin, http.MethodGet, "/v1/auth/me", "")
|
||||
body := usersAPIDecode(t, rec)
|
||||
apiKeyAssertExactFields(t, body, "id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "author", "access")
|
||||
if usersAPIRoleName(t, rec) != "Admin" {
|
||||
t.Errorf("access.role must stay")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registration carries no role key and stores the Owner", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.users = map[uint32]user.User{}
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/register", `{"username": "first", "password": "password1", "email": "first@renew.example"}`)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"role"`) {
|
||||
t.Errorf("registration must carry no role key: %q", rec.Body.String())
|
||||
}
|
||||
for _, member := range w.users.users {
|
||||
if member.RoleID != ownerRoleID || member.IsVerified {
|
||||
t.Errorf("unexpected stored user %+v", member)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registration fails with 500 when the Owner role is not seeded", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.users = map[uint32]user.User{}
|
||||
w.roles.roles = map[uint32]role.Role{}
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/register", `{"username": "first", "password": "password1", "email": "first@renew.example"}`)
|
||||
if rec.Code != http.StatusInternalServerError || len(w.users.users) != 0 {
|
||||
t.Fatalf("unexpected %d %q with %d users", rec.Code, rec.Body.String(), len(w.users.users))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUsersAPIQAGaps(t *testing.T) {
|
||||
t.Run("renewing mails a new code and the old code no longer works", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
before := w.invites.invites[2].HashedCode
|
||||
if rec := w.do(t, uaAdmin, http.MethodPost, "/v1/user/invite/2/renew", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if w.invites.invites[2].HashedCode == before || len(w.mail.SentEmails) != 1 {
|
||||
t.Fatalf("the code must change and be mailed")
|
||||
}
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code2", "username": "kim", "password": "password1"}`)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("the old code must not accept, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepting an invite whose role was deleted is invalid and creates no user", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
temp := w.createTemp("content.read=own")
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = temp
|
||||
w.invites.invites[3] = pending
|
||||
delete(w.roles.roles, temp)
|
||||
users := len(w.users.users)
|
||||
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
|
||||
usersAPIAssertPlain(t, rec, http.StatusNotFound, apperr.InviteInvalidErrorMessage)
|
||||
if len(w.users.users) != users {
|
||||
t.Errorf("no user may be created")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func usersAPIEscalationKey(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var escalation role.EscalationError
|
||||
if !errors.As(err, &escalation) {
|
||||
t.Fatalf("expected an escalation error, got %v", err)
|
||||
}
|
||||
return escalation.Key
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) roleIDFor(name string) uint32 {
|
||||
switch name {
|
||||
case "":
|
||||
return 0
|
||||
case "unknown":
|
||||
return uaUnknownRole
|
||||
}
|
||||
return w.ids[name]
|
||||
}
|
||||
|
||||
type usersAPIOutcome struct {
|
||||
err error
|
||||
escalation string
|
||||
}
|
||||
|
||||
func (o usersAPIOutcome) check(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case o.escalation != "":
|
||||
if !errors.Is(err, role.ErrEscalation) || usersAPIEscalationKey(t, err) != o.escalation {
|
||||
t.Fatalf("expected escalation on %q, got %v", o.escalation, err)
|
||||
}
|
||||
case o.err != nil:
|
||||
if !errors.Is(err, o.err) {
|
||||
t.Fatalf("expected %v, got %v", o.err, err)
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o usersAPIOutcome) refused() bool {
|
||||
return o.err != nil || o.escalation != ""
|
||||
}
|
||||
|
||||
func TestUsersAPIChangeUserRoleUseCase(t *testing.T) {
|
||||
ok := usersAPIOutcome{}
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target uint32
|
||||
role string
|
||||
outcome usersAPIOutcome
|
||||
}{
|
||||
{"Admin moves a Contributor to Writer", uaAdmin, 41, "Writer", ok},
|
||||
{"Admin covers Admin", uaAdmin, 41, "Admin", ok},
|
||||
{"Admin demotes another Admin", uaAdmin, uaOtherAdmin, "Contributor", ok},
|
||||
{"Owner moves an Admin to Content lead", uaOwner, uaAdmin, "Content lead", ok},
|
||||
{"People manager moves a Contributor to Intern", uaPeople, 41, "Intern", ok},
|
||||
{"People manager cannot give Writer", uaPeople, 41, "Writer", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"People manager cannot give Content lead", uaPeople, 41, "Content lead", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"People manager cannot touch a Content lead", uaPeople, uaContentLead, "Intern", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"People manager cannot touch an Admin", uaPeople, uaAdmin, "Intern", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"Admin cannot give Owner", uaAdmin, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
{"Owner cannot give Owner", uaOwner, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
{"Admin cannot change the owner", uaAdmin, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
{"Owner role is checked before the own account", uaOwner, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
{"Admin cannot change its own role", uaAdmin, uaAdmin, "Writer", usersAPIOutcome{err: role.ErrOwnRole}},
|
||||
{"People manager cannot change its own role", uaPeople, uaPeople, "Intern", usersAPIOutcome{err: role.ErrOwnRole}},
|
||||
{"unknown user", uaAdmin, 999, "Writer", usersAPIOutcome{err: user.ErrUserNotFound}},
|
||||
{"unknown role", uaAdmin, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}},
|
||||
{"role id zero", uaAdmin, 41, "", usersAPIOutcome{err: role.ErrUnknownRole}},
|
||||
{"unverified user may be given a role", uaAdmin, uaUnverified, "Writer", ok},
|
||||
{"unknown role before escalation on a higher role", uaPeople, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}},
|
||||
{"locked current role before an unknown role", uaAdmin, uaOwner, "unknown", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
before := w.storedRoleID(row.target)
|
||||
got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(row.caller), TargetID: row.target, RoleID: w.roleIDFor(row.role)})
|
||||
row.outcome.check(t, err)
|
||||
if row.outcome.refused() {
|
||||
if w.users.updateRoleCalls != 0 {
|
||||
t.Errorf("a refused call must not reach the repository write")
|
||||
}
|
||||
if len(w.recorder.events) != 0 {
|
||||
t.Errorf("a refused call must record nothing, got %v", w.recorder.actions())
|
||||
}
|
||||
if after := w.storedRoleID(row.target); after != before {
|
||||
t.Errorf("role changed from %d to %d on a refusal", before, after)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got.Role == nil || got.Role.ID != w.ids[row.role] || got.ID != row.target || got.Kind != useCases.EntryKindUser {
|
||||
t.Errorf("unexpected result %+v", got)
|
||||
}
|
||||
if w.storedRoleID(row.target) != w.ids[row.role] {
|
||||
t.Errorf("stored role is %d", w.storedRoleID(row.target))
|
||||
}
|
||||
if before != w.ids[row.role] && len(w.recorder.events) != 1 {
|
||||
t.Errorf("expected one event, got %v", w.recorder.actions())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("giving the same role again writes nothing and records nothing", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Contributor")})
|
||||
if err != nil || got.Role == nil || got.Role.Name != "Contributor" {
|
||||
t.Fatalf("unexpected %+v, %v", got, err)
|
||||
}
|
||||
if w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 {
|
||||
t.Errorf("a no-op must not write or record")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a same-role call by a caller who does not cover it is still an escalation", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
changeOnly := w.createTemp("users.change_role=all")
|
||||
w.addMember(70, "narrow", "narrow@renew.example", changeOnly, true)
|
||||
_, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(70), TargetID: 41, RoleID: w.id("Contributor")})
|
||||
if !errors.Is(err, role.ErrEscalation) {
|
||||
t.Fatalf("expected escalation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a stale read loses the compare-and-swap", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.beforeUpdateRole = func() {
|
||||
rival := w.users.users[41]
|
||||
rival.RoleID = w.id("Intern")
|
||||
w.users.users[41] = rival
|
||||
}
|
||||
_, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")})
|
||||
if !errors.Is(err, user.ErrRoleChanged) {
|
||||
t.Fatalf("expected ErrRoleChanged, got %v", err)
|
||||
}
|
||||
if w.storedRoleID(41) != w.id("Intern") || len(w.recorder.events) != 0 {
|
||||
t.Errorf("the winner must stay and nothing is recorded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a role deleted meanwhile is an invalid role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
temp := w.createTemp("content.read=own")
|
||||
w.users.beforeUpdateRole = func() { delete(w.roles.roles, temp) }
|
||||
_, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: temp})
|
||||
if !errors.Is(err, role.ErrUnknownRole) {
|
||||
t.Fatalf("expected ErrUnknownRole, got %v", err)
|
||||
}
|
||||
if w.storedRoleID(41) != w.id("Contributor") || len(w.recorder.events) != 0 {
|
||||
t.Errorf("the user must keep the Contributor role and nothing is recorded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the event names the roles and the user", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := w.recorder.events[0]
|
||||
if event.Action != audit.ActionUserChangeRole || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || event.Security {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
if event.Target != audit.NewTarget(audit.TargetUser, 41, "user41") {
|
||||
t.Errorf("unexpected target %+v", event.Target)
|
||||
}
|
||||
if len(event.Changes) != 1 || !reflect.DeepEqual(event.Changes[0], audit.TextChange(audit.FieldRole, "Contributor", "Writer")) {
|
||||
t.Errorf("unexpected changes %+v", event.Changes)
|
||||
}
|
||||
if event.Params[audit.ParamFrom] != w.id("Contributor") || event.Params[audit.ParamTo] != w.id("Writer") {
|
||||
t.Errorf("unexpected params %v", event.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a storage failure records a failed event", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.fail.failNext("UpdateRole")
|
||||
_, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")})
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository failure, got %v", err)
|
||||
}
|
||||
if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || w.recorder.events[0].FailureReason == nil {
|
||||
t.Fatalf("expected one failed event, got %+v", w.recorder.events)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a user whose role cannot be resolved can be repaired by a covering caller", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaNoRole, RoleID: w.id("Writer")})
|
||||
if err != nil || got.Role == nil || got.Role.Name != "Writer" {
|
||||
t.Fatalf("unexpected %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPITransferOwnershipUseCase(t *testing.T) {
|
||||
command := func(w *usersAPIWorld, target uint32, email string) useCases.TransferOwnershipCommand {
|
||||
return useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: target, ConfirmEmail: email}
|
||||
}
|
||||
|
||||
t.Run("the owner role moves and the former owner becomes Admin", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
got, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.storedRoleID(uaAdmin) != ownerRoleID || w.storedRoleID(uaOwner) != adminRoleID {
|
||||
t.Errorf("roles after the transfer: %d and %d", w.storedRoleID(uaAdmin), w.storedRoleID(uaOwner))
|
||||
}
|
||||
if got.Owner.ID != uaAdmin || got.Owner.Name != "jonas" || got.FormerOwner.ID != uaOwner || got.FormerOwner.Role.Name != "Admin" || got.FormerOwner.Role.ID != adminRoleID {
|
||||
t.Errorf("unexpected result %+v", got)
|
||||
}
|
||||
owners := 0
|
||||
for _, member := range w.users.users {
|
||||
if member.RoleID == ownerRoleID && member.IsVerified {
|
||||
owners++
|
||||
}
|
||||
}
|
||||
if owners != 1 {
|
||||
t.Errorf("expected exactly one owner, got %d", owners)
|
||||
}
|
||||
if w.users.users[uaAdmin].TokenVersion != 0 || w.users.users[uaOwner].TokenVersion != 0 {
|
||||
t.Errorf("token versions must not change")
|
||||
}
|
||||
event := w.recorder.events[0]
|
||||
if len(w.recorder.events) != 1 || event.Action != audit.ActionUserTransferOwnership || event.Category != audit.CategoryUsers || !event.Security || event.Outcome != audit.OutcomeSuccess {
|
||||
t.Fatalf("unexpected events %+v", w.recorder.events)
|
||||
}
|
||||
if event.Target != audit.NewTarget(audit.TargetUser, uaAdmin, "jonas") || event.Params[audit.ParamFrom] != uaOwner || event.Params[audit.ParamTo] != uaAdmin {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
refusals := []struct {
|
||||
name string
|
||||
target uint32
|
||||
email string
|
||||
want error
|
||||
}{
|
||||
{"unknown user", 999, "x@renew.example", user.ErrInvalidTransfer},
|
||||
{"yourself", uaOwner, "marta@renew.example", user.ErrInvalidTransfer},
|
||||
{"an unverified user", uaUnverified, "sam@renew.example", user.ErrInvalidTransfer},
|
||||
{"a mismatching email", uaAdmin, "JONAS@renew.example", user.ErrConfirmationMismatch},
|
||||
{"an empty email", uaAdmin, "", user.ErrConfirmationMismatch},
|
||||
}
|
||||
for _, row := range refusals {
|
||||
t.Run("refuses "+row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
_, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, row.target, row.email))
|
||||
if !errors.Is(err, row.want) {
|
||||
t.Fatalf("expected %v, got %v", row.want, err)
|
||||
}
|
||||
if w.users.transferCalls != 0 || len(w.recorder.events) != 0 || w.storedRoleID(uaOwner) != ownerRoleID {
|
||||
t.Errorf("a refusal must write and record nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a caller who lost the owner role meanwhile loses the swap", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.beforeTransfer = func() {
|
||||
_ = w.users.TransferOwnership(uaOwner, uaOtherAdmin, ownerRoleID, adminRoleID)
|
||||
}
|
||||
_, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example"))
|
||||
if !errors.Is(err, user.ErrRoleChanged) {
|
||||
t.Fatalf("expected ErrRoleChanged, got %v", err)
|
||||
}
|
||||
if w.storedRoleID(uaOtherAdmin) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID || len(w.recorder.events) != 0 {
|
||||
t.Errorf("the first transfer must stand and nothing is recorded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a storage failure changes nobody and records a failed event", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.fail.failNext("TransferOwnership")
|
||||
_, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example"))
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository failure, got %v", err)
|
||||
}
|
||||
if w.storedRoleID(uaOwner) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID {
|
||||
t.Errorf("nobody must change")
|
||||
}
|
||||
if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || !w.recorder.events[0].Security {
|
||||
t.Errorf("expected one failed security event, got %+v", w.recorder.events)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a custom role named Admin is not the built-in Admin", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
cmd := command(w, uaAdmin, "jonas@renew.example")
|
||||
delete(w.roles.roles, adminRoleID)
|
||||
_, _ = w.roles.Create(testRole("Admin"))
|
||||
_, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, cmd)
|
||||
if !errors.Is(err, role.ErrRoleNotFound) || w.storedRoleID(uaOwner) != ownerRoleID || w.users.transferCalls != 0 {
|
||||
t.Fatalf("expected ErrRoleNotFound and no write, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testRole(name string) role.Role {
|
||||
return role.Role{Name: name, Version: 1, Grants: grantRows("content.read=own")}
|
||||
}
|
||||
|
||||
func TestUsersAPICreateInviteUseCase(t *testing.T) {
|
||||
ok := usersAPIOutcome{}
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
email string
|
||||
role string
|
||||
outcome usersAPIOutcome
|
||||
}{
|
||||
{"default Writer", uaAdmin, "a@renew.example", "", ok},
|
||||
{"Contributor", uaAdmin, "a@renew.example", "Contributor", ok},
|
||||
{"Admin", uaAdmin, "a@renew.example", "Admin", ok},
|
||||
{"Owner gives a Content lead", uaOwner, "a@renew.example", "Content lead", ok},
|
||||
{"Owner role is locked", uaAdmin, "a@renew.example", "Owner", usersAPIOutcome{err: role.ErrRoleLocked}},
|
||||
{"unknown role", uaAdmin, "a@renew.example", "unknown", usersAPIOutcome{err: role.ErrUnknownRole}},
|
||||
{"People manager invites an Intern", uaPeople, "a@renew.example", "Intern", ok},
|
||||
{"People manager cannot invite the default Writer", uaPeople, "a@renew.example", "", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"People manager cannot invite a Content lead", uaPeople, "a@renew.example", "Content lead", usersAPIOutcome{escalation: "content.update"}},
|
||||
{"Inviter cannot invite the default Writer", uaInviter, "a@renew.example", "", usersAPIOutcome{escalation: "content.read"}},
|
||||
{"pending invite", uaAdmin, "new3@renew.example", "", usersAPIOutcome{err: invite.ErrPendingInvite}},
|
||||
{"email with an account", uaAdmin, "ines@renew.example", "", usersAPIOutcome{err: invite.ErrEmailHasAccount}},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
invitesBefore := len(w.invites.invites)
|
||||
got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(row.caller), Email: row.email, InvitedBy: row.caller, RoleID: w.roleIDFor(row.role)})
|
||||
row.outcome.check(t, err)
|
||||
if row.outcome.refused() {
|
||||
if len(w.invites.invites) != invitesBefore || len(w.mail.SentEmails) != 0 {
|
||||
t.Errorf("a refusal must create no invite and send no mail")
|
||||
}
|
||||
return
|
||||
}
|
||||
wantRole := row.role
|
||||
if wantRole == "" {
|
||||
wantRole = "Writer"
|
||||
}
|
||||
if got.RoleID != w.ids[wantRole] || w.invites.invites[got.ID].RoleID != w.ids[wantRole] {
|
||||
t.Errorf("invite role is %d, want %s", got.RoleID, wantRole)
|
||||
}
|
||||
if len(w.mail.SentEmails) != 1 || w.mail.SentEmails[0].Template != "invitation" {
|
||||
t.Errorf("expected one invitation mail, got %+v", w.mail.SentEmails)
|
||||
}
|
||||
params := w.recorder.events[len(w.recorder.events)-1].Params
|
||||
if params[audit.ParamRoleID] != w.ids[wantRole] || params[audit.ParamRole] != wantRole {
|
||||
t.Errorf("unexpected invite.create params %v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a refused role leaves the expired invite in place", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
_, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: uaUnknownRole})
|
||||
if !errors.Is(err, role.ErrUnknownRole) {
|
||||
t.Fatalf("expected ErrUnknownRole, got %v", err)
|
||||
}
|
||||
if _, present := w.invites.invites[2]; !present {
|
||||
t.Errorf("expired invite 2 must not be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an expired invite is replaced by a new one for the requested role", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: writerRoleID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := w.invites.invites[2]; present || got.RoleID != writerRoleID {
|
||||
t.Errorf("expired invite 2 must be replaced for Writer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failed mail rolls the invite back", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.mail.fail.failNext("Send")
|
||||
_, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "a@renew.example", InvitedBy: uaAdmin})
|
||||
if !errors.Is(err, invite.ErrInviteEmailNotSent) || len(w.invites.invites) != 4 {
|
||||
t.Fatalf("expected a rollback, got %v with %d invites", err, len(w.invites.invites))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIRenewAndCancelInviteUseCases(t *testing.T) {
|
||||
renew := func(w *usersAPIWorld, caller uint32, id uint32) (useCases.InviteView, error) {
|
||||
return w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(caller), ID: id, RenewedBy: caller})
|
||||
}
|
||||
|
||||
t.Run("renewing keeps the role of an expired invite", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
got, err := renew(w, uaAdmin, 2)
|
||||
if err != nil || got.RoleID != w.id("Contributor") || got.StatusAt(time.Now()) != invite.StatusPending {
|
||||
t.Fatalf("unexpected %+v, %v", got, err)
|
||||
}
|
||||
if len(w.mail.SentEmails) != 1 {
|
||||
t.Errorf("a new code must be mailed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("People manager renews a Contributor invite", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if _, err := renew(w, uaPeople, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("People manager cannot renew a Writer invite", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
before := w.invites.invites[1]
|
||||
_, err := renew(w, uaPeople, 1)
|
||||
if usersAPIEscalationKey(t, err) != "content.update" {
|
||||
t.Fatalf("unexpected %v", err)
|
||||
}
|
||||
if w.invites.invites[1].HashedCode != before.HashedCode || len(w.mail.SentEmails) != 0 {
|
||||
t.Errorf("nothing may be renewed or mailed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found comes before the coverage check", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if _, err := renew(w, uaPeople, 999); !errors.Is(err, invite.ErrInviteNotFound) {
|
||||
t.Fatalf("unexpected %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an accepted invite is a conflict before the coverage check", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if _, err := renew(w, uaPeople, 4); !errors.Is(err, invite.ErrInviteAlreadyAccepted) {
|
||||
t.Fatalf("unexpected %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelling needs no coverage", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
if err := w.container.CancelInviteUseCase.CancelInvite(testCtx, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := w.invites.invites[1]; present {
|
||||
t.Errorf("invite 1 must be gone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIAcceptInviteUseCase(t *testing.T) {
|
||||
for _, name := range []string{"Writer", "Contributor", "Admin"} {
|
||||
t.Run("the new user gets the role of the invite: "+name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = w.ids[name]
|
||||
w.invites.invites[3] = pending
|
||||
created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := w.users.users[created.ID]
|
||||
if stored.RoleID != w.ids[name] || !stored.IsVerified || stored.Username != "kim" {
|
||||
t.Errorf("unexpected stored user %+v", stored)
|
||||
}
|
||||
params := w.recorder.events[len(w.recorder.events)-1].Params
|
||||
if params[audit.ParamRoleID] != w.ids[name] || params[audit.ParamRole] != name {
|
||||
t.Errorf("unexpected invite.accept params %v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, roleID := range map[string]uint32{"role id zero": 0, "a deleted role": 99} {
|
||||
t.Run("an invite with "+name+" is invalid", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
pending := w.invites.invites[3]
|
||||
pending.RoleID = roleID
|
||||
w.invites.invites[3] = pending
|
||||
usersBefore := 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) != usersBefore || w.invites.invites[3].AcceptedAt != nil {
|
||||
t.Errorf("no user may be created and the invite stays open")
|
||||
}
|
||||
last := w.recorder.events[len(w.recorder.events)-1]
|
||||
if last.Action != audit.ActionInviteAccept || last.Outcome != audit.OutcomeFailed {
|
||||
t.Errorf("expected a failed invite.accept event, got %+v", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("grants edited before the accept are what the new user gets", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
contributor := w.roles.roles[w.id("Contributor")]
|
||||
contributor.Grants = grantRows("content.read=all", "media.view=all")
|
||||
w.roles.roles[contributor.ID] = contributor
|
||||
created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := w.container.GetMyAccessUseCase.Get(w.users.users[created.ID])
|
||||
if err != nil || access.Role.Name != "Contributor" || len(access.Permissions) != 2 {
|
||||
t.Fatalf("unexpected access %+v, %v", access, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPICreateUserUseCase(t *testing.T) {
|
||||
t.Run("registration stores the Owner role id", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.users.users = map[uint32]user.User{}
|
||||
created, err := w.container.CreateUserUseCase.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := w.users.users[created.ID]
|
||||
if stored.RoleID != ownerRoleID || stored.IsVerified {
|
||||
t.Errorf("unexpected stored user %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unseeded Owner role fails and creates no user", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(users, newMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"})
|
||||
if !errors.Is(err, role.ErrRoleNotFound) || len(users.users) != 0 {
|
||||
t.Fatalf("expected ErrRoleNotFound with no user, got %v and %d users", err, len(users.users))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failed verification mail and a failed cleanup report both errors", func(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
mailer := newMockMailRepo()
|
||||
cleanup := errors.New("cleanup failed")
|
||||
mailer.fail.failNextWith("Send", errors.New("smtp down"))
|
||||
users.fail.failNextWith("Delete", cleanup)
|
||||
uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mailer, "https://cms.example.com", discardRecorder)
|
||||
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"})
|
||||
if !errors.Is(err, user.ErrVerificationEmailNotSent) || !errors.Is(err, cleanup) {
|
||||
t.Fatalf("expected both failures, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a custom role named Owner is not the built-in Owner", func(t *testing.T) {
|
||||
roles := newMockRoleRepo()
|
||||
_, _ = roles.Create(role.Role{Name: role.OwnerName, Version: 1})
|
||||
users := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(users, roles, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
|
||||
if _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Fatalf("expected ErrRoleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIListUsersAndInvitesUseCase(t *testing.T) {
|
||||
t.Run("roles are read once and resolved per row", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.roles.calls = map[string]int{}
|
||||
entries, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.roles.calls["List"] != 1 || w.roles.calls["Get"] != 0 || w.roles.calls["GetByName"] != 0 {
|
||||
t.Errorf("expected one List call, got %v", w.roles.calls)
|
||||
}
|
||||
byKey := map[string]useCases.DirectoryEntry{}
|
||||
for _, entry := range entries {
|
||||
byKey[entry.Kind+strconv.Itoa(int(entry.ID))] = entry
|
||||
}
|
||||
if owner := byKey["user3"]; owner.Role == nil || owner.Role.Name != "Owner" || !owner.Role.BuiltIn || !owner.Role.Locked || owner.Role.ID != ownerRoleID {
|
||||
t.Errorf("unexpected owner entry %+v", owner)
|
||||
}
|
||||
if byKey["user13"].Role != nil || byKey["user14"].Role != nil {
|
||||
t.Errorf("users without a usable role must show no role")
|
||||
}
|
||||
if byKey["invite2"].Role == nil || byKey["invite2"].Role.Name != "Contributor" || byKey["invite1"].Role.Name != "Writer" {
|
||||
t.Errorf("invites must carry their role")
|
||||
}
|
||||
if _, present := byKey["invite4"]; present {
|
||||
t.Errorf("accepted invites are history")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a role list failure is an error", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.roles.fail.failNext("List")
|
||||
if _, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites(); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("unexpected %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIResolution(t *testing.T) {
|
||||
t.Run("role id zero and a deleted role resolve to nothing", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
for _, id := range []uint32{uaNoRole, uaGhostRole} {
|
||||
account := w.users.users[id]
|
||||
if _, err := w.container.GetMyAccessUseCase.Get(account); !errors.Is(err, role.ErrRoleNotFound) {
|
||||
t.Errorf("user %d: expected ErrRoleNotFound, got %v", id, err)
|
||||
}
|
||||
grants, err := w.container.CheckPermissionUseCase.Grants(account)
|
||||
if err != nil || len(grants.Effective()) != 0 {
|
||||
t.Errorf("user %d: expected an empty grant set, got %v, %v", id, grants.Effective(), err)
|
||||
}
|
||||
if _, err := w.container.CheckPermissionUseCase.Require(account, "content.read"); !errors.Is(err, role.ErrMissingPermission) {
|
||||
t.Errorf("user %d: expected a missing permission, got %v", id, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIRoleMembers(t *testing.T) {
|
||||
t.Run("members count users and open invites with one bulk call", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
w.roles.calls = map[string]int{}
|
||||
w.users.getAllCalls = 0
|
||||
listed, err := w.container.ListRolesUseCase.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.roles.calls["MemberCounts"] != 1 || w.users.getAllCalls != 0 {
|
||||
t.Errorf("expected one MemberCounts call and no GetAll, got %v and %d", w.roles.calls, w.users.getAllCalls)
|
||||
}
|
||||
want := map[string]int{"Owner": 1, "Admin": 3, "Writer": 2, "Contributor": 6, "Intern": 1, "Content lead": 1, "People manager": 1, "Viewer": 1, "Inviter": 1}
|
||||
for _, view := range listed {
|
||||
if view.Members != want[view.Name] {
|
||||
t.Errorf("%s: expected %d members, got %d", view.Name, want[view.Name], view.Members)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, state := range []string{"pending", "expired"} {
|
||||
t.Run("a role with only a "+state+" invite cannot be deleted, then can be moved and deleted", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
temp := w.createTemp("content.read=own")
|
||||
now := time.Now()
|
||||
expires := now.Add(48 * time.Hour)
|
||||
if state == "expired" {
|
||||
expires = now.Add(-time.Hour)
|
||||
}
|
||||
w.invites.seed(invite.Invite{Email: "temp@renew.example", HashedCode: "hashed:t", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: now.Add(-time.Hour), ExpiresAt: expires})
|
||||
err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp})
|
||||
if !errors.Is(err, role.ErrRoleHasMembers) {
|
||||
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
|
||||
}
|
||||
moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: temp, TargetID: w.id("Intern")})
|
||||
if err != nil || moved != 1 {
|
||||
t.Fatalf("expected one moved invite, got %d, %v", moved, err)
|
||||
}
|
||||
if w.invites.invites[5].RoleID != w.id("Intern") {
|
||||
t.Errorf("the invite must now be for Intern")
|
||||
}
|
||||
last := w.recorder.events[len(w.recorder.events)-1]
|
||||
if last.Action != audit.ActionRoleReassign || last.Params[audit.ParamMoved] != 1 {
|
||||
t.Errorf("unexpected reassign event %+v", last)
|
||||
}
|
||||
if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil {
|
||||
t.Fatalf("expected the delete to pass, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("moved counts users and invites together", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: w.id("Contributor"), TargetID: w.id("Intern")})
|
||||
if err != nil || moved != 6 {
|
||||
t.Fatalf("expected 6, got %d, %v", moved, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an accepted invite does not block the delete", func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
temp := w.createTemp("content.read=own")
|
||||
accepted := time.Now()
|
||||
w.invites.seed(invite.Invite{Email: "done@renew.example", HashedCode: "hashed:d", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: accepted, ExpiresAt: accepted, AcceptedAt: &accepted})
|
||||
if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil {
|
||||
t.Fatalf("expected the delete to pass, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersAPIActivityVocabulary(t *testing.T) {
|
||||
t.Run("the new actions, field and params validate", func(t *testing.T) {
|
||||
for _, action := range []string{audit.ActionUserChangeRole, audit.ActionUserTransferOwnership} {
|
||||
event := audit.NewEvent(action, audit.CategoryUsers)
|
||||
event.Actor = audit.SystemActor()
|
||||
event.Source = audit.SourceWeb
|
||||
event.Changes = append(event.Changes, audit.TextChange(audit.FieldRole, "a", "b"))
|
||||
event.Params[audit.ParamRole] = "Writer"
|
||||
event.Params[audit.ParamRoleID] = uint32(3)
|
||||
if err := event.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", action, err)
|
||||
}
|
||||
}
|
||||
if audit.ActionUserChangeRole != "user.change_role" || audit.ActionUserTransferOwnership != "user.transfer_ownership" || audit.FieldRole != "role" || audit.ParamRole != "role" || audit.ParamRoleID != "role_id" {
|
||||
t.Errorf("unexpected vocabulary")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
uaOwner uint32 = 3
|
||||
uaAdmin uint32 = 5
|
||||
uaWriter uint32 = 9
|
||||
uaOtherAdmin uint32 = 10
|
||||
uaUnverified uint32 = 12
|
||||
uaNoRole uint32 = 13
|
||||
uaGhostRole uint32 = 14
|
||||
uaPeople uint32 = 31
|
||||
uaViewer uint32 = 32
|
||||
uaInviter uint32 = 33
|
||||
uaContributor uint32 = 41
|
||||
uaContentLead uint32 = 45
|
||||
uaIntern uint32 = 46
|
||||
uaUnknownRole uint32 = 999
|
||||
)
|
||||
|
||||
type usersAPIRoleSpec struct {
|
||||
name string
|
||||
grants []string
|
||||
members []uint32
|
||||
}
|
||||
|
||||
var usersAPICustomRoles = []usersAPIRoleSpec{
|
||||
{"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, []uint32{41, 42, 43, 44}},
|
||||
{"Intern", []string{"content.read=own"}, []uint32{uaIntern}},
|
||||
{"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=all"}, []uint32{uaContentLead}},
|
||||
{"People manager", []string{"users.view=all", "users.invite=all", "users.change_role=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all"}, []uint32{uaPeople}},
|
||||
{"Viewer", []string{"users.view=all", "roles.view=all"}, []uint32{uaViewer}},
|
||||
{"Inviter", []string{"users.invite=all"}, []uint32{uaInviter}},
|
||||
}
|
||||
|
||||
type usersAPIWorld struct {
|
||||
*apiKeyEnv
|
||||
recorder *contentScopeRecorder
|
||||
types *mockContentTypeRepo
|
||||
ids map[string]uint32
|
||||
}
|
||||
|
||||
func newUsersAPIWorld(t *testing.T) *usersAPIWorld {
|
||||
t.Helper()
|
||||
recorder := &contentScopeRecorder{}
|
||||
env := newAPIKeyEnvRecording(t, recorder)
|
||||
world := &usersAPIWorld{apiKeyEnv: env, recorder: recorder, types: newMockContentTypeRepo(), ids: map[string]uint32{}}
|
||||
env.users.users = map[uint32]user.User{}
|
||||
env.users.nextID = 100
|
||||
env.users.knownRoles = env.roles
|
||||
env.roles.users = env.users
|
||||
env.roles.invites = env.invites
|
||||
world.seedUsers()
|
||||
world.seedInvites()
|
||||
world.seedContentTypes()
|
||||
world.wire()
|
||||
return world
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) addMember(id uint32, username string, email string, roleID uint32, verified bool) {
|
||||
w.users.users[id] = user.User{ID: id, Username: username, Email: email, IsVerified: verified, RoleID: roleID, Password: "hashed:password1"}
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) seedUsers() {
|
||||
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
|
||||
w.addMember(uaOwner, "marta", "marta@renew.example", ownerRoleID, true)
|
||||
w.addMember(uaAdmin, "jonas", "jonas@renew.example", adminRoleID, true)
|
||||
w.addMember(uaOtherAdmin, "lea", "lea@renew.example", adminRoleID, true)
|
||||
w.addMember(uaUnverified, "sam", "sam@renew.example", adminRoleID, false)
|
||||
w.addMember(uaWriter, "ines", "ines@renew.example", writerRoleID, true)
|
||||
w.addMember(uaNoRole, "zero", "zero@renew.example", 0, true)
|
||||
w.addMember(uaGhostRole, "ghost", "ghost@renew.example", 99, true)
|
||||
for _, spec := range usersAPICustomRoles {
|
||||
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
|
||||
w.ids[spec.name] = created.ID
|
||||
for _, member := range spec.members {
|
||||
w.addMember(member, "user"+strconv.Itoa(int(member)), "user"+strconv.Itoa(int(member))+"@renew.example", created.ID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) seedInvites() {
|
||||
now := time.Now()
|
||||
accepted := now.Add(-24 * time.Hour)
|
||||
w.invites.seed(invite.Invite{Email: "new1@renew.example", HashedCode: "hashed:code1", InvitedBy: uaAdmin, RoleID: writerRoleID, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(48 * time.Hour)})
|
||||
w.invites.seed(invite.Invite{Email: "old2@renew.example", HashedCode: "hashed:code2", InvitedBy: uaAdmin, RoleID: w.ids["Contributor"], CreatedAt: now.Add(-73 * time.Hour), ExpiresAt: now.Add(-time.Hour)})
|
||||
w.invites.seed(invite.Invite{Email: "new3@renew.example", HashedCode: "hashed:code3", InvitedBy: uaAdmin, RoleID: w.ids["Contributor"], CreatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(48 * time.Hour)})
|
||||
w.invites.seed(invite.Invite{Email: "ines@renew.example", HashedCode: "hashed:code4", InvitedBy: uaAdmin, RoleID: writerRoleID, CreatedAt: now.Add(-48 * time.Hour), ExpiresAt: now.Add(24 * time.Hour), AcceptedAt: &accepted})
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) seedContentTypes() {
|
||||
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
|
||||
w.types.types[1] = contenttype.ContentType{ID: 1, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
|
||||
w.types.types[2] = contenttype.ContentType{ID: 2, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
|
||||
w.types.nextID = 10
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) wire() {
|
||||
c := w.container
|
||||
hasher := &stubHasher{}
|
||||
url := "https://cms.example.com"
|
||||
c.ChangeUserRoleUseCase = useCases.NewChangeUserRoleUseCase(w.users, w.roles, w.types, w.recorder)
|
||||
c.TransferOwnershipUseCase = useCases.NewTransferOwnershipUseCase(w.users, w.roles, w.recorder)
|
||||
c.CreateInviteUseCase = useCases.NewCreateInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder)
|
||||
c.RenewInviteUseCase = useCases.NewRenewInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder)
|
||||
c.CancelInviteUseCase = useCases.NewCancelInviteUseCase(w.invites, w.recorder)
|
||||
c.ListUsersAndInvitesUseCase = useCases.NewListUsersAndInvitesUseCase(w.users, w.invites, w.roles)
|
||||
c.AcceptInviteUseCase = useCases.NewAcceptInviteUseCase(w.users, w.invites, w.roles, hasher, w.recorder)
|
||||
c.CreateUserUseCase = useCases.NewCreateUserUseCase(w.users, w.roles, hasher, w.mail, url, w.recorder)
|
||||
c.ListRolesUseCase = useCases.NewListRolesUseCase(w.roles, w.users, w.types)
|
||||
c.GetRoleUseCase = useCases.NewGetRoleUseCase(w.roles, w.users, w.types)
|
||||
c.CreateRoleUseCase = useCases.NewCreateRoleUseCase(w.roles, w.users, w.types, w.recorder)
|
||||
c.DeleteRoleUseCase = useCases.NewDeleteRoleUseCase(w.roles, w.users, w.types, w.recorder)
|
||||
c.ReassignRoleMembersUseCase = useCases.NewReassignRoleMembersUseCase(w.roles, w.users, w.types, w.recorder)
|
||||
w.rebuildRouter()
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) id(name string) uint32 {
|
||||
return w.ids[name]
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) idText(name string) string {
|
||||
return strconv.FormatUint(uint64(w.ids[name]), 10)
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) caller(userID uint32) useCases.ContentCaller {
|
||||
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[w.users.users[userID].RoleID].GrantSet()}
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) putRole(t *testing.T, caller uint32, target string, roleID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return w.do(t, caller, http.MethodPut, "/v1/user/"+target+"/role", `{"role_id": `+roleID+`}`)
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) storedRoleID(userID uint32) uint32 {
|
||||
return w.users.users[userID].RoleID
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) createTemp(grants ...string) uint32 {
|
||||
created, _ := w.roles.Create(role.Role{Name: "Temp", Version: 1, Grants: grantRows(grants...)})
|
||||
return created.ID
|
||||
}
|
||||
|
||||
func usersAPIDecode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &object); err != nil {
|
||||
t.Fatalf("expected a JSON object, got %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func usersAPIRoleRef(t *testing.T, object map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
ref, ok := object["role"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected role to be an object, got %v", object["role"])
|
||||
}
|
||||
apiKeyAssertExactFields(t, ref, "id", "name", "built_in", "locked")
|
||||
return ref
|
||||
}
|
||||
|
||||
func usersAPIEventActions(w *usersAPIWorld) []string {
|
||||
return w.recorder.actions()
|
||||
}
|
||||
Reference in new issue
Block a user