Embed the userservice and add serve/agent subcommands for one-binary operation
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// BootstrapAdmin seeds the first admin account. It exists because there is no
|
||||
// other way to create one: /register always makes a plain user, and promoting a
|
||||
// user to admin requires an already-existing admin. Running it at startup with
|
||||
// operator-supplied credentials breaks that chicken-and-egg.
|
||||
type BootstrapAdmin struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
clk Clock
|
||||
}
|
||||
|
||||
func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin {
|
||||
return &BootstrapAdmin{users: users, hasher: hasher, clk: clk}
|
||||
}
|
||||
|
||||
// Execute creates the admin if it does not already exist, reporting whether it
|
||||
// created one. It is idempotent: a second run (a restart) finds the account and
|
||||
// does nothing, so it is safe to call on every boot.
|
||||
func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) {
|
||||
email = domain.NormalizeEmail(email)
|
||||
|
||||
if _, err := uc.users.GetByEmail(ctx, email); err == nil {
|
||||
return false, nil // already bootstrapped
|
||||
} else if !errors.Is(err, ErrUserNotFound) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(password) < minPasswordLen {
|
||||
return false, ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return false, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
hash, err := uc.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
u, err := domain.NewUser(email, hash, uc.clk.Now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Direct role assignment is safe here: this is a trusted server-side seed,
|
||||
// not a request. A root admin is also a trusted contributor.
|
||||
u.Role = domain.RoleAdmin
|
||||
u.Verified = true
|
||||
|
||||
if err := uc.users.Insert(ctx, u); err != nil {
|
||||
// A concurrent bootstrap (two replicas booting at once) is fine: whoever
|
||||
// lost the race just observes the account now exists.
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) {
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
return usecase.NewBootstrapAdmin(users, hasher, clk), users
|
||||
}
|
||||
|
||||
func TestBootstrapCreatesAdmin(t *testing.T) {
|
||||
bs, users := newBootstrap()
|
||||
|
||||
created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected an admin to be created")
|
||||
}
|
||||
|
||||
u, err := users.GetByEmail(context.Background(), "root@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("admin not persisted: %v", err)
|
||||
}
|
||||
if u.Role != domain.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin", u.Role)
|
||||
}
|
||||
if !u.Verified {
|
||||
t.Error("bootstrap admin should be verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapIsIdempotent(t *testing.T) {
|
||||
bs, users := newBootstrap()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := bs.Execute(ctx, "root@example.com", "rootpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("second run must not create a duplicate admin")
|
||||
}
|
||||
|
||||
// The account must still be a single admin.
|
||||
if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin {
|
||||
t.Errorf("role changed: %q", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRejectsWeakPassword(t *testing.T) {
|
||||
bs, _ := newBootstrap()
|
||||
if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
|
||||
t.Errorf("got %v, want ErrPasswordTooShort", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// These stubs let a test inject failures the happy-path memstore never produces,
|
||||
// so the use cases' error branches are exercised too.
|
||||
|
||||
var errBoom = errors.New("boom")
|
||||
|
||||
type stubRepo struct {
|
||||
getByEmail func() (*domain.User, error)
|
||||
insert func() error
|
||||
}
|
||||
|
||||
func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() }
|
||||
func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) {
|
||||
return s.getByEmail()
|
||||
}
|
||||
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
|
||||
}
|
||||
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
|
||||
return usecase.ErrUserNotFound
|
||||
}
|
||||
|
||||
type stubHasher struct {
|
||||
hashErr error
|
||||
compareErr error
|
||||
}
|
||||
|
||||
func (s stubHasher) Hash(string) (string, error) {
|
||||
if s.hashErr != nil {
|
||||
return "", s.hashErr
|
||||
}
|
||||
return "hashed", nil
|
||||
}
|
||||
func (s stubHasher) Compare(string, string) error { return s.compareErr }
|
||||
|
||||
type stubIssuer struct{ err error }
|
||||
|
||||
func (s stubIssuer) Issue(*domain.User) (string, error) {
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
func TestRegisterPropagatesHasherError(t *testing.T) {
|
||||
clk := stubClock{time.Now()}
|
||||
reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk)
|
||||
|
||||
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPropagatesInsertError(t *testing.T) {
|
||||
clk := stubClock{time.Now()}
|
||||
repo := stubRepo{insert: func() error { return errBoom }}
|
||||
reg := usecase.NewRegister(repo, stubHasher{}, clk)
|
||||
|
||||
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPropagatesRepoError(t *testing.T) {
|
||||
// A non-ErrUserNotFound repo error must surface as-is, not be masked as
|
||||
// ErrInvalidCredentials.
|
||||
repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }}
|
||||
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{})
|
||||
|
||||
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPropagatesIssuerError(t *testing.T) {
|
||||
repo := stubRepo{getByEmail: func() (*domain.User, error) {
|
||||
return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil
|
||||
}}
|
||||
// Hasher accepts the password (nil compareErr) so we reach token issuance.
|
||||
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom})
|
||||
|
||||
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubClock struct{ t time.Time }
|
||||
|
||||
func (c stubClock) Now() time.Time { return c.t }
|
||||
@@ -0,0 +1,27 @@
|
||||
package usecase
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// Repository-contract errors, returned by UserRepository implementations.
|
||||
ErrEmailExists = errors.New("email already registered")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrWorkerKeyNotFound is returned by WorkerKeyRepository when no live key
|
||||
// matches (by id for revoke, by hash for exchange).
|
||||
ErrWorkerKeyNotFound = errors.New("worker key not found")
|
||||
// ErrInvalidWorkerKey is surfaced to the transport layer for a key that does
|
||||
// not exchange (unknown, revoked, or owner gone). Deliberately opaque so a
|
||||
// caller cannot distinguish the cases while probing.
|
||||
ErrInvalidWorkerKey = errors.New("invalid worker key")
|
||||
|
||||
// Use-case errors surfaced to the transport layer.
|
||||
//
|
||||
// ErrInvalidCredentials is deliberately returned for both an unknown email
|
||||
// and a wrong password, so an attacker cannot use the response to learn
|
||||
// which emails are registered.
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrPasswordTooShort = errors.New("password too short")
|
||||
ErrPasswordTooLong = errors.New("password too long")
|
||||
ErrInvalidRole = errors.New("invalid role")
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// Login verifies credentials and issues a signed token.
|
||||
type Login struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
tokens TokenIssuer
|
||||
}
|
||||
|
||||
func NewLogin(users UserRepository, hasher PasswordHasher, tokens TokenIssuer) *Login {
|
||||
return &Login{users: users, hasher: hasher, tokens: tokens}
|
||||
}
|
||||
|
||||
// Execute returns a signed token and the user on success. It returns
|
||||
// ErrInvalidCredentials for both an unknown email and a wrong password so the
|
||||
// two cases are indistinguishable to a caller probing for valid accounts.
|
||||
func (l *Login) Execute(ctx context.Context, email, password string) (string, *domain.User, error) {
|
||||
u, err := l.users.GetByEmail(ctx, domain.NormalizeEmail(email))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if err := l.hasher.Compare(u.PasswordHash, password); err != nil {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := l.tokens.Issue(u)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return token, u, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package usecase holds the application logic — registration and login — plus
|
||||
// the ports (interfaces) it depends on. The concrete adapters (PostgreSQL,
|
||||
// bcrypt, JWT) are injected from cmd, so this package never imports them.
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// UserRepository persists and looks up users. Implementations return the
|
||||
// sentinel errors in errors.go so the use cases can react without knowing about
|
||||
// SQL or driver types.
|
||||
type UserRepository interface {
|
||||
// Insert stores a new user, returning ErrEmailExists if the email is taken.
|
||||
Insert(ctx context.Context, u *domain.User) error
|
||||
// GetByEmail returns the user with the (normalised) email, or ErrUserNotFound.
|
||||
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
|
||||
// SetRole changes a user's role, returning ErrUserNotFound if no such user
|
||||
// exists.
|
||||
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
|
||||
}
|
||||
|
||||
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
|
||||
// creates to run a worker bound to their account. Implementations return the
|
||||
// sentinel errors in errors.go so the use cases stay free of SQL types.
|
||||
type WorkerKeyRepository interface {
|
||||
// Insert stores a freshly minted key.
|
||||
Insert(ctx context.Context, k *domain.WorkerKey) error
|
||||
// ListByUser returns a user's live (non-revoked) keys, newest first.
|
||||
ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error)
|
||||
// GetActiveByHash returns the non-revoked key with the given hash, or
|
||||
// ErrWorkerKeyNotFound.
|
||||
GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error)
|
||||
// Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no
|
||||
// live key with that id belongs to the user.
|
||||
Revoke(ctx context.Context, id, userID uuid.UUID) error
|
||||
// TouchLastUsed records a successful exchange. Best-effort: a failure here
|
||||
// must not fail the exchange itself.
|
||||
TouchLastUsed(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it.
|
||||
type PasswordHasher interface {
|
||||
Hash(password string) (string, error)
|
||||
Compare(hash, password string) error
|
||||
}
|
||||
|
||||
// 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(u *domain.User) (string, error)
|
||||
}
|
||||
|
||||
// Clock reads the current time; a fake one makes tests deterministic.
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
// minPasswordLen is a floor, not a policy engine — enough to reject the
|
||||
// obviously weak without pretending to measure real strength.
|
||||
minPasswordLen = 8
|
||||
// maxPasswordLen is bcrypt's hard input limit: it ignores bytes past 72, so
|
||||
// accepting a longer password would silently hash only its prefix.
|
||||
maxPasswordLen = 72
|
||||
)
|
||||
|
||||
// Register creates a new account: it validates the password, hashes it, builds
|
||||
// the domain user, and persists it.
|
||||
type Register struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
clk Clock
|
||||
}
|
||||
|
||||
func NewRegister(users UserRepository, hasher PasswordHasher, clk Clock) *Register {
|
||||
return &Register{users: users, hasher: hasher, clk: clk}
|
||||
}
|
||||
|
||||
// Execute registers email/password and returns the persisted user. The returned
|
||||
// user carries no plaintext password, only its hash.
|
||||
func (r *Register) Execute(ctx context.Context, email, password string) (*domain.User, error) {
|
||||
if len(password) < minPasswordLen {
|
||||
return nil, ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return nil, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
hash, err := r.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// NewUser normalises the email and enforces its shape; it returns a domain
|
||||
// validation error the transport layer maps to 400.
|
||||
u, err := domain.NewUser(email, hash, r.clk.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.users.Insert(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/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)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
const secret = "usecase-test-secret-32-bytes-long!!!"
|
||||
|
||||
func newFixtures() (*usecase.Register, *usecase.Login, *memstore.UserRepo) {
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4) // low cost keeps tests fast
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
// The issuer uses the real clock (nil): token expiry is validated against
|
||||
// wall-clock time, so a fixed issue-time would make tokens instantly stale.
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
|
||||
reg := usecase.NewRegister(users, hasher, clk)
|
||||
login := usecase.NewLogin(users, hasher, issuer)
|
||||
return reg, login, users
|
||||
}
|
||||
|
||||
func TestRegisterSuccess(t *testing.T) {
|
||||
reg, _, users := newFixtures()
|
||||
|
||||
u, err := reg.Execute(context.Background(), "Alice@Example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if u.Email != "alice@example.com" {
|
||||
t.Errorf("email not normalised: %q", u.Email)
|
||||
}
|
||||
if u.Role != domain.RoleUser {
|
||||
t.Errorf("role = %q, want user", u.Role)
|
||||
}
|
||||
if strings.Contains(u.PasswordHash, "password123") {
|
||||
t.Error("password stored in cleartext")
|
||||
}
|
||||
if _, err := users.GetByEmail(context.Background(), "alice@example.com"); err != nil {
|
||||
t.Errorf("user not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Execute(ctx, "dup@example.com", "password123"); err != nil {
|
||||
t.Fatalf("first register: %v", err)
|
||||
}
|
||||
_, err := reg.Execute(ctx, "Dup@example.com", "password123") // different case, same email
|
||||
if !errors.Is(err, usecase.ErrEmailExists) {
|
||||
t.Errorf("got %v, want ErrEmailExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPasswordPolicy(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Execute(ctx, "a@b.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
|
||||
t.Errorf("short password: got %v", err)
|
||||
}
|
||||
long := strings.Repeat("x", 73)
|
||||
if _, err := reg.Execute(ctx, "a@b.com", long); !errors.Is(err, usecase.ErrPasswordTooLong) {
|
||||
t.Errorf("long password: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterInvalidEmail(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
_, err := reg.Execute(context.Background(), "not-an-email", "password123")
|
||||
if !errors.Is(err, domain.ErrInvalidEmail) {
|
||||
t.Errorf("got %v, want ErrInvalidEmail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccess(t *testing.T) {
|
||||
reg, login, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, u, err := login.Execute(ctx, "User@Example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("empty token")
|
||||
}
|
||||
if u.Email != "user@example.com" {
|
||||
t.Errorf("wrong user returned: %q", u.Email)
|
||||
}
|
||||
|
||||
// The token must verify and carry this user's id.
|
||||
claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
|
||||
if err != nil {
|
||||
t.Fatalf("issued token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != u.ID.String() {
|
||||
t.Errorf("token sub = %q, want %q", claims.Subject, u.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
reg, login, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := login.Execute(ctx, "user@example.com", "wrongpass1")
|
||||
if !errors.Is(err, usecase.ErrInvalidCredentials) {
|
||||
t.Errorf("got %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUnknownEmailIsIndistinguishable(t *testing.T) {
|
||||
_, login, _ := newFixtures()
|
||||
_, _, err := login.Execute(context.Background(), "ghost@example.com", "password123")
|
||||
if !errors.Is(err, usecase.ErrInvalidCredentials) {
|
||||
t.Errorf("unknown email must return ErrInvalidCredentials, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// CreateWorkerKey mints a long-lived worker key for a user and returns the
|
||||
// one-time plaintext to show once.
|
||||
type CreateWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
clock Clock
|
||||
}
|
||||
|
||||
func NewCreateWorkerKey(keys WorkerKeyRepository, clock Clock) *CreateWorkerKey {
|
||||
return &CreateWorkerKey{keys: keys, clock: clock}
|
||||
}
|
||||
|
||||
// Execute returns the stored key (hash only) and the plaintext secret. The
|
||||
// secret is never persisted, so this is the sole moment it can be surfaced.
|
||||
func (uc *CreateWorkerKey) Execute(ctx context.Context, userID uuid.UUID, name string) (*domain.WorkerKey, string, error) {
|
||||
key, raw, err := domain.NewWorkerKey(userID, name, uc.clock.Now())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := uc.keys.Insert(ctx, key); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return key, raw, nil
|
||||
}
|
||||
|
||||
// ListWorkerKeys returns a user's live keys for display and management.
|
||||
type ListWorkerKeys struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewListWorkerKeys(keys WorkerKeyRepository) *ListWorkerKeys {
|
||||
return &ListWorkerKeys{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
return uc.keys.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
// RevokeWorkerKey retires one of the caller's keys.
|
||||
type RevokeWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewRevokeWorkerKey(keys WorkerKeyRepository) *RevokeWorkerKey {
|
||||
return &RevokeWorkerKey{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *RevokeWorkerKey) Execute(ctx context.Context, userID, id uuid.UUID) error {
|
||||
return uc.keys.Revoke(ctx, id, userID)
|
||||
}
|
||||
|
||||
// ExchangeWorkerKey trades a valid worker key for a short-lived JWT. The JWT
|
||||
// carries the owner's current role and verified flag, so a worker that refreshes
|
||||
// after an admin verifies the owner picks up the upgraded trust on its next
|
||||
// registration.
|
||||
type ExchangeWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
users UserRepository
|
||||
tokens TokenIssuer
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewExchangeWorkerKey(keys WorkerKeyRepository, users UserRepository, tokens TokenIssuer, ttl time.Duration) *ExchangeWorkerKey {
|
||||
return &ExchangeWorkerKey{keys: keys, users: users, tokens: tokens, ttl: ttl}
|
||||
}
|
||||
|
||||
// Execute returns a signed token and its lifetime in seconds. Every failure to
|
||||
// resolve the key to a usable owner collapses to ErrInvalidWorkerKey so a caller
|
||||
// cannot tell an unknown key from a revoked one or a deleted owner.
|
||||
func (uc *ExchangeWorkerKey) Execute(ctx context.Context, rawKey string) (string, int, error) {
|
||||
if rawKey == "" {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
|
||||
key, err := uc.keys.GetActiveByHash(ctx, domain.HashWorkerKey(rawKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWorkerKeyNotFound) {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
u, err := uc.users.GetByID(ctx, key.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
token, err := uc.tokens.Issue(u)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Best-effort: a failed timestamp update must not sink an otherwise valid
|
||||
// exchange the worker depends on to keep running.
|
||||
_ = uc.keys.TouchLastUsed(ctx, key.ID)
|
||||
|
||||
return token, int(uc.ttl.Seconds()), nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// fakeKeyRepo is an in-memory WorkerKeyRepository for the use-case tests.
|
||||
type fakeKeyRepo struct {
|
||||
byHash map[string]*domain.WorkerKey
|
||||
byID map[uuid.UUID]*domain.WorkerKey
|
||||
touched []uuid.UUID
|
||||
}
|
||||
|
||||
func newFakeKeyRepo() *fakeKeyRepo {
|
||||
return &fakeKeyRepo{byHash: map[string]*domain.WorkerKey{}, byID: map[uuid.UUID]*domain.WorkerKey{}}
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
|
||||
r.byHash[k.TokenHash] = k
|
||||
r.byID[k.ID] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
var out []*domain.WorkerKey
|
||||
for _, k := range r.byID {
|
||||
if k.UserID == userID && !k.Revoked() {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) GetActiveByHash(_ context.Context, hash string) (*domain.WorkerKey, error) {
|
||||
k, ok := r.byHash[hash]
|
||||
if !ok || k.Revoked() {
|
||||
return nil, usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
|
||||
k, ok := r.byID[id]
|
||||
if !ok || k.UserID != userID || k.Revoked() {
|
||||
return usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
||||
r.touched = append(r.touched, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
|
||||
t.Helper()
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
keys := newFakeKeyRepo()
|
||||
|
||||
u, err := usecase.NewRegister(users, hasher, clk).Execute(context.Background(), "worker@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
return usecase.NewCreateWorkerKey(keys, clk),
|
||||
usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
|
||||
usecase.NewRevokeWorkerKey(keys),
|
||||
usecase.NewListWorkerKeys(keys),
|
||||
keys, u
|
||||
}
|
||||
|
||||
func TestCreateAndExchangeWorkerKey(t *testing.T) {
|
||||
create, exchange, _, _, keys, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, raw, err := create.Execute(ctx, u.ID, "home-desktop")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if key.Name != "home-desktop" || raw == "" {
|
||||
t.Fatalf("unexpected key %+v raw=%q", key, raw)
|
||||
}
|
||||
|
||||
token, expiresIn, err := exchange.Execute(ctx, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
if expiresIn != int((time.Hour).Seconds()) {
|
||||
t.Errorf("expires_in = %d, want 3600", expiresIn)
|
||||
}
|
||||
|
||||
claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
|
||||
if err != nil {
|
||||
t.Fatalf("issued token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != u.ID.String() {
|
||||
t.Errorf("token sub = %q, want owner %q", claims.Subject, u.ID)
|
||||
}
|
||||
if len(keys.touched) != 1 || keys.touched[0] != key.ID {
|
||||
t.Errorf("exchange did not record last-used, touched=%v", keys.touched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeUnknownKeyIsInvalid(t *testing.T) {
|
||||
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
||||
if _, _, err := exchange.Execute(context.Background(), "scimesh_wk_live_nope"); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeEmptyKeyIsInvalid(t *testing.T) {
|
||||
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
||||
if _, _, err := exchange.Execute(context.Background(), ""); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeRevokedKeyIsInvalid(t *testing.T) {
|
||||
create, exchange, revoke, _, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, raw, err := create.Execute(ctx, u.ID, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := revoke.Execute(ctx, u.ID, key.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, _, err := exchange.Execute(ctx, raw); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("revoked key still exchanges: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeIsScopedToOwner(t *testing.T) {
|
||||
create, _, revoke, _, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, _, err := create.Execute(ctx, u.ID, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A different user must not be able to revoke this key.
|
||||
if err := revoke.Execute(ctx, uuid.New(), key.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
|
||||
t.Errorf("cross-owner revoke returned %v, want ErrWorkerKeyNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnsOnlyLiveKeys(t *testing.T) {
|
||||
create, _, revoke, list, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
live, _, err := create.Execute(ctx, u.ID, "keep")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dead, _, err := create.Execute(ctx, u.ID, "drop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := revoke.Execute(ctx, u.ID, dead.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := list.Execute(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != live.ID {
|
||||
t.Errorf("list = %d keys, want only the live one", len(got))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user