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) } }