From c6a66747eb945f7a635ceb9550c49b0ac6dbb2e0 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:10:01 +0300 Subject: [PATCH] feat(users): add admin-granted verified badge for trusted contributors - migration 0002: users.verified boolean, default false - verified rides in the JWT (role + verified claims) - POST /users/{id}/verify + /unverify, admin-only (403 otherwise) - Issue now takes the whole user so trust claims travel in the token - unit + integration + admin-flow tests --- users/README.md | 30 +++-- users/cmd/userservice/main.go | 7 +- users/internal/auth/jwt.go | 21 +-- users/internal/auth/jwt_test.go | 9 +- users/internal/domain/user.go | 8 +- users/internal/memstore/memstore.go | 12 ++ .../storage/postgres/integration_test.go | 38 ++++++ users/internal/storage/postgres/user_repo.go | 27 +++- users/internal/transport/http/dto.go | 2 + users/internal/transport/http/handlers.go | 32 ++++- users/internal/transport/http/middleware.go | 16 +++ users/internal/transport/http/server.go | 25 ++-- users/internal/transport/http/server_test.go | 127 +++++++++++++++++- users/internal/usecase/errorpaths_test.go | 5 +- users/internal/usecase/login.go | 2 +- users/internal/usecase/ports.go | 8 +- users/internal/usecase/verify.go | 24 ++++ users/internal/usecase/verify_test.go | 73 ++++++++++ users/migrations/0002_user_verified.down.sql | 5 + users/migrations/0002_user_verified.up.sql | 9 ++ 20 files changed, 429 insertions(+), 51 deletions(-) create mode 100644 users/internal/usecase/verify.go create mode 100644 users/internal/usecase/verify_test.go create mode 100644 users/migrations/0002_user_verified.down.sql create mode 100644 users/migrations/0002_user_verified.up.sql diff --git a/users/README.md b/users/README.md index 63b6587..9dd030a 100644 --- a/users/README.md +++ b/users/README.md @@ -19,16 +19,28 @@ layers, dependencies pointing strictly inward: ## Endpoints -| Method | Path | Auth | Purpose | -|--------|-------------|-------------|------------------------------------------| -| GET | `/health` | none | Liveness probe (checks the database) | -| POST | `/register` | none | Create an account (always role `user`) | -| POST | `/login` | none | Verify credentials, return a signed JWT | -| GET | `/me` | Bearer JWT | Return the caller's own account | +| Method | Path | Auth | Purpose | +|--------|---------------------------|--------------|---------------------------------------------| +| GET | `/health` | none | Liveness probe (checks the database) | +| POST | `/register` | none | Create an account (always role `user`) | +| POST | `/login` | none | Verify credentials, return a signed JWT | +| 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 | -Roles are `user` and `admin`. Registration always creates a `user`; promotion to -`admin` is a manual database operation, never a request. The role→permission -mapping lives in the coordinator's authorization checks, not in a table. +Two independent attributes live on an account: + +- **`role`** — `user` or `admin`. Governs what you may do with your own jobs. + Registration always creates a `user`; promotion to `admin` is a manual + database operation, never a request. +- **`verified`** — a boolean trust badge, granted **only by an admin** (the + `/verify` endpoints above, 403 for anyone else). It tells the coordinator + whether this user's volunteer workers are trusted: a verified contributor's + results are accepted directly, an unverified one's must pass quorum + cross-checking. Defaults to false. + +Both attributes ride in the JWT (`role`, `verified` claims), so the coordinator +reads them from the signed token without ever calling this service. ## How it connects to the coordinator diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index fbb0095..6b11926 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -54,9 +54,10 @@ func run() error { issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now) uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clock), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clock), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } handler := apihttp.NewServer(log, uc, issuer) diff --git a/users/internal/auth/jwt.go b/users/internal/auth/jwt.go index 1c029c5..ae02f26 100644 --- a/users/internal/auth/jwt.go +++ b/users/internal/auth/jwt.go @@ -5,17 +5,19 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/emil28092005/SciMesh/users/internal/domain" ) // Claims is the payload of a signed token. Subject (from RegisteredClaims) is // the user id — it becomes the coordinator's jobs.owner_id; Role drives -// authorization. Both services verify this token locally with the shared HS256 -// secret, so no runtime call back to the userservice is ever needed. +// authorization; Verified tells the coordinator whether this user's workers are +// trusted (results accepted without quorum). Both services verify this token +// locally with the shared HS256 secret, so no runtime call back to the +// userservice is ever needed. type Claims struct { - Role domain.Role `json:"role"` + Role domain.Role `json:"role"` + Verified bool `json:"verified"` jwt.RegisteredClaims } @@ -35,13 +37,16 @@ func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer { return Issuer{secret: []byte(secret), ttl: ttl, now: now} } -// Issue returns a signed token for the user, valid for the configured TTL. -func (i Issuer) Issue(userID uuid.UUID, role domain.Role) (string, error) { +// Issue returns a signed token for the user, valid for the configured TTL. It +// takes the whole user so every trust-bearing field (role, verified) travels in +// the token, keeping the two services from needing a runtime lookup. +func (i Issuer) Issue(u *domain.User) (string, error) { now := i.now() claims := Claims{ - Role: role, + Role: u.Role, + Verified: u.Verified, RegisteredClaims: jwt.RegisteredClaims{ - Subject: userID.String(), + Subject: u.ID.String(), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), }, diff --git a/users/internal/auth/jwt_test.go b/users/internal/auth/jwt_test.go index c3d14b1..a01fa7a 100644 --- a/users/internal/auth/jwt_test.go +++ b/users/internal/auth/jwt_test.go @@ -16,7 +16,7 @@ func TestIssueVerifyRoundTrip(t *testing.T) { iss := NewIssuer(testSecret, time.Hour, nil) id := uuid.New() - token, err := iss.Issue(id, domain.RoleAdmin) + token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true}) if err != nil { t.Fatalf("issue: %v", err) } @@ -31,12 +31,15 @@ func TestIssueVerifyRoundTrip(t *testing.T) { if claims.Role != domain.RoleAdmin { t.Errorf("role = %q, want admin", claims.Role) } + if !claims.Verified { + t.Error("verified claim not carried in token") + } } func TestVerifyRejectsExpired(t *testing.T) { // Negative TTL: the token is already expired when issued. iss := NewIssuer(testSecret, -time.Minute, nil) - token, _ := iss.Issue(uuid.New(), domain.RoleUser) + token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) if _, err := iss.Verify(token); err == nil { t.Error("expired token accepted") @@ -44,7 +47,7 @@ func TestVerifyRejectsExpired(t *testing.T) { } func TestVerifyRejectsWrongSecret(t *testing.T) { - token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(uuid.New(), domain.RoleUser) + token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil) if _, err := other.Verify(token); err == nil { diff --git a/users/internal/domain/user.go b/users/internal/domain/user.go index 45db392..77ca642 100644 --- a/users/internal/domain/user.go +++ b/users/internal/domain/user.go @@ -29,8 +29,12 @@ type User struct { Email string PasswordHash string Role Role - CreatedAt time.Time - UpdatedAt time.Time + // Verified marks a trusted contributor whose workers' results the + // coordinator accepts without quorum. Distinct from Role; granted by an + // admin, defaults to false. + Verified bool + CreatedAt time.Time + UpdatedAt time.Time } // NewUser builds a freshly registered account. It normalises the email and diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go index 8ca2f41..f22f639 100644 --- a/users/internal/memstore/memstore.go +++ b/users/internal/memstore/memstore.go @@ -60,6 +60,18 @@ func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error return &u, nil } +func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) error { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[id] + if !ok { + return usecase.ErrUserNotFound + } + u.Verified = verified + 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 d60e27b..b229f95 100644 --- a/users/internal/storage/postgres/integration_test.go +++ b/users/internal/storage/postgres/integration_test.go @@ -107,3 +107,41 @@ func TestUserRepoNotFound(t *testing.T) { t.Errorf("GetByEmail unknown: got %v, want ErrUserNotFound", err) } } + +func TestUserRepoSetVerified(t *testing.T) { + repo := NewUserRepo(testPool(t)) + ctx := context.Background() + u := seedUser(t, repo) + + // A fresh row defaults to unverified. + got, err := repo.GetByID(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if got.Verified { + t.Fatal("new user must default to unverified") + } + + if err := repo.SetVerified(ctx, u.ID, true); err != nil { + t.Fatalf("grant: %v", err) + } + got, _ = repo.GetByID(ctx, u.ID) + if !got.Verified { + t.Error("verified flag not persisted") + } + + if err := repo.SetVerified(ctx, u.ID, false); err != nil { + t.Fatalf("revoke: %v", err) + } + got, _ = repo.GetByID(ctx, u.ID) + if got.Verified { + t.Error("verified flag not cleared") + } +} + +func TestUserRepoSetVerifiedUnknown(t *testing.T) { + repo := NewUserRepo(testPool(t)) + if err := repo.SetVerified(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("got %v, want ErrUserNotFound", err) + } +} diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go index b170fcf..0c4d997 100644 --- a/users/internal/storage/postgres/user_repo.go +++ b/users/internal/storage/postgres/user_repo.go @@ -17,7 +17,7 @@ import ( // uniqueViolation is PostgreSQL's SQLSTATE for a unique-constraint breach. const uniqueViolation = "23505" -var userColumns = []string{"id", "email", "password_hash", "role", "created_at", "updated_at"} +var userColumns = []string{"id", "email", "password_hash", "role", "verified", "created_at", "updated_at"} // UserRepo implements usecase.UserRepository on PostgreSQL. type UserRepo struct { @@ -31,7 +31,7 @@ func NewUserRepo(pool *pgxpool.Pool) *UserRepo { func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error { sql, args, err := psql.Insert("users"). Columns(userColumns...). - Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.CreatedAt, u.UpdatedAt). + Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.Verified, u.CreatedAt, u.UpdatedAt). ToSql() if err != nil { return err @@ -64,12 +64,33 @@ func (r *UserRepo) getBy(ctx context.Context, pred sq.Sqlizer) (*domain.User, er return scanUser(conn(ctx, r.pool).QueryRow(ctx, sql, args...)) } +// SetVerified flips the verified flag and returns ErrUserNotFound when the id +// matches no row (so an admin verifying a deleted user gets a clean 404). +func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error { + sql, args, err := psql.Update("users"). + Set("verified", verified). + 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 role string ) - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.CreatedAt, &u.UpdatedAt); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.Verified, &u.CreatedAt, &u.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, usecase.ErrUserNotFound } diff --git a/users/internal/transport/http/dto.go b/users/internal/transport/http/dto.go index 51b05e5..808ee3d 100644 --- a/users/internal/transport/http/dto.go +++ b/users/internal/transport/http/dto.go @@ -23,6 +23,7 @@ type userResponse struct { ID string `json:"id"` Email string `json:"email"` Role string `json:"role"` + Verified bool `json:"verified"` CreatedAt string `json:"created_at"` } @@ -36,6 +37,7 @@ func toUserResponse(u *domain.User) userResponse { ID: u.ID.String(), Email: u.Email, Role: string(u.Role), + Verified: u.Verified, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), } } diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go index 055d609..615d6e7 100644 --- a/users/internal/transport/http/handlers.go +++ b/users/internal/transport/http/handlers.go @@ -5,15 +5,18 @@ import ( "log/slog" "net/http" + "github.com/google/uuid" + "github.com/emil28092005/SciMesh/users/internal/usecase" ) // Handlers holds the use cases each endpoint drives. type Handlers struct { - register *usecase.Register - login *usecase.Login - users usecase.UserRepository - log *slog.Logger + register *usecase.Register + login *usecase.Login + setVerified *usecase.SetVerified + users usecase.UserRepository + log *slog.Logger } // handleHealth is an unauthenticated liveness probe for the container and load @@ -67,6 +70,27 @@ func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toUserResponse(u)) } +// handleSetVerified grants (verified=true) or revokes (false) the trusted- +// contributor badge for the user in the path. Admin-only; the withAdmin +// middleware has already enforced the role by the time this runs. +func (h *Handlers) handleSetVerified(verified bool) 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.setVerified.Execute(r.Context(), id, verified); 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/middleware.go b/users/internal/transport/http/middleware.go index 4d6ef66..266b1b6 100644 --- a/users/internal/transport/http/middleware.go +++ b/users/internal/transport/http/middleware.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" ) type ctxKey string @@ -94,6 +95,21 @@ func userIDFrom(ctx context.Context) (uuid.UUID, bool) { return id, ok } +// withAdmin rejects any caller whose token role is not admin. It must sit inside +// withJWT, which stamps the role after verifying the token. +func withAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "admin role required", + RequestID: requestIDFrom(r.Context()), + }) + return + } + next.ServeHTTP(w, r) + }) +} + // statusRecorder captures the status code for the access log. type statusRecorder struct { http.ResponseWriter diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go index 58a2a67..256ec6f 100644 --- a/users/internal/transport/http/server.go +++ b/users/internal/transport/http/server.go @@ -13,19 +13,21 @@ import ( // UseCases bundles the application services the handlers drive. type UseCases struct { - Register *usecase.Register - Login *usecase.Login - Users usecase.UserRepository + Register *usecase.Register + Login *usecase.Login + SetVerified *usecase.SetVerified + Users usecase.UserRepository } // NewServer wires the routes and the middleware stack and returns the handler. -// The issuer verifies tokens for the protected /me route. +// The issuer verifies tokens for the JWT-protected routes. func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { h := &Handlers{ - register: uc.Register, - login: uc.Login, - users: uc.Users, - log: log, + register: uc.Register, + login: uc.Login, + setVerified: uc.SetVerified, + users: uc.Users, + log: log, } mux := http.NewServeMux() @@ -36,6 +38,13 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { // /me proves a token round-trips; it sits behind JWT auth. mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer))) + // Admin-only: grant or revoke the trusted-contributor badge. withAdmin sits + // inside withJWT so the role is available from the verified token. + mux.Handle("POST /users/{id}/verify", + chain(h.handleSetVerified(true), withJWT(issuer), withAdmin)) + mux.Handle("POST /users/{id}/unverify", + chain(h.handleSetVerified(false), 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 2ec04fa..b4d07a3 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -31,9 +31,10 @@ func newTestServer() http.Handler { issuer := auth.NewIssuer(secret, time.Hour, nil) uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clk), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) return apihttp.NewServer(log, uc, issuer) @@ -189,14 +190,15 @@ func TestMeInternalError(t *testing.T) { users := failingUsers{UserRepository: memstore.NewUserRepo()} uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clk), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer) // A structurally valid token for a caller the failing repo can't load. - token, err := issuer.Issue(uuid.New(), "user") + token, err := issuer.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) if err != nil { t.Fatal(err) } @@ -210,3 +212,114 @@ func TestMeInternalError(t *testing.T) { t.Error("internal error leaked to the client") } } + +// mintToken issues a token with the package secret for a synthetic caller of the +// given role — enough to drive the admin-gated endpoints. +func mintToken(t *testing.T, role domain.Role) string { + t.Helper() + token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: role}) + if err != nil { + t.Fatal(err) + } + return token +} + +// registerUser creates an account and returns its id. +func registerUser(t *testing.T, h http.Handler, email string) string { + t.Helper() + rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": email, "password": "password123"}) + if rec.Code != http.StatusCreated { + t.Fatalf("register: %d", rec.Code) + } + var reg struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), ®); err != nil { + t.Fatal(err) + } + return reg.ID +} + +func TestAdminVerifiesUserEndToEnd(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "contrib@example.com") + + // Admin grants the badge. + rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("admin verify: got %d, body %s", rec.Code, rec.Body) + } + + // The change is visible when the contributor logs in. + rec = do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "contrib@example.com", "password": "password123"}) + var lr struct { + User struct { + Verified bool `json:"verified"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil { + t.Fatal(err) + } + if !lr.User.Verified { + t.Error("verified badge not reflected after admin granted it") + } +} + +func TestVerifyRequiresAdminRole(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "someone@example.com") + + // A plain user token must not be able to grant the badge. + rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleUser), nil) + if rec.Code != http.StatusForbidden { + t.Errorf("plain user: got %d, want 403", rec.Code) + } +} + +func TestVerifyRequiresAuth(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Errorf("no token: got %d, want 401", rec.Code) + } +} + +func TestVerifyInvalidID(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/not-a-uuid/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusBadRequest { + t.Errorf("bad id: got %d, want 400", rec.Code) + } +} + +func TestVerifyUnknownUser(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown user: got %d, want 404", rec.Code) + } +} + +func TestUnverifyRevokes(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "revoke@example.com") + admin := mintToken(t, domain.RoleAdmin) + + if rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("verify: %d", rec.Code) + } + if rec := do(t, h, http.MethodPost, "/users/"+id+"/unverify", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("unverify: %d", rec.Code) + } + + rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "revoke@example.com", "password": "password123"}) + var lr struct { + User struct { + Verified bool `json:"verified"` + } `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &lr) + if lr.User.Verified { + t.Error("verified should be false after unverify") + } +} diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go index d25f56d..92c2467 100644 --- a/users/internal/usecase/errorpaths_test.go +++ b/users/internal/usecase/errorpaths_test.go @@ -29,6 +29,9 @@ func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) { func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) { return nil, usecase.ErrUserNotFound } +func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error { + return usecase.ErrUserNotFound +} type stubHasher struct { hashErr error @@ -45,7 +48,7 @@ func (s stubHasher) Compare(string, string) error { return s.compareErr } type stubIssuer struct{ err error } -func (s stubIssuer) Issue(uuid.UUID, domain.Role) (string, error) { +func (s stubIssuer) Issue(*domain.User) (string, error) { if s.err != nil { return "", s.err } diff --git a/users/internal/usecase/login.go b/users/internal/usecase/login.go index 7c57c3d..9b8351d 100644 --- a/users/internal/usecase/login.go +++ b/users/internal/usecase/login.go @@ -34,7 +34,7 @@ func (l *Login) Execute(ctx context.Context, email, password string) (string, *d return "", nil, ErrInvalidCredentials } - token, err := l.tokens.Issue(u.ID, u.Role) + token, err := l.tokens.Issue(u) if err != nil { return "", nil, err } diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go index 3f75323..738082a 100644 --- a/users/internal/usecase/ports.go +++ b/users/internal/usecase/ports.go @@ -22,6 +22,9 @@ type UserRepository interface { GetByEmail(ctx context.Context, email string) (*domain.User, error) // GetByID returns the user with id, or ErrUserNotFound. GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) + // SetVerified toggles the verified flag, returning ErrUserNotFound if no + // such user exists. + SetVerified(ctx context.Context, id uuid.UUID, verified bool) error } // PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it. @@ -30,9 +33,10 @@ type PasswordHasher interface { Compare(hash, password string) error } -// TokenIssuer mints a signed access token for an authenticated user. +// TokenIssuer mints a signed access token for an authenticated user. It takes +// the whole user so trust-bearing claims (role, verified) travel in the token. type TokenIssuer interface { - Issue(userID uuid.UUID, role domain.Role) (string, error) + Issue(u *domain.User) (string, error) } // Clock reads the current time; a fake one makes tests deterministic. diff --git a/users/internal/usecase/verify.go b/users/internal/usecase/verify.go new file mode 100644 index 0000000..2a4fc94 --- /dev/null +++ b/users/internal/usecase/verify.go @@ -0,0 +1,24 @@ +package usecase + +import ( + "context" + + "github.com/google/uuid" +) + +// SetVerified grants or revokes a user's trusted-contributor badge. Only an +// admin may call this (enforced in the transport layer); the use case itself +// just applies the change. +type SetVerified struct { + users UserRepository +} + +func NewSetVerified(users UserRepository) *SetVerified { + return &SetVerified{users: users} +} + +// Execute sets the verified flag on the target user, returning ErrUserNotFound +// if the user does not exist. +func (uc *SetVerified) Execute(ctx context.Context, id uuid.UUID, verified bool) error { + return uc.users.SetVerified(ctx, id, verified) +} diff --git a/users/internal/usecase/verify_test.go b/users/internal/usecase/verify_test.go new file mode 100644 index 0000000..1932ba6 --- /dev/null +++ b/users/internal/usecase/verify_test.go @@ -0,0 +1,73 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/memstore" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +func TestSetVerifiedGrantsAndRevokes(t *testing.T) { + reg, _, users := newFixtures() + ctx := context.Background() + + u, err := reg.Execute(ctx, "contrib@example.com", "password123") + if err != nil { + t.Fatal(err) + } + if u.Verified { + t.Fatal("a fresh account must be unverified") + } + + sv := usecase.NewSetVerified(users) + + if err := sv.Execute(ctx, u.ID, true); err != nil { + t.Fatalf("grant: %v", err) + } + got, _ := users.GetByID(ctx, u.ID) + if !got.Verified { + t.Error("verified flag not set") + } + + if err := sv.Execute(ctx, u.ID, false); err != nil { + t.Fatalf("revoke: %v", err) + } + got, _ = users.GetByID(ctx, u.ID) + if got.Verified { + t.Error("verified flag not cleared") + } +} + +func TestSetVerifiedUnknownUser(t *testing.T) { + users := memstore.NewUserRepo() + sv := usecase.NewSetVerified(users) + + if err := sv.Execute(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("got %v, want ErrUserNotFound", err) + } +} + +func TestLoginTokenCarriesVerified(t *testing.T) { + reg, login, users := newFixtures() + ctx := context.Background() + + u, err := reg.Execute(ctx, "trusted@example.com", "password123") + if err != nil { + t.Fatal(err) + } + if err := usecase.NewSetVerified(users).Execute(ctx, u.ID, true); err != nil { + t.Fatal(err) + } + + _, loggedIn, err := login.Execute(ctx, "trusted@example.com", "password123") + if err != nil { + t.Fatalf("login: %v", err) + } + if !loggedIn.Verified { + t.Error("login must reflect the granted verified flag") + } +} diff --git a/users/migrations/0002_user_verified.down.sql b/users/migrations/0002_user_verified.down.sql new file mode 100644 index 0000000..b52a55a --- /dev/null +++ b/users/migrations/0002_user_verified.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE users DROP COLUMN IF EXISTS verified; + +COMMIT; diff --git a/users/migrations/0002_user_verified.up.sql b/users/migrations/0002_user_verified.up.sql new file mode 100644 index 0000000..7b8fe9e --- /dev/null +++ b/users/migrations/0002_user_verified.up.sql @@ -0,0 +1,9 @@ +BEGIN; + +-- A "verified" account is a trusted contributor: the coordinator accepts its +-- workers' results directly, without quorum cross-checking. Distinct from role +-- (which governs what a user may do with their own jobs). Granted by an admin, +-- never self-served; defaults to false, so a fresh account is untrusted. +ALTER TABLE users ADD COLUMN verified boolean NOT NULL DEFAULT false; + +COMMIT;