package system import ( "context" "errors" "testing" "time" "golang.org/x/crypto/bcrypt" ) type authenticationUserRepo struct { UserRepo 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 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 } func (*authenticationCache) Delete(context.Context, string) error { return nil } func (*authenticationCache) Increment(context.Context, string, time.Duration) (int64, error) { return 1, nil } type authenticationSettings struct{ RuntimeSettings } func (*authenticationSettings) UseMultipoint() bool { return true } type authenticationIssuer struct { TokenIssuer claims *AuthClaims issuedUser *User issuedMust bool } 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 } type authenticationAudit struct { AuditRecordRepo logins []*LoginLog } type switchAuthorityUserRepo struct { UserRepo setUserID, setAuthorityID uint findCalls int } func (r *switchAuthorityUserRepo) SetUserAuthority(_ context.Context, userID, authorityID uint) error { r.setUserID, r.setAuthorityID = userID, authorityID return nil } func (r *switchAuthorityUserRepo) FindUserByID(context.Context, uint) (*User, error) { r.findCalls++ return &User{NickName: "changed-in-database"}, nil } type switchAuthorityIssuer struct { TokenIssuer claims *AuthClaims authorityID uint } func (i *switchAuthorityIssuer) ReissueToken(claims *AuthClaims, authorityID uint) (*IssuedToken, error) { i.claims, i.authorityID = claims, authorityID return &IssuedToken{Value: "switched", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil } func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error { a.logins = append(a.logins, value) 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 { t.Fatal(err) } users := NewUserUsecase(&authenticationUserRepo{user: &User{ID: 1, Username: "admin", Password: string(hash), AuthorityID: 888, Enable: 1}}) cacheErr := errors.New("cache unavailable") security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{activeErr: cacheErr}, &authenticationSettings{}, nil) audit := &authenticationAudit{} uc := NewAuthenticationUsecase(users, security, &authenticationIssuer{}, audit) _, err = uc.Login(context.Background(), &LoginAttempt{Username: "admin", Password: "secret", IP: "127.0.0.1", Agent: "test"}) if !errors.Is(err, ErrLoginState) { t.Fatalf("expected login-state failure, got %v", err) } if len(audit.logins) != 1 || !audit.logins[0].Status || audit.logins[0].ErrorMessage != "登录成功" || audit.logins[0].UserID != 1 { t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins) } } func TestRecordLoginAllowsMissingAuditRecorder(t *testing.T) { uc := &AuthenticationUsecase{} uc.recordLogin(context.Background(), &LoginAttempt{Username: "admin"}, false, "failed", 0) } func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) { repo := &switchAuthorityUserRepo{} issuer := &switchAuthorityIssuer{} uc := NewAuthenticationUsecase(NewUserUsecase(repo), nil, issuer, nil) expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "token-nickname", AuthorityID: 888, MustChangePwd: true, ExpiresAt: expiresAt} result, err := uc.SwitchAuthority(context.Background(), claims, 999) if err != nil { t.Fatal(err) } if repo.setUserID != 7 || repo.setAuthorityID != 999 { t.Fatalf("stored authority switch = user:%d authority:%d", repo.setUserID, repo.setAuthorityID) } if repo.findCalls != 0 { t.Fatalf("switch reloaded user %d times", repo.findCalls) } if issuer.claims != claims || issuer.authorityID != 999 { t.Fatalf("reissue input = claims:%p authority:%d", issuer.claims, issuer.authorityID) } if result.User.NickName != "token-nickname" || result.User.AuthorityID != 999 || !result.NeedChangePassword || !result.ExpiresAt.Equal(expiresAt) { 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) } }