Production hardening: password complexity, token refresh, network resilience
Password complexity: real-time validation UI on register, onboarding, and reset screens (uppercase, lowercase, digit, min 8 chars) — Compose + iOS Swift iOS privacy descriptions: camera, photo library, photo save usage strings Token refresh: Ktor interceptor catches 401 "token_expired", refreshes, retries Retry with backoff: 3 retries on 5xx/IO errors, exponential delay (1s base, 10s max) Gzip: ContentEncoding plugin on all platform HTTP clients Request timeouts: 30s request, 10s connect, 30s socket Validation rules: split passwordMissingLetter into uppercase/lowercase (iOS Swift) Test fixes: corrected import paths in 5 existing test files New tests: HTTP client retry/refresh (9), validation rules
This commit is contained in:
@@ -37,6 +37,16 @@ struct ValidationErrorTests {
|
||||
#expect(error.errorDescription == "Password must contain at least one letter")
|
||||
}
|
||||
|
||||
@Test func passwordMissingUppercaseErrorDescription() {
|
||||
let error = ValidationError.passwordMissingUppercase
|
||||
#expect(error.errorDescription == "Password must contain at least one uppercase letter")
|
||||
}
|
||||
|
||||
@Test func passwordMissingLowercaseErrorDescription() {
|
||||
let error = ValidationError.passwordMissingLowercase
|
||||
#expect(error.errorDescription == "Password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
@Test func passwordMissingNumberErrorDescription() {
|
||||
let error = ValidationError.passwordMissingNumber
|
||||
#expect(error.errorDescription == "Password must contain at least one number")
|
||||
@@ -125,32 +135,53 @@ struct ValidationRulesPasswordStrengthTests {
|
||||
#expect(error?.errorDescription == "Password is required")
|
||||
}
|
||||
|
||||
@Test func noLetterReturnsMissingLetter() {
|
||||
let error = ValidationRules.validatePasswordStrength("123456")
|
||||
#expect(error?.errorDescription == "Password must contain at least one letter")
|
||||
@Test func noUppercaseReturnsMissingUppercase() {
|
||||
let error = ValidationRules.validatePasswordStrength("abc123")
|
||||
#expect(error?.errorDescription == "Password must contain at least one uppercase letter")
|
||||
}
|
||||
|
||||
@Test func noLowercaseReturnsMissingLowercase() {
|
||||
let error = ValidationRules.validatePasswordStrength("ABC123")
|
||||
#expect(error?.errorDescription == "Password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
@Test func noNumberReturnsMissingNumber() {
|
||||
let error = ValidationRules.validatePasswordStrength("abcdef")
|
||||
let error = ValidationRules.validatePasswordStrength("Abcdef")
|
||||
#expect(error?.errorDescription == "Password must contain at least one number")
|
||||
}
|
||||
|
||||
@Test func letterAndNumberReturnsNil() {
|
||||
let error = ValidationRules.validatePasswordStrength("abc123")
|
||||
@Test func uppercaseLowercaseAndNumberReturnsNil() {
|
||||
let error = ValidationRules.validatePasswordStrength("Abc123")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsTrueForStrong() {
|
||||
#expect(ValidationRules.isValidPassword("abc123"))
|
||||
@Test func isValidPasswordReturnsTrueForComplex() {
|
||||
#expect(ValidationRules.isValidPassword("Abc123"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForLettersOnly() {
|
||||
@Test func isValidPasswordReturnsFalseForLowercaseOnly() {
|
||||
#expect(!ValidationRules.isValidPassword("abcdef"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForUppercaseOnly() {
|
||||
#expect(!ValidationRules.isValidPassword("ABCDEF"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForNumbersOnly() {
|
||||
#expect(!ValidationRules.isValidPassword("123456"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForNoUppercase() {
|
||||
#expect(!ValidationRules.isValidPassword("abc123"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForNoLowercase() {
|
||||
#expect(!ValidationRules.isValidPassword("ABC123"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForNoDigit() {
|
||||
#expect(!ValidationRules.isValidPassword("Abcdef"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Match Tests
|
||||
|
||||
@@ -7,6 +7,8 @@ enum ValidationError: LocalizedError {
|
||||
case passwordTooShort(minLength: Int)
|
||||
case passwordMismatch
|
||||
case passwordMissingLetter
|
||||
case passwordMissingUppercase
|
||||
case passwordMissingLowercase
|
||||
case passwordMissingNumber
|
||||
case invalidCode(expectedLength: Int)
|
||||
case invalidUsername
|
||||
@@ -24,6 +26,10 @@ enum ValidationError: LocalizedError {
|
||||
return "Passwords do not match"
|
||||
case .passwordMissingLetter:
|
||||
return "Password must contain at least one letter"
|
||||
case .passwordMissingUppercase:
|
||||
return "Password must contain at least one uppercase letter"
|
||||
case .passwordMissingLowercase:
|
||||
return "Password must contain at least one lowercase letter"
|
||||
case .passwordMissingNumber:
|
||||
return "Password must contain at least one number"
|
||||
case .invalidCode(let length):
|
||||
@@ -90,7 +96,7 @@ enum ValidationRules {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Validates a password with letter and number requirements
|
||||
/// Validates a password with uppercase, lowercase, and number requirements
|
||||
/// - Parameter password: The password to validate
|
||||
/// - Returns: ValidationError if invalid, nil if valid
|
||||
static func validatePasswordStrength(_ password: String) -> ValidationError? {
|
||||
@@ -98,11 +104,16 @@ enum ValidationRules {
|
||||
return .required(field: "Password")
|
||||
}
|
||||
|
||||
let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||
let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
|
||||
let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil
|
||||
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
|
||||
|
||||
if !hasLetter {
|
||||
return .passwordMissingLetter
|
||||
if !hasUppercase {
|
||||
return .passwordMissingUppercase
|
||||
}
|
||||
|
||||
if !hasLowercase {
|
||||
return .passwordMissingLowercase
|
||||
}
|
||||
|
||||
if !hasNumber {
|
||||
@@ -112,13 +123,14 @@ enum ValidationRules {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if password has required strength (letter + number)
|
||||
/// Check if password has required strength (uppercase + lowercase + number)
|
||||
/// - Parameter password: The password to check
|
||||
/// - Returns: true if valid strength
|
||||
static func isValidPassword(_ password: String) -> Bool {
|
||||
let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||
let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
|
||||
let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil
|
||||
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
|
||||
return hasLetter && hasNumber
|
||||
return hasUppercase && hasLowercase && hasNumber
|
||||
}
|
||||
|
||||
/// Validates that two passwords match
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>honeyDue needs camera access to take photos of tasks, documents, and receipts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>honeyDue needs photo library access to attach photos to tasks and documents.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>honeyDue needs permission to save photos to your library.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -19,11 +19,18 @@ struct OnboardingCreateAccountContent: View {
|
||||
case username, email, password, confirmPassword
|
||||
}
|
||||
|
||||
private var hasMinLength: Bool { viewModel.password.count >= 8 }
|
||||
private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil }
|
||||
private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil }
|
||||
private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil }
|
||||
private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit }
|
||||
private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword }
|
||||
|
||||
private var isFormValid: Bool {
|
||||
!viewModel.username.isEmpty &&
|
||||
!viewModel.email.isEmpty &&
|
||||
!viewModel.password.isEmpty &&
|
||||
viewModel.password == viewModel.confirmPassword
|
||||
isPasswordComplex &&
|
||||
passwordsMatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -250,6 +257,20 @@ struct OnboardingCreateAccountContent: View {
|
||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
|
||||
)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
|
||||
// Password Requirements
|
||||
if !viewModel.password.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
OnboardingPasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
|
||||
OnboardingPasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
|
||||
OnboardingPasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
|
||||
OnboardingPasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
|
||||
OnboardingPasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.background(
|
||||
@@ -361,6 +382,25 @@ struct OnboardingCreateAccountContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Password Requirement Row
|
||||
|
||||
private struct OnboardingPasswordRequirementRow: View {
|
||||
let isMet: Bool
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Onboarding TextField
|
||||
|
||||
private struct OrganicOnboardingTextField: View {
|
||||
|
||||
@@ -13,13 +13,10 @@ struct ResetPasswordView: View {
|
||||
}
|
||||
|
||||
// Computed Properties
|
||||
private var hasLetter: Bool {
|
||||
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private var hasNumber: Bool {
|
||||
viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
|
||||
}
|
||||
private var hasMinLength: Bool { viewModel.newPassword.count >= 8 }
|
||||
private var hasUppercase: Bool { viewModel.newPassword.range(of: "[A-Z]", options: .regularExpression) != nil }
|
||||
private var hasLowercase: Bool { viewModel.newPassword.range(of: "[a-z]", options: .regularExpression) != nil }
|
||||
private var hasNumber: Bool { viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil }
|
||||
|
||||
private var passwordsMatch: Bool {
|
||||
!viewModel.newPassword.isEmpty &&
|
||||
@@ -28,8 +25,9 @@ struct ResetPasswordView: View {
|
||||
}
|
||||
|
||||
private var isFormValid: Bool {
|
||||
viewModel.newPassword.count >= 8 &&
|
||||
hasLetter &&
|
||||
hasMinLength &&
|
||||
hasUppercase &&
|
||||
hasLowercase &&
|
||||
hasNumber &&
|
||||
passwordsMatch
|
||||
}
|
||||
@@ -90,16 +88,20 @@ struct ResetPasswordView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RequirementRow(
|
||||
isMet: viewModel.newPassword.count >= 8,
|
||||
isMet: hasMinLength,
|
||||
text: "At least 8 characters"
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasLetter,
|
||||
text: "Contains letters"
|
||||
isMet: hasUppercase,
|
||||
text: "Contains an uppercase letter"
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasLowercase,
|
||||
text: "Contains a lowercase letter"
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: hasNumber,
|
||||
text: "Contains numbers"
|
||||
text: "Contains a number"
|
||||
)
|
||||
RequirementRow(
|
||||
isMet: passwordsMatch,
|
||||
|
||||
@@ -13,11 +13,18 @@ struct RegisterView: View {
|
||||
case username, email, password, confirmPassword
|
||||
}
|
||||
|
||||
private var hasMinLength: Bool { viewModel.password.count >= 8 }
|
||||
private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil }
|
||||
private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil }
|
||||
private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil }
|
||||
private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword }
|
||||
private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit }
|
||||
|
||||
private var isFormValid: Bool {
|
||||
!viewModel.username.isEmpty &&
|
||||
!viewModel.email.isEmpty &&
|
||||
!viewModel.password.isEmpty &&
|
||||
!viewModel.confirmPassword.isEmpty
|
||||
isPasswordComplex &&
|
||||
passwordsMatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -130,10 +137,26 @@ struct RegisterView: View {
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.register() }
|
||||
|
||||
Text(L10n.Auth.passwordSuggestion)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Password Requirements
|
||||
if !viewModel.password.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("PASSWORD REQUIREMENTS")
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.tracking(1.2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
|
||||
PasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
|
||||
PasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
|
||||
PasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
|
||||
PasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
@@ -340,6 +363,25 @@ private struct OrganicSecureField: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Requirement Row
|
||||
|
||||
private struct PasswordRequirementRow: View {
|
||||
let isMet: Bool
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Form Background
|
||||
|
||||
private struct OrganicFormBackground: View {
|
||||
|
||||
@@ -36,6 +36,11 @@ class RegisterViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if let error = ValidationRules.validatePasswordStrength(password) {
|
||||
errorMessage = error.errorDescription
|
||||
return
|
||||
}
|
||||
|
||||
if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) {
|
||||
errorMessage = error.errorDescription
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user