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:
@@ -102,12 +102,15 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
|
implementation(libs.ktor.client.encoding)
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.ktor.client.mock)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,5 +41,8 @@ actual fun createHttpClient(): HttpClient {
|
|||||||
headers.append("Accept-Language", getDeviceLanguage())
|
headers.append("Accept-Language", getDeviceLanguage())
|
||||||
headers.append("X-Timezone", getDeviceTimezone())
|
headers.append("X-Timezone", getDeviceTimezone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
|
||||||
|
installCommonPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@
|
|||||||
<string name="auth_register_button">Create Account</string>
|
<string name="auth_register_button">Create Account</string>
|
||||||
<string name="auth_have_account">Already have an account? Sign In</string>
|
<string name="auth_have_account">Already have an account? Sign In</string>
|
||||||
<string name="auth_passwords_dont_match">Passwords don\'t match</string>
|
<string name="auth_passwords_dont_match">Passwords don\'t match</string>
|
||||||
|
<string name="auth_password_requirement_length">At least 8 characters</string>
|
||||||
|
<string name="auth_password_requirement_uppercase">Contains an uppercase letter</string>
|
||||||
|
<string name="auth_password_requirement_lowercase">Contains a lowercase letter</string>
|
||||||
|
<string name="auth_password_requirement_digit">Contains a number</string>
|
||||||
|
<string name="auth_password_requirement_match">Passwords match</string>
|
||||||
|
<string name="auth_password_requirements_title">Password Requirements</string>
|
||||||
|
<string name="auth_password_complexity_error">Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit</string>
|
||||||
|
|
||||||
<!-- Auth - Verify Email -->
|
<!-- Auth - Verify Email -->
|
||||||
<string name="auth_verify_title">Verify Email</string>
|
<string name="auth_verify_title">Verify Email</string>
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ data class AuthResponse(
|
|||||||
val user: User
|
val user: User
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token refresh response - returned by POST /api/auth/refresh/
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TokenRefreshResponse(
|
||||||
|
val token: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth response for registration - matching Go API RegisterResponse
|
* Auth response for registration - matching Go API RegisterResponse
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1253,6 +1253,26 @@ object APILayer {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the current auth token.
|
||||||
|
* Calls POST /api/auth/refresh/ with the current token.
|
||||||
|
* On success, saves the new token to DataManager and TokenStorage.
|
||||||
|
* On failure, returns an error (caller decides whether to trigger logout).
|
||||||
|
*/
|
||||||
|
suspend fun refreshToken(): ApiResult<String> {
|
||||||
|
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
|
||||||
|
val result = authApi.refreshToken(currentToken)
|
||||||
|
if (result is ApiResult.Success) {
|
||||||
|
DataManager.setAuthToken(result.data.token)
|
||||||
|
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
|
||||||
|
}
|
||||||
|
return when (result) {
|
||||||
|
is ApiResult.Success -> ApiResult.Success(result.data.token)
|
||||||
|
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
|
||||||
|
else -> ApiResult.Error("Unexpected state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult<User> {
|
suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult<User> {
|
||||||
// Check DataManager first
|
// Check DataManager first
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
package com.tt.honeyDue.network
|
package com.tt.honeyDue.network
|
||||||
|
|
||||||
|
import com.tt.honeyDue.data.DataManager
|
||||||
|
import com.tt.honeyDue.models.TokenRefreshResponse
|
||||||
|
import com.tt.honeyDue.storage.TokenStorage
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.compression.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.plugins.logging.*
|
import io.ktor.client.plugins.logging.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
expect fun getLocalhostAddress(): String
|
expect fun getLocalhostAddress(): String
|
||||||
@@ -23,6 +34,142 @@ expect fun getDeviceLanguage(): String
|
|||||||
*/
|
*/
|
||||||
expect fun getDeviceTimezone(): String
|
expect fun getDeviceTimezone(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutex to prevent multiple concurrent token refresh attempts.
|
||||||
|
* When one request triggers a 401, only one refresh call is made;
|
||||||
|
* other requests wait for it to complete.
|
||||||
|
*/
|
||||||
|
private val tokenRefreshMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install common plugins shared across all platform HttpClient instances.
|
||||||
|
* This includes retry with exponential backoff, request timeouts,
|
||||||
|
* gzip content encoding, and 401 token refresh handling.
|
||||||
|
*/
|
||||||
|
fun HttpClientConfig<*>.installCommonPlugins() {
|
||||||
|
// Task 1: Gzip support — tells the server we accept compressed responses
|
||||||
|
install(ContentEncoding) {
|
||||||
|
gzip()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2: Retry with exponential backoff for server errors and IO exceptions
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
retryOnExceptionIf { _, cause ->
|
||||||
|
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
||||||
|
// but not on application-level exceptions like serialization errors
|
||||||
|
cause is io.ktor.client.network.sockets.ConnectTimeoutException ||
|
||||||
|
cause is io.ktor.client.plugins.HttpRequestTimeoutException ||
|
||||||
|
cause.cause is kotlinx.io.IOException ||
|
||||||
|
cause is kotlinx.io.IOException
|
||||||
|
}
|
||||||
|
exponentialDelay(
|
||||||
|
base = 1000.0, // 1 second base
|
||||||
|
maxDelayMs = 10000 // 10 second max
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 4: Request timeout configuration
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 30_000 // 30 seconds
|
||||||
|
connectTimeoutMillis = 10_000 // 10 seconds
|
||||||
|
socketTimeoutMillis = 30_000 // 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 3: Token refresh on 401 responses
|
||||||
|
HttpResponseValidator {
|
||||||
|
validateResponse { response ->
|
||||||
|
if (response.status.value == 401) {
|
||||||
|
// Check if this is a token_expired error (not invalid credentials)
|
||||||
|
val bodyText = response.bodyAsText()
|
||||||
|
val isTokenExpired = bodyText.contains("token_expired") ||
|
||||||
|
bodyText.contains("Token has expired") ||
|
||||||
|
bodyText.contains("expired")
|
||||||
|
|
||||||
|
if (isTokenExpired) {
|
||||||
|
val currentToken = DataManager.authToken.value
|
||||||
|
if (currentToken != null) {
|
||||||
|
// Use mutex to prevent concurrent refresh attempts
|
||||||
|
val refreshed = tokenRefreshMutex.withLock {
|
||||||
|
// Double-check: another coroutine may have already refreshed
|
||||||
|
val tokenAfterLock = DataManager.authToken.value
|
||||||
|
if (tokenAfterLock != currentToken) {
|
||||||
|
// Token was already refreshed by another coroutine
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
attemptTokenRefresh(currentToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!refreshed) {
|
||||||
|
// Refresh failed — clear auth and trigger logout
|
||||||
|
DataManager.clear()
|
||||||
|
}
|
||||||
|
// Throw so the caller can retry (or handle the logout)
|
||||||
|
throw TokenExpiredException(refreshed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
|
||||||
|
* Returns true if refresh succeeded and the new token was saved.
|
||||||
|
*/
|
||||||
|
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
||||||
|
return try {
|
||||||
|
// Use a minimal client to avoid recursive interceptor triggers
|
||||||
|
val refreshClient = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 15_000
|
||||||
|
connectTimeoutMillis = 10_000
|
||||||
|
socketTimeoutMillis = 15_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val baseUrl = ApiConfig.getBaseUrl()
|
||||||
|
val response = refreshClient.post("$baseUrl/auth/refresh/") {
|
||||||
|
header("Authorization", "Token $currentToken")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
refreshClient.close()
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val tokenResponse = response.body<TokenRefreshResponse>()
|
||||||
|
// Save the new token to both DataManager and persistent storage
|
||||||
|
DataManager.setAuthToken(tokenResponse.token)
|
||||||
|
TokenStorage.saveToken(tokenResponse.token)
|
||||||
|
println("[ApiClient] Token refreshed successfully")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
println("[ApiClient] Token refresh failed: ${response.status.value}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[ApiClient] Token refresh error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a 401 response indicates an expired token.
|
||||||
|
* [refreshed] indicates whether the token was successfully refreshed.
|
||||||
|
* Callers can catch this and retry the request if refreshed is true.
|
||||||
|
*/
|
||||||
|
class TokenExpiredException(val refreshed: Boolean) : Exception(
|
||||||
|
if (refreshed) "Token was expired but has been refreshed — retry the request"
|
||||||
|
else "Token expired and refresh failed — user must re-authenticate"
|
||||||
|
)
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
val httpClient = createHttpClient()
|
val httpClient = createHttpClient()
|
||||||
|
|
||||||
@@ -41,9 +188,9 @@ object ApiClient {
|
|||||||
* Print current environment configuration
|
* Print current environment configuration
|
||||||
*/
|
*/
|
||||||
init {
|
init {
|
||||||
println("🌐 API Client initialized")
|
println("API Client initialized")
|
||||||
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
|
println("Environment: ${ApiConfig.getEnvironmentName()}")
|
||||||
println("🔗 Base URL: ${getBaseUrl()}")
|
println("Base URL: ${getBaseUrl()}")
|
||||||
println("📁 Media URL: ${getMediaBaseUrl()}")
|
println("Media URL: ${getMediaBaseUrl()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,6 +248,25 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token Refresh
|
||||||
|
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/auth/refresh/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
val errorMessage = ErrorParser.parseError(response)
|
||||||
|
ApiResult.Error(errorMessage, response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Google Sign In
|
// Google Sign In
|
||||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.tt.honeyDue.ui.components.HandleErrors
|
import com.tt.honeyDue.ui.components.HandleErrors
|
||||||
import com.tt.honeyDue.ui.components.auth.AuthHeader
|
import com.tt.honeyDue.ui.components.auth.AuthHeader
|
||||||
|
import com.tt.honeyDue.ui.components.auth.RequirementItem
|
||||||
import com.tt.honeyDue.ui.components.common.ErrorCard
|
import com.tt.honeyDue.ui.components.common.ErrorCard
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import com.tt.honeyDue.viewmodel.AuthViewModel
|
import com.tt.honeyDue.viewmodel.AuthViewModel
|
||||||
@@ -162,16 +163,58 @@ fun RegisterScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password Requirements
|
||||||
|
if (password.isNotEmpty()) {
|
||||||
|
val hasMinLength = password.length >= 8
|
||||||
|
val hasUppercase = password.any { it.isUpperCase() }
|
||||||
|
val hasLowercase = password.any { it.isLowerCase() }
|
||||||
|
val hasDigit = password.any { it.isDigit() }
|
||||||
|
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
|
||||||
|
|
||||||
|
OrganicCard(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(OrganicSpacing.md),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.auth_password_requirements_title),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ErrorCard(message = errorMessage)
|
ErrorCard(message = errorMessage)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||||
|
|
||||||
|
val passwordComplexityMessage = stringResource(Res.string.auth_password_complexity_error)
|
||||||
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
|
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
|
||||||
|
|
||||||
|
val hasMinLength = password.length >= 8
|
||||||
|
val hasUppercase = password.any { it.isUpperCase() }
|
||||||
|
val hasLowercase = password.any { it.isLowerCase() }
|
||||||
|
val hasDigit = password.any { it.isDigit() }
|
||||||
|
val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
|
||||||
|
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
|
||||||
|
|
||||||
OrganicPrimaryButton(
|
OrganicPrimaryButton(
|
||||||
text = stringResource(Res.string.auth_register_button),
|
text = stringResource(Res.string.auth_register_button),
|
||||||
onClick = {
|
onClick = {
|
||||||
when {
|
when {
|
||||||
password != confirmPassword -> {
|
!isPasswordComplex -> {
|
||||||
|
errorMessage = passwordComplexityMessage
|
||||||
|
}
|
||||||
|
!passwordsMatch -> {
|
||||||
errorMessage = passwordsDontMatchMessage
|
errorMessage = passwordsDontMatchMessage
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@@ -183,7 +226,7 @@ fun RegisterScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = username.isNotEmpty() && email.isNotEmpty() &&
|
enabled = username.isNotEmpty() && email.isNotEmpty() &&
|
||||||
password.isNotEmpty() && !isLoading,
|
password.isNotEmpty() && isPasswordComplex && passwordsMatch && !isLoading,
|
||||||
isLoading = isLoading
|
isLoading = isLoading
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ fun ResetPasswordScreen(
|
|||||||
val isSuccess = currentStep == com.tt.honeyDue.viewmodel.PasswordResetStep.SUCCESS
|
val isSuccess = currentStep == com.tt.honeyDue.viewmodel.PasswordResetStep.SUCCESS
|
||||||
|
|
||||||
// Password validation
|
// Password validation
|
||||||
val hasLetter = newPassword.any { it.isLetter() }
|
val hasMinLength = newPassword.length >= 8
|
||||||
|
val hasUppercase = newPassword.any { it.isUpperCase() }
|
||||||
|
val hasLowercase = newPassword.any { it.isLowerCase() }
|
||||||
val hasNumber = newPassword.any { it.isDigit() }
|
val hasNumber = newPassword.any { it.isDigit() }
|
||||||
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
|
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
|
||||||
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
|
val isFormValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -192,19 +194,23 @@ fun ResetPasswordScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
RequirementItem(
|
RequirementItem(
|
||||||
"At least 8 characters",
|
stringResource(Res.string.auth_password_requirement_length),
|
||||||
newPassword.length >= 8
|
hasMinLength
|
||||||
)
|
)
|
||||||
RequirementItem(
|
RequirementItem(
|
||||||
"Contains letters",
|
stringResource(Res.string.auth_password_requirement_uppercase),
|
||||||
hasLetter
|
hasUppercase
|
||||||
)
|
)
|
||||||
RequirementItem(
|
RequirementItem(
|
||||||
"Contains numbers",
|
stringResource(Res.string.auth_password_requirement_lowercase),
|
||||||
|
hasLowercase
|
||||||
|
)
|
||||||
|
RequirementItem(
|
||||||
|
stringResource(Res.string.auth_password_requirement_digit),
|
||||||
hasNumber
|
hasNumber
|
||||||
)
|
)
|
||||||
RequirementItem(
|
RequirementItem(
|
||||||
"Passwords match",
|
stringResource(Res.string.auth_password_requirement_match),
|
||||||
passwordsMatch
|
passwordsMatch
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
|
import com.tt.honeyDue.ui.components.auth.RequirementItem
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||||
import honeydue.composeapp.generated.resources.*
|
import honeydue.composeapp.generated.resources.*
|
||||||
@@ -55,10 +56,16 @@ fun OnboardingCreateAccountContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isLoading = registerState is ApiResult.Loading
|
val isLoading = registerState is ApiResult.Loading
|
||||||
|
val hasMinLength = password.length >= 8
|
||||||
|
val hasUppercase = password.any { it.isUpperCase() }
|
||||||
|
val hasLowercase = password.any { it.isLowerCase() }
|
||||||
|
val hasDigit = password.any { it.isDigit() }
|
||||||
|
val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
|
||||||
|
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
|
||||||
val isFormValid = username.isNotBlank() &&
|
val isFormValid = username.isNotBlank() &&
|
||||||
email.isNotBlank() &&
|
email.isNotBlank() &&
|
||||||
password.isNotBlank() &&
|
isPasswordComplex &&
|
||||||
password == confirmPassword
|
passwordsMatch
|
||||||
|
|
||||||
WarmGradientBackground(
|
WarmGradientBackground(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@@ -209,6 +216,25 @@ fun OnboardingCreateAccountContent(
|
|||||||
} else null
|
} else null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Password Requirements
|
||||||
|
if (password.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.auth_password_requirements_title),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
|
||||||
|
RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
if (localErrorMessage != null) {
|
if (localErrorMessage != null) {
|
||||||
OrganicCard(
|
OrganicCard(
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package com.tt.honeyDue.network
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.engine.mock.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class HttpClientPluginsTest {
|
||||||
|
|
||||||
|
// ==================== Retry Logic Tests ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRetryTriggersOn500() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
if (requestCount < 3) {
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Internal Server Error"}""",
|
||||||
|
status = HttpStatusCode.InternalServerError,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
respond(
|
||||||
|
content = """{"status":"ok"}""",
|
||||||
|
status = HttpStatusCode.OK,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
// First request + 2 retries = 3 total
|
||||||
|
assertEquals(3, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRetryTriggersOn503() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
if (requestCount < 2) {
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Service Unavailable"}""",
|
||||||
|
status = HttpStatusCode.ServiceUnavailable,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
respond(
|
||||||
|
content = """{"status":"ok"}""",
|
||||||
|
status = HttpStatusCode.OK,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
assertEquals(2, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoRetryOn400() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Bad Request"}""",
|
||||||
|
status = HttpStatusCode.BadRequest,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
|
// No retries on 4xx — exactly 1 request
|
||||||
|
assertEquals(1, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoRetryOn401() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Unauthorized"}""",
|
||||||
|
status = HttpStatusCode.Unauthorized,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||||
|
// No retries on 401 — exactly 1 request
|
||||||
|
assertEquals(1, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoRetryOn404() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Not Found"}""",
|
||||||
|
status = HttpStatusCode.NotFound,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||||
|
assertEquals(1, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoRetryOn422() = runTest {
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Unprocessable Entity"}""",
|
||||||
|
status = HttpStatusCode.UnprocessableEntity,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
maxRetries = 3
|
||||||
|
retryIf { _, response ->
|
||||||
|
response.status.value in 500..599
|
||||||
|
}
|
||||||
|
constantDelay(millis = 1, randomizationMs = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/test")
|
||||||
|
assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
|
||||||
|
assertEquals(1, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Token Refresh / 401 Handling Tests ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTokenExpiredExceptionIsRefreshed() {
|
||||||
|
val exception = TokenExpiredException(refreshed = true)
|
||||||
|
assertTrue(exception.refreshed)
|
||||||
|
assertTrue(exception.message!!.contains("refreshed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTokenExpiredExceptionNotRefreshed() {
|
||||||
|
val exception = TokenExpiredException(refreshed = false)
|
||||||
|
assertTrue(!exception.refreshed)
|
||||||
|
assertTrue(exception.message!!.contains("re-authenticate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest {
|
||||||
|
// A 401 that does NOT contain "expired" or "token_expired" should NOT
|
||||||
|
// throw TokenExpiredException — it should just return the 401 response.
|
||||||
|
var requestCount = 0
|
||||||
|
val client = HttpClient(MockEngine) {
|
||||||
|
engine {
|
||||||
|
addHandler {
|
||||||
|
requestCount++
|
||||||
|
respond(
|
||||||
|
content = """{"error":"Invalid credentials"}""",
|
||||||
|
status = HttpStatusCode.Unauthorized,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
// No response validator installed — simulates behavior where
|
||||||
|
// the validator only triggers on "expired" keyword in body
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("https://example.com/auth/login/")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||||
|
val body = response.bodyAsText()
|
||||||
|
assertTrue(body.contains("Invalid credentials"))
|
||||||
|
assertEquals(1, requestCount)
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.viewmodel
|
package com.tt.honeyDue.viewmodel
|
||||||
|
|
||||||
import com.honeydue.android.viewmodel.AuthViewModel
|
import com.tt.honeyDue.viewmodel.AuthViewModel
|
||||||
import com.honeydue.shared.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.viewmodel
|
package com.tt.honeyDue.viewmodel
|
||||||
|
|
||||||
import com.honeydue.android.viewmodel.ContractorViewModel
|
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||||
import com.honeydue.shared.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.viewmodel
|
package com.tt.honeyDue.viewmodel
|
||||||
|
|
||||||
import com.honeydue.android.viewmodel.DocumentViewModel
|
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||||
import com.honeydue.shared.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.viewmodel
|
package com.tt.honeyDue.viewmodel
|
||||||
|
|
||||||
import com.honeydue.android.viewmodel.ResidenceViewModel
|
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
||||||
import com.honeydue.shared.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
@@ -18,15 +18,6 @@ class ResidenceViewModelTest {
|
|||||||
assertIs<ApiResult.Idle>(viewModel.residencesState.value)
|
assertIs<ApiResult.Idle>(viewModel.residencesState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testInitialResidenceSummaryState() {
|
|
||||||
// Given
|
|
||||||
val viewModel = ResidenceViewModel()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIs<ApiResult.Idle>(viewModel.residenceSummaryState.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInitialCreateResidenceState() {
|
fun testInitialCreateResidenceState() {
|
||||||
// Given
|
// Given
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.viewmodel
|
package com.tt.honeyDue.viewmodel
|
||||||
|
|
||||||
import com.honeydue.android.viewmodel.TaskViewModel
|
import com.tt.honeyDue.viewmodel.TaskViewModel
|
||||||
import com.honeydue.shared.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
|||||||
@@ -52,5 +52,8 @@ actual fun createHttpClient(): HttpClient {
|
|||||||
setAllowsCellularAccess(true)
|
setAllowsCellularAccess(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
|
||||||
|
installCommonPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
|
|||||||
headers.append("Accept-Language", getDeviceLanguage())
|
headers.append("Accept-Language", getDeviceLanguage())
|
||||||
headers.append("X-Timezone", getDeviceTimezone())
|
headers.append("X-Timezone", getDeviceTimezone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
|
||||||
|
installCommonPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
|
|||||||
headers.append("Accept-Language", getDeviceLanguage())
|
headers.append("Accept-Language", getDeviceLanguage())
|
||||||
headers.append("X-Timezone", getDeviceTimezone())
|
headers.append("X-Timezone", getDeviceTimezone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
|
||||||
|
installCommonPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
|
|||||||
headers.append("Accept-Language", getDeviceLanguage())
|
headers.append("Accept-Language", getDeviceLanguage())
|
||||||
headers.append("X-Timezone", getDeviceTimezone())
|
headers.append("X-Timezone", getDeviceTimezone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
|
||||||
|
installCommonPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto
|
|||||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||||
|
ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" }
|
||||||
|
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||||
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
|
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ struct ValidationErrorTests {
|
|||||||
#expect(error.errorDescription == "Password must contain at least one letter")
|
#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() {
|
@Test func passwordMissingNumberErrorDescription() {
|
||||||
let error = ValidationError.passwordMissingNumber
|
let error = ValidationError.passwordMissingNumber
|
||||||
#expect(error.errorDescription == "Password must contain at least one number")
|
#expect(error.errorDescription == "Password must contain at least one number")
|
||||||
@@ -125,32 +135,53 @@ struct ValidationRulesPasswordStrengthTests {
|
|||||||
#expect(error?.errorDescription == "Password is required")
|
#expect(error?.errorDescription == "Password is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func noLetterReturnsMissingLetter() {
|
@Test func noUppercaseReturnsMissingUppercase() {
|
||||||
let error = ValidationRules.validatePasswordStrength("123456")
|
let error = ValidationRules.validatePasswordStrength("abc123")
|
||||||
#expect(error?.errorDescription == "Password must contain at least one letter")
|
#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() {
|
@Test func noNumberReturnsMissingNumber() {
|
||||||
let error = ValidationRules.validatePasswordStrength("abcdef")
|
let error = ValidationRules.validatePasswordStrength("Abcdef")
|
||||||
#expect(error?.errorDescription == "Password must contain at least one number")
|
#expect(error?.errorDescription == "Password must contain at least one number")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func letterAndNumberReturnsNil() {
|
@Test func uppercaseLowercaseAndNumberReturnsNil() {
|
||||||
let error = ValidationRules.validatePasswordStrength("abc123")
|
let error = ValidationRules.validatePasswordStrength("Abc123")
|
||||||
#expect(error == nil)
|
#expect(error == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func isValidPasswordReturnsTrueForStrong() {
|
@Test func isValidPasswordReturnsTrueForComplex() {
|
||||||
#expect(ValidationRules.isValidPassword("abc123"))
|
#expect(ValidationRules.isValidPassword("Abc123"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func isValidPasswordReturnsFalseForLettersOnly() {
|
@Test func isValidPasswordReturnsFalseForLowercaseOnly() {
|
||||||
#expect(!ValidationRules.isValidPassword("abcdef"))
|
#expect(!ValidationRules.isValidPassword("abcdef"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func isValidPasswordReturnsFalseForUppercaseOnly() {
|
||||||
|
#expect(!ValidationRules.isValidPassword("ABCDEF"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test func isValidPasswordReturnsFalseForNumbersOnly() {
|
@Test func isValidPasswordReturnsFalseForNumbersOnly() {
|
||||||
#expect(!ValidationRules.isValidPassword("123456"))
|
#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
|
// MARK: - Password Match Tests
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ enum ValidationError: LocalizedError {
|
|||||||
case passwordTooShort(minLength: Int)
|
case passwordTooShort(minLength: Int)
|
||||||
case passwordMismatch
|
case passwordMismatch
|
||||||
case passwordMissingLetter
|
case passwordMissingLetter
|
||||||
|
case passwordMissingUppercase
|
||||||
|
case passwordMissingLowercase
|
||||||
case passwordMissingNumber
|
case passwordMissingNumber
|
||||||
case invalidCode(expectedLength: Int)
|
case invalidCode(expectedLength: Int)
|
||||||
case invalidUsername
|
case invalidUsername
|
||||||
@@ -24,6 +26,10 @@ enum ValidationError: LocalizedError {
|
|||||||
return "Passwords do not match"
|
return "Passwords do not match"
|
||||||
case .passwordMissingLetter:
|
case .passwordMissingLetter:
|
||||||
return "Password must contain at least one letter"
|
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:
|
case .passwordMissingNumber:
|
||||||
return "Password must contain at least one number"
|
return "Password must contain at least one number"
|
||||||
case .invalidCode(let length):
|
case .invalidCode(let length):
|
||||||
@@ -90,7 +96,7 @@ enum ValidationRules {
|
|||||||
return nil
|
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
|
/// - Parameter password: The password to validate
|
||||||
/// - Returns: ValidationError if invalid, nil if valid
|
/// - Returns: ValidationError if invalid, nil if valid
|
||||||
static func validatePasswordStrength(_ password: String) -> ValidationError? {
|
static func validatePasswordStrength(_ password: String) -> ValidationError? {
|
||||||
@@ -98,11 +104,16 @@ enum ValidationRules {
|
|||||||
return .required(field: "Password")
|
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
|
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
|
||||||
|
|
||||||
if !hasLetter {
|
if !hasUppercase {
|
||||||
return .passwordMissingLetter
|
return .passwordMissingUppercase
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasLowercase {
|
||||||
|
return .passwordMissingLowercase
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasNumber {
|
if !hasNumber {
|
||||||
@@ -112,13 +123,14 @@ enum ValidationRules {
|
|||||||
return nil
|
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
|
/// - Parameter password: The password to check
|
||||||
/// - Returns: true if valid strength
|
/// - Returns: true if valid strength
|
||||||
static func isValidPassword(_ password: String) -> Bool {
|
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
|
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
|
||||||
return hasLetter && hasNumber
|
return hasUppercase && hasLowercase && hasNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates that two passwords match
|
/// Validates that two passwords match
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</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>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
case username, email, password, confirmPassword
|
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 {
|
private var isFormValid: Bool {
|
||||||
!viewModel.username.isEmpty &&
|
!viewModel.username.isEmpty &&
|
||||||
!viewModel.email.isEmpty &&
|
!viewModel.email.isEmpty &&
|
||||||
!viewModel.password.isEmpty &&
|
isPasswordComplex &&
|
||||||
viewModel.password == viewModel.confirmPassword
|
passwordsMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -250,6 +257,20 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
|
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
|
||||||
)
|
)
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
.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)
|
.padding(OrganicSpacing.cozy)
|
||||||
.background(
|
.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
|
// MARK: - Organic Onboarding TextField
|
||||||
|
|
||||||
private struct OrganicOnboardingTextField: View {
|
private struct OrganicOnboardingTextField: View {
|
||||||
|
|||||||
@@ -13,13 +13,10 @@ struct ResetPasswordView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Computed Properties
|
// Computed Properties
|
||||||
private var hasLetter: Bool {
|
private var hasMinLength: Bool { viewModel.newPassword.count >= 8 }
|
||||||
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
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 hasNumber: Bool {
|
|
||||||
viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private var passwordsMatch: Bool {
|
private var passwordsMatch: Bool {
|
||||||
!viewModel.newPassword.isEmpty &&
|
!viewModel.newPassword.isEmpty &&
|
||||||
@@ -28,8 +25,9 @@ struct ResetPasswordView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isFormValid: Bool {
|
private var isFormValid: Bool {
|
||||||
viewModel.newPassword.count >= 8 &&
|
hasMinLength &&
|
||||||
hasLetter &&
|
hasUppercase &&
|
||||||
|
hasLowercase &&
|
||||||
hasNumber &&
|
hasNumber &&
|
||||||
passwordsMatch
|
passwordsMatch
|
||||||
}
|
}
|
||||||
@@ -90,16 +88,20 @@ struct ResetPasswordView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
RequirementRow(
|
RequirementRow(
|
||||||
isMet: viewModel.newPassword.count >= 8,
|
isMet: hasMinLength,
|
||||||
text: "At least 8 characters"
|
text: "At least 8 characters"
|
||||||
)
|
)
|
||||||
RequirementRow(
|
RequirementRow(
|
||||||
isMet: hasLetter,
|
isMet: hasUppercase,
|
||||||
text: "Contains letters"
|
text: "Contains an uppercase letter"
|
||||||
|
)
|
||||||
|
RequirementRow(
|
||||||
|
isMet: hasLowercase,
|
||||||
|
text: "Contains a lowercase letter"
|
||||||
)
|
)
|
||||||
RequirementRow(
|
RequirementRow(
|
||||||
isMet: hasNumber,
|
isMet: hasNumber,
|
||||||
text: "Contains numbers"
|
text: "Contains a number"
|
||||||
)
|
)
|
||||||
RequirementRow(
|
RequirementRow(
|
||||||
isMet: passwordsMatch,
|
isMet: passwordsMatch,
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ struct RegisterView: View {
|
|||||||
case username, email, password, confirmPassword
|
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 {
|
private var isFormValid: Bool {
|
||||||
!viewModel.username.isEmpty &&
|
!viewModel.username.isEmpty &&
|
||||||
!viewModel.email.isEmpty &&
|
!viewModel.email.isEmpty &&
|
||||||
!viewModel.password.isEmpty &&
|
isPasswordComplex &&
|
||||||
!viewModel.confirmPassword.isEmpty
|
passwordsMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -130,10 +137,26 @@ struct RegisterView: View {
|
|||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.onSubmit { viewModel.register() }
|
.onSubmit { viewModel.register() }
|
||||||
|
|
||||||
Text(L10n.Auth.passwordSuggestion)
|
// Password Requirements
|
||||||
.font(.system(size: 12, weight: .medium))
|
if !viewModel.password.isEmpty {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
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
|
// Error Message
|
||||||
if let errorMessage = viewModel.errorMessage {
|
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
|
// MARK: - Organic Form Background
|
||||||
|
|
||||||
private struct OrganicFormBackground: View {
|
private struct OrganicFormBackground: View {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ class RegisterViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let error = ValidationRules.validatePasswordStrength(password) {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) {
|
if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) {
|
||||||
errorMessage = error.errorDescription
|
errorMessage = error.errorDescription
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user