feat: Users API, ownership transfer and removal of the legacy role string

This commit is contained in:
Florian Sylvain committed 2026-10-27 03:08:15 +01:00
1 parent 094df63a7f
commit 80f7037d3a
124 files changed
+4644 -824

No files matched your search

+2 -1
View File
@@ -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)
+6 -6
View File
@@ -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)
}
+4 -4
View File
@@ -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{}
+2 -2
View File
@@ -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) {
+7 -7
View File
@@ -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")
+3 -3
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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})
+4 -4
View File
@@ -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
}
+4 -5
View File
@@ -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}
}
+1 -1
View File
@@ -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)
}
+5 -5
View File
@@ -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),
+6 -6
View File
@@ -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
View File
@@ -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,
)
+4 -4
View File
@@ -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
View File
@@ -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)
}
+24 -24
View File
@@ -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,
)
+1 -1
View File
@@ -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,
)
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
+4 -4
View File
@@ -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
}
+6 -3
View File
@@ -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)
+9 -5
View File
@@ -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")
}
})
}
+1 -1
View File
@@ -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,
)
+9 -5
View File
@@ -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")
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
+10 -5
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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 {
+1 -2
View File
@@ -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)
+7 -12
View File
@@ -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 {
+9 -6
View File
@@ -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)
}
})
+3 -3
View File
@@ -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)
}
})
+6 -6
View File
@@ -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)
}
}
+16 -17
View File
@@ -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)
})
}
+4 -6
View File
@@ -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)
}
})
+21 -26
View File
@@ -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())
+13 -76
View File
@@ -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)
}
})
}
+2 -2
View File
@@ -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),
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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{
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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)
})
+8 -2
View File
@@ -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
}
+1 -1
View File
@@ -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,
)
+5 -5
View File
@@ -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
}
+2 -2
View File
@@ -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",
+5 -5
View File
@@ -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" {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+152
View File
@@ -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)
}
}
}
}
}
})
}
+311
View File
@@ -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")
}
})
}
+638
View File
@@ -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))
}
})
}
+420
View File
@@ -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)
}
})
}
+39
View File
@@ -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")
}
})
}
+715
View File
@@ -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")
}
})
}
+183
View File
@@ -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()
}