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

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