feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package usecase
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
func rawURLDecode(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package usecase
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
ErrorKindUnauthorized ErrorKind = "UNAUTHORIZED"
|
||||
ErrorKindForbidden ErrorKind = "FORBIDDEN"
|
||||
)
|
||||
|
||||
func wrapAuthRepositoryError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrAuthCredentials):
|
||||
return newError(
|
||||
ErrorKindUnauthorized,
|
||||
"AUTH_INVALID_CREDENTIALS",
|
||||
"invalid credentials",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAuthDisabled):
|
||||
return newError(
|
||||
ErrorKindForbidden,
|
||||
"AUTH_ACCOUNT_OR_DEVICE_DISABLED",
|
||||
"account or device is disabled",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAuthForbidden):
|
||||
return newError(
|
||||
ErrorKindForbidden,
|
||||
"AUTH_FORBIDDEN",
|
||||
"identity is not allowed to perform this operation",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAuthExpired):
|
||||
return newError(
|
||||
ErrorKindUnauthorized,
|
||||
"AUTH_SESSION_EXPIRED",
|
||||
"session expired",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAuthRevoked):
|
||||
return newError(
|
||||
ErrorKindUnauthorized,
|
||||
"AUTH_INVALID_TOKEN",
|
||||
"invalid token",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAuthConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"AUTH_RESOURCE_CONFLICT",
|
||||
"authentication resource already exists",
|
||||
err,
|
||||
)
|
||||
default:
|
||||
return wrapRepositoryError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type PasswordManager interface {
|
||||
Hash(string) (string, error)
|
||||
Verify(string, string) error
|
||||
VerifyDummy(string)
|
||||
}
|
||||
|
||||
type OpaqueTokenGenerator interface {
|
||||
NewToken() (string, error)
|
||||
}
|
||||
|
||||
type AuthRepository interface {
|
||||
FindUserByUsername(context.Context, string) (domain.User, error)
|
||||
ProvisionUser(context.Context, domain.User) (domain.User, error)
|
||||
ProvisionDevice(context.Context, domain.Device) (domain.Device, error)
|
||||
SetUserActive(context.Context, string, bool, time.Time) error
|
||||
SetDeviceEnabled(context.Context, string, bool, time.Time) error
|
||||
CreateAdminSession(
|
||||
context.Context,
|
||||
domain.AdminSession,
|
||||
time.Time,
|
||||
) error
|
||||
AuthenticateAdminSession(
|
||||
context.Context,
|
||||
string,
|
||||
time.Time,
|
||||
) (domain.AuthPrincipal, error)
|
||||
RevokeAdminSession(context.Context, string, time.Time) error
|
||||
CreateAccessTokenAndBindDevice(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
domain.AccessToken,
|
||||
time.Time,
|
||||
) (domain.Device, error)
|
||||
AuthenticateAccessToken(
|
||||
context.Context,
|
||||
string,
|
||||
time.Time,
|
||||
) (domain.AuthPrincipal, error)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAuthCredentials = errors.New("authentication credentials are invalid")
|
||||
ErrAuthDisabled = errors.New("authentication subject is disabled")
|
||||
ErrAuthForbidden = errors.New("authentication role is forbidden")
|
||||
ErrAuthExpired = errors.New("authentication session expired")
|
||||
ErrAuthRevoked = errors.New("authentication session revoked")
|
||||
ErrAuthConflict = errors.New("authentication resource conflict")
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
const opaqueTokenBytes = 32
|
||||
|
||||
type CryptoTokenGenerator struct{}
|
||||
|
||||
func (CryptoTokenGenerator) NewToken() (string, error) {
|
||||
value := make([]byte, opaqueTokenBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, value); err != nil {
|
||||
return "", errors.New("generate opaque token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminSessionLifetime = 8 * time.Hour
|
||||
AccessTokenLifetime = time.Hour
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
repository AuthRepository
|
||||
passwords PasswordManager
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
tokens OpaqueTokenGenerator
|
||||
}
|
||||
|
||||
type LoginAdminCommand struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type AdminSessionResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
User domain.User
|
||||
}
|
||||
|
||||
type LoginBuyerDeviceCommand struct {
|
||||
Username string
|
||||
Password string
|
||||
DeviceID string
|
||||
DeviceToken string
|
||||
AppVersion string
|
||||
AndroidVersion string
|
||||
}
|
||||
|
||||
type AccessTokenResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
User domain.User
|
||||
Device domain.Device
|
||||
}
|
||||
|
||||
type ProvisionUserCommand struct {
|
||||
Username string
|
||||
Password string
|
||||
Role domain.UserRole
|
||||
Active bool
|
||||
}
|
||||
|
||||
type ProvisionDeviceCommand struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type ProvisionDeviceResult struct {
|
||||
Device domain.Device
|
||||
DeviceToken string
|
||||
}
|
||||
|
||||
type SetUserActiveCommand struct {
|
||||
Username string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type SetDeviceEnabledCommand struct {
|
||||
DeviceID string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
repository AuthRepository,
|
||||
passwords PasswordManager,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
tokens OpaqueTokenGenerator,
|
||||
) (*AuthService, error) {
|
||||
switch {
|
||||
case repository == nil:
|
||||
return nil, errors.New("auth repository is required")
|
||||
case passwords == nil:
|
||||
return nil, errors.New("password manager is required")
|
||||
case clock == nil:
|
||||
return nil, errors.New("clock is required")
|
||||
case ids == nil:
|
||||
return nil, errors.New("ID generator is required")
|
||||
case tokens == nil:
|
||||
return nil, errors.New("opaque token generator is required")
|
||||
default:
|
||||
return &AuthService{
|
||||
repository: repository,
|
||||
passwords: passwords,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
tokens: tokens,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) LoginAdmin(
|
||||
ctx context.Context,
|
||||
command LoginAdminCommand,
|
||||
) (AdminSessionResult, error) {
|
||||
username, err := validateLoginInput(command.Username, command.Password)
|
||||
if err != nil {
|
||||
return AdminSessionResult{}, err
|
||||
}
|
||||
user, err := s.verifiedUser(ctx, username, command.Password)
|
||||
if err != nil {
|
||||
return AdminSessionResult{}, err
|
||||
}
|
||||
if !user.IsActive {
|
||||
return AdminSessionResult{}, wrapAuthRepositoryError(ErrAuthDisabled)
|
||||
}
|
||||
if user.Role != domain.UserRoleAdmin {
|
||||
return AdminSessionResult{}, wrapAuthRepositoryError(ErrAuthCredentials)
|
||||
}
|
||||
|
||||
rawToken, session, now, err := s.newAdminSession(user.ID)
|
||||
if err != nil {
|
||||
return AdminSessionResult{}, err
|
||||
}
|
||||
if err := s.repository.CreateAdminSession(ctx, session, now); err != nil {
|
||||
return AdminSessionResult{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
return AdminSessionResult{
|
||||
Token: rawToken,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) AuthenticateAdmin(
|
||||
ctx context.Context,
|
||||
rawToken string,
|
||||
) (domain.AuthPrincipal, error) {
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return domain.AuthPrincipal{},
|
||||
wrapAuthRepositoryError(ErrAuthRevoked)
|
||||
}
|
||||
principal, err := s.repository.AuthenticateAdminSession(
|
||||
ctx,
|
||||
hashSecret(rawToken),
|
||||
s.clock.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.AuthPrincipal{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
if principal.Role != domain.UserRoleAdmin || principal.DeviceID != "" {
|
||||
return domain.AuthPrincipal{},
|
||||
wrapAuthRepositoryError(ErrAuthForbidden)
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) LogoutAdmin(
|
||||
ctx context.Context,
|
||||
rawToken string,
|
||||
) error {
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return nil
|
||||
}
|
||||
if err := s.repository.RevokeAdminSession(
|
||||
ctx,
|
||||
hashSecret(rawToken),
|
||||
s.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
return wrapAuthRepositoryError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) LoginBuyerDevice(
|
||||
ctx context.Context,
|
||||
command LoginBuyerDeviceCommand,
|
||||
) (AccessTokenResult, error) {
|
||||
username, err := validateLoginInput(command.Username, command.Password)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.DeviceID) == "" ||
|
||||
!validOpaqueToken(command.DeviceToken) {
|
||||
return AccessTokenResult{},
|
||||
wrapAuthRepositoryError(ErrAuthCredentials)
|
||||
}
|
||||
appVersion := strings.TrimSpace(command.AppVersion)
|
||||
androidVersion := strings.TrimSpace(command.AndroidVersion)
|
||||
versionFields := make(map[string]string)
|
||||
if appVersion == "" {
|
||||
versionFields["app_version"] = "required"
|
||||
} else if len([]byte(appVersion)) > domain.MaxVersionBytes {
|
||||
versionFields["app_version"] = "must not exceed 128 UTF-8 bytes"
|
||||
}
|
||||
if androidVersion == "" {
|
||||
versionFields["android_version"] = "required"
|
||||
} else if len([]byte(androidVersion)) > domain.MaxVersionBytes {
|
||||
versionFields["android_version"] =
|
||||
"must not exceed 128 UTF-8 bytes"
|
||||
}
|
||||
if len(versionFields) > 0 {
|
||||
return AccessTokenResult{}, invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
versionFields,
|
||||
)
|
||||
}
|
||||
user, err := s.verifiedUser(ctx, username, command.Password)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
if !user.IsActive {
|
||||
return AccessTokenResult{}, wrapAuthRepositoryError(ErrAuthDisabled)
|
||||
}
|
||||
if user.Role != domain.UserRoleBuyer {
|
||||
return AccessTokenResult{},
|
||||
wrapAuthRepositoryError(ErrAuthCredentials)
|
||||
}
|
||||
|
||||
rawToken, access, now, err := s.newAccessToken(
|
||||
user.ID,
|
||||
strings.TrimSpace(command.DeviceID),
|
||||
)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
device, err := s.repository.CreateAccessTokenAndBindDevice(
|
||||
ctx,
|
||||
user.ID,
|
||||
access.DeviceID,
|
||||
hashSecret(command.DeviceToken),
|
||||
appVersion,
|
||||
androidVersion,
|
||||
access,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
device.TokenHash = ""
|
||||
return AccessTokenResult{
|
||||
Token: rawToken,
|
||||
ExpiresAt: access.ExpiresAt,
|
||||
User: user,
|
||||
Device: device,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) AuthenticateAccessToken(
|
||||
ctx context.Context,
|
||||
rawToken string,
|
||||
) (domain.AuthPrincipal, error) {
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return domain.AuthPrincipal{},
|
||||
wrapAuthRepositoryError(ErrAuthRevoked)
|
||||
}
|
||||
principal, err := s.repository.AuthenticateAccessToken(
|
||||
ctx,
|
||||
hashSecret(rawToken),
|
||||
s.clock.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.AuthPrincipal{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
if principal.Role != domain.UserRoleBuyer || principal.DeviceID == "" {
|
||||
return domain.AuthPrincipal{},
|
||||
wrapAuthRepositoryError(ErrAuthForbidden)
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ProvisionUser(
|
||||
ctx context.Context,
|
||||
command ProvisionUserCommand,
|
||||
) (domain.User, error) {
|
||||
if err := domain.ValidateUserInput(
|
||||
command.Username,
|
||||
command.Password,
|
||||
command.Role,
|
||||
); err != nil {
|
||||
var validation *domain.AuthValidationError
|
||||
if errors.As(err, &validation) {
|
||||
return domain.User{}, invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
validation.Fields,
|
||||
)
|
||||
}
|
||||
return domain.User{}, invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
map[string]string{},
|
||||
)
|
||||
}
|
||||
passwordHash, err := s.passwords.Hash(command.Password)
|
||||
if err != nil {
|
||||
return domain.User{}, internalAuthFailure(err)
|
||||
}
|
||||
id, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.User{}, internalAuthFailure(err)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
user, err := s.repository.ProvisionUser(ctx, domain.User{
|
||||
ID: id,
|
||||
Username: domain.NormalizeUsername(command.Username),
|
||||
PasswordHash: passwordHash,
|
||||
Role: command.Role,
|
||||
IsActive: command.Active,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.User{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ProvisionDevice(
|
||||
ctx context.Context,
|
||||
command ProvisionDeviceCommand,
|
||||
) (ProvisionDeviceResult, error) {
|
||||
if err := domain.ValidateDeviceName(command.Name); err != nil {
|
||||
return ProvisionDeviceResult{}, invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
map[string]string{"name": err.Error()},
|
||||
)
|
||||
}
|
||||
id, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return ProvisionDeviceResult{}, internalAuthFailure(err)
|
||||
}
|
||||
rawToken, err := s.tokens.NewToken()
|
||||
if err != nil {
|
||||
return ProvisionDeviceResult{}, internalAuthFailure(err)
|
||||
}
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return ProvisionDeviceResult{},
|
||||
internalAuthFailure(errors.New("token generator returned invalid token"))
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
device, err := s.repository.ProvisionDevice(ctx, domain.Device{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(command.Name),
|
||||
TokenHash: hashSecret(rawToken),
|
||||
IsEnabled: command.Enabled,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return ProvisionDeviceResult{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
device.TokenHash = ""
|
||||
return ProvisionDeviceResult{
|
||||
Device: device,
|
||||
DeviceToken: rawToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) SetUserActive(
|
||||
ctx context.Context,
|
||||
command SetUserActiveCommand,
|
||||
) error {
|
||||
username := domain.NormalizeUsername(command.Username)
|
||||
if username == "" ||
|
||||
!utf8.ValidString(username) ||
|
||||
len([]byte(username)) > domain.MaxUsernameBytes {
|
||||
return invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
map[string]string{"username": "invalid"},
|
||||
)
|
||||
}
|
||||
if err := s.repository.SetUserActive(
|
||||
ctx,
|
||||
username,
|
||||
command.Active,
|
||||
s.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
return wrapAuthRepositoryError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) SetDeviceEnabled(
|
||||
ctx context.Context,
|
||||
command SetDeviceEnabledCommand,
|
||||
) error {
|
||||
deviceID := strings.TrimSpace(command.DeviceID)
|
||||
if !isUUID(deviceID) {
|
||||
return invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
map[string]string{"device_id": "must be a UUID"},
|
||||
)
|
||||
}
|
||||
if err := s.repository.SetDeviceEnabled(
|
||||
ctx,
|
||||
deviceID,
|
||||
command.Enabled,
|
||||
s.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
return wrapAuthRepositoryError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) verifiedUser(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
password string,
|
||||
) (domain.User, error) {
|
||||
user, err := s.repository.FindUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRepositoryNotFound) {
|
||||
s.passwords.VerifyDummy(password)
|
||||
return domain.User{},
|
||||
wrapAuthRepositoryError(ErrAuthCredentials)
|
||||
}
|
||||
return domain.User{}, wrapAuthRepositoryError(err)
|
||||
}
|
||||
if err := s.passwords.Verify(user.PasswordHash, password); err != nil {
|
||||
return domain.User{}, wrapAuthRepositoryError(ErrAuthCredentials)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) newAdminSession(
|
||||
userID string,
|
||||
) (string, domain.AdminSession, time.Time, error) {
|
||||
rawToken, err := s.tokens.NewToken()
|
||||
if err != nil {
|
||||
return "", domain.AdminSession{}, time.Time{},
|
||||
internalAuthFailure(err)
|
||||
}
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return "", domain.AdminSession{}, time.Time{},
|
||||
internalAuthFailure(errors.New("token generator returned invalid token"))
|
||||
}
|
||||
id, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return "", domain.AdminSession{}, time.Time{},
|
||||
internalAuthFailure(err)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
return rawToken, domain.AdminSession{
|
||||
ID: id,
|
||||
TokenHash: hashSecret(rawToken),
|
||||
UserID: userID,
|
||||
ExpiresAt: now.Add(AdminSessionLifetime),
|
||||
CreatedAt: now,
|
||||
}, now, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) newAccessToken(
|
||||
userID string,
|
||||
deviceID string,
|
||||
) (string, domain.AccessToken, time.Time, error) {
|
||||
rawToken, err := s.tokens.NewToken()
|
||||
if err != nil {
|
||||
return "", domain.AccessToken{}, time.Time{},
|
||||
internalAuthFailure(err)
|
||||
}
|
||||
if !validOpaqueToken(rawToken) {
|
||||
return "", domain.AccessToken{}, time.Time{},
|
||||
internalAuthFailure(errors.New("token generator returned invalid token"))
|
||||
}
|
||||
id, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return "", domain.AccessToken{}, time.Time{},
|
||||
internalAuthFailure(err)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
return rawToken, domain.AccessToken{
|
||||
ID: id,
|
||||
TokenHash: hashSecret(rawToken),
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
ExpiresAt: now.Add(AccessTokenLifetime),
|
||||
CreatedAt: now,
|
||||
}, now, nil
|
||||
}
|
||||
|
||||
func validateLoginInput(username, password string) (string, error) {
|
||||
normalized := domain.NormalizeUsername(username)
|
||||
fields := make(map[string]string)
|
||||
if normalized == "" ||
|
||||
len([]byte(normalized)) > domain.MaxUsernameBytes {
|
||||
fields["username"] = "invalid"
|
||||
}
|
||||
if password == "" ||
|
||||
!utf8.ValidString(password) ||
|
||||
len([]byte(password)) > domain.MaxPasswordBytes {
|
||||
fields["password"] = "invalid"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return "", invalidError(
|
||||
"AUTH_VALIDATION_FAILED",
|
||||
"authentication input is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func hashSecret(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validOpaqueToken(value string) bool {
|
||||
decoded, err := decodeOpaqueToken(value)
|
||||
return err == nil && len(decoded) == opaqueTokenBytes
|
||||
}
|
||||
|
||||
func decodeOpaqueToken(value string) ([]byte, error) {
|
||||
// Tokens are generated with RawURLEncoding; accepting padding would create
|
||||
// multiple textual representations of the same credential.
|
||||
return rawURLDecode(value)
|
||||
}
|
||||
|
||||
func internalAuthFailure(err error) error {
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
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
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type TaskService struct {
|
||||
|
||||
type CreateTaskCommand struct {
|
||||
CreatorSubject string
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
SourceRef *string
|
||||
Title string
|
||||
@@ -61,6 +62,7 @@ type TaskPage struct {
|
||||
|
||||
type CancelTaskCommand struct {
|
||||
CreatorSubject string
|
||||
ActorUserID string
|
||||
TaskID string
|
||||
Reason string
|
||||
}
|
||||
@@ -91,6 +93,7 @@ func (s *TaskService) Create(
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||
command.Title = strings.TrimSpace(command.Title)
|
||||
command.SKU = strings.TrimSpace(command.SKU)
|
||||
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
|
||||
@@ -105,6 +108,13 @@ func (s *TaskService) Create(
|
||||
map[string]string{"image_asset_id": "must be a UUID"},
|
||||
)
|
||||
}
|
||||
if !isUUID(command.ActorUserID) {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
map[string]string{"actor_user_id": "must be a UUID"},
|
||||
)
|
||||
}
|
||||
if err := domain.ValidateTaskInput(
|
||||
command.CreatorSubject,
|
||||
command.SourceRef,
|
||||
@@ -156,28 +166,31 @@ func (s *TaskService) Create(
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
actorUserID := command.ActorUserID
|
||||
task := domain.PurchaseTask{
|
||||
ID: taskID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
Currency: domain.CurrencyCNY,
|
||||
Status: domain.TaskStatusPending,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: taskID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
CreatedByUserID: &actorUserID,
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
Currency: domain.CurrencyCNY,
|
||||
Status: domain.TaskStatusPending,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: taskID,
|
||||
Type: "TASK_CREATED",
|
||||
Message: "task created",
|
||||
OccurredAt: now,
|
||||
ID: eventID,
|
||||
TaskID: taskID,
|
||||
ActorUserID: &actorUserID,
|
||||
Type: "TASK_CREATED",
|
||||
Message: "task created",
|
||||
OccurredAt: now,
|
||||
}
|
||||
requestHash, err := hashCreateTaskCommand(command, budget)
|
||||
if err != nil {
|
||||
@@ -323,6 +336,7 @@ func (s *TaskService) Cancel(
|
||||
command CancelTaskCommand,
|
||||
) (domain.PurchaseTask, error) {
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
fields := make(map[string]string)
|
||||
@@ -332,6 +346,9 @@ func (s *TaskService) Cancel(
|
||||
if !isUUID(command.TaskID) {
|
||||
fields["task_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(command.ActorUserID) {
|
||||
fields["actor_user_id"] = "must be a UUID"
|
||||
}
|
||||
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
|
||||
fields["reason"] = "too long"
|
||||
}
|
||||
@@ -352,12 +369,14 @@ func (s *TaskService) Cancel(
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
actorUserID := command.ActorUserID
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
Type: "TASK_CANCELED",
|
||||
Message: "task canceled",
|
||||
OccurredAt: now,
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &actorUserID,
|
||||
Type: "TASK_CANCELED",
|
||||
Message: "task canceled",
|
||||
OccurredAt: now,
|
||||
}
|
||||
task, err := s.repository.CancelPendingTask(
|
||||
ctx,
|
||||
@@ -378,6 +397,7 @@ func hashCreateTaskCommand(
|
||||
budget *int64,
|
||||
) (string, error) {
|
||||
payload := struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
SourceRef *string `json:"source_ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
@@ -386,6 +406,7 @@ func hashCreateTaskCommand(
|
||||
Quantity int `json:"quantity"`
|
||||
MaxBudgetCents *int64 `json:"max_budget_cents"`
|
||||
}{
|
||||
ActorUserID: command.ActorUserID,
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
||||
|
||||
result, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: " local-admin ",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
IdempotencyKey: " create-1 ",
|
||||
SourceRef: &sourceRef,
|
||||
Title: " Demo title ",
|
||||
@@ -34,6 +35,9 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
||||
result.Task.SKU != "SKU-1" ||
|
||||
result.Task.SourceRef == nil ||
|
||||
*result.Task.SourceRef != "source-1" ||
|
||||
result.Task.CreatedByUserID == nil ||
|
||||
*result.Task.CreatedByUserID !=
|
||||
"00000000-0000-4000-8000-000000000099" ||
|
||||
result.Task.MaxBudgetCents == nil ||
|
||||
*result.Task.MaxBudgetCents != 2000 {
|
||||
t.Fatalf("created task = %+v", result.Task)
|
||||
@@ -46,7 +50,10 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
||||
)
|
||||
}
|
||||
if repository.event.Type != "TASK_CREATED" ||
|
||||
repository.event.TaskID != result.Task.ID {
|
||||
repository.event.TaskID != result.Task.ID ||
|
||||
repository.event.ActorUserID == nil ||
|
||||
*repository.event.ActorUserID !=
|
||||
"00000000-0000-4000-8000-000000000099" {
|
||||
t.Fatalf("event = %+v", repository.event)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +63,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
IdempotencyKey: "create-1",
|
||||
Title: "title",
|
||||
SKU: "sku",
|
||||
@@ -66,6 +74,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
||||
|
||||
_, err = service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
IdempotencyKey: "create-2",
|
||||
Title: "",
|
||||
SKU: "",
|
||||
@@ -112,10 +121,16 @@ func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Cancel(context.Background(), CancelTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||
Reason: "no longer needed",
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
|
||||
if repository.event.ActorUserID == nil ||
|
||||
*repository.event.ActorUserID !=
|
||||
"00000000-0000-4000-8000-000000000099" {
|
||||
t.Fatalf("cancel event = %+v", repository.event)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClock struct{}
|
||||
@@ -194,8 +209,9 @@ func (repository *fakeTaskRepository) CancelPendingTask(
|
||||
_ string,
|
||||
_ string,
|
||||
_ time.Time,
|
||||
_ domain.TaskEvent,
|
||||
event domain.TaskEvent,
|
||||
) (domain.PurchaseTask, error) {
|
||||
repository.event = event
|
||||
return domain.PurchaseTask{}, repository.cancelErr
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user