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
This commit is contained in:
Efremenko Arhip
2026-07-26 19:10:01 +03:00
parent 0c1f5f06d4
commit c6a66747eb
20 changed files with 429 additions and 51 deletions
+4 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+6 -2
View File
@@ -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.
+24
View File
@@ -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)
}
+73
View File
@@ -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")
}
}