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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user