This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
package auth_test
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"backend/internal/modules/auth"
"backend/internal/modules/users"
"backend/internal/platform/security"
)
// fakeUserFinder is an in-memory auth.UserFinder used to unit-test
// auth.Service without a real users repository/database.
type fakeUserFinder struct {
byID map[uuid.UUID]*users.User
byEmail map[string]*users.User
}
func newFakeUserFinder() *fakeUserFinder {
return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}}
}
func (f *fakeUserFinder) add(email, password, role string, active bool) *users.User {
hash, err := security.HashPassword(password)
if err != nil {
panic(err)
}
u := &users.User{ID: uuid.New(), Email: email, PasswordHash: hash, Role: role, IsActive: active}
f.byID[u.ID] = u
f.byEmail[u.Email] = u
return u
}
func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) {
u, ok := f.byEmail[email]
if !ok {
return nil, users.ErrNotFound
}
return u, nil
}
func (f *fakeUserFinder) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) {
u, ok := f.byID[id]
if !ok {
return nil, users.ErrNotFound
}
return u, nil
}
// fakeRefreshStore is an in-memory auth.RefreshStore used to unit-test
// rotation/revocation without a real Redis instance.
type fakeRefreshStore struct {
byToken map[string]uuid.UUID
byUser map[uuid.UUID]map[string]bool
nextID int
}
func newFakeRefreshStore() *fakeRefreshStore {
return &fakeRefreshStore{byToken: map[string]uuid.UUID{}, byUser: map[uuid.UUID]map[string]bool{}}
}
func (f *fakeRefreshStore) newToken() string {
f.nextID++
return "token-" + uuid.NewString()
}
func (f *fakeRefreshStore) Issue(_ context.Context, _ security.Audience, userID uuid.UUID, _ time.Duration) (string, error) {
token := f.newToken()
f.byToken[token] = userID
if f.byUser[userID] == nil {
f.byUser[userID] = map[string]bool{}
}
f.byUser[userID][token] = true
return token, nil
}
func (f *fakeRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
userID, ok := f.byToken[oldToken]
if !ok {
return "", uuid.UUID{}, auth.ErrInvalidRefreshToken
}
delete(f.byToken, oldToken)
delete(f.byUser[userID], oldToken)
newToken, err := f.Issue(ctx, aud, userID, ttl)
return newToken, userID, err
}
func (f *fakeRefreshStore) Revoke(_ context.Context, _ security.Audience, token string) error {
userID, ok := f.byToken[token]
if !ok {
return nil
}
delete(f.byToken, token)
delete(f.byUser[userID], token)
return nil
}
func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audience, userID uuid.UUID) error {
for token := range f.byUser[userID] {
delete(f.byToken, token)
}
delete(f.byUser, userID)
return nil
}
func TestService_Login_Success(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if pair.AccessToken == "" || pair.RefreshToken == "" {
t.Fatal("Login() returned an empty token pair")
}
if user.Email != "admin@example.com" {
t.Fatalf("Login() user = %+v, want email admin@example.com", user)
}
}
func TestService_Login_WrongPasswordRejected(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "wrong-password")
if !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err)
}
}
func TestService_Login_WrongRoleSpaceRejected(t *testing.T) {
// A customer account must not be able to log into the admin space, even
// with the correct password -- the two spaces are strictly separated.
finder := newFakeUserFinder()
finder.add("shopper@example.com", "correct-password", users.RoleCustomer, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper@example.com", "correct-password")
if !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err)
}
}
func TestService_Login_DisabledAccountRejected(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, false)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
_, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
if !errors.Is(err, auth.ErrAccountDisabled) {
t.Fatalf("Login() error = %v, want ErrAccountDisabled", err)
}
}
func TestService_Refresh_RotatesToken(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
newPair, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken)
if err != nil {
t.Fatalf("Refresh() error = %v", err)
}
if newPair.RefreshToken == pair.RefreshToken {
t.Fatal("Refresh() returned the same refresh token instead of rotating it")
}
}
func TestService_Refresh_RejectsReusedToken(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil {
t.Fatalf("first Refresh() error = %v", err)
}
// The old (already-rotated) refresh token must never work again.
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil {
t.Fatal("second Refresh() with the same (rotated-out) token succeeded, want error")
}
}
func TestService_Logout_RevokesToken(t *testing.T) {
finder := newFakeUserFinder()
finder.add("admin@example.com", "correct-password", users.RoleAdmin, true)
svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour)
pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if err := svc.Logout(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil {
t.Fatalf("Logout() error = %v", err)
}
if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil {
t.Fatal("Refresh() succeeded after logout, want error")
}
}
+244
View File
@@ -0,0 +1,244 @@
package orders_test
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"backend/internal/modules/orders"
"backend/internal/modules/pricing"
"backend/internal/modules/products"
"backend/internal/modules/units"
)
// fakeRepository is an in-memory orders.Repository used to unit-test
// orders.Service without a real database.
type fakeRepository struct {
orders map[uuid.UUID]*orders.Order
items map[uuid.UUID][]*orders.OrderItem
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{orders: map[uuid.UUID]*orders.Order{}, items: map[uuid.UUID][]*orders.OrderItem{}}
}
func (r *fakeRepository) Create(_ context.Context, order *orders.Order, items []*orders.OrderItem) error {
cp := *order
r.orders[order.ID] = &cp
for _, item := range items {
item.OrderID = order.ID
}
r.items[order.ID] = items
return nil
}
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Order, []*orders.OrderItem, error) {
o, ok := r.orders[id]
if !ok {
return nil, nil, orders.ErrNotFound
}
return o, r.items[id], nil
}
func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) {
var list []*orders.Order
for _, o := range r.orders {
if status == "" || o.Status == status {
list = append(list, o)
}
}
return list, nil
}
func (r *fakeRepository) UpdateStatus(_ context.Context, id uuid.UUID, status string) (*orders.Order, error) {
o, ok := r.orders[id]
if !ok {
return nil, orders.ErrNotFound
}
o.Status = status
return o, nil
}
// fakeProducts / fakeUnits / fakePrices back the small consumer-defined
// interfaces orders.Service depends on.
type fakeProducts struct {
byID map[uuid.UUID]*products.Product
}
func (f *fakeProducts) Get(_ context.Context, id uuid.UUID) (*products.Product, error) {
p, ok := f.byID[id]
if !ok {
return nil, products.ErrNotFound
}
return p, nil
}
type fakeUnits struct {
byID map[uuid.UUID]*units.Unit
}
func (f *fakeUnits) Get(_ context.Context, id uuid.UUID) (*units.Unit, error) {
u, ok := f.byID[id]
if !ok {
return nil, units.ErrNotFound
}
return u, nil
}
type fakePrices struct {
byID map[uuid.UUID]*pricing.PriceTier
}
func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error) {
t, ok := f.byID[tierID]
if !ok {
return 0, nil, pricing.ErrNotFound
}
if multiplier < 1 {
multiplier = 1
}
return t.PriceCents * multiplier, t, nil
}
type fakeNotifier struct {
newOrderCalls []string
statusChangeCalls []string
}
func (f *fakeNotifier) NotifyNewOrder(_ context.Context, summary string) {
f.newOrderCalls = append(f.newOrderCalls, summary)
}
func (f *fakeNotifier) NotifyOrderStatusChange(_ context.Context, summary string) {
f.statusChangeCalls = append(f.statusChangeCalls, summary)
}
// testFixture wires a full set of fakes with one product/unit/tier, ready
// for a service under test.
type testFixture struct {
repo *fakeRepository
products *fakeProducts
units *fakeUnits
prices *fakePrices
notifier *fakeNotifier
productID uuid.UUID
unitID uuid.UUID
tierID uuid.UUID
}
func newFixture() *testFixture {
productID := uuid.New()
unitID := uuid.New()
tierID := uuid.New()
return &testFixture{
repo: newFakeRepository(),
products: &fakeProducts{byID: map[uuid.UUID]*products.Product{
productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true},
}},
units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{
unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"},
}},
prices: &fakePrices{byID: map[uuid.UUID]*pricing.PriceTier{
tierID: {ID: tierID, ProductID: productID, UnitID: unitID, Quantity: 5, PriceCents: 4500},
}},
notifier: &fakeNotifier{},
productID: productID,
unitID: unitID,
tierID: tierID,
}
}
func (f *testFixture) service() *orders.Service {
return orders.NewService(f.repo, f.products, f.units, f.prices, f.notifier)
}
func TestService_Create_ComputesTotalAndNotifies(t *testing.T) {
f := newFixture()
svc := f.service()
order, items, err := svc.Create(context.Background(), orders.CreateInput{
CustomerName: "Alice",
CustomerEmail: "alice@example.com",
Items: []orders.ItemInput{
{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 2},
},
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if order.TotalCents != 9000 {
t.Fatalf("Create() order.TotalCents = %d, want 9000 (2x 4500)", order.TotalCents)
}
if order.Status != orders.StatusPending {
t.Fatalf("Create() order.Status = %q, want %q", order.Status, orders.StatusPending)
}
if len(items) != 1 || items[0].ProductName != "Honey jar" || items[0].UnitSymbol != "kg" {
t.Fatalf("Create() items = %+v, want snapshot of product name/unit symbol", items)
}
if len(f.notifier.newOrderCalls) != 1 {
t.Fatalf("Create() triggered %d new-order notifications, want exactly 1", len(f.notifier.newOrderCalls))
}
}
func TestService_Create_RejectsEmptyOrder(t *testing.T) {
f := newFixture()
svc := f.service()
_, _, err := svc.Create(context.Background(), orders.CreateInput{CustomerName: "Alice", CustomerEmail: "a@example.com"})
if !errors.Is(err, orders.ErrEmptyOrder) {
t.Fatalf("Create() error = %v, want ErrEmptyOrder", err)
}
}
func TestService_Create_RejectsTierProductMismatch(t *testing.T) {
f := newFixture()
svc := f.service()
otherProduct := uuid.New()
f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true}
_, _, err := svc.Create(context.Background(), orders.CreateInput{
CustomerName: "Alice",
CustomerEmail: "alice@example.com",
Items: []orders.ItemInput{
// tierID actually belongs to f.productID, not otherProduct.
{ProductID: otherProduct, PriceTierID: f.tierID, Multiplier: 1},
},
})
if !errors.Is(err, orders.ErrProductMismatch) {
t.Fatalf("Create() error = %v, want ErrProductMismatch", err)
}
}
func TestService_UpdateStatus_ValidatesStatus(t *testing.T) {
f := newFixture()
svc := f.service()
order, _, err := svc.Create(context.Background(), orders.CreateInput{
CustomerName: "Alice",
CustomerEmail: "alice@example.com",
Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}},
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err := svc.UpdateStatus(context.Background(), order.ID, "not-a-real-status"); !errors.Is(err, orders.ErrInvalidStatus) {
t.Fatalf("UpdateStatus() error = %v, want ErrInvalidStatus", err)
}
updated, err := svc.UpdateStatus(context.Background(), order.ID, orders.StatusConfirmed)
if err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
if updated.Status != orders.StatusConfirmed {
t.Fatalf("UpdateStatus() status = %q, want %q", updated.Status, orders.StatusConfirmed)
}
if len(f.notifier.statusChangeCalls) != 1 {
t.Fatalf("UpdateStatus() triggered %d status-change notifications, want exactly 1", len(f.notifier.statusChangeCalls))
}
}
+142
View File
@@ -0,0 +1,142 @@
package pricing_test
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"backend/internal/modules/pricing"
)
// fakeRepository is an in-memory pricing.Repository used to unit-test
// pricing.Service without a real database.
type fakeRepository struct {
tiers map[uuid.UUID]*pricing.PriceTier
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{tiers: map[uuid.UUID]*pricing.PriceTier{}}
}
func (r *fakeRepository) Create(_ context.Context, t *pricing.PriceTier) error {
cp := *t
r.tiers[t.ID] = &cp
return nil
}
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*pricing.PriceTier, error) {
t, ok := r.tiers[id]
if !ok {
return nil, pricing.ErrNotFound
}
cp := *t
return &cp, nil
}
func (r *fakeRepository) ListByProduct(_ context.Context, productID uuid.UUID) ([]*pricing.PriceTier, error) {
var list []*pricing.PriceTier
for _, t := range r.tiers {
if t.ProductID == productID {
cp := *t
list = append(list, &cp)
}
}
return list, nil
}
func (r *fakeRepository) Update(_ context.Context, t *pricing.PriceTier) error {
if _, ok := r.tiers[t.ID]; !ok {
return pricing.ErrNotFound
}
cp := *t
r.tiers[t.ID] = &cp
return nil
}
func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error {
if _, ok := r.tiers[id]; !ok {
return pricing.ErrNotFound
}
delete(r.tiers, id)
return nil
}
func TestService_PriceForQuantity_MultipliesTierPrice(t *testing.T) {
repo := newFakeRepository()
svc := pricing.NewService(repo)
ctx := context.Background()
productID := uuid.New()
unitID := uuid.New()
// "5 kg -> 45.00" tier, spec section 16 example.
tier, err := svc.Create(ctx, productID, unitID, 5, 4500, 0)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
total, gotTier, err := svc.PriceForQuantity(ctx, tier.ID, 2)
if err != nil {
t.Fatalf("PriceForQuantity() error = %v", err)
}
if total != 9000 {
t.Fatalf("PriceForQuantity() total = %d, want 9000 (2x 4500)", total)
}
if gotTier.ID != tier.ID {
t.Fatalf("PriceForQuantity() returned tier %s, want %s", gotTier.ID, tier.ID)
}
}
func TestService_PriceForQuantity_ZeroOrNegativeMultiplierTreatedAsOne(t *testing.T) {
repo := newFakeRepository()
svc := pricing.NewService(repo)
ctx := context.Background()
tier, err := svc.Create(ctx, uuid.New(), uuid.New(), 1, 1000, 0)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
total, _, err := svc.PriceForQuantity(ctx, tier.ID, 0)
if err != nil {
t.Fatalf("PriceForQuantity() error = %v", err)
}
if total != 1000 {
t.Fatalf("PriceForQuantity() with multiplier=0 total = %d, want 1000 (treated as 1x)", total)
}
}
func TestService_PriceForQuantity_UnknownTierRejected(t *testing.T) {
svc := pricing.NewService(newFakeRepository())
_, _, err := svc.PriceForQuantity(context.Background(), uuid.New(), 1)
if !errors.Is(err, pricing.ErrNotFound) {
t.Fatalf("PriceForQuantity() error = %v, want ErrNotFound", err)
}
}
func TestService_ListByProduct_FiltersByProduct(t *testing.T) {
repo := newFakeRepository()
svc := pricing.NewService(repo)
ctx := context.Background()
productA := uuid.New()
productB := uuid.New()
unitID := uuid.New()
if _, err := svc.Create(ctx, productA, unitID, 1, 1000, 0); err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err := svc.Create(ctx, productB, unitID, 1, 2000, 0); err != nil {
t.Fatalf("Create() error = %v", err)
}
list, err := svc.ListByProduct(ctx, productA)
if err != nil {
t.Fatalf("ListByProduct() error = %v", err)
}
if len(list) != 1 || list[0].ProductID != productA {
t.Fatalf("ListByProduct(productA) = %+v, want exactly one tier for productA", list)
}
}
+150
View File
@@ -0,0 +1,150 @@
package security_test
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"backend/internal/platform/security"
)
const testSecret = "test-secret-please-do-not-use-in-prod"
func TestIssueAndParseAccessToken_RoundTrip(t *testing.T) {
userID := uuid.New()
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
if err != nil {
t.Fatalf("IssueAccessToken() error = %v", err)
}
claims, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin)
if err != nil {
t.Fatalf("ParseAccessToken() error = %v", err)
}
if claims.Subject != userID.String() {
t.Errorf("claims.Subject = %q, want %q", claims.Subject, userID.String())
}
if claims.Role != "admin" {
t.Errorf("claims.Role = %q, want %q", claims.Role, "admin")
}
if claims.ID == "" {
t.Error("claims.ID (jti) is empty, want a non-empty token id")
}
}
func TestIssueAccessToken_RejectsEmptyRole(t *testing.T) {
_, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "", security.AudienceAdmin)
if err == nil {
t.Fatal("IssueAccessToken() error = nil, want error for empty role")
}
}
func TestParseAccessToken_WrongAudienceRejected(t *testing.T) {
userID := uuid.New()
token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin)
if err != nil {
t.Fatalf("IssueAccessToken() error = %v", err)
}
// An admin-space token must never be accepted as a customer-space token,
// even though both spaces share the same signing secret.
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceCustomer); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error when audience does not match expected space")
}
}
func TestParseAccessToken_WrongSecretRejected(t *testing.T) {
token, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "admin", security.AudienceAdmin)
if err != nil {
t.Fatalf("IssueAccessToken() error = %v", err)
}
if _, err := security.ParseAccessToken("a-different-secret", token, security.AudienceAdmin); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error for a token signed with a different secret")
}
}
func TestParseAccessToken_ExpiredTokenRejected(t *testing.T) {
token, err := security.IssueAccessToken(testSecret, -time.Minute, uuid.New(), "admin", security.AudienceAdmin)
if err != nil {
t.Fatalf("IssueAccessToken() error = %v", err)
}
if _, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error for an already-expired token")
}
}
func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) {
// Craft a token that declares "alg":"none" and carries otherwise valid
// claims, to make sure the parser refuses it outright rather than
// trusting an attacker-chosen algorithm.
claims := security.Claims{
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
Subject: uuid.New().String(),
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.NewString(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("craft none-alg token: %v", err)
}
if _, err := security.ParseAccessToken(testSecret, unsigned, security.AudienceAdmin); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error for an alg=none token")
}
}
func TestParseAccessToken_RejectsInvalidSubject(t *testing.T) {
secretBytes := []byte(testSecret)
claims := security.Claims{
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
Subject: "not-a-uuid",
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.NewString(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(secretBytes)
if err != nil {
t.Fatalf("sign token: %v", err)
}
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error for a non-UUID sub claim")
}
}
func TestParseAccessToken_RejectsMissingRole(t *testing.T) {
secretBytes := []byte(testSecret)
claims := security.Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: uuid.New().String(),
Audience: jwt.ClaimStrings{string(security.AudienceAdmin)},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.NewString(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(secretBytes)
if err != nil {
t.Fatalf("sign token: %v", err)
}
if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil {
t.Fatal("ParseAccessToken() error = nil, want error when role claim is missing")
}
}
+71
View File
@@ -0,0 +1,71 @@
package security_test
import (
"testing"
"backend/internal/platform/security"
)
func TestHashAndVerifyPassword_RoundTrip(t *testing.T) {
hash, err := security.HashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
ok, err := security.VerifyPassword(hash, "correct-horse-battery-staple")
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !ok {
t.Fatal("VerifyPassword() = false, want true for the correct password")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash, err := security.HashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
ok, err := security.VerifyPassword(hash, "wrong-password")
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if ok {
t.Fatal("VerifyPassword() = true, want false for a wrong password")
}
}
func TestHashPassword_UniqueSaltPerCall(t *testing.T) {
hash1, err := security.HashPassword("same-password")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
hash2, err := security.HashPassword("same-password")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if hash1 == hash2 {
t.Fatal("two hashes of the same password with random salts must differ")
}
}
func TestVerifyPassword_InvalidFormat(t *testing.T) {
_, err := security.VerifyPassword("not-a-valid-hash", "whatever")
if err == nil {
t.Fatal("VerifyPassword() error = nil, want error for malformed hash")
}
}
func TestVerifyPassword_TamperedHash(t *testing.T) {
hash, err := security.HashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
tampered := hash[:len(hash)-4] + "abcd"
ok, _ := security.VerifyPassword(tampered, "correct-horse-battery-staple")
if ok {
t.Fatal("VerifyPassword() = true for a tampered hash, want false")
}
}
+62
View File
@@ -0,0 +1,62 @@
package site_test
import (
"context"
"testing"
"backend/internal/modules/site"
)
// fakeRepository is an in-memory site.Repository backing the single
// site_settings row, used to unit-test site.Service without a real database.
type fakeRepository struct {
settings site.Settings
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{settings: site.Settings{ID: 1}}
}
func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) {
cp := r.settings
return &cp, nil
}
func (r *fakeRepository) Update(_ context.Context, s *site.Settings) error {
r.settings.Name = s.Name
r.settings.Description = s.Description
r.settings.Slug = s.Slug
return nil
}
func TestService_Update_PersistsFields(t *testing.T) {
svc := site.NewService(newFakeRepository())
ctx := context.Background()
updated, err := svc.Update(ctx, "My Shop", "A small shop", "my-shop")
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if updated.Name != "My Shop" || updated.Description != "A small shop" || updated.Slug != "my-shop" {
t.Fatalf("Update() = %+v, fields not persisted as expected", updated)
}
fetched, err := svc.Get(ctx)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if fetched.Name != "My Shop" {
t.Fatalf("Get() after Update() = %+v, want name %q", fetched, "My Shop")
}
}
func TestService_Get_DefaultsBeforeAnyUpdate(t *testing.T) {
svc := site.NewService(newFakeRepository())
settings, err := svc.Get(context.Background())
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if settings.Name != "" || settings.Slug != "" {
t.Fatalf("Get() before any update = %+v, want empty defaults", settings)
}
}
+157
View File
@@ -0,0 +1,157 @@
package telegram_test
import (
"context"
"io"
"log/slog"
"testing"
"backend/internal/modules/telegram"
)
// fakeRepository is an in-memory telegram.Repository (the single settings
// row) used to unit-test telegram.Service without a real database.
type fakeRepository struct {
settings telegram.Settings
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{settings: telegram.Settings{ID: 1}}
}
func (r *fakeRepository) Get(_ context.Context) (*telegram.Settings, error) {
cp := r.settings
return &cp, nil
}
func (r *fakeRepository) Update(_ context.Context, s *telegram.Settings) error {
r.settings.Enabled = s.Enabled
r.settings.ChatID = s.ChatID
r.settings.NotifyNewOrder = s.NotifyNewOrder
r.settings.NotifyStatusChange = s.NotifyStatusChange
if s.BotToken != "" {
r.settings.BotToken = s.BotToken
}
return nil
}
// fakeSender records every message it was asked to send, instead of making
// a real network call to the Telegram Bot API.
type fakeSender struct {
sent []string
err error
}
func (s *fakeSender) Send(_ context.Context, botToken, chatID, text string) error {
if s.err != nil {
return s.err
}
s.sent = append(s.sent, text)
return nil
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestService_Update_KeepsExistingTokenWhenBlank(t *testing.T) {
repo := newFakeRepository()
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
ctx := context.Background()
if _, err := svc.Update(ctx, true, "secret-token", "12345", true, false); err != nil {
t.Fatalf("first Update() error = %v", err)
}
// Flip a flag without resending the token.
settings, err := svc.Update(ctx, true, "", "12345", false, true)
if err != nil {
t.Fatalf("second Update() error = %v", err)
}
if !settings.NotifyStatusChange || settings.NotifyNewOrder {
t.Fatalf("Update() flags = %+v, want NotifyStatusChange=true NotifyNewOrder=false", settings)
}
if repo.settings.BotToken != "secret-token" {
t.Fatalf("Update() with blank bot_token overwrote the stored token: got %q", repo.settings.BotToken)
}
}
func TestService_Get_NeverExposesRawToken(t *testing.T) {
// This mirrors the handler's toResponse() contract: the service layer
// itself returns the raw Settings (needed internally to call the
// Telegram API), but the HTTP handler must never marshal BotToken back
// to the client. This test guards the service-level data so a future
// change to the handler can't accidentally start doing so without
// resetting the flag on read.
repo := newFakeRepository()
svc := telegram.NewService(repo, &fakeSender{}, discardLogger())
ctx := context.Background()
if _, err := svc.Update(ctx, true, "super-secret", "12345", true, false); err != nil {
t.Fatalf("Update() error = %v", err)
}
settings, err := svc.Get(ctx)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if settings.BotToken != "super-secret" {
t.Fatalf("Get() lost the stored bot token: got %q", settings.BotToken)
}
}
func TestService_NotifyNewOrder_SkipsWhenDisabled(t *testing.T) {
repo := newFakeRepository()
sender := &fakeSender{}
svc := telegram.NewService(repo, sender, discardLogger())
ctx := context.Background()
if _, err := svc.Update(ctx, false, "token", "12345", true, false); err != nil {
t.Fatalf("Update() error = %v", err)
}
svc.NotifyNewOrder(ctx, "new order!")
if len(sender.sent) != 0 {
t.Fatalf("NotifyNewOrder() sent a message while Telegram is disabled: %v", sender.sent)
}
}
func TestService_NotifyNewOrder_SkipsWhenEventNotEnabled(t *testing.T) {
repo := newFakeRepository()
sender := &fakeSender{}
svc := telegram.NewService(repo, sender, discardLogger())
ctx := context.Background()
// Enabled overall, but the "new order" event specifically is off.
if _, err := svc.Update(ctx, true, "token", "12345", false, true); err != nil {
t.Fatalf("Update() error = %v", err)
}
svc.NotifyNewOrder(ctx, "new order!")
if len(sender.sent) != 0 {
t.Fatalf("NotifyNewOrder() sent a message while notify_new_order=false: %v", sender.sent)
}
}
func TestService_NotifyNewOrder_SendsWhenEnabled(t *testing.T) {
repo := newFakeRepository()
sender := &fakeSender{}
svc := telegram.NewService(repo, sender, discardLogger())
ctx := context.Background()
if _, err := svc.Update(ctx, true, "token", "12345", true, false); err != nil {
t.Fatalf("Update() error = %v", err)
}
svc.NotifyNewOrder(ctx, "new order!")
if len(sender.sent) != 1 || sender.sent[0] != "new order!" {
t.Fatalf("NotifyNewOrder() sent = %v, want exactly one message \"new order!\"", sender.sent)
}
}
func TestService_SendTestMessage_RequiresConfiguredCredentials(t *testing.T) {
svc := telegram.NewService(newFakeRepository(), &fakeSender{}, discardLogger())
if err := svc.SendTestMessage(context.Background()); err == nil {
t.Fatal("SendTestMessage() error = nil, want error when bot token/chat id are not configured")
}
}
+183
View File
@@ -0,0 +1,183 @@
package users_test
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"backend/internal/modules/users"
"backend/internal/platform/security"
)
// fakeRepository is an in-memory users.Repository used to unit-test
// users.Service without a real database.
type fakeRepository struct {
byID map[uuid.UUID]*users.User
byEmail map[string]uuid.UUID
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{
byID: make(map[uuid.UUID]*users.User),
byEmail: make(map[string]uuid.UUID),
}
}
func (r *fakeRepository) Create(_ context.Context, u *users.User) error {
if _, exists := r.byEmail[u.Email]; exists {
return users.ErrEmailTaken
}
cp := *u
r.byID[u.ID] = &cp
r.byEmail[u.Email] = u.ID
return nil
}
func (r *fakeRepository) FindByEmail(_ context.Context, email string) (*users.User, error) {
id, ok := r.byEmail[email]
if !ok {
return nil, users.ErrNotFound
}
cp := *r.byID[id]
return &cp, nil
}
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) {
u, ok := r.byID[id]
if !ok {
return nil, users.ErrNotFound
}
cp := *u
return &cp, nil
}
func (r *fakeRepository) List(_ context.Context) ([]*users.User, error) {
list := make([]*users.User, 0, len(r.byID))
for _, u := range r.byID {
cp := *u
list = append(list, &cp)
}
return list, nil
}
func (r *fakeRepository) Update(_ context.Context, u *users.User) error {
existing, ok := r.byID[u.ID]
if !ok {
return users.ErrNotFound
}
if existing.Email != u.Email {
if _, taken := r.byEmail[u.Email]; taken {
return users.ErrEmailTaken
}
delete(r.byEmail, existing.Email)
r.byEmail[u.Email] = u.ID
}
cp := *u
r.byID[u.ID] = &cp
return nil
}
func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error {
u, ok := r.byID[id]
if !ok {
return users.ErrNotFound
}
delete(r.byEmail, u.Email)
delete(r.byID, id)
return nil
}
func TestService_Create_HashesPasswordAndPersists(t *testing.T) {
svc := users.NewService(newFakeRepository())
ctx := context.Background()
user, err := svc.Create(ctx, "admin@example.com", "super-strong-password", users.RoleAdmin)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if user.PasswordHash == "super-strong-password" {
t.Fatal("Create() stored the plaintext password instead of a hash")
}
ok, err := security.VerifyPassword(user.PasswordHash, "super-strong-password")
if err != nil || !ok {
t.Fatalf("stored password hash does not verify against the original password (ok=%v, err=%v)", ok, err)
}
}
func TestService_Create_RejectsInvalidRole(t *testing.T) {
svc := users.NewService(newFakeRepository())
if _, err := svc.Create(context.Background(), "a@example.com", "super-strong-password", "superadmin"); err == nil {
t.Fatal("Create() error = nil, want error for an invalid role")
}
}
func TestService_Create_DuplicateEmailRejected(t *testing.T) {
svc := users.NewService(newFakeRepository())
ctx := context.Background()
if _, err := svc.Create(ctx, "dup@example.com", "super-strong-password", users.RoleAdmin); err != nil {
t.Fatalf("first Create() error = %v", err)
}
_, err := svc.Create(ctx, "dup@example.com", "another-strong-password", users.RoleAdmin)
if !errors.Is(err, users.ErrEmailTaken) {
t.Fatalf("second Create() error = %v, want ErrEmailTaken", err)
}
}
func TestService_SetPassword_ChangesHash(t *testing.T) {
svc := users.NewService(newFakeRepository())
ctx := context.Background()
user, err := svc.Create(ctx, "user@example.com", "first-strong-password", users.RoleAdmin)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
oldHash := user.PasswordHash
if err := svc.SetPassword(ctx, user.ID, "second-strong-password"); err != nil {
t.Fatalf("SetPassword() error = %v", err)
}
updated, err := svc.FindByID(ctx, user.ID)
if err != nil {
t.Fatalf("FindByID() error = %v", err)
}
if updated.PasswordHash == oldHash {
t.Fatal("SetPassword() did not change the stored hash")
}
ok, _ := security.VerifyPassword(updated.PasswordHash, "second-strong-password")
if !ok {
t.Fatal("new password does not verify against the updated hash")
}
ok, _ = security.VerifyPassword(updated.PasswordHash, "first-strong-password")
if ok {
t.Fatal("old password still verifies after SetPassword()")
}
}
func TestService_Delete_RemovesUser(t *testing.T) {
svc := users.NewService(newFakeRepository())
ctx := context.Background()
user, err := svc.Create(ctx, "todelete@example.com", "super-strong-password", users.RoleAdmin)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if err := svc.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if _, err := svc.FindByID(ctx, user.ID); !errors.Is(err, users.ErrNotFound) {
t.Fatalf("FindByID() after delete error = %v, want ErrNotFound", err)
}
}
func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) {
svc := users.NewService(newFakeRepository())
if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) {
t.Fatalf("Delete() error = %v, want ErrNotFound", err)
}
}