add users logic

This commit is contained in:
Efremenko Arhip
2026-07-26 14:38:24 +03:00
parent 16db1e41f7
commit a3db1a1e67
65 changed files with 3626 additions and 19 deletions
+18
View File
@@ -0,0 +1,18 @@
package usecase
import "errors"
var (
// Repository-contract errors, returned by UserRepository implementations.
ErrEmailExists = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
// 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")
)
+42
View File
@@ -0,0 +1,42 @@
package usecase
import (
"context"
"errors"
"github.com/emil28092005/SciMesh/users/internal/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.ID, u.Role)
if err != nil {
return "", nil, err
}
return token, u, nil
}
+41
View File
@@ -0,0 +1,41 @@
// 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/users/internal/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)
}
// 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.
type TokenIssuer interface {
Issue(userID uuid.UUID, role domain.Role) (string, error)
}
// Clock reads the current time; a fake one makes tests deterministic.
type Clock interface {
Now() time.Time
}
+56
View File
@@ -0,0 +1,56 @@
package usecase
import (
"context"
"github.com/emil28092005/SciMesh/users/internal/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
}
+133
View File
@@ -0,0 +1,133 @@
package usecase_test
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/emil28092005/SciMesh/users/internal/auth"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/memstore"
"github.com/emil28092005/SciMesh/users/internal/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)
}
}