Embed the userservice and add serve/agent subcommands for one-binary operation

This commit is contained in:
Emil
2026-08-02 19:49:54 +03:00
parent 9883def0c2
commit 1473bbe2a8
38 changed files with 3638 additions and 9 deletions
@@ -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))
}
}