Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite

Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation:

KMP Architecture:
- Fix subscription purchase/restore response contract (VerificationResponse aligned)
- Add feature benefits auth token + APILayer init flow
- Remove ResidenceFormScreen direct API bypass (use APILayer)
- Wire paywall purchase/restore to real SubscriptionApi calls

iOS Platform:
- Add iOS Keychain token storage via Swift KeychainHelper
- Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager)
- DocumentViewModelWrapper observes DataManager for auto-updates
- Add missing accessibility identifiers (document, task columns, Google Sign-In)

XCUITest Rewrite:
- Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups
- Create AuthCriticalPathTests and NavigationCriticalPathTests
- Delete 14 legacy brittle test files (Suite0-10, templates)
- Fix CaseraTests module import (@testable import Casera)

All platforms build clean. TEST BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 18:50:13 -06:00
parent 7444f73b46
commit 5e3596db77
47 changed files with 982 additions and 6075 deletions

View File

@@ -0,0 +1,33 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import com.example.casera.network.APILayer
import com.example.casera.ui.subscription.UpgradeScreen
import kotlinx.coroutines.launch
/**
* iOS: Purchase flow is handled in Swift via StoreKitManager.
* Restore calls backend to refresh subscription status.
*/
@Composable
actual fun PlatformUpgradeScreen(
onNavigateBack: () -> Unit,
onSubscriptionChanged: () -> Unit
) {
val scope = rememberCoroutineScope()
UpgradeScreen(
onNavigateBack = onNavigateBack,
onPurchase = { _ ->
// iOS purchase flow is handled by StoreKitManager in Swift layer
onNavigateBack()
},
onRestorePurchases = {
scope.launch {
APILayer.getSubscriptionStatus(forceRefresh = true)
onSubscriptionChanged()
}
}
)
}

View File

@@ -3,33 +3,71 @@ package com.example.casera.storage
import platform.Foundation.NSUserDefaults
import kotlin.concurrent.Volatile
/**
* Protocol for iOS Keychain operations. Implemented in Swift (KeychainHelper)
* and injected before DataManager initialization.
*
* Kotlin/Native cannot directly use the Security framework (SecItem* APIs)
* because CFStringRef keys like kSecClass don't bridge to NSCopying.
*/
interface KeychainDelegate {
fun save(key: String, value: String): Boolean
fun get(key: String): String?
fun delete(key: String): Boolean
}
/**
* iOS implementation of TokenManager.
*
* SECURITY NOTE: Currently uses NSUserDefaults for token storage.
* For production hardening, migrate to iOS Keychain via a Swift helper
* exposed to KMP through an expect/actual boundary or SKIE bridge.
* NSUserDefaults is not encrypted and should not store long-lived auth tokens
* in apps handling sensitive data.
* Uses iOS Keychain via [KeychainDelegate] for secure token storage.
* Falls back to NSUserDefaults if delegate is not set (should not happen
* in production — delegate is set in iOSApp.init before DataManager init).
*
* Migration plan:
* 1. Create a Swift KeychainHelper class with save/get/delete methods
* 2. Expose it to Kotlin via SKIE or a protocol-based expect/actual
* 3. Use service "com.tt.casera", account "auth_token"
* On first read, migrates any existing NSUserDefaults token to Keychain.
*/
actual class TokenManager {
private val prefs = NSUserDefaults.standardUserDefaults
actual fun saveToken(token: String) {
prefs.setObject(token, forKey = TOKEN_KEY)
prefs.synchronize()
val delegate = keychainDelegate
if (delegate != null) {
delegate.save(TOKEN_KEY, token)
// Clean up old NSUserDefaults entry if it exists
prefs.removeObjectForKey(TOKEN_KEY)
prefs.synchronize()
} else {
// Fallback (should not happen in production)
prefs.setObject(token, forKey = TOKEN_KEY)
prefs.synchronize()
}
}
actual fun getToken(): String? {
val delegate = keychainDelegate
// Try Keychain first
if (delegate != null) {
val keychainToken = delegate.get(TOKEN_KEY)
if (keychainToken != null) return keychainToken
// Check NSUserDefaults for migration
val oldToken = prefs.stringForKey(TOKEN_KEY)
if (oldToken != null) {
// Migrate to Keychain
delegate.save(TOKEN_KEY, oldToken)
prefs.removeObjectForKey(TOKEN_KEY)
prefs.synchronize()
return oldToken
}
return null
}
// Fallback to NSUserDefaults (should not happen in production)
return prefs.stringForKey(TOKEN_KEY)
}
actual fun clearToken() {
keychainDelegate?.delete(TOKEN_KEY)
prefs.removeObjectForKey(TOKEN_KEY)
prefs.synchronize()
}
@@ -37,6 +75,12 @@ actual class TokenManager {
companion object {
private const val TOKEN_KEY = "auth_token"
/**
* Set from Swift in iOSApp.init() BEFORE DataManager.initialize().
* This enables Keychain storage for auth tokens.
*/
var keychainDelegate: KeychainDelegate? = null
@Volatile
private var instance: TokenManager? = null