feat: roles SPA Users, Profile, API keys, Activity

This commit is contained in:
Florian Sylvain committed 2026-10-28 00:10:15 +01:00
1 parent 3db490a615
commit 34dffe222f
98 files changed
+4957 -434

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
+1 -1
View File
@@ -77,7 +77,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles, e.users),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
+1 -1
View File
@@ -116,7 +116,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles, env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, newSeededMockRoleRepo(), hasher, mail, "https://cms.example.com", recorder),
+1 -1
View File
@@ -59,7 +59,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: start,
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
+1 -1
View File
@@ -115,7 +115,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+1 -1
View File
@@ -496,7 +496,7 @@ func TestInvitationsHttp(t *testing.T) {
if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
} else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
}
}
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
+1 -1
View File
@@ -75,7 +75,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+497
View File
@@ -0,0 +1,497 @@
package test
import (
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
const rabyKey = "role_assigned_by"
func rabySeedAssigner(w *usersAPIWorld, target uint32, assigner uint32) {
member := w.users.users[target]
member.RoleAssignedByID = assigner
w.users.users[target] = member
}
func rabyAssignerOf(t *testing.T, rec *httptest.ResponseRecorder, container string) any {
t.Helper()
body := usersAPIDecode(t, rec)
if container != "" {
body = body[container].(map[string]any)
}
value, present := body[rabyKey]
if !present {
t.Fatalf("the %s key must always be present: %q", rabyKey, rec.Body.String())
}
return value
}
func rabyWant(id float64, name string) map[string]any {
return map[string]any{"id": id, "name": name}
}
func rabyAcceptKim(t *testing.T, w *usersAPIWorld) (user.User, *httptest.ResponseRecorder) {
t.Helper()
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
if rec.Code >= 300 {
t.Fatalf("the accept must succeed, got %d %q", rec.Code, rec.Body.String())
}
for _, member := range w.users.users {
if member.Username == "kim" {
return member, rec
}
}
t.Fatalf("kim was not created")
return user.User{}, rec
}
func TestRoleAssignedByIsSetOnChangeRole(t *testing.T) {
w := newUsersAPIWorld(t)
rec := w.putRole(t, uaAdmin, "41", w.idText("Writer"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if got := rabyAssignerOf(t, rec, ""); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("the answering item must carry the caller, got %v", got)
}
if w.users.users[41].RoleAssignedByID != uaAdmin {
t.Errorf("expected 5 stored, got %d", w.users.users[41].RoleAssignedByID)
}
me := w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if got := rabyAssignerOf(t, me, "access"); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("unexpected access.role_assigned_by %v", got)
}
if rec := w.putRole(t, uaOwner, "41", w.idText("Intern")); rec.Code != http.StatusOK {
t.Fatalf("unexpected %d", rec.Code)
}
if w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("expected 3 stored, got %d", w.users.users[41].RoleAssignedByID)
}
}
func TestRoleAssignedByUnchangedOnNoOp(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaOwner)
w.users.updateRoleCalls = 0
rec := w.putRole(t, uaAdmin, "41", w.idText("Contributor"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.updateRoleCalls != 0 {
t.Errorf("a no-op change must not write")
}
if w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("the assigner must stay 3, got %d", w.users.users[41].RoleAssignedByID)
}
if got := rabyAssignerOf(t, rec, ""); !reflect.DeepEqual(got, rabyWant(float64(uaOwner), "marta")) {
t.Errorf("the item must show the existing assigner, got %v", got)
}
}
func TestRoleAssignedByUnchangedOnRefusal(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
role func(w *usersAPIWorld) string
hook func(w *usersAPIWorld)
status int
}{
{"role_locked", uaAdmin, "3", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusForbidden},
{"own_role", uaAdmin, "5", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusForbidden},
{"escalation", uaPeople, "41", func(w *usersAPIWorld) string { return w.idText("Admin") }, nil, http.StatusForbidden},
{"user_not_found", uaAdmin, "999", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusNotFound},
{"invalid_role", uaAdmin, "41", func(*usersAPIWorld) string { return "9999" }, nil, http.StatusBadRequest},
{"user_role_changed", uaAdmin, "41", func(w *usersAPIWorld) string { return w.idText("Writer") }, func(w *usersAPIWorld) {
w.users.beforeUpdateRole = func() {
member := w.users.users[41]
member.RoleID = w.id("Intern")
w.users.users[41] = member
}
}, http.StatusConflict},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
w := newUsersAPIWorld(t)
for _, id := range []uint32{3, 5, 41} {
rabySeedAssigner(w, id, uaViewer)
}
if row.hook != nil {
row.hook(w)
}
rec := w.putRole(t, row.caller, row.target, row.role(w))
if rec.Code != row.status {
t.Fatalf("expected %d, got %d %q", row.status, rec.Code, rec.Body.String())
}
for _, id := range []uint32{3, 5, 41} {
if w.users.users[id].RoleAssignedByID != uaViewer {
t.Errorf("user %d: the assigner must not change, got %d", id, w.users.users[id].RoleAssignedByID)
}
}
})
}
}
func TestRoleAssignedByAtAcceptInvite(t *testing.T) {
t.Run("the inviter becomes the assigner", func(t *testing.T) {
w := newUsersAPIWorld(t)
kim, rec := rabyAcceptKim(t, w)
if kim.RoleAssignedByID != uaAdmin {
t.Errorf("expected 5, got %d", kim.RoleAssignedByID)
}
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("the accept body must not carry the key: %q", rec.Body.String())
}
})
t.Run("a missing inviter gives NULL and still succeeds", func(t *testing.T) {
w := newUsersAPIWorld(t)
delete(w.users.users, uaAdmin)
kim, _ := rabyAcceptKim(t, w)
if kim.RoleAssignedByID != 0 {
t.Errorf("expected NULL, got %d", kim.RoleAssignedByID)
}
})
}
func TestRoleAssignedByAtTransfer(t *testing.T) {
transfer := func(t *testing.T, w *usersAPIWorld) *httptest.ResponseRecorder {
t.Helper()
return w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"))
}
t.Run("the new owner is assigned by the former owner and the former owner is cleared", func(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, uaOwner, uaViewer)
rabySeedAssigner(w, uaAdmin, uaViewer)
w.users.transferCalls = 0
if rec := transfer(t, w); rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.transferCalls != 1 || w.users.users[uaAdmin].RoleAssignedByID != uaOwner || w.users.users[uaOwner].RoleAssignedByID != 0 {
t.Errorf("unexpected assigners %d and %d after %d transaction(s)", w.users.users[uaAdmin].RoleAssignedByID, w.users.users[uaOwner].RoleAssignedByID, w.users.transferCalls)
}
})
t.Run("a failed or stale transaction changes neither", func(t *testing.T) {
for name, arrange := range map[string]func(w *usersAPIWorld){
"failure": func(w *usersAPIWorld) { w.users.fail.failNext("TransferOwnership") },
"stale from": func(w *usersAPIWorld) {
w.users.beforeTransfer = func() { rabySeedRole(w, uaOwner, w.id("Admin")) }
},
} {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, uaOwner, uaViewer)
rabySeedAssigner(w, uaAdmin, uaViewer)
arrange(w)
if rec := transfer(t, w); rec.Code < 400 {
t.Fatalf("%s: expected an error, got %d", name, rec.Code)
}
if w.users.users[uaAdmin].RoleAssignedByID != uaViewer || w.users.users[uaOwner].RoleAssignedByID != uaViewer {
t.Errorf("%s: no assigner may change", name)
}
}
})
}
func rabySeedRole(w *usersAPIWorld, id uint32, roleID uint32) {
member := w.users.users[id]
member.RoleID = roleID
w.users.users[id] = member
}
func TestRoleAssignedByAtRegister(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 >= 300 || len(w.users.users) != 1 {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
for _, member := range w.users.users {
if member.RoleAssignedByID != 0 {
t.Errorf("the first user has no assigner, got %d", member.RoleAssignedByID)
}
}
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("the register body must not carry the key")
}
}
func TestRoleAssignedByUnchangedOnReassign(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaOwner)
rec := w.do(t, uaAdmin, http.MethodPost, "/v1/role/"+w.idText("Contributor")+"/reassign", `{"target_role_id": `+w.idText("Intern")+`}`)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.users[41].RoleID != w.id("Intern") || w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("41 must move and keep its assigner, got %+v", w.users.users[41])
}
}
func rabyList(t *testing.T, w *usersAPIWorld, caller uint32) []map[string]any {
t.Helper()
rec := w.do(t, caller, http.MethodGet, "/v1/user", "")
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatal(err)
}
return items
}
func TestUserListCarriesRoleAssignedBy(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
items := rabyList(t, w, uaAdmin)
seenUsers, seenInvites := 0, 0
for _, item := range items {
if item["kind"] == "invite" {
seenInvites++
apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at")
continue
}
seenUsers++
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", rabyKey, "status", "created_at")
switch item["id"] {
case float64(41):
if !reflect.DeepEqual(item[rabyKey], rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("user 41 must show its assigner, got %v", item[rabyKey])
}
default:
if item[rabyKey] != nil {
t.Errorf("user %v must show null, got %v", item["id"], item[rabyKey])
}
}
}
if seenUsers == 0 || seenInvites == 0 {
t.Fatalf("expected both kinds, got %d users and %d invites", seenUsers, seenInvites)
}
t.Run("no per-row lookup", func(t *testing.T) {
quiet := newUsersAPIWorld(t)
quiet.users.getCalls = 0
rabyList(t, quiet, uaAdmin)
baseline := quiet.users.getCalls
busy := newUsersAPIWorld(t)
for id := range busy.users.users {
rabySeedAssigner(busy, id, uaAdmin)
}
busy.users.getCalls = 0
rabyList(t, busy, uaAdmin)
if busy.users.getCalls != baseline {
t.Errorf("expected %d Get calls like the list without assigners, got %d", baseline, busy.users.getCalls)
}
})
t.Run("a vanished assigner is null", func(t *testing.T) {
gone := newUsersAPIWorld(t)
rabySeedAssigner(gone, 41, 777)
for _, item := range rabyList(t, gone, uaAdmin) {
if item["id"] == float64(41) && item[rabyKey] != nil {
t.Errorf("expected null, got %v", item[rabyKey])
}
}
})
}
func TestRoleAssignedByFollowsRename(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
renamed := w.users.users[uaAdmin]
renamed.DisplayName = "Jonas W."
w.users.users[uaAdmin] = renamed
me := w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if got := rabyAssignerOf(t, me, "access"); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "Jonas W.")) {
t.Errorf("expected the new name, got %v", got)
}
before := usersAPIDecode(t, me)
delete(w.users.users, uaAdmin)
me = w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if me.Code != http.StatusOK {
t.Fatalf("unexpected %d", me.Code)
}
if got := rabyAssignerOf(t, me, "access"); got != nil {
t.Errorf("a removed assigner must read null, got %v", got)
}
after := usersAPIDecode(t, me)
if !reflect.DeepEqual(before["access"].(map[string]any)["role"], after["access"].(map[string]any)["role"]) {
t.Errorf("nothing else about the response may change")
}
}
func TestAuthMeKeepsOtherShapes(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
keys := func(value any) []string {
raw, _ := json.Marshal(value)
var object map[string]any
_ = json.Unmarshal(raw, &object)
names := []string{}
for key := range object {
names = append(names, key)
}
return names
}
for _, name := range keys(publicuser.New(w.users.users[41])) {
if name == rabyKey {
t.Errorf("PublicUser must not carry %s", rabyKey)
}
}
login := w.anonymous(t, http.MethodPost, "/v1/auth/login", `{"username": "user41", "password": "password1"}`)
verify := w.anonymous(t, http.MethodPost, "/v1/auth/verify", `{"code": "x"}`)
for name, rec := range map[string]*httptest.ResponseRecorder{"login": login, "verify": verify} {
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("%s must not carry the key: %q", name, rec.Body.String())
}
}
me := usersAPIDecode(t, w.do(t, 41, http.MethodGet, "/v1/auth/me", ""))
if _, leaked := me[rabyKey]; leaked {
t.Errorf("the key lives under access only")
}
}
func TestRoleAssignedByNoNewGate(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
for _, item := range rabyList(t, w, uaViewer) {
if item["kind"] == "user" {
if _, present := item[rabyKey]; !present {
t.Errorf("a users.view caller must read the key")
}
}
}
for _, caller := range []uint32{uaWriter, uaInviter, 41} {
me := w.do(t, caller, http.MethodGet, "/v1/auth/me", "")
if me.Code != http.StatusOK {
t.Fatalf("user %d: unexpected %d", caller, me.Code)
}
rabyAssignerOf(t, me, "access")
}
for _, path := range []string{"/v1/user", "/v1/auth/me"} {
if rec := w.anonymous(t, http.MethodGet, path, ""); rec.Code != http.StatusUnauthorized {
t.Errorf("%s: expected 401, got %d", path, rec.Code)
}
}
}
func TestRoleAssignedByPersistence(t *testing.T) {
t.Run("UpdateRole writes the assigner with the compare-and-swap", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
admin, writer := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
updated, err := repo.UpdateRole(5, admin, writer, 3)
if err != nil || updated.RoleAssignedByID != 3 || userRow(t, db, 5).RoleAssignedByID == nil || *userRow(t, db, 5).RoleAssignedByID != 3 {
t.Fatalf("unexpected %+v, %v", updated, err)
}
if _, err := repo.UpdateRole(5, admin, writer, 9); err == nil || *userRow(t, db, 5).RoleAssignedByID != 3 {
t.Errorf("a stale swap must change nothing, got %v", err)
}
if _, err := repo.UpdateRole(5, writer, admin, 0); err != nil || userRow(t, db, 5).RoleAssignedByID != nil {
t.Errorf("a zero assigner must store NULL, got %v", err)
}
})
t.Run("TransferOwnership sets and clears the assigners in one transaction", 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, uaRoleIDPtr(owner), true)
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
uaPersistedUser(t, db, 12, uaRoleIDPtr(admin), false)
db.Exec("UPDATE users SET role_assigned_by_id = 12 WHERE id = 3")
if err := repo.TransferOwnership(3, 12, owner, admin); err == nil {
t.Fatal("an unverified successor must be refused")
}
if row := userRow(t, db, 3); row.RoleAssignedByID == nil || *row.RoleAssignedByID != 12 || roleIDOf(row) != owner {
t.Errorf("a refused transfer must change nothing, got %+v", row)
}
if err := repo.TransferOwnership(3, 5, owner, admin); err != nil {
t.Fatal(err)
}
if row := userRow(t, db, 3); row.RoleAssignedByID != nil {
t.Errorf("the former owner must read NULL")
}
if row := userRow(t, db, 5); row.RoleAssignedByID == nil || *row.RoleAssignedByID != 3 {
t.Errorf("the new owner must be assigned by 3")
}
})
t.Run("AcceptWithUser stores the assigner and Create leaves NULL", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin := storedRole(t, db, "Admin").ID
row := uaPersistedInvite(t, db, "kim@x.example", uaRoleIDPtr(admin), false, false)
created, err := persistence.NewInviteRepository(db).AcceptWithUser(row.ID, time.Now(), user.User{Username: "kim", Email: "kim@x.example", Password: "x", IsVerified: true, RoleID: admin, RoleAssignedByID: 5})
if err != nil || created.RoleAssignedByID != 5 || *userRow(t, db, created.ID).RoleAssignedByID != 5 {
t.Fatalf("unexpected %+v, %v", created, err)
}
bare, err := persistence.NewUserRepository(db).Create(user.User{Username: "b", Email: "b@x.example", Password: "x"})
if err != nil || userRow(t, db, bare.ID).RoleAssignedByID != nil {
t.Errorf("Create must leave NULL, got %v", err)
}
})
t.Run("a bulk reassign leaves the assigner", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin, writer := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
db.Exec("UPDATE users SET role_assigned_by_id = 3 WHERE id = 5")
if _, err := persistence.NewRoleRepository(db).ReassignMembers(admin, writer); err != nil {
t.Fatal(err)
}
if row := userRow(t, db, 5); roleIDOf(row) != writer || row.RoleAssignedByID == nil || *row.RoleAssignedByID != 3 {
t.Errorf("unexpected %+v", row)
}
})
}
func TestRoleAssignedByAcceptFailsOnInviterLookupError(t *testing.T) {
w := newUsersAPIWorld(t)
w.users.fail.failNextWith("Get", errors.New("inviter lookup failed"))
before := len(w.users.users)
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
if rec.Code < 500 {
t.Fatalf("expected a failure, got %d %q", rec.Code, rec.Body.String())
}
if len(w.users.users) != before {
t.Errorf("no user may be created")
}
if w.invites.invites[3].AcceptedAt != nil {
t.Errorf("the invite must stay unaccepted")
}
}
func TestRoleAssignedByChangeRoleAuditOfDanglingRole(t *testing.T) {
w := newUsersAPIWorld(t)
member := w.users.users[41]
member.RoleID = 9999
w.users.users[41] = member
rec := w.putRole(t, uaAdmin, "41", w.idText("Writer"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
event := w.recorder.events[len(w.recorder.events)-1]
if _, present := event.Params[audit.ParamFrom]; present {
t.Errorf("a role that was not held leaves from absent, got %v", event.Params)
}
if event.Params[audit.ParamTo] != w.ids["Writer"] {
t.Errorf("to = %v", event.Params[audit.ParamTo])
}
if len(event.Changes) != 0 {
t.Errorf("no blank role change expected, got %v", event.Changes)
}
}
+2 -2
View File
@@ -864,7 +864,7 @@ func TestContentTypeTypedGrantCleanup(t *testing.T) {
func TestGetMyAccessTypedGrants(t *testing.T) {
t.Run("the events editor lists typed entries including none, in order", func(t *testing.T) {
world := newRolesAPIWorld(t)
access, err := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[raEventsEditor])
access, err := useCases.NewGetMyAccessUseCase(world.roles, world.users).Get(world.users.users[raEventsEditor])
if err != nil {
t.Fatal(err)
}
@@ -878,7 +878,7 @@ func TestGetMyAccessTypedGrants(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, hand.ID, true)
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
access, _ := useCases.NewGetMyAccessUseCase(world.roles, world.users).Get(world.users.users[60])
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
t.Fatalf("unexpected permissions %v", access.Permissions)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
roles := newSeededMockRoleRepo()
counting := &contentScopeRoleRepo{Repository: roles}
custom, _ := roles.Create(role.Role{Name: "Messy", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all", "schema.view=own")})
access, err := useCases.NewGetMyAccessUseCase(counting).Get(user.User{ID: 30, RoleID: custom.ID})
access, err := useCases.NewGetMyAccessUseCase(counting, newMockUserRepo()).Get(user.User{ID: 30, RoleID: custom.ID})
if err != nil {
t.Fatal(err)
}
@@ -130,7 +130,7 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
})
t.Run("The Owner holds the 34 keys", func(t *testing.T) {
access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID})
access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID})
if err != nil || len(access.Permissions) != 34 {
t.Errorf("expected 34 permissions, got %d (%v)", len(access.Permissions), err)
}
+1 -1
View File
@@ -215,7 +215,7 @@ func (w *contentScopeWorld) wire() {
c := w.container
c.GetUserUseCase = useCases.NewGetUserUseCase(w.userRepo)
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
+2 -2
View File
@@ -352,14 +352,14 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) {
func TestGetMyAccessUseCase(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())
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo())
if _, err := uc.Get(user.User{ID: 11}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo())
if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
+1 -1
View File
@@ -138,7 +138,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
+1 -1
View File
@@ -177,7 +177,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
}
+5
View File
@@ -192,6 +192,11 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
}
for _, row := range after.rows["users"] {
delete(row, "role_id")
assignedBy, columnAdded := row["role_assigned_by_id"]
if !columnAdded || assignedBy != nil {
t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row)
}
delete(row, "role_assigned_by_id")
}
for _, row := range after.rows["invites"] {
if row["role_id"] == nil {
+1 -1
View File
@@ -40,7 +40,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: rig.start,
+1 -1
View File
@@ -65,7 +65,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
}
+1 -1
View File
@@ -60,7 +60,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
+1 -1
View File
@@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
handler := userhandler.NewHandler(update)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
+2 -2
View File
@@ -56,7 +56,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
inviteHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
nil,
@@ -250,7 +250,7 @@ func TestUserRolesHttp(t *testing.T) {
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
continue
}
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
want := wantRoles[decodedString(t, item, "username")]
var ref struct {
Name string `json:"name"`
+6 -1
View File
@@ -49,6 +49,7 @@ type mockUserRepo struct {
updateDisplayNameCalls int
lastUpdateDisplayName string
getAllCalls int
getCalls int
updateRoleCalls int
transferCalls int
beforeUpdateRole func()
@@ -77,6 +78,7 @@ func (m *mockUserRepo) sortedIds() []uint32 {
}
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
m.getCalls++
if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err
}
@@ -271,7 +273,7 @@ func (m *mockUserRepo) roleExists(id uint32) bool {
return present
}
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User, error) {
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (user.User, error) {
m.updateRoleCalls++
if err := m.fail.trigger("UpdateRole"); err != nil {
return user.User{}, err
@@ -292,6 +294,7 @@ func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User,
return user.User{}, user.ErrRoleChanged
}
stored.RoleID = to
stored.RoleAssignedByID = assignedBy
m.users[id] = stored
return stored, nil
}
@@ -315,7 +318,9 @@ func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uin
return user.ErrInvalidTransfer
}
former.RoleID = formerRoleID
former.RoleAssignedByID = 0
successor.RoleID = ownerRoleID
successor.RoleAssignedByID = from
m.users[from] = former
m.users[to] = successor
return nil
+10 -2
View File
@@ -170,7 +170,7 @@ func TestUsersAPIChangeRoleHTTP(t *testing.T) {
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")
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "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)
}
@@ -341,6 +341,9 @@ func TestUsersAPIInviteHTTP(t *testing.T) {
default:
assertRefusal(t, rec, row.status, row.code)
}
if got := rec.Header().Get(apperr.ErrorCodeHeader); got != inviteConflictCodes[row.code] {
t.Errorf("%s = %q, expected %q", apperr.ErrorCodeHeader, got, inviteConflictCodes[row.code])
}
if len(w.mail.SentEmails) != 0 || len(w.invites.invites) != 4 {
t.Errorf("a refusal creates no invite and sends no mail")
}
@@ -565,7 +568,7 @@ func TestUsersAPIListHTTP(t *testing.T) {
seen[key] = true
switch kind {
case "user":
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
default:
apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at")
}
@@ -636,3 +639,8 @@ func TestUsersAPIPublicUserShape(t *testing.T) {
}
})
}
var inviteConflictCodes = map[string]string{
apperr.PendingInviteErrorMessage: "invite_pending",
apperr.EmailHasAccountErrorMessage: "email_has_account",
}
+6 -6
View File
@@ -62,20 +62,20 @@ func TestUsersAPIUserRepository(t *testing.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)
updated, err := repo.UpdateRole(5, admin, writer, 0)
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) {
if _, err := repo.UpdateRole(5, admin, owner, 0); !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) {
if _, err := repo.UpdateRole(999, admin, writer, 0); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
if _, err := repo.UpdateRole(5, writer, 999); !errors.Is(err, role.ErrRoleNotFound) {
if _, err := repo.UpdateRole(5, writer, 999, 0); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != writer {
@@ -88,7 +88,7 @@ func TestUsersAPIUserRepository(t *testing.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 {
if _, err := repo.UpdateRole(5, 0, writer, 0); err != nil || roleIDOf(userRow(t, db, 5)) != writer {
t.Fatalf("unexpected %v", err)
}
})
@@ -98,7 +98,7 @@ func TestUsersAPIUserRepository(t *testing.T) {
repo := persistence.NewUserRepository(db)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if _, err := repo.UpdateRole(5, 1, 2); err == nil {
if _, err := repo.UpdateRole(5, 1, 2, 0); err == nil {
t.Errorf("expected an error")
}
})
+1
View File
@@ -113,6 +113,7 @@ func (w *usersAPIWorld) wire() {
hasher := &stubHasher{}
url := "https://cms.example.com"
c.ChangeUserRoleUseCase = useCases.NewChangeUserRoleUseCase(w.users, w.roles, w.types, w.recorder)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roles, w.users)
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)