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