Files
RenewCMS/test/users_api_http_test.go
T

647 lines
28 KiB
Go

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", "pending_email", "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",
}