From 9a458ec4ef83650618800519d78e5050b8c3ff03 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:14:32 +0300 Subject: [PATCH] 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. --- users/README.md | 2 + users/cmd/userservice/main.go | 1 + users/internal/memstore/memstore.go | 12 +++++ .../storage/postgres/integration_test.go | 18 +++++++ users/internal/storage/postgres/user_repo.go | 21 +++++++++ users/internal/transport/http/errors.go | 2 + users/internal/transport/http/handlers.go | 22 +++++++++ users/internal/transport/http/server.go | 7 +++ users/internal/transport/http/server_test.go | 47 +++++++++++++++++++ users/internal/usecase/errorpaths_test.go | 3 ++ users/internal/usecase/errors.go | 1 + users/internal/usecase/ports.go | 3 ++ users/internal/usecase/role.go | 28 +++++++++++ 13 files changed, 167 insertions(+) create mode 100644 users/internal/usecase/role.go diff --git a/users/README.md b/users/README.md index 9dd030a..4a7efa8 100644 --- a/users/README.md +++ b/users/README.md @@ -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: diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index 175da99..97f55f4 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -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, } diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go index f22f639..96307e9 100644 --- a/users/internal/memstore/memstore.go +++ b/users/internal/memstore/memstore.go @@ -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 } diff --git a/users/internal/storage/postgres/integration_test.go b/users/internal/storage/postgres/integration_test.go index b229f95..89d37e5 100644 --- a/users/internal/storage/postgres/integration_test.go +++ b/users/internal/storage/postgres/integration_test.go @@ -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) + } +} diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go index 0c4d997..cb786fd 100644 --- a/users/internal/storage/postgres/user_repo.go +++ b/users/internal/storage/postgres/user_repo.go @@ -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 diff --git a/users/internal/transport/http/errors.go b/users/internal/transport/http/errors.go index 5239bba..8ccbe7e 100644 --- a/users/internal/transport/http/errors.go +++ b/users/internal/transport/http/errors.go @@ -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: diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go index 615d6e7..beaca96 100644 --- a/users/internal/transport/http/handlers.go +++ b/users/internal/transport/http/handlers.go @@ -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 }`. diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go index 256ec6f..3a57af7 100644 --- a/users/internal/transport/http/server.go +++ b/users/internal/transport/http/server.go @@ -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)) diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index b4d07a3..f0ee300 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -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") diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go index 92c2467..4fa9a1e 100644 --- a/users/internal/usecase/errorpaths_test.go +++ b/users/internal/usecase/errorpaths_test.go @@ -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 diff --git a/users/internal/usecase/errors.go b/users/internal/usecase/errors.go index 4e8131d..9a0b3e2 100644 --- a/users/internal/usecase/errors.go +++ b/users/internal/usecase/errors.go @@ -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") ) diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go index 738082a..d94bfd1 100644 --- a/users/internal/usecase/ports.go +++ b/users/internal/usecase/ports.go @@ -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. diff --git a/users/internal/usecase/role.go b/users/internal/usecase/role.go new file mode 100644 index 0000000..b756c47 --- /dev/null +++ b/users/internal/usecase/role.go @@ -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) +}