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", "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) } 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 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") } 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", "role_assigned_by", "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)) } }) } var inviteConflictCodes = map[string]string{ apperr.PendingInviteErrorMessage: "invite_pending", apperr.EmailHasAccountErrorMessage: "email_has_account", }