package usecase import ( "context" "encoding/base64" "errors" "fmt" "testing" "time" "cmroubao/backend-api/internal/domain" ) func TestAuthServiceAdminSessionLifecycle(t *testing.T) { fixture := newAuthServiceFixture(t) fixture.repository.users["admin"] = domain.User{ ID: "user-admin", Username: "admin", PasswordHash: "hashed:password-123", Role: domain.UserRoleAdmin, IsActive: true, } result, err := fixture.service.LoginAdmin( context.Background(), LoginAdminCommand{Username: " ADMIN ", Password: "password-123"}, ) if err != nil { t.Fatalf("LoginAdmin() error = %v", err) } if result.ExpiresAt.Sub(fixture.clock.now) != AdminSessionLifetime { t.Fatalf("admin lifetime = %s", result.ExpiresAt.Sub(fixture.clock.now)) } if result.User.PasswordHash != "" { t.Fatal("LoginAdmin() exposed password hash") } if fixture.repository.adminSession.TokenHash == result.Token || len(fixture.repository.adminSession.TokenHash) != 64 { t.Fatal("admin session was not stored as a SHA-256 hash") } fixture.repository.adminPrincipal = domain.AuthPrincipal{ UserID: result.User.ID, Username: result.User.Username, Role: domain.UserRoleAdmin, SessionID: fixture.repository.adminSession.ID, ExpiresAt: result.ExpiresAt, } principal, err := fixture.service.AuthenticateAdmin( context.Background(), result.Token, ) if err != nil { t.Fatalf("AuthenticateAdmin() error = %v", err) } if principal.Role != domain.UserRoleAdmin || principal.DeviceID != "" { t.Fatalf("AuthenticateAdmin() principal = %+v", principal) } if err := fixture.service.LogoutAdmin( context.Background(), result.Token, ); err != nil { t.Fatalf("LogoutAdmin() error = %v", err) } if fixture.repository.revokedHash != fixture.repository.adminSession.TokenHash { t.Fatal("LogoutAdmin() did not revoke the hashed token") } } func TestAuthServiceBuyerDeviceTokenLifecycle(t *testing.T) { fixture := newAuthServiceFixture(t) fixture.repository.users["buyer"] = domain.User{ ID: "user-buyer", Username: "buyer", PasswordHash: "hashed:password-123", Role: domain.UserRoleBuyer, IsActive: true, } deviceToken := validTestToken(99) fixture.repository.device = domain.Device{ ID: "device-1", Name: "Device", TokenHash: hashSecret(deviceToken), IsEnabled: true, } result, err := fixture.service.LoginBuyerDevice( context.Background(), LoginBuyerDeviceCommand{ Username: "buyer", Password: "password-123", DeviceID: "device-1", DeviceToken: deviceToken, AppVersion: "0.1.0", AndroidVersion: "16", }, ) if err != nil { t.Fatalf("LoginBuyerDevice() error = %v", err) } if result.ExpiresAt.Sub(fixture.clock.now) != AccessTokenLifetime { t.Fatalf("access lifetime = %s", result.ExpiresAt.Sub(fixture.clock.now)) } if result.User.PasswordHash != "" || result.Device.TokenHash != "" { t.Fatal("LoginBuyerDevice() exposed a credential hash") } if fixture.repository.access.TokenHash == result.Token || len(fixture.repository.access.TokenHash) != 64 { t.Fatal("access token was not stored as a SHA-256 hash") } if fixture.repository.deviceTokenHash != hashSecret(deviceToken) { t.Fatal("device credential was not passed as a hash") } fixture.repository.accessPrincipal = domain.AuthPrincipal{ UserID: result.User.ID, Username: result.User.Username, Role: domain.UserRoleBuyer, SessionID: fixture.repository.access.ID, DeviceID: result.Device.ID, ExpiresAt: result.ExpiresAt, } principal, err := fixture.service.AuthenticateAccessToken( context.Background(), result.Token, ) if err != nil { t.Fatalf("AuthenticateAccessToken() error = %v", err) } if principal.DeviceID != "device-1" { t.Fatalf("AuthenticateAccessToken() principal = %+v", principal) } } func TestAuthServiceRejectsWrongRoleDisabledAndMalformedTokens(t *testing.T) { fixture := newAuthServiceFixture(t) fixture.repository.users["buyer"] = domain.User{ ID: "buyer", Username: "buyer", PasswordHash: "hashed:password-123", Role: domain.UserRoleBuyer, IsActive: true, } _, err := fixture.service.LoginAdmin( context.Background(), LoginAdminCommand{Username: "buyer", Password: "password-123"}, ) assertAuthCode(t, err, "AUTH_INVALID_CREDENTIALS") fixture.repository.users["buyer"] = domain.User{ ID: "buyer", Username: "buyer", PasswordHash: "hashed:password-123", Role: domain.UserRoleBuyer, IsActive: false, } _, err = fixture.service.LoginBuyerDevice( context.Background(), LoginBuyerDeviceCommand{ Username: "buyer", Password: "password-123", DeviceID: "device", DeviceToken: validTestToken(90), AppVersion: "0.1.0", AndroidVersion: "16", }, ) assertAuthCode(t, err, "AUTH_ACCOUNT_OR_DEVICE_DISABLED") _, err = fixture.service.AuthenticateAdmin( context.Background(), "not-a-token", ) assertAuthCode(t, err, "AUTH_INVALID_TOKEN") } func TestAuthServiceProvisionsHashedCredentials(t *testing.T) { fixture := newAuthServiceFixture(t) user, err := fixture.service.ProvisionUser( context.Background(), ProvisionUserCommand{ Username: " Admin ", Password: "password-123", Role: domain.UserRoleAdmin, Active: true, }, ) if err != nil { t.Fatalf("ProvisionUser() error = %v", err) } if user.Username != "admin" || user.PasswordHash != "" { t.Fatalf("ProvisionUser() = %+v", user) } stored := fixture.repository.users["admin"] if stored.PasswordHash != "hashed:password-123" { t.Fatalf("stored password hash = %q", stored.PasswordHash) } device, err := fixture.service.ProvisionDevice( context.Background(), ProvisionDeviceCommand{Name: " Test Device ", Enabled: true}, ) if err != nil { t.Fatalf("ProvisionDevice() error = %v", err) } if device.DeviceToken == "" || device.Device.TokenHash != "" { t.Fatalf("ProvisionDevice() = %+v", device) } if fixture.repository.device.TokenHash == device.DeviceToken || fixture.repository.device.TokenHash != hashSecret(device.DeviceToken) { t.Fatal("stored device token is not a SHA-256 hash") } } func TestAuthServiceChangesUserAndDeviceStatus(t *testing.T) { fixture := newAuthServiceFixture(t) if err := fixture.service.SetUserActive( context.Background(), SetUserActiveCommand{Username: " Buyer ", Active: false}, ); err != nil { t.Fatalf("SetUserActive() error = %v", err) } deviceID := "00000000-0000-4000-8000-000000000099" if err := fixture.service.SetDeviceEnabled( context.Background(), SetDeviceEnabledCommand{DeviceID: deviceID, Enabled: false}, ); err != nil { t.Fatalf("SetDeviceEnabled() error = %v", err) } if fixture.repository.userStatusName != "buyer" || fixture.repository.userActive || fixture.repository.deviceStatusID != deviceID || fixture.repository.deviceEnabled { t.Fatalf( "user/device state = %q/%v %q/%v", fixture.repository.userStatusName, fixture.repository.userActive, fixture.repository.deviceStatusID, fixture.repository.deviceEnabled, ) } err := fixture.service.SetDeviceEnabled( context.Background(), SetDeviceEnabledCommand{DeviceID: "not-a-uuid", Enabled: true}, ) assertAuthCode(t, err, "AUTH_VALIDATION_FAILED") } func TestAuthServiceUnknownUserUsesPrecomputedDummyVerification(t *testing.T) { fixture := newAuthServiceFixture(t) _, err := fixture.service.LoginAdmin( context.Background(), LoginAdminCommand{ Username: "missing-user", Password: "password-123", }, ) assertAuthCode(t, err, "AUTH_INVALID_CREDENTIALS") if fixture.passwords.dummyVerifications != 1 { t.Fatalf( "dummy verifications = %d, want 1", fixture.passwords.dummyVerifications, ) } if fixture.passwords.hashCalls != 0 { t.Fatalf("Hash() calls for missing user = %d", fixture.passwords.hashCalls) } } func assertAuthCode(t *testing.T, err error, code string) { t.Helper() var authError *Error if !errors.As(err, &authError) || authError.Code != code { t.Fatalf("error = %v, want code %s", err, code) } } type authServiceFixture struct { service *AuthService repository *fakeAuthRepository clock fixedAuthClock passwords *fakePasswordManager } func newAuthServiceFixture(t *testing.T) authServiceFixture { t.Helper() repository := &fakeAuthRepository{ users: make(map[string]domain.User), } clock := fixedAuthClock{ now: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC), } passwords := &fakePasswordManager{} service, err := NewAuthService( repository, passwords, clock, &sequenceIDGenerator{}, &sequenceTokenGenerator{}, ) if err != nil { t.Fatalf("NewAuthService() error = %v", err) } return authServiceFixture{ service: service, repository: repository, clock: clock, passwords: passwords, } } type fixedAuthClock struct { now time.Time } func (clock fixedAuthClock) Now() time.Time { return clock.now } type fakePasswordManager struct { hashCalls int dummyVerifications int } func (manager *fakePasswordManager) Hash(value string) (string, error) { manager.hashCalls++ return "hashed:" + value, nil } func (*fakePasswordManager) Verify(encoded, plain string) error { if encoded != "hashed:"+plain { return errors.New("mismatch") } return nil } func (manager *fakePasswordManager) VerifyDummy(string) { manager.dummyVerifications++ } type sequenceIDGenerator struct { next int } func (generator *sequenceIDGenerator) NewID() (string, error) { generator.next++ return fmt.Sprintf("id-%d", generator.next), nil } type sequenceTokenGenerator struct { next byte } func (generator *sequenceTokenGenerator) NewToken() (string, error) { generator.next++ return validTestToken(generator.next), nil } func validTestToken(seed byte) string { value := make([]byte, opaqueTokenBytes) for index := range value { value[index] = seed + byte(index) } return base64.RawURLEncoding.EncodeToString(value) } type fakeAuthRepository struct { users map[string]domain.User device domain.Device adminSession domain.AdminSession adminPrincipal domain.AuthPrincipal access domain.AccessToken accessPrincipal domain.AuthPrincipal deviceTokenHash string revokedHash string authenticationErr error userActive bool userStatusName string deviceEnabled bool deviceStatusID string } func (repository *fakeAuthRepository) FindUserByUsername( _ context.Context, username string, ) (domain.User, error) { user, found := repository.users[username] if !found { return domain.User{}, ErrRepositoryNotFound } return user, nil } func (repository *fakeAuthRepository) ProvisionUser( _ context.Context, user domain.User, ) (domain.User, error) { repository.users[user.Username] = user return user, nil } func (repository *fakeAuthRepository) ProvisionDevice( _ context.Context, device domain.Device, ) (domain.Device, error) { repository.device = device return device, nil } func (repository *fakeAuthRepository) SetUserActive( _ context.Context, username string, active bool, _ time.Time, ) error { repository.userStatusName = username repository.userActive = active return nil } func (repository *fakeAuthRepository) SetDeviceEnabled( _ context.Context, deviceID string, enabled bool, _ time.Time, ) error { repository.deviceStatusID = deviceID repository.deviceEnabled = enabled return nil } func (repository *fakeAuthRepository) CreateAdminSession( _ context.Context, session domain.AdminSession, _ time.Time, ) error { repository.adminSession = session return nil } func (repository *fakeAuthRepository) AuthenticateAdminSession( _ context.Context, _ string, _ time.Time, ) (domain.AuthPrincipal, error) { return repository.adminPrincipal, repository.authenticationErr } func (repository *fakeAuthRepository) RevokeAdminSession( _ context.Context, tokenHash string, _ time.Time, ) error { repository.revokedHash = tokenHash return nil } func (repository *fakeAuthRepository) CreateAccessTokenAndBindDevice( _ context.Context, _ string, _ string, deviceTokenHash string, _ string, _ string, access domain.AccessToken, _ time.Time, ) (domain.Device, error) { repository.deviceTokenHash = deviceTokenHash repository.access = access return repository.device, nil } func (repository *fakeAuthRepository) AuthenticateAccessToken( _ context.Context, _ string, _ time.Time, ) (domain.AuthPrincipal, error) { return repository.accessPrincipal, repository.authenticationErr }