Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

342 lines
12 KiB
Go

package service
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/Tencent/WeKnora/internal/types"
)
func init() {
_ = os.Setenv("JWT_SECRET", "test-jwt-secret-for-user-auth-token-tests")
}
type stubAuthTokenRepo struct {
tokens map[string]*types.AuthToken
revokedUserIDs []string
}
func (s *stubAuthTokenRepo) CreateToken(context.Context, *types.AuthToken) error { return nil }
func (s *stubAuthTokenRepo) GetTokenByValue(_ context.Context, tokenValue string) (*types.AuthToken, error) {
token, ok := s.tokens[tokenValue]
if !ok {
return nil, errors.New("token not found")
}
return token, nil
}
func (s *stubAuthTokenRepo) GetTokensByUserID(context.Context, string) ([]*types.AuthToken, error) {
return nil, nil
}
func (s *stubAuthTokenRepo) UpdateToken(context.Context, *types.AuthToken) error { return nil }
func (s *stubAuthTokenRepo) DeleteToken(context.Context, string) error { return nil }
func (s *stubAuthTokenRepo) DeleteExpiredTokens(context.Context) error { return nil }
func (s *stubAuthTokenRepo) RevokeTokensByUserID(_ context.Context, userID string) error {
s.revokedUserIDs = append(s.revokedUserIDs, userID)
return nil
}
type stubUserRepoForAuth struct {
users map[string]*types.User
updateCalls int
}
func (s *stubUserRepoForAuth) CreateUser(context.Context, *types.User) error { return nil }
func (s *stubUserRepoForAuth) GetUserByID(_ context.Context, id string) (*types.User, error) {
user, ok := s.users[id]
if !ok {
return nil, errors.New("user not found")
}
return user, nil
}
func (s *stubUserRepoForAuth) GetUsersByIDs(context.Context, []string) (map[string]*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) GetUserByEmail(context.Context, string) (*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) GetUserByUsername(context.Context, string) (*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) GetUserByTenantID(context.Context, uint64) (*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) UpdateUser(context.Context, *types.User) error {
s.updateCalls++
return nil
}
func (s *stubUserRepoForAuth) DeleteUser(context.Context, string) error { return nil }
func (s *stubUserRepoForAuth) ListUsers(context.Context, int, int) ([]*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) ListSystemAdmins(context.Context, int, int) ([]*types.User, int64, error) {
return nil, 0, nil
}
func (s *stubUserRepoForAuth) RevokeSystemAdmin(context.Context, string, string) (*types.User, error) {
return nil, nil
}
func (s *stubUserRepoForAuth) SearchUsers(context.Context, string, int) ([]*types.User, error) {
return nil, nil
}
func newAuthTestUserService(tokenRepo *stubAuthTokenRepo) *userService {
return &userService{
userRepo: &stubUserRepoForAuth{
users: map[string]*types.User{
"user-1": {ID: "user-1", TenantID: 1, IsActive: true},
},
},
tokenRepo: tokenRepo,
}
}
func signTestJWT(claims jwt.MapClaims) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(getJwtSecret()))
if err != nil {
panic(err)
}
return signed
}
func TestValidateTokenRejectsRefreshToken(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
refreshJWT := signTestJWT(jwt.MapClaims{
"user_id": "user-1",
"type": "refresh",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenRepo.tokens[refreshJWT] = &types.AuthToken{
UserID: "user-1",
Token: refreshJWT,
TokenType: "refresh_token",
}
_, _, err := svc.ValidateToken(ctx, refreshJWT)
if err == nil || err.Error() != "refresh token cannot be used as access token" {
t.Fatalf("ValidateToken(refresh JWT) err = %v, want refresh rejection", err)
}
legacyRefresh := signTestJWT(jwt.MapClaims{
"user_id": "user-1",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenRepo.tokens[legacyRefresh] = &types.AuthToken{
UserID: "user-1",
Token: legacyRefresh,
TokenType: "refresh_token",
}
_, _, err = svc.ValidateToken(ctx, legacyRefresh)
if err == nil || err.Error() != "refresh token cannot be used as access token" {
t.Fatalf("ValidateToken(legacy refresh in DB) err = %v, want refresh rejection", err)
}
}
func TestRefreshTokenRejectsAccessTokenRecord(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
refreshJWT := signTestJWT(jwt.MapClaims{
"user_id": "user-1",
"type": "refresh",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenRepo.tokens[refreshJWT] = &types.AuthToken{
UserID: "user-1",
Token: refreshJWT,
TokenType: "access_token",
}
_, _, err := svc.RefreshToken(ctx, refreshJWT)
if err == nil || err.Error() != "not a refresh token" {
t.Fatalf("RefreshToken(access token record) err = %v, want not a refresh token", err)
}
}
func TestExistingTokensRejectNewlyInactiveUser(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
access := signTestJWT(jwt.MapClaims{"user_id": "user-1", "type": "access", "exp": time.Now().Add(time.Hour).Unix()})
refresh := signTestJWT(jwt.MapClaims{"user_id": "user-1", "type": "refresh", "exp": time.Now().Add(time.Hour).Unix()})
tokenRepo.tokens[access] = &types.AuthToken{UserID: "user-1", Token: access, TokenType: "access_token"}
tokenRepo.tokens[refresh] = &types.AuthToken{UserID: "user-1", Token: refresh, TokenType: "refresh_token"}
if _, _, err := svc.ValidateToken(ctx, access); err != nil {
t.Fatalf("active user's token should work: %v", err)
}
svc.userRepo.(*stubUserRepoForAuth).users["user-1"].IsActive = false
if _, _, err := svc.ValidateToken(ctx, access); err == nil {
t.Fatal("inactive user's existing access token was accepted")
}
if _, _, err := svc.RefreshToken(ctx, refresh); err == nil {
t.Fatal("inactive user's existing refresh token was accepted")
}
}
func TestLogoutRevokesAllUserTokens(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
expiredAccess := signTestJWT(jwt.MapClaims{
"user_id": "user-1",
"type": "access",
"exp": time.Now().Add(-time.Hour).Unix(),
})
if err := svc.Logout(ctx, expiredAccess); err != nil {
t.Fatalf("Logout(expired access token) err = %v", err)
}
if len(tokenRepo.revokedUserIDs) != 1 || tokenRepo.revokedUserIDs[0] != "user-1" {
t.Fatalf("RevokeTokensByUserID calls = %v, want [user-1]", tokenRepo.revokedUserIDs)
}
}
func TestAdminResetPasswordHashesPasswordAndRevokesSessions(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
repo := svc.userRepo.(*stubUserRepoForAuth)
if err := svc.AdminResetPassword(ctx, "user-1", "NewSecure9"); err != nil {
t.Fatalf("AdminResetPassword() err = %v", err)
}
if repo.updateCalls != 1 {
t.Fatalf("UpdateUser calls = %d, want 1", repo.updateCalls)
}
user := repo.users["user-1"]
if user.PasswordHash == "NewSecure9" || user.PasswordHash == "" {
t.Fatalf("password was not stored as a hash")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("NewSecure9")); err != nil {
t.Fatalf("stored hash does not match new password: %v", err)
}
if len(tokenRepo.revokedUserIDs) != 1 || tokenRepo.revokedUserIDs[0] != "user-1" {
t.Fatalf("RevokeTokensByUserID calls = %v, want [user-1]", tokenRepo.revokedUserIDs)
}
}
func TestAdminResetPasswordRejectsInvalidPasswordBeforeWrite(t *testing.T) {
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
repo := svc.userRepo.(*stubUserRepoForAuth)
err := svc.AdminResetPassword(context.Background(), "user-1", "ab")
if !errors.Is(err, ErrPasswordPolicy) {
t.Fatalf("AdminResetPassword() err = %v, want ErrPasswordPolicy", err)
}
if repo.updateCalls != 0 || len(tokenRepo.revokedUserIDs) != 0 {
t.Fatalf("invalid password caused side effects: updates=%d revocations=%v",
repo.updateCalls, tokenRepo.revokedUserIDs)
}
}
func TestChangePasswordRequiresPolicyAndRevokesSessions(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
repo := svc.userRepo.(*stubUserRepoForAuth)
hashed, err := bcrypt.GenerateFromPassword([]byte("OldSecure9"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
repo.users["user-1"].PasswordHash = string(hashed)
if err := svc.ChangePassword(ctx, "user-1", "OldSecure9", "ab"); !errors.Is(err, ErrPasswordPolicy) {
t.Fatalf("ChangePassword(weak) err = %v, want ErrPasswordPolicy", err)
}
if repo.updateCalls != 0 || len(tokenRepo.revokedUserIDs) != 0 {
t.Fatalf("weak password caused side effects: updates=%d revocations=%v",
repo.updateCalls, tokenRepo.revokedUserIDs)
}
if err := svc.ChangePassword(ctx, "user-1", "wrong-pass", "NewSecure9"); !errors.Is(err, ErrInvalidOldPassword) {
t.Fatalf("ChangePassword(wrong old) err = %v, want ErrInvalidOldPassword", err)
}
if err := svc.ChangePassword(ctx, "user-1", "OldSecure9", "NewSecure9"); err != nil {
t.Fatalf("ChangePassword() err = %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(repo.users["user-1"].PasswordHash), []byte("NewSecure9")); err != nil {
t.Fatalf("stored hash does not match new password: %v", err)
}
if len(tokenRepo.revokedUserIDs) != 1 || tokenRepo.revokedUserIDs[0] != "user-1" {
t.Fatalf("revoked users = %v, want [user-1]", tokenRepo.revokedUserIDs)
}
}
func TestChangePasswordRejectsSamePassword(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
repo := svc.userRepo.(*stubUserRepoForAuth)
hashed, err := bcrypt.GenerateFromPassword([]byte("OldSecure9"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
repo.users["user-1"].PasswordHash = string(hashed)
if err := svc.ChangePassword(ctx, "user-1", "OldSecure9", "OldSecure9"); !errors.Is(err, ErrSamePassword) {
t.Fatalf("ChangePassword(same) err = %v, want ErrSamePassword", err)
}
if repo.updateCalls != 0 || len(tokenRepo.revokedUserIDs) != 0 {
t.Fatalf("same password caused side effects: updates=%d revocations=%v", repo.updateCalls, tokenRepo.revokedUserIDs)
}
}
func TestChangePasswordHonoursRuntimeComplexPolicy(t *testing.T) {
ctx := context.Background()
tokenRepo := &stubAuthTokenRepo{tokens: map[string]*types.AuthToken{}}
svc := newAuthTestUserService(tokenRepo)
svc.systemSettingSvc = &stubComplexPasswordSettings{enabled: true}
repo := svc.userRepo.(*stubUserRepoForAuth)
hashed, err := bcrypt.GenerateFromPassword([]byte("OldSecure9"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
repo.users["user-1"].PasswordHash = string(hashed)
if err := svc.ChangePassword(ctx, "user-1", "wrong-pass", "weak"); !errors.Is(err, ErrInvalidOldPassword) {
t.Fatalf("ChangePassword(wrong old, weak new) err = %v, want ErrInvalidOldPassword", err)
}
if err := svc.ChangePassword(ctx, "user-1", "OldSecure9", "NewSecure9"); !errors.Is(err, ErrComplexPasswordPolicy) {
t.Fatalf("ChangePassword(simple new) err = %v, want ErrComplexPasswordPolicy", err)
}
if repo.updateCalls != 0 || len(tokenRepo.revokedUserIDs) != 0 {
t.Fatalf("complex-policy reject caused side effects: updates=%d revocations=%v",
repo.updateCalls, tokenRepo.revokedUserIDs)
}
if err := svc.ChangePassword(ctx, "user-1", "OldSecure9", "NewSecure9!"); err != nil {
t.Fatalf("ChangePassword(complex new) err = %v", err)
}
}
func TestUserIDFromSignedTokenAcceptsExpiredToken(t *testing.T) {
expired := signTestJWT(jwt.MapClaims{
"user_id": "user-1",
"type": "access",
"exp": time.Now().Add(-time.Hour).Unix(),
})
userID, err := userIDFromSignedToken(expired)
if err != nil {
t.Fatalf("userIDFromSignedToken(expired) err = %v", err)
}
if userID != "user-1" {
t.Fatalf("userIDFromSignedToken(expired) = %q, want user-1", userID)
}
}