package handler import ( "context" "crypto/rand" "encoding/binary" "errors" "fmt" "net/http" "net/http/httptest" "os" "strconv" "testing" "unicode" "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" mocksauth "github.com/descope/go-sdk/descope/tests/mocks/auth" ) // 정책을 받아 필수 요구사항을 모두 포함하는 비밀번호를 생성한다. func generatePasswordFromPolicy(policy *descope.PasswordPolicy) string { minLen := int(policy.MinLength) if minLen < 8 { minLen = 12 // 안전한 기본값 } pwd := make([]rune, 0, minLen) if policy.Lowercase { pwd = append(pwd, 'a') } if policy.Uppercase { pwd = append(pwd, 'B') } if policy.Number { pwd = append(pwd, '3') } if policy.NonAlphanumeric { pwd = append(pwd, '!') } charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" for len(pwd) < minLen { pwd = append(pwd, rune(charset[randomInt(len(charset))])) } // 섞어서 예측 가능성을 낮춘다. for i := range pwd { j := randomInt(len(pwd)) pwd[i], pwd[j] = pwd[j], pwd[i] } return string(pwd) } func randomInt(n int) int { if n <= 0 { return 0 } var b [8]byte if _, err := rand.Read(b[:]); err != nil { return 0 } return int(binary.BigEndian.Uint64(b[:]) % uint64(n)) } func TestGeneratePasswordUsesNonAlphanumericRequirement(t *testing.T) { mockAuth := &mocksauth.MockAuthentication{ MockPassword: &mocksauth.MockPassword{ PolicyResponse: &descope.PasswordPolicy{ MinLength: 8, Lowercase: true, Uppercase: true, Number: true, NonAlphanumeric: true, }, }, } policy, err := mockAuth.Password().GetPasswordPolicy(context.Background()) if err != nil { t.Fatalf("정책 조회 실패: %v", err) } if !policy.NonAlphanumeric { t.Fatalf("정책에 비영문자 요구사항이 표시되지 않음") } pwd := generatePasswordFromPolicy(policy) if len(pwd) < int(policy.MinLength) { t.Fatalf("비밀번호 길이가 정책 최소 길이 미만: got %d, want >= %d", len(pwd), policy.MinLength) } var hasLower, hasUpper, hasNumber, hasSymbol bool for _, r := range pwd { switch { case unicode.IsLower(r): hasLower = true case unicode.IsUpper(r): hasUpper = true case unicode.IsNumber(r): hasNumber = true case !unicode.IsLetter(r) && !unicode.IsNumber(r): hasSymbol = true } } if policy.Lowercase && !hasLower { t.Fatalf("소문자 요구사항 미충족: %q", pwd) } if policy.Uppercase && !hasUpper { t.Fatalf("대문자 요구사항 미충족: %q", pwd) } if policy.Number && !hasNumber { t.Fatalf("숫자 요구사항 미충족: %q", pwd) } if policy.NonAlphanumeric && !hasSymbol { t.Fatalf("비영문자 요구사항 미충족: %q", pwd) } } // 통합 테스트: 실제 Descope 정책으로 비밀번호를 생성하고 교체 플로우를 검증한다. // 필요 env: // DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, TEST_DESCOPE_LOGIN_ID, TEST_DESCOPE_CURRENT_PASSWORD func TestDescopePasswordPolicyAndChange(t *testing.T) { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") loginID := os.Getenv("DESCOPE_TEST_ACCOUNT") if projectID == "" || managementKey == "" || loginID == "" { t.Skip("환경변수(DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, DESCOPE_TEST_ACCOUNT) 미설정으로 통합 테스트 건너뜀") } logf := func(format string, args ...any) { t.Logf(format, args...) fmt.Printf(format+"\n", args...) } ctx := context.Background() cl, err := client.NewWithConfig(&client.Config{ ProjectID: projectID, ManagementKey: managementKey, }) if err != nil { t.Fatalf("Descope 클라이언트 초기화 실패: %v", err) } policy, err := cl.Auth.Password().GetPasswordPolicy(ctx) if err != nil { t.Fatalf("비밀번호 정책 조회 실패: %v", err) } logf("정책: min=%d lower=%v upper=%v number=%v nonAlpha=%v", policy.MinLength, policy.Lowercase, policy.Uppercase, policy.Number, policy.NonAlphanumeric) // 테스트 계정이 없으면 생성 users, _, err := cl.Management.User().SearchAll(ctx, &descope.UserSearchOptions{ LoginIDs: []string{loginID}, Limit: 1, Page: 0, }) if err != nil { t.Fatalf("테스트 계정 검색 실패: %v", err) } if len(users) == 0 { logf("테스트 계정 미존재, 생성 시도: %s", loginID) if _, err := cl.Management.User().CreateTestUser(ctx, loginID, &descope.UserRequest{ User: descope.User{ Email: loginID, }, }); err != nil { t.Fatalf("테스트 계정 생성 실패: %v", err) } } else { logf("테스트 계정 존재 확인: %s", loginID) } // 1) 기초 비밀번호 설정 (알려진 값으로 초기화) basePassword := generatePasswordFromPolicy(policy) if err := cl.Management.User().SetActivePassword(ctx, loginID, basePassword); err != nil { logf("초기 비밀번호 설정 실패: status=%d err=%v", statusFromError(err), err) t.Fatalf("초기 비밀번호 설정 실패: %v", err) } logf("초기 비밀번호 설정 완료: %s", basePassword) // 2) 초기 비밀번호 로그인 검증 wOld := httptest.NewRecorder() _, err = cl.Auth.Password().SignIn(ctx, loginID, basePassword, wOld) logf("기초 비밀번호 로그인: status=%d err=%v", statusFromError(err), err) if err != nil { t.Fatalf("기초 비밀번호 로그인 실패: %v", err) } // 3) 새 비밀번호 생성 및 변경 newPassword := generatePasswordFromPolicy(policy) if newPassword == basePassword { newPassword = newPassword + "Z9!" } logf("새 비밀번호 생성: %s", newPassword) if err := cl.Management.User().SetActivePassword(ctx, loginID, newPassword); err != nil { logf("비밀번호 변경 실패: status=%d err=%v", statusFromError(err), err) t.Fatalf("비밀번호 변경 실패: %v", err) } logf("비밀번호 변경 성공(status=200)") // 4) 새 비밀번호로 로그인 확인 wNew := httptest.NewRecorder() _, err = cl.Auth.Password().SignIn(ctx, loginID, newPassword, wNew) logf("새 비밀번호 로그인: status=%d err=%v", statusFromError(err), err) if err != nil { t.Fatalf("새 비밀번호 로그인 실패: %v", err) } } func statusFromError(err error) int { if err == nil { return http.StatusOK } var de *descope.Error if errors.As(err, &de) { if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok { switch v := statusRaw.(type) { case int: return v case string: if n, convErr := strconv.Atoi(v); convErr == nil { return n } } } } return http.StatusInternalServerError }