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