647 lines
28 KiB
Go
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", "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",
|
|
}
|