feat(users): admin promote/demote endpoints

POST /users/{id}/promote and /demote set a user's role (admin/user), admin-only
(403 otherwise). Mirrors the verify endpoints: SetRole use case + repo method,
validated role. Unit, admin-flow, and integration tests included.
This commit is contained in:
Efremenko Arhip
2026-07-26 21:14:32 +03:00
parent 49eb662798
commit 9a458ec4ef
13 changed files with 167 additions and 0 deletions
+2
View File
@@ -52,6 +52,8 @@ func statusForError(err error) (int, string) {
return http.StatusBadRequest, "password must be at least 8 characters"
case errors.Is(err, usecase.ErrPasswordTooLong):
return http.StatusBadRequest, "password must be at most 72 bytes"
case errors.Is(err, usecase.ErrInvalidRole):
return http.StatusBadRequest, "invalid role"
case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail):
return http.StatusBadRequest, "email is not a valid address"
default:
+22
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
@@ -15,6 +16,7 @@ type Handlers struct {
register *usecase.Register
login *usecase.Login
setVerified *usecase.SetVerified
setRole *usecase.SetRole
users usecase.UserRepository
log *slog.Logger
}
@@ -91,6 +93,26 @@ func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc {
}
}
// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin-
// only; the withAdmin middleware has already enforced the caller's role.
func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid user id",
RequestID: requestIDFrom(r.Context()),
})
return
}
if err := h.setRole.Execute(r.Context(), id, role); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
// It writes a 400 and returns false on any problem, so callers can `if
// !decodeJSON(...) { return }`.
+7
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"github.com/emil28092005/SciMesh/users/internal/auth"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
@@ -16,6 +17,7 @@ type UseCases struct {
Register *usecase.Register
Login *usecase.Login
SetVerified *usecase.SetVerified
SetRole *usecase.SetRole
Users usecase.UserRepository
}
@@ -26,6 +28,7 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
register: uc.Register,
login: uc.Login,
setVerified: uc.SetVerified,
setRole: uc.SetRole,
users: uc.Users,
log: log,
}
@@ -44,6 +47,10 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
chain(h.handleSetVerified(true), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/unverify",
chain(h.handleSetVerified(false), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/promote",
chain(h.handleSetRole(domain.RoleAdmin), withJWT(issuer), withAdmin))
mux.Handle("POST /users/{id}/demote",
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
// Outermost first: every request gets an ID and an access-log line.
return chain(mux, withRequestID, withAccessLog(log))
@@ -34,6 +34,7 @@ func newTestServer() http.Handler {
Register: usecase.NewRegister(users, hasher, clk),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
Users: users,
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -193,6 +194,7 @@ func TestMeInternalError(t *testing.T) {
Register: usecase.NewRegister(users, hasher, clk),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
Users: users,
}
h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer)
@@ -300,6 +302,51 @@ func TestVerifyUnknownUser(t *testing.T) {
}
}
func TestAdminPromotesAndDemotes(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "promote@example.com")
admin := mintToken(t, domain.RoleAdmin)
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("promote: got %d, body %s", rec.Code, rec.Body)
}
// The promoted user now logs in as an admin.
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "promote@example.com", "password": "password123"})
var lr struct {
User struct {
Role string `json:"role"`
} `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &lr)
if lr.User.Role != "admin" {
t.Errorf("role after promote = %q, want admin", lr.User.Role)
}
if rec := do(t, h, http.MethodPost, "/users/"+id+"/demote", admin, nil); rec.Code != http.StatusNoContent {
t.Fatalf("demote: got %d", rec.Code)
}
}
func TestPromoteRequiresAdmin(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "target@example.com")
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
t.Errorf("plain user promote: got %d, want 403", rec.Code)
}
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", "", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("no token: got %d, want 401", rec.Code)
}
}
func TestPromoteUnknownUser(t *testing.T) {
h := newTestServer()
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/promote", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusNotFound {
t.Errorf("unknown user promote: got %d, want 404", rec.Code)
}
}
func TestUnverifyRevokes(t *testing.T) {
h := newTestServer()
id := registerUser(t, h, "revoke@example.com")