优化
This commit is contained in:
@@ -61,9 +61,16 @@ func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *Login
|
||||
}
|
||||
|
||||
func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) {
|
||||
config, _ := uc.security.Current(ctx)
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil || config == nil {
|
||||
return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState)
|
||||
}
|
||||
if config != nil && config.LockEnable {
|
||||
if locked, _ := uc.security.LoginLocked(ctx, attempt.Username); locked {
|
||||
locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr)
|
||||
}
|
||||
if locked {
|
||||
uc.recordLogin(ctx, attempt, false, "账号已锁定", 0)
|
||||
return nil, &AccountLockedError{Minutes: config.LockDuration}
|
||||
}
|
||||
@@ -75,7 +82,10 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
if config.CaptchaTimeout > 0 {
|
||||
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
|
||||
}
|
||||
failures, _ := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
|
||||
failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
|
||||
if counterErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr)
|
||||
}
|
||||
requireCaptcha = config.CaptchaOpen == 0 || failures > config.CaptchaOpen
|
||||
}
|
||||
if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) {
|
||||
@@ -89,9 +99,14 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
|
||||
if config != nil && config.LockEnable {
|
||||
lockTTL := time.Duration(config.LockDuration) * time.Minute
|
||||
failures, _ := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
|
||||
failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
|
||||
if stateErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
|
||||
}
|
||||
if int(failures) >= config.LockThreshold {
|
||||
_ = uc.security.LockLogin(ctx, attempt.Username, lockTTL)
|
||||
if stateErr = uc.security.LockLogin(ctx, attempt.Username, lockTTL); stateErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0)
|
||||
@@ -130,6 +145,52 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil
|
||||
}
|
||||
|
||||
func userHasAuthority(user *User, authorityID uint) bool {
|
||||
if user == nil || authorityID == 0 {
|
||||
return false
|
||||
}
|
||||
if user.AuthorityID == authorityID {
|
||||
return true
|
||||
}
|
||||
for _, authority := range user.Authorities {
|
||||
if authority.AuthorityID == authorityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (uc *AuthenticationUsecase) currentTokenUser(ctx context.Context, claims *AuthClaims) (*User, error) {
|
||||
if claims == nil || claims.UUID == "" || claims.IssuedAt.IsZero() {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
user, err := uc.users.UserByUUID(ctx, claims.UUID)
|
||||
if err != nil || user == nil || user.ID != claims.ID || user.Username != claims.Username {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
if user.Enable != 1 {
|
||||
return nil, &UserDisabledError{UserID: user.ID}
|
||||
}
|
||||
if !userHasAuthority(user, claims.AuthorityID) {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
passwordVersion := int64(0)
|
||||
if user.PasswordUpdatedAt != nil {
|
||||
passwordVersion = user.PasswordUpdatedAt.UnixNano()
|
||||
}
|
||||
if passwordVersion != claims.PasswordVersion {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil || config == nil {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
if config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After(user.PasswordUpdatedAt.AddDate(0, 0, config.PwdExpireDays)) {
|
||||
user.MustChangePassword = true
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SwitchAuthority changes the current user's active role and re-signs the
|
||||
// current claims. Role switching keeps the original JWT expiry; it
|
||||
// does not treat a role switch as a fresh login or rotate the multipoint
|
||||
@@ -166,12 +227,17 @@ func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := uc.currentTokenUser(ctx, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims.NickName = user.NickName
|
||||
claims.MustChangePwd = user.MustChangePassword
|
||||
result := &TokenAuthentication{Claims: claims}
|
||||
if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime {
|
||||
return result, nil
|
||||
}
|
||||
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, MustChangePassword: claims.MustChangePwd}
|
||||
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, claims.MustChangePwd, 0)
|
||||
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, user.MustChangePassword, 0)
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -11,29 +11,42 @@ import (
|
||||
|
||||
type authenticationUserRepo struct {
|
||||
UserRepo
|
||||
user *User
|
||||
user *User
|
||||
findByUUIDErr error
|
||||
}
|
||||
|
||||
func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*User, error) {
|
||||
return r.user, nil
|
||||
}
|
||||
|
||||
func (r *authenticationUserRepo) FindUserByUUID(context.Context, string) (*User, error) {
|
||||
return r.user, r.findByUUIDErr
|
||||
}
|
||||
|
||||
func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (*authenticationUserRepo) FillDepartmentNamePaths(context.Context, *User) error { return nil }
|
||||
|
||||
type authenticationSecurityRepo struct{ SecurityRepo }
|
||||
|
||||
func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
|
||||
return &SecurityConfig{CaptchaOpen: 2, CaptchaTimeout: 60}, nil
|
||||
}
|
||||
|
||||
type authenticationCache struct{ activeErr error }
|
||||
type authenticationCache struct {
|
||||
activeErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (c *authenticationCache) Get(_ context.Context, key string) (string, bool, error) {
|
||||
if key == "admin" {
|
||||
return "", false, c.activeErr
|
||||
}
|
||||
if c.getErr != nil {
|
||||
return "", false, c.getErr
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
func (*authenticationCache) Set(context.Context, string, string, time.Duration) error { return nil }
|
||||
@@ -46,12 +59,20 @@ type authenticationSettings struct{ RuntimeSettings }
|
||||
|
||||
func (*authenticationSettings) UseMultipoint() bool { return true }
|
||||
|
||||
type authenticationIssuer struct{ TokenIssuer }
|
||||
type authenticationIssuer struct {
|
||||
TokenIssuer
|
||||
claims *AuthClaims
|
||||
issuedUser *User
|
||||
issuedMust bool
|
||||
}
|
||||
|
||||
func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) {
|
||||
func (i *authenticationIssuer) IssueToken(user *User, _ uint, must bool, _ time.Duration) (*IssuedToken, error) {
|
||||
i.issuedUser, i.issuedMust = user, must
|
||||
return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
|
||||
}
|
||||
|
||||
func (i *authenticationIssuer) ParseToken(string) (*AuthClaims, error) { return i.claims, nil }
|
||||
|
||||
func (*authenticationIssuer) ReissueToken(claims *AuthClaims, _ uint) (*IssuedToken, error) {
|
||||
return &IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
|
||||
}
|
||||
@@ -93,6 +114,12 @@ func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) er
|
||||
return nil
|
||||
}
|
||||
|
||||
type authenticationTokenRepo struct{ APITokenRepo }
|
||||
|
||||
func (*authenticationTokenRepo) IsTokenDisabled(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
@@ -137,3 +164,87 @@ func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T)
|
||||
t.Fatalf("switch result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsSecurityCacheFailure(t *testing.T) {
|
||||
cacheErr := errors.New("cache unavailable")
|
||||
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{getErr: cacheErr}, &authenticationSettings{}, nil)
|
||||
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{}), security, &authenticationIssuer{}, &authenticationAudit{})
|
||||
|
||||
_, err := uc.Login(context.Background(), &LoginAttempt{Username: "admin", Password: "secret", IP: "127.0.0.1"})
|
||||
if !errors.Is(err, ErrLoginState) {
|
||||
t.Fatalf("expected login-state failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateTokenRejectsStaleUserState(t *testing.T) {
|
||||
issuedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
baseClaims := AuthClaims{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, IssuedAt: issuedAt, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
passwordChanged := issuedAt.Add(time.Minute)
|
||||
tests := []struct {
|
||||
name string
|
||||
user *User
|
||||
want error
|
||||
}{
|
||||
{name: "disabled", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 0}, want: ErrUserDisabled},
|
||||
{name: "role removed", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 999, Enable: 1}, want: ErrTokenDisabled},
|
||||
{name: "password changed", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &passwordChanged}, want: ErrTokenDisabled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
claims := baseClaims
|
||||
issuer := &authenticationIssuer{claims: &claims}
|
||||
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
|
||||
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: test.user}), security, issuer, &authenticationAudit{})
|
||||
_, err := uc.AuthenticateToken(context.Background(), "token")
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("AuthenticateToken error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateTokenRefreshesFromCurrentUser(t *testing.T) {
|
||||
issuedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "old", AuthorityID: 888, IssuedAt: issuedAt, BufferTime: 2 * time.Hour, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
user := &User{ID: 7, UUID: "uuid", Username: "admin", NickName: "current", AuthorityID: 888, Enable: 1, MustChangePassword: true}
|
||||
issuer := &authenticationIssuer{claims: claims}
|
||||
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
|
||||
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, issuer, &authenticationAudit{})
|
||||
|
||||
result, err := uc.AuthenticateToken(context.Background(), "token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Refreshed == nil || result.Claims.NickName != "current" || !result.Claims.MustChangePwd {
|
||||
t.Fatalf("unexpected authentication result: %+v", result)
|
||||
}
|
||||
if issuer.issuedUser != user || !issuer.issuedMust {
|
||||
t.Fatalf("refresh used stale user state: user=%+v must=%v", issuer.issuedUser, issuer.issuedMust)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateTokenRejectsPasswordChangeWithinIssuedAtSecond(t *testing.T) {
|
||||
issuedAt := time.Now().Add(-time.Minute).Truncate(time.Second)
|
||||
passwordAtIssue := issuedAt.Add(100 * time.Millisecond)
|
||||
passwordChanged := issuedAt.Add(900 * time.Millisecond)
|
||||
if passwordAtIssue.Unix() != passwordChanged.Unix() {
|
||||
t.Fatal("test setup did not keep password changes in one second")
|
||||
}
|
||||
claims := &AuthClaims{
|
||||
ID: 7,
|
||||
UUID: "uuid",
|
||||
Username: "admin",
|
||||
AuthorityID: 888,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
PasswordVersion: passwordAtIssue.UnixNano(),
|
||||
}
|
||||
user := &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &passwordChanged}
|
||||
issuer := &authenticationIssuer{claims: claims}
|
||||
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
|
||||
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, issuer, &authenticationAudit{})
|
||||
|
||||
if _, err := uc.AuthenticateToken(context.Background(), "token"); !errors.Is(err, ErrTokenDisabled) {
|
||||
t.Fatalf("AuthenticateToken error = %v, want ErrTokenDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ type MediaSettings struct {
|
||||
ChunkDir string
|
||||
}
|
||||
|
||||
const DefaultMaxMediaFileSize int64 = 100 << 20
|
||||
|
||||
func (s MediaSettings) EffectiveMaxFileSize() int64 {
|
||||
if s.MaxFileSize > 0 {
|
||||
return s.MaxFileSize
|
||||
}
|
||||
return DefaultMaxMediaFileSize
|
||||
}
|
||||
|
||||
// RuntimeSettings exposes only the active values needed by the application.
|
||||
// The data implementation resolves every call from conf.Runtime so hot reloads
|
||||
// take effect without rebuilding services.
|
||||
@@ -73,18 +82,20 @@ type IssuedToken struct {
|
||||
}
|
||||
|
||||
type AuthClaims struct {
|
||||
UUID string
|
||||
ID uint
|
||||
Username string
|
||||
NickName string
|
||||
AuthorityID uint
|
||||
UserType string
|
||||
BufferTime time.Duration
|
||||
MustChangePwd bool
|
||||
Issuer string
|
||||
Audience []string
|
||||
NotBefore time.Time
|
||||
ExpiresAt time.Time
|
||||
UUID string
|
||||
ID uint
|
||||
Username string
|
||||
NickName string
|
||||
AuthorityID uint
|
||||
UserType string
|
||||
BufferTime time.Duration
|
||||
MustChangePwd bool
|
||||
PasswordVersion int64
|
||||
Issuer string
|
||||
Audience []string
|
||||
IssuedAt time.Time
|
||||
NotBefore time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
+25
-1
@@ -26,10 +26,19 @@ type MediaUsecase struct {
|
||||
settings RuntimeSettings
|
||||
}
|
||||
|
||||
var ErrMediaTooLarge = errors.New("文件超过大小上限")
|
||||
|
||||
func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase {
|
||||
return &MediaUsecase{MediaRepo: repo, files: files, settings: settings}
|
||||
}
|
||||
|
||||
func (uc *MediaUsecase) maxMediaFileSize() int64 {
|
||||
if uc.settings == nil {
|
||||
return MediaSettings{}.EffectiveMaxFileSize()
|
||||
}
|
||||
return uc.settings.MediaSettings().EffectiveMaxFileSize()
|
||||
}
|
||||
|
||||
var allowedMediaExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true,
|
||||
".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true,
|
||||
@@ -70,10 +79,25 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM
|
||||
}
|
||||
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
||||
hash := md5.New()
|
||||
stored, err := uc.files.Put(ctx, key, io.TeeReader(buffered, hash))
|
||||
maxSize := uc.maxMediaFileSize()
|
||||
limited := &io.LimitedReader{R: buffered, N: maxSize + 1}
|
||||
stored, err := uc.files.Put(ctx, key, io.TeeReader(limited, hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readSize := maxSize + 1 - limited.N
|
||||
if stored == nil {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return nil, errors.New("文件存储未返回结果")
|
||||
}
|
||||
if readSize > maxSize || stored.Size > maxSize {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return nil, ErrMediaTooLarge
|
||||
}
|
||||
if stored.Size != readSize {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return nil, errors.New("文件存储大小不一致")
|
||||
}
|
||||
media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(name), "."), Key: key, Size: stored.Size, Mime: suppliedMIME, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID}
|
||||
if save {
|
||||
count, countErr := uc.MediaKeyReferences(ctx, key)
|
||||
|
||||
@@ -2,6 +2,9 @@ package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -79,3 +82,169 @@ func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) {
|
||||
t.Fatalf("second cursor/more = %q/%v", cursor, more)
|
||||
}
|
||||
}
|
||||
|
||||
type mediaLimitSettings struct {
|
||||
RuntimeSettings
|
||||
max int64
|
||||
}
|
||||
|
||||
func (s mediaLimitSettings) MediaSettings() MediaSettings {
|
||||
return MediaSettings{MaxFileSize: s.max}
|
||||
}
|
||||
|
||||
type mediaLimitStorage struct {
|
||||
readSize int64
|
||||
reportedSize *int64
|
||||
deleted bool
|
||||
composeCalled bool
|
||||
composed *StoredFile
|
||||
composedHash string
|
||||
}
|
||||
|
||||
func (s *mediaLimitStorage) Put(_ context.Context, _ string, reader io.Reader) (*StoredFile, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.readSize = int64(len(data))
|
||||
size := s.readSize
|
||||
if s.reportedSize != nil {
|
||||
size = *s.reportedSize
|
||||
}
|
||||
return &StoredFile{Size: size}, nil
|
||||
}
|
||||
func (*mediaLimitStorage) Open(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
func (s *mediaLimitStorage) Delete(context.Context, string) error {
|
||||
s.deleted = true
|
||||
return nil
|
||||
}
|
||||
func (s *mediaLimitStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) {
|
||||
s.composeCalled = true
|
||||
return s.composed, s.composedHash, nil
|
||||
}
|
||||
func (*mediaLimitStorage) DeletePrefix(context.Context, string) error { return nil }
|
||||
func (*mediaLimitStorage) List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
type mediaUploadRepo struct {
|
||||
MediaRepo
|
||||
session *UploadSession
|
||||
chunks []*UploadChunk
|
||||
claimed bool
|
||||
failed bool
|
||||
}
|
||||
|
||||
func (r *mediaUploadRepo) FindUploadSession(context.Context, uint) (*UploadSession, error) {
|
||||
if r.session == nil {
|
||||
return nil, ErrUploadSessionNotFound
|
||||
}
|
||||
return r.session, nil
|
||||
}
|
||||
func (r *mediaUploadRepo) ClaimUploadSession(context.Context, uint) (bool, error) {
|
||||
return r.claimed, nil
|
||||
}
|
||||
func (r *mediaUploadRepo) FailUploadSession(context.Context, uint) error {
|
||||
r.failed = true
|
||||
return nil
|
||||
}
|
||||
func (r *mediaUploadRepo) ListChunks(context.Context, uint) ([]*UploadChunk, error) {
|
||||
return r.chunks, nil
|
||||
}
|
||||
|
||||
func md5Text(value string) string {
|
||||
sum := md5.Sum([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestUploadRejectsDataPastConfiguredLimit(t *testing.T) {
|
||||
storage := &mediaLimitStorage{}
|
||||
uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4})
|
||||
|
||||
_, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("12345"), false)
|
||||
if !errors.Is(err, ErrMediaTooLarge) {
|
||||
t.Fatalf("Upload() error = %v, want ErrMediaTooLarge", err)
|
||||
}
|
||||
if storage.readSize != 5 || !storage.deleted {
|
||||
t.Fatalf("storage read/deleted = %d/%v, want 5/true", storage.readSize, storage.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsStorageSizeMismatch(t *testing.T) {
|
||||
reported := int64(3)
|
||||
storage := &mediaLimitStorage{reportedSize: &reported}
|
||||
uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4})
|
||||
|
||||
if _, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("1234"), false); err == nil {
|
||||
t.Fatal("Upload() error = nil, want storage size mismatch")
|
||||
}
|
||||
if !storage.deleted {
|
||||
t.Fatal("mismatched stored object was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitUploadRejectsInvalidLayout(t *testing.T) {
|
||||
uc := NewMediaUsecase(&mediaUploadRepo{}, &mediaLimitStorage{}, mediaLimitSettings{max: 10})
|
||||
validHash := strings.Repeat("0", md5.Size*2)
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
size int64
|
||||
chunkSize int64
|
||||
total int
|
||||
wantLarge bool
|
||||
}{
|
||||
{name: "invalid hash", hash: "bad", size: 5, chunkSize: 3, total: 2},
|
||||
{name: "zero size", hash: validHash, size: 0, chunkSize: 3, total: 1},
|
||||
{name: "over limit", hash: validHash, size: 11, chunkSize: 3, total: 4, wantLarge: true},
|
||||
{name: "wrong total", hash: validHash, size: 5, chunkSize: 3, total: 3},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, _, err := uc.InitUpload(context.Background(), 1, "sample.txt", test.hash, test.size, test.chunkSize, test.total)
|
||||
if err == nil {
|
||||
t.Fatal("InitUpload() error = nil")
|
||||
}
|
||||
if test.wantLarge && !errors.Is(err, ErrMediaTooLarge) {
|
||||
t.Fatalf("InitUpload() error = %v, want ErrMediaTooLarge", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveChunkRejectsIndexAndSizeMismatch(t *testing.T) {
|
||||
repo := &mediaUploadRepo{session: &UploadSession{ID: 7, UserID: 1, FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"}}
|
||||
uc := NewMediaUsecase(repo, &mediaLimitStorage{}, mediaLimitSettings{max: 10})
|
||||
|
||||
if err := uc.SaveChunk(context.Background(), 1, 7, 2, md5Text("abc"), strings.NewReader("abc")); err == nil {
|
||||
t.Fatal("SaveChunk() accepted an out-of-range index")
|
||||
}
|
||||
if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("ab"), strings.NewReader("ab")); err == nil {
|
||||
t.Fatal("SaveChunk() accepted a short chunk")
|
||||
}
|
||||
if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("abcd"), strings.NewReader("abcd")); err == nil {
|
||||
t.Fatal("SaveChunk() accepted an oversized chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteUploadRejectsMismatchedChunkSizesBeforeCompose(t *testing.T) {
|
||||
repo := &mediaUploadRepo{
|
||||
session: &UploadSession{ID: 7, UserID: 1, FileName: "sample.txt", FileHash: md5Text("abcde"), FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"},
|
||||
chunks: []*UploadChunk{{Index: 0, Size: 3}, {Index: 1, Size: 1}},
|
||||
claimed: true,
|
||||
}
|
||||
storage := &mediaLimitStorage{}
|
||||
uc := NewMediaUsecase(repo, storage, mediaLimitSettings{max: 10})
|
||||
|
||||
if _, err := uc.CompleteUpload(context.Background(), 1, 7, "text/plain"); err == nil {
|
||||
t.Fatal("CompleteUpload() accepted mismatched chunk sizes")
|
||||
}
|
||||
if storage.composeCalled {
|
||||
t.Fatal("Compose() was called before chunk-size validation completed")
|
||||
}
|
||||
if !repo.failed {
|
||||
t.Fatal("failed upload session was not returned to the uploading state")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,45 @@ import (
|
||||
|
||||
var ErrUploadSessionNotFound = errors.New("upload session not found")
|
||||
|
||||
func validMD5(value string) bool {
|
||||
if len(value) != md5.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (uc *MediaUsecase) validateUploadLayout(size, chunkSize int64, total int) error {
|
||||
if size <= 0 || chunkSize <= 0 || total <= 0 {
|
||||
return errors.New("文件大小和分片参数必须大于 0")
|
||||
}
|
||||
if size > uc.maxMediaFileSize() {
|
||||
return ErrMediaTooLarge
|
||||
}
|
||||
expectedTotal := (size-1)/chunkSize + 1
|
||||
if int64(total) != expectedTotal {
|
||||
return fmt.Errorf("分片数量不匹配: %d/%d", total, expectedTotal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectedChunkSize(session *UploadSession, index int) (int64, error) {
|
||||
if session == nil || session.FileSize <= 0 || session.ChunkSize <= 0 || session.ChunkTotal <= 0 {
|
||||
return 0, errors.New("上传会话分片参数非法")
|
||||
}
|
||||
if index < 0 || index >= session.ChunkTotal {
|
||||
return 0, errors.New("分片序号超出范围")
|
||||
}
|
||||
if index < session.ChunkTotal-1 {
|
||||
return session.ChunkSize, nil
|
||||
}
|
||||
last := session.FileSize - session.ChunkSize*int64(session.ChunkTotal-1)
|
||||
if last <= 0 || last > session.ChunkSize {
|
||||
return 0, errors.New("上传会话分片参数非法")
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (uc *MediaUsecase) chunkPrefix(uploadID uint) string {
|
||||
directory := "uploads/chunks"
|
||||
if uc.settings != nil {
|
||||
@@ -37,14 +76,24 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
|
||||
if err := validateMediaName(name); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if completed, err := uc.FindCompletedSession(ctx, userID, hash); err == nil && completed.MediaID != 0 {
|
||||
if !validMD5(hash) {
|
||||
return nil, nil, nil, errors.New("文件 MD5 非法")
|
||||
}
|
||||
if err := uc.validateUploadLayout(size, chunkSize, total); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
completed, err := uc.FindCompletedSession(ctx, userID, strings.ToLower(hash))
|
||||
if err == nil && completed.MediaID != 0 {
|
||||
if media, findErr := uc.FindMedia(ctx, completed.MediaID); findErr == nil {
|
||||
copy := &MediaFile{Name: name, URL: media.URL, Tag: media.Tag, Key: media.Key}
|
||||
if createErr := uc.CreateMedia(ctx, copy); createErr == nil {
|
||||
return nil, copy, nil, nil
|
||||
}
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, ErrUploadSessionNotFound) {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
hash = strings.ToLower(hash)
|
||||
session, err := uc.FindUploadingSession(ctx, userID, hash)
|
||||
if errors.Is(err, ErrUploadSessionNotFound) {
|
||||
session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"}
|
||||
@@ -53,6 +102,8 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
} else if session.FileSize != size || session.ChunkSize != chunkSize || session.ChunkTotal != total {
|
||||
return nil, nil, nil, errors.New("上传参数与已有会话不一致")
|
||||
}
|
||||
chunks, err := uc.ListChunks(ctx, session.ID)
|
||||
if err != nil {
|
||||
@@ -76,6 +127,14 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
|
||||
if session.Status != "uploading" {
|
||||
return errors.New("上传会话状态不允许收片")
|
||||
}
|
||||
expected = strings.ToLower(strings.TrimSpace(expected))
|
||||
if !validMD5(expected) {
|
||||
return errors.New("分片 MD5 非法")
|
||||
}
|
||||
wantSize, err := expectedChunkSize(session, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := md5.New()
|
||||
key := uc.chunkKey(uploadID, index)
|
||||
temporary, err := os.CreateTemp("", "kra-upload-chunk-*")
|
||||
@@ -85,11 +144,15 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
defer temporary.Close()
|
||||
if _, err = io.Copy(io.MultiWriter(temporary, hash), reader); err != nil {
|
||||
written, err := io.Copy(io.MultiWriter(temporary, hash), io.LimitReader(reader, wantSize+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != wantSize {
|
||||
return fmt.Errorf("分片 %d 大小不匹配: %d/%d", index, written, wantSize)
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if actual != expected {
|
||||
if !strings.EqualFold(actual, expected) {
|
||||
return fmt.Errorf("分片 %d 校验失败", index)
|
||||
}
|
||||
if _, err = temporary.Seek(0, io.SeekStart); err != nil {
|
||||
@@ -99,7 +162,15 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size})
|
||||
if stored == nil || stored.Size != written {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return errors.New("分片存储大小不一致")
|
||||
}
|
||||
if err = uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: written}); err != nil {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) {
|
||||
session, err := uc.FindUploadSession(ctx, uploadID)
|
||||
@@ -129,26 +200,38 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
|
||||
}
|
||||
sort.Slice(chunks, func(i, j int) bool { return chunks[i].Index < chunks[j].Index })
|
||||
names := make([]string, 0, len(chunks))
|
||||
var totalSize int64
|
||||
for index, chunk := range chunks {
|
||||
if chunk.Index != index {
|
||||
return fail(errors.New("分片序号不连续"))
|
||||
}
|
||||
expectedSize, sizeErr := expectedChunkSize(session, index)
|
||||
if sizeErr != nil || chunk.Size != expectedSize || chunk.Size > session.FileSize-totalSize {
|
||||
return fail(errors.New("分片大小不一致"))
|
||||
}
|
||||
totalSize += chunk.Size
|
||||
names = append(names, uc.chunkKey(uploadID, index))
|
||||
}
|
||||
if totalSize != session.FileSize {
|
||||
return fail(fmt.Errorf("文件大小不匹配: %d/%d", totalSize, session.FileSize))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(session.FileName))
|
||||
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
||||
stored, hash, err := uc.files.Compose(ctx, names, key)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if hash != session.FileHash {
|
||||
if stored == nil || stored.Size != session.FileSize {
|
||||
if stored != nil {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
}
|
||||
return fail(errors.New("合并文件大小不匹配"))
|
||||
}
|
||||
if !strings.EqualFold(hash, session.FileHash) {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return fail(errors.New("整文件校验失败"))
|
||||
}
|
||||
// Keep the size declared when the upload session was created. The full-file
|
||||
// MD5 above remains the integrity check even if storage reports another
|
||||
// byte count.
|
||||
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(session.FileName), "."), Key: key, Size: session.FileSize, Mime: mime, MD5: hash, UserID: userID}
|
||||
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(session.FileName), "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID}
|
||||
if err = uc.CreateMedia(ctx, media); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
+21
-13
@@ -154,39 +154,47 @@ func NewTaskApplicationUsecase(tasks *TaskUsecase, runtime TaskRuntime) *TaskApp
|
||||
return &TaskApplicationUsecase{tasks: tasks, runtime: runtime}
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) syncRuntime(ctx context.Context, id uint) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := uc.runtime.ScheduleID(ctx, id); err != nil {
|
||||
repairCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
if reloadErr := uc.runtime.Reload(repairCtx); reloadErr != nil {
|
||||
return &TaskScheduleError{Err: errors.Join(err, fmt.Errorf("重载任务运行时失败: %w", reloadErr))}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Create(ctx context.Context, value *TimedTask) error {
|
||||
if err := uc.tasks.Create(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := uc.runtime.ScheduleID(ctx, value.ID); err != nil {
|
||||
return &TaskScheduleError{Err: err}
|
||||
}
|
||||
return nil
|
||||
return uc.syncRuntime(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Update(ctx context.Context, value *TimedTask) error {
|
||||
if err := uc.tasks.Update(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := uc.runtime.ScheduleID(ctx, value.ID); err != nil {
|
||||
return &TaskScheduleError{Err: err}
|
||||
}
|
||||
return nil
|
||||
return uc.syncRuntime(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Delete(ctx context.Context, id uint) error {
|
||||
if err := uc.tasks.DeleteTask(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
uc.runtime.Remove(id)
|
||||
return uc.tasks.DeleteTask(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Toggle(ctx context.Context, id uint, enabled bool) error {
|
||||
if err := uc.tasks.ToggleTask(ctx, id, enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := uc.runtime.ScheduleID(ctx, id); err != nil {
|
||||
return &TaskScheduleError{Err: err}
|
||||
}
|
||||
return nil
|
||||
return uc.syncRuntime(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Trigger(ctx context.Context, id uint) error {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type applicationTaskRepo struct {
|
||||
mu sync.Mutex
|
||||
deleted bool
|
||||
deleteErr error
|
||||
createdID uint
|
||||
toggleCalls int
|
||||
updateCalls int
|
||||
createdCalls int
|
||||
nameExists bool
|
||||
nameExistsErr error
|
||||
deleteObserved chan struct{}
|
||||
}
|
||||
|
||||
func (r *applicationTaskRepo) CreateTask(_ context.Context, value *TimedTask) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.createdCalls++
|
||||
if value.ID == 0 {
|
||||
value.ID = r.createdID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) UpdateTask(context.Context, *TimedTask) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.updateCalls++
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) DeleteTask(context.Context, uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.deleteErr != nil {
|
||||
return r.deleteErr
|
||||
}
|
||||
r.deleted = true
|
||||
if r.deleteObserved != nil {
|
||||
close(r.deleteObserved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) FindTask(context.Context, uint) (*TimedTask, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *applicationTaskRepo) ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *applicationTaskRepo) ToggleTask(context.Context, uint, bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.toggleCalls++
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) RecordTaskLog(context.Context, *TimedTaskLog) error { return nil }
|
||||
func (r *applicationTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *applicationTaskRepo) CleanupLogs(context.Context) error { return nil }
|
||||
func (r *applicationTaskRepo) TaskNameExists(context.Context, string, uint) (bool, error) {
|
||||
return r.nameExists, r.nameExistsErr
|
||||
}
|
||||
|
||||
type applicationTaskRuntime struct {
|
||||
mu sync.Mutex
|
||||
scheduleErr error
|
||||
reloadErr error
|
||||
scheduleCalls int
|
||||
reloadCalls int
|
||||
removeCalls int
|
||||
removeBeforeDelete bool
|
||||
repo *applicationTaskRepo
|
||||
reloadContextActive bool
|
||||
}
|
||||
|
||||
func (r *applicationTaskRuntime) ScheduleID(context.Context, uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.scheduleCalls++
|
||||
return r.scheduleErr
|
||||
}
|
||||
func (r *applicationTaskRuntime) Remove(uint) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.removeCalls++
|
||||
if r.repo != nil {
|
||||
r.repo.mu.Lock()
|
||||
r.removeBeforeDelete = !r.repo.deleted
|
||||
r.repo.mu.Unlock()
|
||||
}
|
||||
}
|
||||
func (r *applicationTaskRuntime) TriggerID(context.Context, uint) error { return nil }
|
||||
func (r *applicationTaskRuntime) NextRuns() map[uint]time.Time { return nil }
|
||||
func (r *applicationTaskRuntime) Reload(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.reloadCalls++
|
||||
r.reloadContextActive = ctx.Err() == nil
|
||||
return r.reloadErr
|
||||
}
|
||||
func (r *applicationTaskRuntime) Subscribe(uint) chan []byte { return make(chan []byte) }
|
||||
func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {}
|
||||
|
||||
func validApplicationTask(id uint) *TimedTask {
|
||||
const methodName = "biz-test-application-task"
|
||||
RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil })
|
||||
return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true}
|
||||
}
|
||||
|
||||
func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 41}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := uc.Create(ctx, validApplicationTask(0)); err != nil {
|
||||
t.Fatalf("Create() error = %v, want successful reload compensation", err)
|
||||
}
|
||||
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
|
||||
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
|
||||
}
|
||||
if !runtime.reloadContextActive {
|
||||
t.Fatal("reload compensation inherited the canceled request context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 42}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
|
||||
err := uc.Create(context.Background(), validApplicationTask(0))
|
||||
if !errors.Is(err, ErrTaskSchedule) {
|
||||
t.Fatalf("Create() error = %v, want ErrTaskSchedule", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*TaskApplicationUsecase) error
|
||||
}{
|
||||
{name: "update", run: func(uc *TaskApplicationUsecase) error {
|
||||
return uc.Update(context.Background(), validApplicationTask(7))
|
||||
}},
|
||||
{name: "toggle", run: func(uc *TaskApplicationUsecase) error { return uc.Toggle(context.Background(), 7, true) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := test.run(uc); err != nil {
|
||||
t.Fatalf("operation error = %v", err)
|
||||
}
|
||||
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
|
||||
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if runtime.removeBeforeDelete {
|
||||
t.Fatal("runtime entry was removed before the database row")
|
||||
}
|
||||
if runtime.removeCalls != 1 {
|
||||
t.Fatalf("Remove() calls = %d, want 1", runtime.removeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err == nil {
|
||||
t.Fatal("Delete() error = nil, want database error")
|
||||
}
|
||||
if runtime.removeCalls != 0 {
|
||||
t.Fatalf("Remove() calls = %d, want 0", runtime.removeCalls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user