482 lines
12 KiB
Go
482 lines
12 KiB
Go
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
|
|
}
|