test(users): cover config, usecase error paths, and /me 500 path
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const validSecret = "a-secret-that-is-at-least-32-bytes!!"
|
||||
|
||||
// setBaseEnv wires the minimum valid environment. ENV_FILE points at a path that
|
||||
// does not exist so a developer's stray .env never leaks into the test.
|
||||
func setBaseEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ENV_FILE", "/nonexistent/.env")
|
||||
t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable")
|
||||
t.Setenv("JWT_SECRET", validSecret)
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Addr != ":8081" {
|
||||
t.Errorf("Addr default = %q, want :8081", cfg.Addr)
|
||||
}
|
||||
if cfg.TokenTTL != 24*time.Hour {
|
||||
t.Errorf("TokenTTL default = %v, want 24h", cfg.TokenTTL)
|
||||
}
|
||||
if cfg.JWTSecret != validSecret {
|
||||
t.Errorf("JWTSecret = %q", cfg.JWTSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresDatabaseURL(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when DATABASE_URL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresJWTSecret(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when JWT_SECRET is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsShortJWTSecret(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_SECRET", "too-short")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when JWT_SECRET is under 32 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigOverrides(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("USERSERVICE_ADDR", ":9000")
|
||||
t.Setenv("JWT_TTL", "1h")
|
||||
t.Setenv("BCRYPT_COST", "6")
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Addr != ":9000" {
|
||||
t.Errorf("Addr = %q, want :9000", cfg.Addr)
|
||||
}
|
||||
if cfg.TokenTTL != time.Hour {
|
||||
t.Errorf("TokenTTL = %v, want 1h", cfg.TokenTTL)
|
||||
}
|
||||
if cfg.BcryptCost != 6 {
|
||||
t.Errorf("BcryptCost = %d, want 6", cfg.BcryptCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMalformedDuration(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_TTL", "not-a-duration")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error for malformed JWT_TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMalformedInt(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("BCRYPT_COST", "abc")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error for malformed BCRYPT_COST")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,7 +12,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/auth"
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/users/internal/memstore"
|
||||
apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
@@ -165,3 +170,40 @@ func TestHealth(t *testing.T) {
|
||||
t.Errorf("health: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// failingUsers is a UserRepository whose reads fail with an unexpected (non-
|
||||
// sentinel) error, so the handler must map it to 500 and not leak internals.
|
||||
type failingUsers struct{ usecase.UserRepository }
|
||||
|
||||
func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
|
||||
return nil, errors.New("db exploded")
|
||||
}
|
||||
|
||||
func TestMeInternalError(t *testing.T) {
|
||||
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)
|
||||
|
||||
users := failingUsers{UserRepository: memstore.NewUserRepo()}
|
||||
uc := apihttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clk),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
Users: users,
|
||||
}
|
||||
h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer)
|
||||
|
||||
// A structurally valid token for a caller the failing repo can't load.
|
||||
token, err := issuer.Issue(uuid.New(), "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := do(t, h, http.MethodGet, "/me", token, nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("got %d, want 500", rec.Code)
|
||||
}
|
||||
// The body must not disclose the underlying error.
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) {
|
||||
t.Error("internal error leaked to the client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/users/internal/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
|
||||
}
|
||||
|
||||
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(uuid.UUID, domain.Role) (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 }
|
||||
Reference in New Issue
Block a user