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
@@ -27,6 +27,8 @@ layers, dependencies pointing strictly inward:
| GET | `/me` | Bearer JWT | Return the caller's own account |
| POST | `/users/{id}/verify` | Bearer admin | Grant the trusted-contributor badge |
| POST | `/users/{id}/unverify` | Bearer admin | Revoke the badge |
| POST | `/users/{id}/promote` | Bearer admin | Set the user's role to admin |
| POST | `/users/{id}/demote` | Bearer admin | Set the user's role back to user |
Two independent attributes live on an account:
+1
View File
@@ -57,6 +57,7 @@ func run() error {
Register: usecase.NewRegister(users, hasher, clock),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
Users: users,
}
+12
View File
@@ -72,6 +72,18 @@ func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) e
return nil
}
func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return usecase.ErrUserNotFound
}
u.Role = role
r.byID[id] = u
return nil
}
// Clock is a fixed usecase.Clock for deterministic tests.
type Clock struct{ T time.Time }
@@ -145,3 +145,21 @@ func TestUserRepoSetVerifiedUnknown(t *testing.T) {
t.Errorf("got %v, want ErrUserNotFound", err)
}
}
func TestUserRepoSetRole(t *testing.T) {
repo := NewUserRepo(testPool(t))
ctx := context.Background()
u := seedUser(t, repo)
if err := repo.SetRole(ctx, u.ID, domain.RoleAdmin); err != nil {
t.Fatalf("promote: %v", err)
}
got, _ := repo.GetByID(ctx, u.ID)
if got.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", got.Role)
}
if err := repo.SetRole(ctx, uuid.New(), domain.RoleAdmin); !errors.Is(err, usecase.ErrUserNotFound) {
t.Errorf("unknown user: got %v, want ErrUserNotFound", err)
}
}
@@ -85,6 +85,27 @@ func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool)
return nil
}
// SetRole changes a user's role and returns ErrUserNotFound when the id matches
// no row.
func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error {
sql, args, err := psql.Update("users").
Set("role", string(role)).
Set("updated_at", sq.Expr("now()")).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return usecase.ErrUserNotFound
}
return nil
}
func scanUser(row pgx.Row) (*domain.User, error) {
var (
u domain.User
+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")
@@ -32,6 +32,9 @@ func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
return usecase.ErrUserNotFound
}
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
return usecase.ErrUserNotFound
}
type stubHasher struct {
hashErr error
+1
View File
@@ -15,4 +15,5 @@ var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrPasswordTooShort = errors.New("password too short")
ErrPasswordTooLong = errors.New("password too long")
ErrInvalidRole = errors.New("invalid role")
)
+3
View File
@@ -25,6 +25,9 @@ type UserRepository interface {
// SetVerified toggles the verified flag, returning ErrUserNotFound if no
// such user exists.
SetVerified(ctx context.Context, id uuid.UUID, verified bool) error
// SetRole changes a user's role, returning ErrUserNotFound if no such user
// exists.
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
}
// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it.
+28
View File
@@ -0,0 +1,28 @@
package usecase
import (
"context"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/domain"
)
// SetRole promotes or demotes a user. Only an admin may call this (enforced in
// the transport layer); the use case validates the target role and applies it.
type SetRole struct {
users UserRepository
}
func NewSetRole(users UserRepository) *SetRole {
return &SetRole{users: users}
}
// Execute assigns role to the user, returning ErrInvalidRole for an unknown role
// or ErrUserNotFound if the user does not exist.
func (uc *SetRole) Execute(ctx context.Context, id uuid.UUID, role domain.Role) error {
if !role.Valid() {
return ErrInvalidRole
}
return uc.users.SetRole(ctx, id, role)
}