diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt new file mode 100644 index 00000000..69c0072c --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt @@ -0,0 +1,413 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState +import com.m57.hermescontrol.ui.chat.ConnectorActionState +import com.m57.hermescontrol.ui.chat.ConnectorUiItem +import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionIntegrationsSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testLoadingState_displaysLoadingIndicator() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loading, + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed() + } + + @Test + fun testConnectedItem_displaysConnectedStatusAndReconnectAction() { + var clickedSlug: String? = null + var clickedReconnect: Boolean? = null + + val item = + ConnectorUiItem( + slug = "github", + name = "GitHub", + isConnected = true, + isEnabled = true, + status = ConnectorStatus.CONNECTED, + connectionStatus = "connected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { slug, reconnect -> + clickedSlug = slug + clickedReconnect = reconnect + }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed() + composeTestRule.onNodeWithText("GitHub").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed() + + composeTestRule.onNodeWithTag("session_integrations_action_github").performClick() + assertEquals("github", clickedSlug) + assertEquals(true, clickedReconnect) + } + + @Test + fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() { + var clickedSlug: String? = null + var clickedReconnect: Boolean? = null + + val item = + ConnectorUiItem( + slug = "slack", + name = "Slack", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { slug, reconnect -> + clickedSlug = slug + clickedReconnect = reconnect + }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed() + + composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick() + assertEquals("slack", clickedSlug) + assertEquals(false, clickedReconnect) + } + + @Test + fun testConnectingState_displaysSpinnerAndDisablesActions() { + val item = + ConnectorUiItem( + slug = "google", + name = "Google Drive", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed() + } + + @Test + fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() { + var refreshed = false + val item = + ConnectorUiItem( + slug = "jira", + name = "Jira", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.INITIATED, + connectionStatus = "initiated", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = { refreshed = true }, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick() + assertTrue(refreshed) + } + + @Test + fun testUnavailablePhase_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() { + var retryTriggered = false + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = { retryTriggered = true }, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick() + assertTrue(retryTriggered) + } + + @Test + fun testEmptyState_displaysNoIntegrationsMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed() + } + + @Test + fun testNoSessionState_displaysNoActiveSession() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + ), + sessionId = null, + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed() + } + + @Test + fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() { + val item = + ConnectorUiItem( + slug = "linear", + name = "Linear", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + sessionId = "sess-runtime-999", + items = listOf(item), + ), + sessionId = null, + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed() + } + + @Test + fun testUnsupportedBackend_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testUnsupportedRuntime_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testNotOwner_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testExpiredAndRevokedItems_displayStatusAndAction() { + val expiredItem = + ConnectorUiItem( + slug = "github", + name = "GitHub", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.EXPIRED, + connectionStatus = "expired", + ) + val revokedItem = + ConnectorUiItem( + slug = "jira", + name = "Jira", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.REVOKED, + connectionStatus = "revoked", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(expiredItem, revokedItem), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index ac2e6d77..33a15a4f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys import com.m57.hermescontrol.data.config.ConnectionProfile @@ -500,6 +501,11 @@ object AuthManager { _baseUrlFlow.value = "" } + @VisibleForTesting + fun setBaseUrlForTest(baseUrl: String) { + _baseUrlFlow.value = baseUrl + } + fun getToken(): String? { if (tokenInitialized) return cachedToken synchronized(this) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt new file mode 100644 index 00000000..13d16db0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt @@ -0,0 +1,233 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.util.ConnectorUrlValidator + +/** + * High-level connection status for a connector item. + * Deliberate precedence: if [connected] is true, status resolves to [CONNECTED] + * regardless of expired/revoked/stale statuses reported by backend. + */ +enum class ConnectorStatus { + CONNECTED, + INITIATED, + EXPIRED, + REVOKED, + FAILED, + DISCONNECTED, + UNKNOWN, + ; + + val isConnected: Boolean get() = this == CONNECTED + + companion object { + fun from( + connected: Boolean, + rawStatus: String?, + ): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus) + + fun from( + connected: Boolean?, + rawStatus: String?, + ): ConnectorStatus { + if (connected == true) return CONNECTED + if (connected == false) { + return when (rawStatus?.lowercase()?.trim()) { + "expired" -> EXPIRED + "revoked" -> REVOKED + "initiated" -> INITIATED + "failed", "error" -> FAILED + "disconnected" -> DISCONNECTED + null, "", "active", "connected", "authorized" -> DISCONNECTED + else -> UNKNOWN + } + } + // Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED + return when (rawStatus?.lowercase()?.trim()) { + "authorized", "active", "connected" -> CONNECTED + "expired" -> EXPIRED + "revoked" -> REVOKED + "initiated" -> INITIATED + "failed", "error" -> FAILED + "disconnected" -> DISCONNECTED + null, "" -> DISCONNECTED + else -> UNKNOWN + } + } + } +} + +/** + * Status resulting from a connect/reconnect initiation. + */ +enum class ConnectorConnectStatus { + ACTIVE, + INITIATED, + FAILED, + UNKNOWN, + ; + + val isActive: Boolean get() = this == ACTIVE + + companion object { + fun from(rawStatus: String?): ConnectorConnectStatus = + when (rawStatus?.lowercase()?.trim()) { + "active", "connected" -> ACTIVE + "initiated" -> INITIATED + "failed", "error" -> FAILED + null, "" -> UNKNOWN + else -> UNKNOWN + } + } +} + +/** + * Individual connector item returned by `connectors.list`. + */ +data class ConnectorItem( + val connector: String, + val name: String = connector, + val connected: Boolean = false, + val enabled: Boolean = true, + val connectionStatus: String? = null, + val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus), + val connectUrl: String? = null, +) { + val isConnected: Boolean get() = connected || status.isConnected + + /** Safe validated HTTPS URL, or null if invalid or absent. */ + val safeConnectUrl: String? + get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null + + override fun toString(): String { + val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null" + return "ConnectorItem(" + + "connector=$connector, " + + "name=$name, " + + "connected=$connected, " + + "enabled=$enabled, " + + "status=$status, " + + "connectUrl=$redactedUrl" + + ")" + } +} + +/** + * Result of a connect/reconnect action for an individual connector. + * Instructions from arbitrary backends are intentionally not exposed for UI display. + */ +data class ConnectorConnectItem( + val connector: String, + val status: ConnectorConnectStatus, + val connectUrl: String? = null, +) { + val isConnectUrlValid: Boolean + get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl) + + val safeConnectUrl: String? + get() = if (isConnectUrlValid) connectUrl else null + + override fun toString(): String { + val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null" + return "ConnectorConnectItem(" + + "connector=$connector, " + + "status=$status, " + + "connectUrl=$redactedUrl, " + + "isConnectUrlValid=$isConnectUrlValid" + + ")" + } +} + +/** + * Summary counts returned by `connectors.connect`. + */ +data class ConnectorConnectSummary( + val total: Int = 0, + val active: Int = 0, + val initiated: Int = 0, + val failed: Int = 0, +) + +/** + * Typed safe connector errors mapped from RPC codes and reasons. + * Raw exceptions or auth tokens are never exposed. + */ +sealed interface ConnectorError { + val message: String + + data class InvalidParams( + override val message: String = "Invalid connector parameters.", + ) : ConnectorError + + data class NotOwner( + override val message: String = "Session is not owned by this transport.", + ) : ConnectorError + + data class Unavailable( + override val message: String = "Connectors are not available in this session.", + ) : ConnectorError + + data class UnsupportedRuntime( + override val message: String = "Connectors must be managed on the session's compute host.", + ) : ConnectorError + + data class RequestFailed( + override val message: String = "Connector request failed. Please try again.", + ) : ConnectorError + + data class InvalidResponse( + override val message: String = "Connector service returned an invalid response.", + ) : ConnectorError + + data class UnsupportedBackend( + override val message: String = "Connector service is not supported by this backend.", + ) : ConnectorError + + data class MalformedEnvelope( + override val message: String = "Malformed response envelope received from gateway.", + ) : ConnectorError + + data class NetworkError( + override val message: String = "Failed to communicate with gateway.", + ) : ConnectorError + + data class Other( + val code: Int = 0, + override val message: String = "Connector operation failed.", + ) : ConnectorError +} + +/** + * Result envelope for `connectors.list`. + */ +sealed interface ConnectorListResult { + data class Success( + val available: Boolean, + val connectors: List, + ) : ConnectorListResult + + data class Error( + val error: ConnectorError, + ) : ConnectorListResult + + fun getOrNull(): List? = (this as? Success)?.connectors + + fun errorOrNull(): ConnectorError? = (this as? Error)?.error +} + +/** + * Result envelope for `connectors.connect`. + */ +sealed interface ConnectorConnectResult { + data class Success( + val results: List, + val summary: ConnectorConnectSummary, + ) : ConnectorConnectResult + + data class Error( + val error: ConnectorError, + ) : ConnectorConnectResult + + fun getOrNull(): List? = (this as? Success)?.results + + fun errorOrNull(): ConnectorError? = (this as? Error)?.error +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt new file mode 100644 index 00000000..2e150d59 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt @@ -0,0 +1,389 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorConnectSummary +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Dedicated parser for connector payloads with strict envelope validation, + * resilient type coercion, legacy alias normalization, and safe error mapping. + */ +object ConnectorParser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Parse raw result from `connectors.list` RPC. + * Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing. + */ + fun parseListResult(raw: Any?): ConnectorListResult { + val root = + toJsonElement(raw) as? JsonObject + ?: return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."), + ) + + // Required envelope check: "connectors" must exist and be an array. + val connectorsArray = + root["connectors"] as? JsonArray + ?: return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."), + ) + + // Optional 'available' flag (defaults to true if omitted, unless explicitly false) + val available = parseBoolean(root["available"], default = true) + + val items = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in connectorsArray) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectorItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + items.add(item) + } + } + + // Entirely invalid nonempty connectors array must fail rather than looking silently valid empty. + if (connectorsArray.isNotEmpty() && items.isEmpty()) { + return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."), + ) + } + + return ConnectorListResult.Success( + available = available, + connectors = items, + ) + } + + /** + * Parse raw result from `connectors.connect` RPC. + * Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid. + */ + fun parseConnectResult(raw: Any?): ConnectorConnectResult { + val rawElement = toJsonElement(raw) + + // Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ] + if (rawElement is JsonArray) { + val results = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in rawElement) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + results.add(item) + } + } + if (results.isNotEmpty()) { + val summary = + ConnectorConnectSummary( + total = results.size, + active = results.count { it.status == ConnectorConnectStatus.ACTIVE }, + initiated = results.count { it.status == ConnectorConnectStatus.INITIATED }, + failed = results.count { it.status == ConnectorConnectStatus.FAILED }, + ) + return ConnectorConnectResult.Success( + results = results, + summary = summary, + ) + } + return ConnectorConnectResult.Error( + ConnectorError.InvalidResponse("Connect response contains no valid results."), + ) + } + + val root = + rawElement as? JsonObject + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect response must be a JSON object."), + ) + + // Required envelope check: "results" must exist and be an array. + val resultsArray = + root["results"] as? JsonArray + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."), + ) + + val results = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in resultsArray) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + results.add(item) + } + } + + // Connect must return at least nonempty valid results + if (results.isEmpty()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidResponse("Connect response contains no valid results."), + ) + } + + // Current summary object is required in standard envelope + val summaryObj = + root["summary"] as? JsonObject + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."), + ) + val summary = parseConnectSummary(summaryObj) + + return ConnectorConnectResult.Success( + results = results, + summary = summary, + ) + } + + private fun parseConnectorItem(obj: JsonObject): ConnectorItem? { + // Strict string identifier with backend slug validation; legacy aliases supported + val slug = + parseIdentifier(obj["connector"]) + ?: parseIdentifier(obj["slug"]) + ?: parseIdentifier(obj["id"]) + ?: return null // Skip items without valid connector identifier + + // Display name: primary "name", legacy aliases "displayName", "title", fallback to slug + val name = + parseString(obj["name"]) + ?: parseString(obj["displayName"]) + ?: parseString(obj["title"]) + ?: slug + + val rawConnected = parseBooleanOrNull(obj["connected"]) + val enabled = parseBoolean(obj["enabled"], default = true) + + // Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status" + val rawStatus = + parseString(obj["connectionStatus"]) + ?: parseString(obj["status"]) + ?: parseString(obj["connection_status"]) + + // Compute typed status respecting connected precedence: + // When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED. + // Explicit connected=false is authoritative and not overridden by status active. + val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus) + val effectiveConnected = rawConnected ?: status.isConnected + + // Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl" + val connectUrl = + parseString(obj["connect_url"]) + ?: parseString(obj["connectUrl"]) + ?: parseString(obj["auth_url"]) + ?: parseString(obj["authUrl"]) + + return ConnectorItem( + connector = slug, + name = name, + connected = effectiveConnected, + enabled = enabled, + connectionStatus = rawStatus, + status = status, + connectUrl = connectUrl, + ) + } + + private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? { + val slug = + parseIdentifier(obj["connector"]) + ?: parseIdentifier(obj["slug"]) + ?: parseIdentifier(obj["id"]) + ?: return null + + val rawStatus = + parseString(obj["status"]) + ?: parseString(obj["connectionStatus"]) + ?: parseString(obj["connection_status"]) + + val status = ConnectorConnectStatus.from(rawStatus) + + val connectUrl = + parseString(obj["connect_url"]) + ?: parseString(obj["connectUrl"]) + ?: parseString(obj["auth_url"]) + ?: parseString(obj["authUrl"]) + + return ConnectorConnectItem( + connector = slug, + status = status, + connectUrl = connectUrl, + ) + } + + private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary { + if (obj == null) return ConnectorConnectSummary() + return ConnectorConnectSummary( + total = parseInt(obj["total"], default = 0), + active = parseInt(obj["active"], default = 0), + initiated = parseInt(obj["initiated"], default = 0), + failed = parseInt(obj["failed"], default = 0), + ) + } + + /** + * Map RPC error codes and reasons into typed [ConnectorError]s. + * Raw server strings and sensitive parameters are never forwarded or exposed in error messages. + */ + fun mapRpcError( + code: Int, + message: String? = null, + data: JsonElement? = null, + ): ConnectorError { + val reason = + when (data) { + is JsonObject -> (data["reason"] as? JsonPrimitive)?.content + is JsonPrimitive -> data.content + else -> null + }?.uppercase()?.trim() + + return when { + code == 4000 || reason == "INVALID_PARAMS" -> { + ConnectorError.InvalidParams() + } + + code == 4001 || reason == "NOT_OWNER" -> { + ConnectorError.NotOwner() + } + + code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> { + ConnectorError.Unavailable() + } + + code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> { + ConnectorError.UnsupportedRuntime() + } + + code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> { + ConnectorError.InvalidResponse() + } + + code == 5034 -> { + ConnectorError.RequestFailed() + } + + code == -32601 -> { + ConnectorError.UnsupportedBackend() + } + + else -> { + ConnectorError.Other(code = code) + } + } + } + + internal fun parseStrictString(element: JsonElement?): String? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive && element.isString) { + return element.content + } + return null + } + + internal fun parseIdentifier(element: JsonElement?): String? { + val str = parseStrictString(element)?.trim() ?: return null + return if (ConnectorRepository.isValidSlug(str)) str else null + } + + internal fun parseString(element: JsonElement?): String? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive) { + if (!element.isString && element.content.isBlank()) return null + return element.content + } + return null + } + + internal fun parseBooleanOrNull(element: JsonElement?): Boolean? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive) { + element.booleanOrNull?.let { return it } + val content = element.content.trim() + if (content.equals("true", ignoreCase = true) || content == "1") return true + if (content.equals("false", ignoreCase = true) || content == "0") return false + } + return null + } + + internal fun parseBoolean( + element: JsonElement?, + default: Boolean, + ): Boolean = parseBooleanOrNull(element) ?: default + + internal fun parseInt( + element: JsonElement?, + default: Int, + ): Int { + if (element == null || element is JsonNull) return default + if (element is JsonPrimitive) { + element.intOrNull?.let { return it } + element.content.toIntOrNull()?.let { return it } + } + return default + } + + fun toJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + @Suppress("UNCHECKED_CAST") + (value as Map).mapValues { (_, v) -> toJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { toJsonElement(it) }) + } + + is String -> { + val trimmed = value.trim() + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + json.parseToJsonElement(value) + } catch (_: Exception) { + JsonPrimitive(value) + } + } else { + JsonPrimitive(value) + } + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt new file mode 100644 index 00000000..1c39ce40 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import kotlinx.coroutines.CancellationException + +/** + * Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091). + * Public injectable interface enabling isolated testing in ViewModels and controllers. + */ +interface ConnectorRepository { + /** + * Request connector catalog and authorization status for [sessionId]. + */ + suspend fun listConnectors(sessionId: String): ConnectorListResult + + /** + * Initiate authorization flow for [connectors] slugs in [sessionId]. + * If [reconnect] is true, restarts authorization flow even if already connected. + */ + suspend fun connect( + sessionId: String, + connectors: List, + reconnect: Boolean = false, + ): ConnectorConnectResult + + companion object : ConnectorRepository by HermesConnectorRepository() { + private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$") + + fun isValidSlug(slug: String?): Boolean { + if (slug.isNullOrBlank()) return false + return SLUG_REGEX.matches(slug) + } + } +} + +/** + * Default implementation of [ConnectorRepository] backed by [HermesWsClient]. + */ +class HermesConnectorRepository( + private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } finally { + if (!deferred.isCompleted) { + deferred.cancel() + } + } + }, +) : ConnectorRepository { + override suspend fun listConnectors(sessionId: String): ConnectorListResult { + if (sessionId.isBlank()) { + return ConnectorListResult.Error( + ConnectorError.InvalidParams("session_id required"), + ) + } + + return try { + val params = mapOf("session_id" to sessionId) + val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) + ConnectorParser.parseListResult(result) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + ConnectorListResult.Error( + ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data), + ) + } catch (_: Exception) { + ConnectorListResult.Error( + ConnectorError.NetworkError("Failed to communicate with gateway."), + ) + } + } + + override suspend fun connect( + sessionId: String, + connectors: List, + reconnect: Boolean, + ): ConnectorConnectResult { + if (sessionId.isBlank()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("session_id required"), + ) + } + + if (connectors.isEmpty()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"), + ) + } + + for (slug in connectors) { + if (!ConnectorRepository.isValidSlug(slug)) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"), + ) + } + } + + return try { + val params = + mapOf( + "session_id" to sessionId, + "connectors" to connectors, + "reconnect" to reconnect, + ) + val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params) + ConnectorParser.parseConnectResult(result) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + ConnectorConnectResult.Error( + ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data), + ) + } catch (_: Exception) { + ConnectorConnectResult.Error( + ConnectorError.NetworkError("Failed to communicate with gateway."), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index cca84223..4a2c7515 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.session.ActiveSessionHolder +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +31,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -702,6 +705,8 @@ object HermesWsClient { /** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */ class HermesRpcException( message: String, + val code: Int = 0, + val data: JsonElement? = null, ) : Exception(message) /** @@ -741,6 +746,15 @@ object HermesWsClient { send(method, params) { reqId -> pendingCalls[reqId] = PendingCall(method, deferred) } + deferred.invokeOnCompletion { cause -> + if (cause is CancellationException) { + pendingCalls.remove(id)?.let { call -> + call.timeoutJob?.cancel() + removeQueuedMessage(id) + disconnectIfIdleInBackground() + } + } + } // Arm the per-request timeout (fires if the server never answers). pendingCalls[id]?.timeoutJob = wsScope.launch { @@ -760,7 +774,13 @@ object HermesWsClient { removeQueuedMessage(id) call.timeoutJob?.cancel() if (error != null) { - call.deferred.completeExceptionally(HermesRpcException(error.message)) + call.deferred.completeExceptionally( + HermesRpcException( + message = error.message, + code = error.code, + data = error.data, + ), + ) } else { call.deferred.complete(result) } @@ -808,7 +828,9 @@ object HermesWsClient { params = decoratedParams.mapValues { it.value.toJsonElement() }, ) val json = OkHttpProvider.json.encodeToString(request) - if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") + if (BuildConfig.DEBUG) { + Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) + } var reconnect = false synchronized(outboundLock) { if (method == WsMethods.PROMPT_SUBMIT) { @@ -1191,7 +1213,7 @@ object HermesWsClient { markQueuedMessageSent(msg) continue } - if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg") + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) if (!webSocket.send(msg)) { recoverRejectedSocket(webSocket) break @@ -1209,7 +1231,7 @@ object HermesWsClient { text: String, ) { if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return - if (BuildConfig.DEBUG) Log.d(TAG, "← $text") + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) lastPongTimestamp = monotonicTimeMs() // Resolve any in-flight `request()` awaiting this RPC result/error // (issue #526) before fanning the parsed event out to collectors. @@ -1269,7 +1291,7 @@ object HermesWsClient { parsed } } catch (e: Exception) { - Log.e(TAG, "Failed to parse message", e) + Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") WsEvent.Unknown(text) } when (event) { @@ -1381,4 +1403,60 @@ object HermesWsClient { } } } + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) { + _connectionStatus.value = status + } + + @VisibleForTesting + internal fun formatSafeOutgoingFrameLog( + id: String, + method: String, + paramsKeys: Collection?, + byteLength: Int, + queued: Boolean = false, + ): String { + val prefix = if (queued) "→ (queued)" else "→" + val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" + return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" + } + + @VisibleForTesting + internal fun formatSafeQueuedFrameLog(msg: String): String = + try { + val req = OkHttpProvider.json.decodeFromString(msg) + formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) + } catch (_: Throwable) { + "→ (queued) frame bytes=${msg.length}" + } + + @VisibleForTesting + internal fun formatSafeIncomingFrameLog(text: String): String = + try { + val element = OkHttpProvider.json.parseToJsonElement(text) + if (element is JsonObject) { + val id = (element["id"] as? JsonPrimitive)?.content + val method = (element["method"] as? JsonPrimitive)?.content + val hasResult = element.containsKey("result") + val resultKeys = + (element["result"] as? JsonObject)?.keys?.joinToString( + ",", + prefix = "[", + postfix = "]", + ) + val errorObj = element["error"] as? JsonObject + val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content + val sb = StringBuilder("← ") + if (id != null) sb.append("id=").append(id).append(" ") + if (method != null) sb.append("method=").append(method).append(" ") + if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") + if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") + sb.append("bytes=").append(text.length).toString() + } else { + "← non-object frame bytes=${text.length}" + } + } catch (_: Throwable) { + "← frame bytes=${text.length}" + } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 088ebccf..4c0a68c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -91,6 +91,14 @@ object WsMethods { /** Rolling tail snapshot of a subagent's live execution transcript. */ const val SUBAGENT_TAIL = "subagent.tail" + // ── Connectors (issue #1091) ─────────────────────────────────────────── + + /** List available third-party connectors and authorization state for a session. */ + const val CONNECTORS_LIST = "connectors.list" + + /** Initiate or restart authorization flow for one or more connectors. */ + const val CONNECTORS_CONNECT = "connectors.connect" + // ── Billing / subscription (issue #628) ───────────────────────────── // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt new file mode 100644 index 00000000..effd5442 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt @@ -0,0 +1,942 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectorRepository +import com.m57.hermescontrol.util.ConnectorUrlValidator +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Explicit load phases for connector list rendering. + */ +sealed interface ConnectorsLoadPhase { + data object Initial : ConnectorsLoadPhase + + data object Loading : ConnectorsLoadPhase + + data class Refreshing( + val previousItems: List, + ) : ConnectorsLoadPhase + + data class Loaded( + val available: Boolean, + ) : ConnectorsLoadPhase + + data class Error( + val error: ConnectorError, + val canRetry: Boolean = true, + ) : ConnectorsLoadPhase + + data class Unavailable( + val message: String, + ) : ConnectorsLoadPhase + + data class UnsupportedRuntime( + val message: String, + ) : ConnectorsLoadPhase + + data class UnsupportedBackend( + val message: String, + ) : ConnectorsLoadPhase + + data class NotOwner( + val message: String, + ) : ConnectorsLoadPhase +} + +/** + * Mutating action phases for individual connector operations. + */ +sealed interface ConnectorActionState { + data object Idle : ConnectorActionState + + data class Connecting( + val slug: String, + val isReconnect: Boolean, + ) : ConnectorActionState + + data class AwaitingAuthorization( + val slug: String, + ) : ConnectorActionState +} + +/** + * Immutable safe UI item representation. Raw connect URLs are strictly omitted. + */ +data class ConnectorUiItem( + val slug: String, + val name: String, + val isConnected: Boolean, + val isEnabled: Boolean, + val status: ConnectorStatus, + val connectionStatus: String?, +) { + companion object { + fun from(item: ConnectorItem): ConnectorUiItem = + ConnectorUiItem( + slug = item.connector, + name = item.name, + isConnected = item.connected, + isEnabled = item.enabled, + status = item.status, + connectionStatus = item.connectionStatus, + ) + } +} + +/** + * Transient one-shot browser launch event. Redacts URL in [toString]. + */ +data class ConnectBrowserEvent( + val slug: String, + val url: String, + val eventId: Long = System.nanoTime(), + val sessionId: String? = null, + val generation: Long = 0L, +) { + override fun toString(): String = + "ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)" +} + +/** + * Complete UI state exposed to the session integrations view. + */ +data class ChatConnectorsUiState( + val isVisible: Boolean = false, + val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial, + val items: List = emptyList(), + val actionState: ConnectorActionState = ConnectorActionState.Idle, + val browserLaunchEvent: ConnectBrowserEvent? = null, + val errorMessage: String? = null, + val isOldBackend: Boolean = false, + val sessionId: String? = null, + val generation: Long = 0L, +) { + val isLoading: Boolean + get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting +} + +/** + * Controller / Delegate managing session Integrations (connectors) state, + * lifecycle, deduplication, and authorization flows (issue #1091). + */ +class ChatConnectorsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val repository: ConnectorRepository = ConnectorRepository, + private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value }, +) { + private val _uiState = MutableStateFlow(ChatConnectorsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var isForeground: Boolean = false + private var sessionGeneration: Long = 0L + private var refreshRequestId: Long = 0L + private var refreshJob: Job? = null + private var connectJob: Job? = null + private var pendingRefreshAfterMutation: Boolean = false + private var pendingTrailingRefresh: Boolean = false + private var pendingBrowserEvent: ConnectBrowserEvent? = null + private var browserLaunched: Boolean = false + + /** + * Show integrations panel and trigger load if in foreground. + */ + fun show() { + if (_uiState.value.isVisible) return + val sid = runtimeSessionId() + _uiState.update { + it.copy( + isVisible = true, + sessionId = sid, + generation = sessionGeneration, + ) + } + if (isForeground) { + if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) { + refresh(force = false) + } + } + } + + /** + * Hide integrations panel, cancelling ongoing jobs, incrementing generation, + * and clearing transient state. + */ + fun hide() { + sessionGeneration++ + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + _uiState.update { + it.copy( + isVisible = false, + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + generation = sessionGeneration, + ) + } + } + + /** + * Lifecycle resume hook. Refreshes once when returning from external browser authorization. + * Guarded to only execute on true paused -> resume transitions. + */ + fun onResume() { + val wasForeground = isForeground + isForeground = true + if (wasForeground) return + if (!_uiState.value.isVisible) return + + val sid = runtimeSessionId() + val pending = pendingBrowserEvent + if (pending != null) { + pendingBrowserEvent = null + if (pending.generation == sessionGeneration && + pending.sessionId == sid && + _uiState.value.actionState is ConnectorActionState.AwaitingAuthorization + ) { + _uiState.update { it.copy(browserLaunchEvent = pending) } + } + } + + if (pendingRefreshAfterMutation || pendingTrailingRefresh) { + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + refresh(force = true) + return + } + + val currentAction = _uiState.value.actionState + if (currentAction is ConnectorActionState.AwaitingAuthorization) { + if (browserLaunched) { + // Actual return from external browser authorization: refresh once to confirm authorization + browserLaunched = false + refresh(force = true) + return + } + // Minted unconsumed event waiting to be launched: do NOT prematurely refresh + return + } + + // Returned from background: initial load or reload interrupted read if needed and not old backend + val currentPhase = _uiState.value.loadPhase + val needsCatalogLoad = + ( + currentPhase is ConnectorsLoadPhase.Initial || + currentPhase is ConnectorsLoadPhase.Loading || + currentPhase is ConnectorsLoadPhase.Refreshing + ) && + refreshJob?.isActive != true && + !_uiState.value.isOldBackend + + if (needsCatalogLoad) { + refresh(force = false) + } + } + + /** + * Lifecycle pause hook. Retains unconsumed browser event bound to session + generation. + */ + fun onPause() { + isForeground = false + val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent + if (unconsumed != null && + unconsumed.generation == sessionGeneration && + unconsumed.sessionId == runtimeSessionId() + ) { + pendingBrowserEvent = unconsumed + } + _uiState.update { it.copy(browserLaunchEvent = null) } + } + + /** + * Notify of active session change with generation guard (A -> B -> A). + */ + fun onActiveSessionChanged(newSessionId: String?) { + sessionGeneration++ + val currentGen = sessionGeneration + + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + + if (newSessionId.isNullOrBlank()) { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = null, + generation = currentGen, + ) + } + return + } + + if (_uiState.value.isVisible && isForeground) { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Loading, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = newSessionId, + generation = currentGen, + ) + } + executeRefresh(newSessionId, currentGen) + } else { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = newSessionId, + generation = currentGen, + ) + } + } + } + + /** + * Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors. + */ + fun onTransportReconnected() { + if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) { + val sessionId = runtimeSessionId() + if (!sessionId.isNullOrBlank()) { + refresh(force = true) + } + } + } + + /** + * Request connector catalog and authorization status. + * Deduplicated and coalesced for both normal and forced calls, and blocked in background. + */ + fun refresh(force: Boolean = false) { + if (!_uiState.value.isVisible || !isForeground) return + if (_uiState.value.isOldBackend && !force) return + + val sessionId = runtimeSessionId() + if (sessionId.isNullOrBlank()) return + + if (_uiState.value.actionState is ConnectorActionState.Connecting) { + pendingRefreshAfterMutation = true + return + } + + if (refreshJob?.isActive == true) { + if (force) { + pendingTrailingRefresh = true + } + return + } + + val gen = sessionGeneration + val previousItems = _uiState.value.items + _uiState.update { current -> + if (previousItems.isEmpty()) { + current.copy( + loadPhase = ConnectorsLoadPhase.Loading, + sessionId = sessionId, + generation = gen, + ) + } else { + current.copy( + loadPhase = ConnectorsLoadPhase.Refreshing(previousItems), + sessionId = sessionId, + generation = gen, + ) + } + } + + executeRefresh(sessionId, gen) + } + + private fun executeRefresh( + targetSessionId: String, + generation: Long, + ) { + val requestId = ++refreshRequestId + refreshJob = + scope.launch { + var currentSession = targetSessionId + try { + var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) } + + // Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY) + if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) { + val freshSession = runtimeSessionId() + if (!freshSession.isNullOrBlank() && freshSession != currentSession) { + currentSession = freshSession + result = withContext(ioDispatcher) { repository.listConnectors(currentSession) } + } + } + + // Check target sid AND generation AND request token AND visibility before applying + val activeSid = runtimeSessionId() + if (requestId != refreshRequestId || + generation != sessionGeneration || + currentSession != activeSid || + !_uiState.value.isVisible + ) { + return@launch + } + + when (result) { + is ConnectorListResult.Success -> { + if (!result.available) { + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + _uiState.update { current -> + current.copy( + loadPhase = + ConnectorsLoadPhase.Unavailable( + "Connectors are unavailable for this session.", + ), + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + sessionId = currentSession, + generation = generation, + ) + } + return@launch + } + + val uiItems = result.connectors.map { ConnectorUiItem.from(it) } + _uiState.update { current -> + val currentAction = current.actionState + val updatedAction = + if (currentAction is ConnectorActionState.AwaitingAuthorization) { + val matchingItem = uiItems.find { it.slug == currentAction.slug } + // End authorization if connector removed from catalog or now connected + if (matchingItem == null || matchingItem.isConnected) { + ConnectorActionState.Idle + } else { + currentAction + } + } else { + currentAction + } + + current.copy( + loadPhase = ConnectorsLoadPhase.Loaded(result.available), + items = uiItems, + actionState = updatedAction, + isOldBackend = false, + errorMessage = null, // error clears on refresh success + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorListResult.Error -> { + when (val err = result.error) { + is ConnectorError.UnsupportedBackend -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message), + isOldBackend = true, + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.Unavailable -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Unavailable(err.message), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.UnsupportedRuntime -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.NotOwner -> { + val notOwnerMsg = + "Session is not owned by this transport. Please reconnect the session." + _uiState.update { + it.copy( + loadPhase = + ConnectorsLoadPhase.NotOwner( + notOwnerMsg, + ), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + else -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true), + sessionId = currentSession, + generation = generation, + ) + } + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + _uiState.update { + it.copy( + loadPhase = + ConnectorsLoadPhase.Error( + ConnectorError.NetworkError(t.message ?: "Failed to load connectors"), + canRetry = true, + ), + sessionId = currentSession, + generation = generation, + ) + } + } finally { + refreshJob = null + if (pendingTrailingRefresh && + generation == sessionGeneration && + _uiState.value.isVisible + ) { + if (isForeground) { + pendingTrailingRefresh = false + refresh(force = true) + } + } else { + pendingTrailingRefresh = false + } + } + } + } + + /** + * Initiate authorization flow for connector [slug]. + * Guarded by visibility, foreground state, catalog readiness, and item enabled status. + * Never replays connect mutations into a different session. + */ + fun connect( + slug: String, + reconnect: Boolean = false, + ) { + if (!_uiState.value.isVisible || !isForeground) return + + if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) { + _uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") } + return + } + + val currentPhase = _uiState.value.loadPhase + if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) { + _uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") } + return + } + + val targetSession = runtimeSessionId() + if (targetSession.isNullOrBlank()) { + _uiState.update { it.copy(errorMessage = "No active session.") } + return + } + + if (!ConnectorRepository.isValidSlug(slug)) { + _uiState.update { it.copy(errorMessage = "Invalid connector slug.") } + return + } + + val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug } + if (catalogItem == null) { + _uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") } + return + } + if (!catalogItem.isEnabled) { + _uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") } + return + } + + if (_uiState.value.actionState is ConnectorActionState.Connecting) { + return + } + + // Cancel any prior in-flight read before starting mutation + refreshJob?.cancel() + refreshJob = null + pendingTrailingRefresh = false + + val gen = sessionGeneration + _uiState.update { + it.copy( + actionState = ConnectorActionState.Connecting(slug, reconnect), + errorMessage = null, + sessionId = targetSession, + generation = gen, + ) + } + + connectJob = + scope.launch { + var needsRefresh = false + try { + val result = + withContext(ioDispatcher) { + repository.connect(targetSession, listOf(slug), reconnect) + } + + // Verify generation, session equality, and visibility before applying + val activeSid = runtimeSessionId() + if (gen != sessionGeneration || + targetSession != activeSid || + !_uiState.value.isVisible + ) { + return@launch + } + + when (result) { + is ConnectorConnectResult.Success -> { + // Require exact slug match; never fallback to unrelated connector + val item = result.results.firstOrNull { it.connector == slug } + if (item == null) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector '$slug' not found in response.", + ) + } + return@launch + } + + when (item.status) { + ConnectorConnectStatus.ACTIVE -> { + _uiState.update { it.copy(actionState = ConnectorActionState.Idle) } + needsRefresh = true + } + + ConnectorConnectStatus.INITIATED -> { + val safeUrl = item.safeConnectUrl + if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) { + val event = + ConnectBrowserEvent( + slug = slug, + url = safeUrl, + sessionId = targetSession, + generation = gen, + ) + browserLaunched = false + if (isForeground) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.AwaitingAuthorization(slug), + browserLaunchEvent = event, + ) + } + } else { + // In background: hold transiently for foreground resume + pendingBrowserEvent = event + _uiState.update { + it.copy( + actionState = ConnectorActionState.AwaitingAuthorization(slug), + browserLaunchEvent = null, + ) + } + } + } else { + // Unsafe or absent URL is a retryable generic failure + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Invalid or unsafe authorization URL received.", + ) + } + } + } + + ConnectorConnectStatus.FAILED -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector authorization failed. Please try again.", + ) + } + } + + else -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector operation returned unexpected status.", + ) + } + } + } + } + + is ConnectorConnectResult.Error -> { + when (val err = result.error) { + is ConnectorError.UnsupportedBackend -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message), + actionState = ConnectorActionState.Idle, + isOldBackend = true, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.Unavailable -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Unavailable(err.message), + actionState = ConnectorActionState.Idle, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.UnsupportedRuntime -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message), + actionState = ConnectorActionState.Idle, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.NotOwner -> { + val msg = "Session is not owned by this transport. Please reconnect the session." + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.NotOwner(msg), + actionState = ConnectorActionState.Idle, + errorMessage = msg, + ) + } + } + + else -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = err.message, + ) + } + } + } + } + } + } catch (e: CancellationException) { + _uiState.update { + if (it.actionState is ConnectorActionState.Connecting) { + it.copy(actionState = ConnectorActionState.Idle) + } else { + it + } + } + throw e + } catch (t: Throwable) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = t.message ?: "Connector connection failed.", + ) + } + } finally { + connectJob = null + _uiState.update { + if (it.actionState is ConnectorActionState.Connecting) { + it.copy(actionState = ConnectorActionState.Idle) + } else { + it + } + } + val shouldRefresh = needsRefresh || pendingRefreshAfterMutation + if (shouldRefresh && + gen == sessionGeneration && + _uiState.value.isVisible + ) { + if (isForeground) { + pendingRefreshAfterMutation = false + refresh(force = true) + } else { + pendingRefreshAfterMutation = true + } + } else if (gen != sessionGeneration || !_uiState.value.isVisible) { + pendingRefreshAfterMutation = false + } + } + } + } + + /** + * Consume transient browser launch event (legacy no-arg API). + */ + fun consumeBrowserEvent() { + val currentSid = runtimeSessionId() + val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent + if (event != null && + isForeground && + _uiState.value.isVisible && + event.generation == sessionGeneration && + event.sessionId == currentSid + ) { + browserLaunched = true + } + pendingBrowserEvent = null + _uiState.update { it.copy(browserLaunchEvent = null) } + } + + /** + * Consume transient browser launch event validating identity, session, generation, and foreground state. + */ + fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? { + var consumed: ConnectBrowserEvent? = null + val currentSid = runtimeSessionId() + val pending = pendingBrowserEvent + if (pending != null && pending.eventId == eventId) { + if (isForeground && + _uiState.value.isVisible && + pending.generation == sessionGeneration && + pending.sessionId == currentSid + ) { + consumed = pending + pendingBrowserEvent = null + } else { + pendingBrowserEvent = null + } + } + _uiState.update { current -> + val event = current.browserLaunchEvent + if (event != null && event.eventId == eventId) { + if (isForeground && + current.isVisible && + event.generation == sessionGeneration && + event.sessionId == currentSid + ) { + consumed = event + current.copy(browserLaunchEvent = null) + } else { + // Invalid context: clear stale event and reject handoff + current.copy(browserLaunchEvent = null) + } + } else { + current + } + } + if (consumed != null) { + browserLaunched = true + } + return consumed + } + + /** + * Safely take transient browser launch event if valid. + */ + fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? { + val targetId = + eventId + ?: _uiState.value.browserLaunchEvent?.eventId + ?: pendingBrowserEvent?.eventId + ?: return null + return consumeBrowserEvent(targetId) + } + + /** + * Report an error launching the external browser intent. + */ + fun launchError(message: String) { + pendingBrowserEvent = null + browserLaunched = false + _uiState.update { + it.copy( + browserLaunchEvent = null, + actionState = ConnectorActionState.Idle, + errorMessage = message, + ) + } + } + + fun onLaunchError(message: String) = launchError(message) + + /** + * Clear active error message. + */ + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + /** + * Teardown ongoing coroutines and invalidate state generation. + */ + fun destroy() { + sessionGeneration++ + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt new file mode 100644 index 00000000..6aff6dab --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt @@ -0,0 +1,84 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +/** + * Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091). + * + * Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder] + * and server/profile context transitions to invalidate context on changes, and forwards + * lifecycle and browser launch events safely. + */ +class ChatConnectorsViewModel( + delegate: ChatConnectorsDelegate? = null, +) : ViewModel() { + private val delegateInstance: ChatConnectorsDelegate = + delegate ?: ChatConnectorsDelegate( + scope = viewModelScope, + runtimeSessionId = { ActiveSessionHolder.activeSessionId.value }, + ) + + val uiState: StateFlow = delegateInstance.uiState + + init { + var lastContext: Triple? = null + + viewModelScope.launch { + combine( + ActiveSessionHolder.activeSessionId, + AuthManager.activeProfileId, + AuthManager.baseUrlFlow, + ) { sessionId, profileId, baseUrl -> + Triple(sessionId, profileId, baseUrl) + }.collect { current -> + if (lastContext == null || current != lastContext) { + lastContext = current + delegateInstance.onActiveSessionChanged(current.first) + } + } + } + + viewModelScope.launch { + HermesWsClient.connectionStatus + .filter { it == ConnectionStatus.CONNECTED } + .collect { + delegateInstance.onTransportReconnected() + } + } + } + + fun show() = delegateInstance.show() + + fun hide() = delegateInstance.hide() + + fun onResume() = delegateInstance.onResume() + + fun onPause() = delegateInstance.onPause() + + fun refresh(force: Boolean = false) = delegateInstance.refresh(force) + + fun connect( + slug: String, + reconnect: Boolean = false, + ) = delegateInstance.connect(slug, reconnect) + + fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId) + + fun launchError(message: String) = delegateInstance.launchError(message) + + fun clearError() = delegateInstance.clearError() + + override fun onCleared() { + super.onCleared() + delegateInstance.destroy() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 221b0cac..db8bfc30 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1,6 +1,8 @@ package com.m57.hermescontrol.ui.chat import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -30,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -44,6 +50,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -58,11 +65,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -90,6 +99,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow +import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip @@ -104,6 +114,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog +import com.m57.hermescontrol.util.ConnectorUrlValidator import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -143,10 +154,60 @@ fun ChatScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() + val connectorsViewModel: ChatConnectorsViewModel = viewModel() + val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } + var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } + + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> { + if (browserAuthInFlight) { + browserAuthDeparted = true + } + connectorsViewModel.onPause() + } + + Lifecycle.Event.ON_RESUME -> { + connectorsViewModel.onResume() + if (browserAuthInFlight && browserAuthDeparted) { + browserAuthInFlight = false + browserAuthDeparted = false + ExternalActivityLifecycleGuard.externalActivityReturned() + } + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + val activity = + generateSequence(context) { (it as? ContextWrapper)?.baseContext } + .filterIsInstance() + .firstOrNull() + val isChangingConfigs = activity?.isChangingConfigurations == true + if (!isChangingConfigs) { + if (browserAuthInFlight && browserAuthDeparted) { + browserAuthInFlight = false + browserAuthDeparted = false + ExternalActivityLifecycleGuard.externalActivityReturned() + } + connectorsViewModel.onPause() + connectorsViewModel.hide() + } + } + } + + val browserEvent = connectorsState.browserLaunchEvent val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -299,7 +360,6 @@ fun ChatScreen( var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() - val context = LocalContext.current val launchExternalActivity: (() -> Unit) -> Unit = { launch -> ExternalActivityLifecycleGuard.launchExternalActivity( acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, @@ -310,6 +370,45 @@ fun ChatScreen( ) } + val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response) + val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch) + + LaunchedEffect(browserEvent) { + if (browserEvent != null) { + val eventId = browserEvent.eventId + val taken = connectorsViewModel.takeBrowserEvent(eventId) + if (taken != null) { + if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) { + connectorsViewModel.launchError(invalidResponseError) + return@LaunchedEffect + } + try { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + launchExternalActivity { + browserAuthDeparted = false + browserAuthInFlight = true + context.startActivity(intent) + } + } catch (_: ActivityNotFoundException) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } catch (_: SecurityException) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } catch (_: Exception) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } + } + } + } + val mediaLaunchers = rememberChatMediaLaunchers( inputFieldValue = inputFieldValue, @@ -428,6 +527,39 @@ fun ChatScreen( contentDescription = stringResource(R.string.content_desc_new_chat), ) } + + // Session actions overflow menu (issue #1091) + var showSessionMenu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showSessionMenu = true }, + modifier = Modifier.testTag("chat_session_menu_button"), + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.session_integrations_menu_item), + ) + } + DropdownMenu( + expanded = showSessionMenu, + onDismissRequest = { showSessionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.session_integrations_menu_item)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Extension, + contentDescription = null, + ) + }, + onClick = { + showSessionMenu = false + connectorsViewModel.show() + }, + modifier = Modifier.testTag("chat_menu_session_integrations"), + ) + } + } }, ) { _ -> Column( @@ -763,5 +895,14 @@ fun ChatScreen( onDismiss = { viewingImage = null }, ) } + + SessionIntegrationsSheet( + uiState = connectorsState, + sessionId = connectorsState.sessionId, + onDismiss = connectorsViewModel::hide, + onRefresh = { connectorsViewModel.refresh(force = true) }, + onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) }, + onClearError = connectorsViewModel::clearError, + ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt new file mode 100644 index 00000000..a45daba8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt @@ -0,0 +1,752 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState +import com.m57.hermescontrol.ui.chat.ConnectorActionState +import com.m57.hermescontrol.ui.chat.ConnectorUiItem +import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase + +/** + * Visual elements for a connector status badge. + */ +data class ConnectorStatusVisuals( + val icon: ImageVector, + val color: Color, + val label: String, +) + +@Composable +fun resolveConnectorStatusVisuals( + status: ConnectorStatus, + isConnected: Boolean, +): ConnectorStatusVisuals { + val statusColors = LocalHermesStatusColors.current + return when { + isConnected || status == ConnectorStatus.CONNECTED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.CheckCircle, + color = statusColors.success, + label = stringResource(R.string.session_integrations_status_connected), + ) + } + + status == ConnectorStatus.INITIATED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.HourglassTop, + color = statusColors.info, + label = stringResource(R.string.session_integrations_awaiting), + ) + } + + status == ConnectorStatus.EXPIRED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.Warning, + color = statusColors.warning, + label = stringResource(R.string.session_integrations_status_expired), + ) + } + + status == ConnectorStatus.REVOKED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.Warning, + color = statusColors.warning, + label = stringResource(R.string.session_integrations_status_revoked), + ) + } + + status == ConnectorStatus.FAILED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.ErrorOutline, + color = statusColors.error, + label = stringResource(R.string.session_integrations_status_failed), + ) + } + + status == ConnectorStatus.DISCONNECTED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.LinkOff, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + label = stringResource(R.string.session_integrations_status_disconnected), + ) + } + + else -> { + ConnectorStatusVisuals( + icon = Icons.AutoMirrored.Filled.HelpOutline, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + label = stringResource(R.string.session_integrations_status_unknown), + ) + } + } +} + +@Composable +fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? = + when (loadPhase) { + is ConnectorsLoadPhase.Error -> { + mapConnectorErrorMessage(loadPhase.error) + } + + is ConnectorsLoadPhase.Unavailable -> { + stringResource(R.string.session_integrations_err_unavailable) + } + + is ConnectorsLoadPhase.UnsupportedRuntime -> { + stringResource( + R.string.session_integrations_err_unsupported_runtime, + ) + } + + is ConnectorsLoadPhase.UnsupportedBackend -> { + stringResource( + R.string.session_integrations_err_unsupported_backend, + ) + } + + is ConnectorsLoadPhase.NotOwner -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + else -> { + null + } + } + +@Composable +fun mapConnectorErrorMessage(error: ConnectorError): String = + when (error) { + is ConnectorError.InvalidParams -> { + stringResource(R.string.session_integrations_err_invalid_params) + } + + is ConnectorError.NotOwner -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + is ConnectorError.Unavailable -> { + stringResource(R.string.session_integrations_err_unavailable) + } + + is ConnectorError.UnsupportedRuntime -> { + stringResource(R.string.session_integrations_err_unsupported_runtime) + } + + is ConnectorError.UnsupportedBackend -> { + stringResource(R.string.session_integrations_err_unsupported_backend) + } + + is ConnectorError.RequestFailed -> { + stringResource(R.string.session_integrations_err_request_failed) + } + + is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> { + stringResource(R.string.session_integrations_err_invalid_response) + } + + is ConnectorError.NetworkError -> { + stringResource(R.string.session_integrations_err_network) + } + + is ConnectorError.Other -> { + stringResource(R.string.session_integrations_err_generic) + } + } + +@Composable +fun mapActionErrorMessage(rawError: String?): String = + when { + rawError.isNullOrBlank() -> { + stringResource(R.string.session_integrations_err_generic) + } + + rawError.contains("not owned", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + rawError.contains( + "No active session", + ignoreCase = true, + ) -> { + stringResource(R.string.session_integrations_no_session) + } + + rawError.contains("Invalid connector slug", ignoreCase = true) || + rawError.contains("Invalid params", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_invalid_params) + } + + rawError.contains("authorization URL", ignoreCase = true) || + rawError.contains("unexpected status", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_invalid_response) + } + + rawError.contains("authorization failed", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_request_failed) + } + + rawError.contains("browser", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_browser_launch) + } + + else -> { + stringResource(R.string.session_integrations_err_generic) + } + } + +/** + * Native bottom sheet for session integrations / connectors (issue #1091). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionIntegrationsSheet( + uiState: ChatConnectorsUiState, + sessionId: String? = uiState.sessionId, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + onClearError: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + if (!uiState.isVisible) return + + val effectiveSessionId = sessionId ?: uiState.sessionId + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag("session_integrations_sheet"), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + SessionIntegrationsContent( + uiState = uiState, + sessionId = effectiveSessionId, + onDismiss = onDismiss, + onRefresh = onRefresh, + onConnect = onConnect, + onClearError = onClearError, + ) + } +} + +/** + * Stateless content composable for testing and decoupled layout. + */ +@Composable +fun SessionIntegrationsContent( + uiState: ChatConnectorsUiState, + sessionId: String? = uiState.sessionId, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + onClearError: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + val effectiveSessionId = sessionId ?: uiState.sessionId + val isSessionBlank = effectiveSessionId.isNullOrBlank() + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) { + // Top Header + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.session_integrations_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("session_integrations_title"), + ) + Spacer(modifier = Modifier.height(2.dp)) + val subtitleText = + if (!isSessionBlank) { + val displayId = + if (effectiveSessionId.length > + 12 + ) { + "${effectiveSessionId.take(12)}…" + } else { + effectiveSessionId + } + stringResource(R.string.session_integrations_subtitle, displayId) + } else { + stringResource(R.string.session_integrations_no_session) + } + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("session_integrations_subtitle"), + ) + } + + IconButton( + onClick = onRefresh, + enabled = !uiState.isLoading && !isSessionBlank, + modifier = Modifier.testTag("session_integrations_refresh_button"), + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.session_integrations_refresh), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + IconButton( + onClick = onDismiss, + modifier = Modifier.testTag("session_integrations_close_button"), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.session_integrations_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Action Error Banner + if (uiState.errorMessage != null) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("session_integrations_action_error_banner"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = mapActionErrorMessage(uiState.errorMessage), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onClearError, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.session_integrations_close), + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp), + ) + } + } + } + } + + // Awaiting Authorization Banner (manual Refresh status affordance) + val awaitingSlug = + (uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug + if (awaitingSlug != null) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("session_integrations_awaiting_banner"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.OpenInBrowser, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.session_integrations_awaiting), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.session_integrations_awaiting_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + FilledTonalButton( + onClick = onRefresh, + modifier = + Modifier + .align(Alignment.End) + .testTag("session_integrations_refresh_status_button"), + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.session_integrations_refresh_status), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + // Content States + when { + isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_no_session_state"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.LinkOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.session_integrations_no_session), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + uiState.loadPhase is ConnectorsLoadPhase.Loading -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_loading"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.session_integrations_connecting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + uiState.loadPhase !is ConnectorsLoadPhase.Loading && + uiState.loadPhase !is ConnectorsLoadPhase.Refreshing && + uiState.loadPhase !is ConnectorsLoadPhase.Loaded && + uiState.items.isEmpty() -> { + // Non-loaded error / unavailable / unsupported / not owner terminal states + val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: "" + val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_error"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(36.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("session_integrations_error_text"), + ) + if (canRetry) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onRefresh, + modifier = Modifier.testTag("session_integrations_retry_button"), + ) { + Text(stringResource(R.string.session_integrations_retry)) + } + } + } + } + } + + uiState.items.isEmpty() -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_empty"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.session_integrations_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + // List of items + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_integrations_list"), + ) { + if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) { + item { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_integrations_refreshing_indicator"), + ) + } + } + + items( + items = uiState.items, + key = { it.slug }, + ) { item -> + ConnectorRow( + item = item, + actionState = uiState.actionState, + isLoading = uiState.isLoading, + onConnect = onConnect, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } + } +} + +@Composable +fun ConnectorRow( + item: ConnectorUiItem, + actionState: ConnectorActionState, + isLoading: Boolean, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected) + val isCurrentConnecting = + actionState is ConnectorActionState.Connecting && actionState.slug == item.slug + val isAnyActionPending = + actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization + val needsReconnect = + item.isConnected || + item.status == ConnectorStatus.EXPIRED || + item.status == ConnectorStatus.REVOKED + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + .testTag("session_integrations_item_${item.slug}"), + verticalAlignment = Alignment.CenterVertically, + ) { + // Accessible Status Indicator Icon + Box( + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .background(visuals.color.copy(alpha = 0.15f)) + .testTag("session_integrations_status_${item.slug}"), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = visuals.icon, + contentDescription = visuals.label, + tint = visuals.color, + modifier = Modifier.size(16.dp), + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = visuals.label, + style = MaterialTheme.typography.bodySmall, + color = visuals.color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + if (isCurrentConnecting) { + CircularProgressIndicator( + modifier = + Modifier + .size(20.dp) + .testTag("session_integrations_spinner_${item.slug}"), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + val buttonText = + if (needsReconnect) { + stringResource(R.string.session_integrations_reconnect) + } else { + stringResource(R.string.session_integrations_connect) + } + + OutlinedButton( + onClick = { onConnect(item.slug, needsReconnect) }, + enabled = item.isEnabled && !isAnyActionPending && !isLoading, + modifier = Modifier.testTag("session_integrations_action_${item.slug}"), + ) { + Text( + text = buttonText, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt b/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt new file mode 100644 index 00000000..9497a91f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.util + +import java.net.URI + +/** + * Validates external connector authorization URLs. + * + * Enforces strict security constraints: + * - Must use HTTPS scheme (case-insensitive). + * - Must have a non-empty, valid host without spaces, backslashes, or control characters. + * - Must NOT contain user-info credentials (e.g. `https://user:pass@domain`). + * - Must NOT contain backslashes anywhere in the URI (guard against SSRF/open-redirect bypasses). + * - Port (if specified) must be valid (1..65535). + */ +object ConnectorUrlValidator { + private val HOST_PATTERN = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$""") + + fun isValidHttpsUrl(urlString: String?): Boolean { + if (urlString.isNullOrBlank()) return false + + // Disallow backslashes, whitespace, and control characters anywhere in the raw URL + for (ch in urlString) { + if (ch == '\\' || ch.isWhitespace() || ch.isISOControl()) { + return false + } + } + + return try { + val uri = URI(urlString) + val scheme = uri.scheme ?: return false + if (!scheme.equals("https", ignoreCase = true)) { + return false + } + + val host = uri.host + if (host.isNullOrBlank()) { + return false + } + + // Userinfo is strictly prohibited (e.g. credentials embedded in URL) + if (uri.rawUserInfo != null) { + return false + } + + // Check host structure (must match domain / alphanumeric pattern or valid IP) + if (!HOST_PATTERN.matches(host)) { + return false + } + + // Verify port if explicitly provided + if (uri.port != -1 && (uri.port < 1 || uri.port > 65535)) { + return false + } + + true + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 66e586fc..8e05c1b1 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1198,4 +1198,36 @@ الإعدادات تم إيقاف النقاش إيقاف التوليد + + + التكاملات + الجلسة النشطة: %1$s + لا توجد جلسة نشطة + لا توجد تكاملات متاحة لهذه الجلسة + تحديث التكاملات + ربط + إعادة الربط + جاري الاتصال… + بانتظار التفويض + أكمل تسجيل الدخول في المتصفح، ثم ارجع إلى هنا. + تحديث الحالة + متصل + غير متصل + منتهي الصلاحية + تم إلغاء الوصول + فشل + غير معروف + معلمات التكامل غير صالحة + الجلسة غير مملوكة بواسطة مسار النقل هذا. يرجى إعادة الاتصال بالجلسة. + التكاملات غير متوفرة في هذه الجلسة + يجب إدارة التكاملات على جهاز مضيف الجلسة + التكاملات غير مدعومة من هذا الخادم + فشل طلب التكامل. يرجى المحاولة مرة أخرى. + استجابة غير صالحة من خدمة التكامل + تعذر الاتصال بالبوابة + تعذر فتح المتصفح للتفويض + فشلت عملية التكامل + إعادة المحاولة + إغلاق + تكاملات الجلسة \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 074382d3..39b4ad51 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -888,4 +888,36 @@ 빠름 빠른 모드 이 모델에서는 빠른 모드를 사용할 수 없습니다 + + + 연동 + 활성 세션: %1$s + 활성 세션 없음 + 이 세션에서 사용할 수 있는 연동이 없습니다 + 연동 새로고침 + 연결 + 다시 연결 + 연결 중… + 인증 대기 중 + 브라우저에서 로그인을 완료한 후 다시 돌아오세요. + 상태 새로고침 + 연결됨 + 연결 해제됨 + 만료됨 + 취소됨 + 실패 + 알 수 없음 + 잘못된 커넥터 매개변수 + 이 전송 계층이 세션을 소유하고 있지 않습니다. 세션을 다시 연결하세요. + 이 세션에서는 연동을 사용할 수 없습니다 + 연동은 세션 호스트에서 관리해야 합니다 + 백엔드에서 연동 서비스를 지원하지 않습니다 + 커넥터 요청에 실패했습니다. 다시 시도해 주세요. + 커넥터 서비스에서 잘못된 응답을 반환했습니다 + 게이트웨이와의 통신에 실패했습니다 + 인증을 위한 브라우저를 열 수 없습니다 + 커넥터 작업에 실패했습니다 + 다시 시도 + 닫기 + 세션 연동 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 4f941cc8..a6c25201 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1175,4 +1175,36 @@ 仍要保存 调整限制 设置 + + + 集成 + 当前会话:%1$s + 无活动会话 + 此会话暂无可用集成 + 刷新集成 + 连接 + 重新连接 + 正在连接… + 等待授权 + 请在浏览器中完成登录,然后返回此处。 + 刷新状态 + 已连接 + 未连接 + 已过期 + 已撤销 + 失败 + 未知 + 连接器参数无效 + 此传输通道未拥有该会话。请重新连接会话。 + 此会话中不可使用集成 + 集成必须在会话所在的主机上进行管理 + 后端不支持集成服务 + 连接器请求失败,请重试。 + 连接器服务返回无效响应 + 与网关通信失败 + 无法打开浏览器以进行授权 + 连接器操作失败 + 重试 + 关闭 + 会话集成 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54c24310..21d1b61d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1387,4 +1387,36 @@ Verification Code Submit Skip + + + Integrations + Active Session: %1$s + No active session + No integrations available for this session + Refresh integrations + Connect + Reconnect + Connecting… + Awaiting authorization + Complete sign-in in your browser, then return here. + Refresh status + Connected + Disconnected + Expired + Revoked + Failed + Unknown + Invalid connector parameters + Session is not owned by this transport. Please reconnect the session. + Integrations are not available in this session + Integrations must be managed on the session compute host + Integrations are not supported by this backend + Connector request failed. Please try again. + Invalid response from connector service + Failed to communicate with gateway + Could not open browser for authorization + Connector operation failed + Retry + Close + Session Integrations diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt new file mode 100644 index 00000000..b8108c2e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt @@ -0,0 +1,742 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorParserTest { + @Test + fun `parseListResult parses standard valid envelope`() { + val payload = + mapOf( + "available" to true, + "connectors" to + listOf( + mapOf( + "connector" to "linear", + "name" to "Linear", + "connected" to true, + "enabled" to true, + "connectionStatus" to "active", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertTrue(success.available) + assertEquals(1, success.connectors.size) + val item = success.connectors[0] + assertEquals("linear", item.connector) + assertEquals("Linear", item.name) + assertTrue(item.connected) + assertTrue(item.enabled) + assertEquals(ConnectorStatus.CONNECTED, item.status) + assertTrue(item.isConnected) + } + + @Test + fun `parseListResult handles unavailable state`() { + val payload = + mapOf( + "available" to false, + "connectors" to emptyList>(), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertFalse(success.available) + assertTrue(success.connectors.isEmpty()) + } + + @Test + fun `parseListResult handles empty connectors list`() { + val payload = + mapOf( + "available" to true, + "connectors" to emptyList>(), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertTrue(success.available) + assertTrue(success.connectors.isEmpty()) + } + + @Test + fun `parseListResult deliberate boolean connected precedence over stale backend statuses`() { + // When connected is true, status is ALWAYS CONNECTED even if backend reports expired or revoked + val payloadExpired = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c1", "connected" to true, "connectionStatus" to "expired"), + mapOf("connector" to "c2", "connected" to true, "connectionStatus" to "revoked"), + ), + ) + val resultExpired = ConnectorParser.parseListResult(payloadExpired) as ConnectorListResult.Success + assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[0].status) + assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[1].status) + + // When connected is false, status reflects expired or revoked, and stale "active" resolves to DISCONNECTED + val payloadFalse = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "expired"), + mapOf("connector" to "c4", "connected" to false, "connectionStatus" to "revoked"), + mapOf("connector" to "c5", "connected" to false, "connectionStatus" to "active"), + mapOf("connector" to "c6", "connected" to false, "connectionStatus" to "initiated"), + mapOf("connector" to "c7", "connected" to false, "connectionStatus" to "error"), + ), + ) + val resultFalse = ConnectorParser.parseListResult(payloadFalse) as ConnectorListResult.Success + assertEquals(ConnectorStatus.EXPIRED, resultFalse.connectors[0].status) + assertEquals(ConnectorStatus.REVOKED, resultFalse.connectors[1].status) + assertEquals(ConnectorStatus.DISCONNECTED, resultFalse.connectors[2].status) + assertEquals(ConnectorStatus.INITIATED, resultFalse.connectors[3].status) + assertEquals(ConnectorStatus.FAILED, resultFalse.connectors[4].status) + } + + @Test + fun `parseListResult supports legacy aliases`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf( + "slug" to "github", + "displayName" to "GitHub App", + "status" to "expired", + "auth_url" to "https://github.com/login/oauth/authorize", + ), + mapOf( + "id" to "jira", + "title" to "Jira Cloud", + "connection_status" to "revoked", + "connectUrl" to "https://auth.atlassian.com/oauth", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(2, success.connectors.size) + + val c1 = success.connectors[0] + assertEquals("github", c1.connector) + assertEquals("GitHub App", c1.name) + assertEquals(ConnectorStatus.EXPIRED, c1.status) + assertEquals("https://github.com/login/oauth/authorize", c1.safeConnectUrl) + + val c2 = success.connectors[1] + assertEquals("jira", c2.connector) + assertEquals("Jira Cloud", c2.name) + assertEquals(ConnectorStatus.REVOKED, c2.status) + assertEquals("https://auth.atlassian.com/oauth", c2.safeConnectUrl) + } + + @Test + fun `parseListResult safely handles wrong optional field types without crash`() { + val payload = + mapOf( + "available" to "true", + "connectors" to + listOf( + mapOf( + "connector" to "slack", + "name" to 12345, // Number instead of string + "connected" to "true", // String instead of boolean + "enabled" to "false", // String instead of boolean + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + val item = success.connectors[0] + assertEquals("slack", item.connector) + assertEquals("12345", item.name) + assertTrue(item.connected) + assertFalse(item.enabled) + } + + @Test + fun `parseListResult maps unknown status to UNKNOWN`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf( + "connector" to "notion", + "connected" to false, + "connectionStatus" to "pending_approval_future_state", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) as ConnectorListResult.Success + assertEquals(ConnectorStatus.UNKNOWN, result.connectors[0].status) + } + + @Test + fun `parseListResult fails on malformed required envelopes without fabricating empty success`() { + // Missing "connectors" key + val missingConnectors = mapOf("available" to true) + val r1 = ConnectorParser.parseListResult(missingConnectors) + assertTrue(r1 is ConnectorListResult.Error) + assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // "connectors" is a string instead of array + val stringConnectors = mapOf("connectors" to "invalid") + val r2 = ConnectorParser.parseListResult(stringConnectors) + assertTrue(r2 is ConnectorListResult.Error) + assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Raw array at root instead of object + val rawList = listOf("c1", "c2") + val r3 = ConnectorParser.parseListResult(rawList) + assertTrue(r3 is ConnectorListResult.Error) + assertTrue((r3 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Primitive string at root + val r4 = ConnectorParser.parseListResult("some random string") + assertTrue(r4 is ConnectorListResult.Error) + assertTrue((r4 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Null root + val r5 = ConnectorParser.parseListResult(null) + assertTrue(r5 is ConnectorListResult.Error) + assertTrue((r5 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseConnectResult parses standard connect initiated response`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "linear", + "status" to "initiated", + "connect_url" to "https://linear.app/oauth/authorize?client_id=123", + "instruction" to "Arbitrary backend instruction that must not be exposed as UI text", + ), + ), + "summary" to + mapOf( + "total" to 1, + "active" to 0, + "initiated" to 1, + "failed" to 0, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(1, success.results.size) + val item = success.results[0] + assertEquals("linear", item.connector) + assertEquals(ConnectorConnectStatus.INITIATED, item.status) + assertTrue(item.isConnectUrlValid) + assertEquals("https://linear.app/oauth/authorize?client_id=123", item.safeConnectUrl) + assertEquals(1, success.summary.total) + assertEquals(1, success.summary.initiated) + assertEquals(0, success.summary.active) + } + + @Test + fun `parseConnectResult parses connect already active response`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "linear", + "status" to "active", + ), + ), + "summary" to + mapOf( + "total" to 1, + "active" to 1, + "initiated" to 0, + "failed" to 0, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(1, success.results.size) + val item = success.results[0] + assertEquals("linear", item.connector) + assertEquals(ConnectorConnectStatus.ACTIVE, item.status) + assertTrue(item.status.isActive) + assertNull(item.safeConnectUrl) + } + + @Test + fun `parseConnectResult supports legacy aliases and unknown status`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "slug" to "github", + "connectionStatus" to "failed", + "auth_url" to "https://github.com/login", + ), + mapOf( + "connector" to "notion", + "status" to "custom_unknown_status", + ), + ), + "summary" to + mapOf( + "total" to "2", // String count coerced safely + "failed" to "1", + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success + assertEquals(2, result.results.size) + assertEquals("github", result.results[0].connector) + assertEquals(ConnectorConnectStatus.FAILED, result.results[0].status) + assertEquals("https://github.com/login", result.results[0].safeConnectUrl) + + assertEquals("notion", result.results[1].connector) + assertEquals(ConnectorConnectStatus.UNKNOWN, result.results[1].status) + + assertEquals(2, result.summary.total) + assertEquals(1, result.summary.failed) + assertEquals(0, result.summary.active) + } + + @Test + fun `parseConnectResult rejects insecure or invalid auth URLs`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "insecure_http", + "status" to "initiated", + "connect_url" to "http://example.com/oauth", + ), + mapOf( + "connector" to "js_proto", + "status" to "initiated", + "connect_url" to "javascript:alert(1)", + ), + mapOf( + "connector" to "user_creds", + "status" to "initiated", + "connect_url" to "https://user:pass@evil.com/", + ), + ), + "summary" to + mapOf( + "total" to 3, + "initiated" to 3, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success + assertEquals(3, result.results.size) + for (item in result.results) { + assertFalse(item.isConnectUrlValid) + assertNull(item.safeConnectUrl) + } + } + + @Test + fun `parseConnectResult fails on malformed required envelope without fabricating success`() { + // Missing results + val r1 = ConnectorParser.parseConnectResult(mapOf("summary" to mapOf("total" to 0))) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Results is a string + val r2 = ConnectorParser.parseConnectResult(mapOf("results" to "not a list")) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Primitive string + val r3 = ConnectorParser.parseConnectResult("random text") + assertTrue(r3 is ConnectorConnectResult.Error) + } + + @Test + fun `toString redacts auth URLs on ConnectorItem and ConnectorConnectItem`() { + val sensitiveUrl = "https://example.com/oauth?client_secret=super_secret_token_12345" + val item = + ConnectorItem( + connector = "linear", + name = "Linear", + connectUrl = sensitiveUrl, + ) + assertFalse(item.toString().contains("super_secret_token_12345")) + assertTrue(item.toString().contains("[REDACTED]")) + + val connectItem = + ConnectorConnectItem( + connector = "linear", + status = ConnectorConnectStatus.INITIATED, + connectUrl = sensitiveUrl, + ) + assertFalse(connectItem.toString().contains("super_secret_token_12345")) + assertTrue(connectItem.toString().contains("[REDACTED]")) + } + + @Test + fun `mapRpcError correctly maps backend codes and reasons`() { + // 4000 INVALID_PARAMS + val e4000 = + ConnectorParser.mapRpcError( + code = 4000, + message = "session_id required", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))), + ) + assertTrue(e4000 is ConnectorError.InvalidParams) + + // 4001 NOT_OWNER + val e4001 = + ConnectorParser.mapRpcError( + code = 4001, + message = "session not owned", + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + assertTrue(e4001 is ConnectorError.NotOwner) + + // 4031 CONNECTORS_UNAVAILABLE + val e4031 = + ConnectorParser.mapRpcError( + code = 4031, + message = "connectors unavailable", + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + assertTrue(e4031 is ConnectorError.Unavailable) + + // 5033 UNSUPPORTED_RUNTIME + val e5033 = + ConnectorParser.mapRpcError( + code = 5033, + message = "compute host required", + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + assertTrue(e5033 is ConnectorError.UnsupportedRuntime) + + // 5034 INVALID_CONNECTOR_RESPONSE + val e5034a = + ConnectorParser.mapRpcError( + code = 5034, + message = "invalid response", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + assertTrue(e5034a is ConnectorError.InvalidResponse) + + // 5034 CONNECTOR_REQUEST_FAILED + val e5034b = + ConnectorParser.mapRpcError( + code = 5034, + message = "request failed", + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTOR_REQUEST_FAILED"))), + ) + assertTrue(e5034b is ConnectorError.RequestFailed) + + // -32601 Method not found + val e32601 = + ConnectorParser.mapRpcError( + code = -32601, + message = "Method not found", + data = null, + ) + assertTrue(e32601 is ConnectorError.UnsupportedBackend) + + // Other code + val eOther = + ConnectorParser.mapRpcError( + code = 9999, + message = "Custom failure", + data = null, + ) + assertTrue(eOther is ConnectorError.Other) + assertEquals(9999, (eOther as ConnectorError.Other).code) + } + + @Test + fun `mapRpcError never leaks raw backend messages or secrets into typed error or toString`() { + val secretApiKey = "sk-live-secret-super-sensitive-api-key-987654321" + val secretDbPass = "postgres://user:super_secret_db_pass@10.0.0.1:5432/db" + + // Known error code with secret in message + val e4000 = + ConnectorParser.mapRpcError( + code = 4000, + message = "Invalid auth header: $secretApiKey", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))), + ) + assertFalse("Error message must not leak secret", e4000.message.contains(secretApiKey)) + assertFalse("Error toString must not leak secret", e4000.toString().contains(secretApiKey)) + assertEquals("Invalid connector parameters.", e4000.message) + + // Unknown error code with secret in message + val eUnknown = + ConnectorParser.mapRpcError( + code = 8888, + message = "Internal connection failed to $secretDbPass", + data = null, + ) + assertFalse("Unknown error message must not leak secret", eUnknown.message.contains(secretDbPass)) + assertFalse("Unknown error toString must not leak secret", eUnknown.toString().contains(secretDbPass)) + assertEquals("Connector operation failed.", eUnknown.message) + } + + @Test + fun `parseConnectorItem strictly rejects non-string identifiers and invalid slug formats`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to true, "name" to "Boolean Slug"), + mapOf("id" to 12345, "name" to "Number ID"), + mapOf("slug" to "INVALID UPPERCASE", "name" to "Uppercase"), + mapOf("connector" to "-leading-hyphen", "name" to "Leading Hyphen"), + mapOf("connector" to "slug with spaces", "name" to "Spaces"), + mapOf("connector" to "valid_slug-1", "name" to "Valid Item"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(1, success.connectors.size) + assertEquals("valid_slug-1", success.connectors[0].connector) + assertEquals("Valid Item", success.connectors[0].name) + } + + @Test + fun `parseListResult deduplicates items by slug to prevent duplicate keys in lazy lists`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "github", "name" to "First GitHub"), + mapOf("connector" to "github", "name" to "Duplicate GitHub"), + mapOf("connector" to "slack", "name" to "Slack"), + mapOf("connector" to "slack", "name" to "Duplicate Slack"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(2, success.connectors.size) + assertEquals("github", success.connectors[0].connector) + assertEquals("First GitHub", success.connectors[0].name) + assertEquals("slack", success.connectors[1].connector) + assertEquals("Slack", success.connectors[1].name) + } + + @Test + fun `parseListResult fails with MalformedEnvelope when nonempty connectors array contains only invalid items`() { + val payload = + mapOf( + "connectors" to + listOf( + "not an object", + 12345, + mapOf("connector" to true), + mapOf("slug" to "INVALID SLUG"), + mapOf("bogus" to "no identifier"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Error) + assertTrue((result as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseListResult tolerates mixed junk items when valid items exist`() { + val payload = + mapOf( + "connectors" to + listOf( + "random junk string", + mapOf("connector" to "linear", "name" to "Linear"), + 12345, + mapOf("connector" to true), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(1, success.connectors.size) + assertEquals("linear", success.connectors[0].connector) + } + + @Test + fun `parseConnectResult fails when results is empty or all items invalid`() { + // Empty results array + val payloadEmpty = + mapOf( + "results" to emptyList>(), + "summary" to mapOf("total" to 0), + ) + val rEmpty = ConnectorParser.parseConnectResult(payloadEmpty) + assertTrue(rEmpty is ConnectorConnectResult.Error) + assertTrue((rEmpty as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + + // All invalid items + val payloadInvalid = + mapOf( + "results" to + listOf( + mapOf("connector" to true), + mapOf("id" to 12345), + mapOf("connector" to "INVALID UPPER"), + ), + "summary" to mapOf("total" to 3), + ) + val rInvalid = ConnectorParser.parseConnectResult(payloadInvalid) + assertTrue(rInvalid is ConnectorConnectResult.Error) + assertTrue((rInvalid as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + } + + @Test + fun `parseConnectResult fails when summary object is missing or non-object in standard envelope`() { + // Missing summary + val payloadMissingSummary = + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "active"), + ), + ) + val r1 = ConnectorParser.parseConnectResult(payloadMissingSummary) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Non-object summary (string) + val payloadStringSummary = + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "active"), + ), + "summary" to "invalid_string_summary", + ) + val r2 = ConnectorParser.parseConnectResult(payloadStringSummary) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseConnectResult tolerates purposeful legacy bare array form and derives summary`() { + val legacyPayload = + listOf( + mapOf("connector" to "linear", "status" to "active"), + mapOf("connector" to "github", "status" to "initiated", "connect_url" to "https://github.com/login"), + ) + + val result = ConnectorParser.parseConnectResult(legacyPayload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(2, success.results.size) + assertEquals("linear", success.results[0].connector) + assertEquals(ConnectorConnectStatus.ACTIVE, success.results[0].status) + assertEquals("github", success.results[1].connector) + assertEquals(ConnectorConnectStatus.INITIATED, success.results[1].status) + + // Summary should be synthesized + assertEquals(2, success.summary.total) + assertEquals(1, success.summary.active) + assertEquals(1, success.summary.initiated) + assertEquals(0, success.summary.failed) + } + + @Test + fun `legacy connected consistency - status authorized yields effective connected when boolean is missing`() { + val payload = + mapOf( + "connectors" to + listOf( + // Missing "connected" boolean, status "authorized" + mapOf("connector" to "c1", "connectionStatus" to "authorized"), + // Missing "connected" boolean, status "active" + mapOf("connector" to "c2", "connectionStatus" to "active"), + // Missing "connected" boolean, status "connected" + mapOf("connector" to "c3", "connectionStatus" to "connected"), + // Missing "connected" boolean, status "expired" + mapOf("connector" to "c4", "connectionStatus" to "expired"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(4, success.connectors.size) + + val c1 = success.connectors[0] + assertEquals(ConnectorStatus.CONNECTED, c1.status) + assertTrue(c1.connected) + assertTrue(c1.isConnected) + + val c2 = success.connectors[1] + assertEquals(ConnectorStatus.CONNECTED, c2.status) + assertTrue(c2.connected) + assertTrue(c2.isConnected) + + val c3 = success.connectors[2] + assertEquals(ConnectorStatus.CONNECTED, c3.status) + assertTrue(c3.connected) + assertTrue(c3.isConnected) + + val c4 = success.connectors[3] + assertEquals(ConnectorStatus.EXPIRED, c4.status) + assertFalse(c4.connected) + assertFalse(c4.isConnected) + } + + @Test + fun `explicit connected false is authoritative and not overridden by status active or authorized`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c1", "connected" to false, "connectionStatus" to "active"), + mapOf("connector" to "c2", "connected" to false, "connectionStatus" to "authorized"), + mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "connected"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + for (item in success.connectors) { + assertFalse(item.connected) + assertFalse(item.isConnected) + assertEquals(ConnectorStatus.DISCONNECTED, item.status) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt new file mode 100644 index 00000000..bb95cc84 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -0,0 +1,376 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class ConnectorRepositoryTest { + @Test + fun `exact method constants match upstream gateway expectations`() { + assertEquals("connectors.list", WsMethods.CONNECTORS_LIST) + assertEquals("connectors.connect", WsMethods.CONNECTORS_CONNECT) + } + + @Test + fun `listConnectors sends exact parameters without extra profile injection`() = + runBlocking { + var recordedMethod: String? = null + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { method, params -> + recordedMethod = method + recordedParams = params + mapOf("available" to true, "connectors" to emptyList>()) + }, + ) + + val result = repo.listConnectors("sess-abc-123") + assertTrue(result is ConnectorListResult.Success) + assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod) + assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams) + // Verify no profile or singular slug mutations + assertFalse(recordedParams?.containsKey("profile") == true) + assertFalse(recordedParams?.containsKey("connector") == true) + } + + @Test + fun `connect sends exact parameters with connectors array and reconnect boolean`() = + runBlocking { + var recordedMethod: String? = null + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { method, params -> + recordedMethod = method + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "initiated"), + ), + "summary" to mapOf("total" to 1, "initiated" to 1), + ) + }, + ) + + val result = + repo.connect( + sessionId = "sess-456", + connectors = listOf("linear", "github_app-1"), + reconnect = true, + ) + + assertTrue(result is ConnectorConnectResult.Success) + assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod) + assertEquals("sess-456", recordedParams?.get("session_id")) + assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors")) + assertEquals(true, recordedParams?.get("reconnect")) + + // Verify no singular slug key mutation + assertFalse(recordedParams?.containsKey("connector") == true) + } + + @Test + fun `connect preserves reconnect false default`() = + runBlocking { + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { _, params -> + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "slack", "status" to "active"), + ), + "summary" to mapOf("total" to 1, "active" to 1), + ) + }, + ) + + repo.connect(sessionId = "sess-789", connectors = listOf("slack")) + assertEquals(false, recordedParams?.get("reconnect")) + } + + @Test + fun `listConnectors validates non-blank session_id before network`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + val r1 = repo.listConnectors("") + val r2 = repo.listConnectors(" ") + + assertFalse(rpcCalled) + assertTrue(r1 is ConnectorListResult.Error) + assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.InvalidParams) + assertTrue(r2 is ConnectorListResult.Error) + assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.InvalidParams) + } + + @Test + fun `connect validates session_id and connectors list before network`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + // Blank session_id + val r1 = repo.connect(sessionId = "", connectors = listOf("linear")) + assertFalse(rpcCalled) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + + // Empty connectors + val r2 = repo.connect(sessionId = "sess-1", connectors = emptyList()) + assertFalse(rpcCalled) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + } + + @Test + fun `connect validates slug pattern matches backend expectations`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + // Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols + val invalidSlugs = + listOf( + "Linear", // uppercase + "-linear", // leading hyphen + "_linear", // leading underscore + "linear app", // space + "linear/app", // slash + "linear@app", // symbol + "", // empty + ) + + for (invalidSlug in invalidSlugs) { + rpcCalled = false + val result = repo.connect(sessionId = "sess-1", connectors = listOf(invalidSlug)) + assertFalse("Expected RPC not to be called for invalid slug '$invalidSlug'", rpcCalled) + assertTrue("Expected InvalidParams error for '$invalidSlug'", result is ConnectorConnectResult.Error) + assertTrue((result as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + } + + // Valid slugs + assertTrue(ConnectorRepository.isValidSlug("gmail")) + assertTrue(ConnectorRepository.isValidSlug("google_drive")) + assertTrue(ConnectorRepository.isValidSlug("jira-service-desk")) + assertTrue(ConnectorRepository.isValidSlug("0auth_provider")) + } + + @Test + fun `cancellation exception rethrows and is not swallowed`() = + runBlocking { + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw CancellationException("coroutine cancelled") + }, + ) + + try { + repo.listConnectors("sess-1") + fail("Expected CancellationException to be rethrown on listConnectors") + } catch (e: CancellationException) { + assertEquals("coroutine cancelled", e.message) + } + + try { + repo.connect("sess-1", listOf("linear")) + fail("Expected CancellationException to be rethrown on connect") + } catch (e: CancellationException) { + assertEquals("coroutine cancelled", e.message) + } + } + + @Test + fun `maps HermesRpcException to typed errors`() = + runBlocking { + // 4031 CONNECTORS_UNAVAILABLE + val repo4031 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Connectors unavailable", + code = 4031, + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + }, + ) + val r4031 = repo4031.listConnectors("sess-1") + assertTrue(r4031 is ConnectorListResult.Error) + assertTrue((r4031 as ConnectorListResult.Error).error is ConnectorError.Unavailable) + + // 4001 NOT_OWNER + val repo4001 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Session not owned", + code = 4001, + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + }, + ) + val r4001 = repo4001.connect("sess-1", listOf("linear")) + assertTrue(r4001 is ConnectorConnectResult.Error) + assertTrue((r4001 as ConnectorConnectResult.Error).error is ConnectorError.NotOwner) + + // 5033 UNSUPPORTED_RUNTIME + val repo5033 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Compute host required", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + }, + ) + val r5033 = repo5033.connect("sess-1", listOf("linear")) + assertTrue(r5033 is ConnectorConnectResult.Error) + assertTrue((r5033 as ConnectorConnectResult.Error).error is ConnectorError.UnsupportedRuntime) + + // 5034 INVALID_CONNECTOR_RESPONSE + val repo5034 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Invalid connector response", + code = 5034, + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + }, + ) + val r5034 = repo5034.connect("sess-1", listOf("linear")) + assertTrue(r5034 is ConnectorConnectResult.Error) + assertTrue((r5034 as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + + // -32601 Unsupported backend + val repo32601 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Method not found", + code = -32601, + data = null, + ) + }, + ) + val r32601 = repo32601.listConnectors("sess-1") + assertTrue(r32601 is ConnectorListResult.Error) + assertTrue((r32601 as ConnectorListResult.Error).error is ConnectorError.UnsupportedBackend) + } + + @Test + fun `generic network exception maps to typed NetworkError`() = + runBlocking { + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw java.io.IOException("Connection reset by peer") + }, + ) + + val rList = repo.listConnectors("sess-1") + assertTrue(rList is ConnectorListResult.Error) + assertTrue((rList as ConnectorListResult.Error).error is ConnectorError.NetworkError) + + val rConnect = repo.connect("sess-1", listOf("linear")) + assertTrue(rConnect is ConnectorConnectResult.Error) + assertTrue((rConnect as ConnectorConnectResult.Error).error is ConnectorError.NetworkError) + } + + @Test + fun `default rpcRequest cancels underlying deferred on caller cancellation for listConnectors`() = + runBlocking { + mockkObject(HermesWsClient) + try { + val deferred = CompletableDeferred() + every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any(), any()) } returns deferred + every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any()) } returns deferred + + val repo = HermesConnectorRepository() + val job = + launch { + repo.listConnectors("sess-cancel-test") + } + + yield() + assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled) + + job.cancel() + job.join() + + assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled) + } finally { + unmockkObject(HermesWsClient) + } + } + + @Test + fun `default rpcRequest cancels underlying deferred on caller cancellation for connect`() = + runBlocking { + mockkObject(HermesWsClient) + try { + val deferred = CompletableDeferred() + every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any(), any()) } returns deferred + every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any()) } returns deferred + + val repo = HermesConnectorRepository() + val job = + launch { + repo.connect("sess-cancel-test", listOf("linear")) + } + + yield() + assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled) + + job.cancel() + job.join() + + assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled) + } finally { + unmockkObject(HermesWsClient) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt new file mode 100644 index 00000000..29bfaa84 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt @@ -0,0 +1,458 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class HermesWsClientLoggingSecurityTest { + private val capturedDebugLogs = Collections.synchronizedList(mutableListOf()) + private val capturedInfoLogs = Collections.synchronizedList(mutableListOf()) + private val capturedWarnLogs = Collections.synchronizedList(mutableListOf()) + private val capturedErrorLogs = Collections.synchronizedList(mutableListOf()) + + @Before + fun setUp() { + capturedDebugLogs.clear() + capturedInfoLogs.clear() + capturedWarnLogs.clear() + capturedErrorLogs.clear() + + mockkStatic(Log::class) + every { Log.v(any(), any()) } answers { + capturedDebugLogs.add(secondArg()) + 0 + } + every { Log.d(any(), any()) } answers { + capturedDebugLogs.add(secondArg()) + 0 + } + every { Log.i(any(), any()) } answers { + capturedInfoLogs.add(secondArg()) + 0 + } + every { Log.w(any(), any()) } answers { + capturedWarnLogs.add(secondArg()) + 0 + } + every { Log.w(any(), any(), any()) } answers { + capturedWarnLogs.add(secondArg()) + 0 + } + every { Log.e(any(), any()) } answers { + capturedErrorLogs.add(secondArg()) + 0 + } + every { Log.e(any(), any(), any()) } answers { + capturedErrorLogs.add(secondArg()) + 0 + } + + mockkObject(AuthManager) + every { AuthManager.activeProfileId } returns MutableStateFlow(null) + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.wsUrl() } returns "ws://127.0.0.1:9119/" + + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as AtomicBoolean).set(false) + + val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalCloseField.isAccessible = true + (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + + val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generationField.isAccessible = true + (generationField.get(HermesWsClient) as AtomicInteger).set(1) + + HermesWsClient.rejectAllPending() + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.setAppForeground(true) + } + + @After + fun tearDown() { + HermesWsClient.rejectAllPending() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + unmockkAll() + } + + private fun createWsListener(generation: Int? = null): WebSocketListener { + val targetGen = + generation ?: run { + val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generationField.isAccessible = true + (generationField.get(HermesWsClient) as AtomicInteger).get() + } + val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") + val constructor = listenerClass.declaredConstructors.single() + constructor.isAccessible = true + return constructor.newInstance(targetGen) as WebSocketListener + } + + private fun attachMockSocket(): WebSocket { + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") + socketField.isAccessible = true + socketField.set(HermesWsClient, socket) + + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as AtomicBoolean).set(true) + + val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalCloseField.isAccessible = true + (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + + return socket + } + + @Suppress("UNCHECKED_CAST") + private fun getPendingCalls(): ConcurrentHashMap { + val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") + field.isAccessible = true + return field.get(HermesWsClient) as ConcurrentHashMap + } + + private fun getTimeoutJob(pendingCall: Any): Job? { + val field = pendingCall.javaClass.getDeclaredField("timeoutJob") + field.isAccessible = true + return field.get(pendingCall) as? Job + } + + // ── Formatter unit tests ────────────────────────────────────────────────── + + @Test + fun `formatSafeOutgoingFrameLog does not leak payload parameters or urls`() { + val sensitiveUrl = "https://example.com/oauth/authorize?token=secret123" + val log = + HermesWsClient.formatSafeOutgoingFrameLog( + id = "req-42", + method = WsMethods.CONNECTORS_CONNECT, + paramsKeys = listOf("session_id", "connectors", "reconnect"), + byteLength = 256, + queued = false, + ) + + assertFalse("Log must not contain sensitive URL", log.contains(sensitiveUrl)) + assertFalse("Log must not contain 'secret'", log.contains("secret")) + assertTrue("Log should contain id", log.contains("id=req-42")) + assertTrue("Log should contain method", log.contains("method=connectors.connect")) + assertTrue( + "Log should contain parameter keys only", + log.contains("paramsKeys=[session_id,connectors,reconnect]"), + ) + assertTrue("Log should contain byte length", log.contains("bytes=256")) + } + + @Test + fun `formatSafeQueuedFrameLog does not leak sensitive payload`() { + val sensitiveUrl = "https://auth.provider.com/login?key=supersecret" + val rawJson = + buildJsonObject { + put("id", "q-1") + put("method", "connectors.connect") + put( + "params", + buildJsonObject { + put("session_id", "sid-1") + put("secret_url", sensitiveUrl) + }, + ) + }.toString() + + val log = HermesWsClient.formatSafeQueuedFrameLog(rawJson) + + assertFalse("Queued frame log must not leak sensitive url", log.contains(sensitiveUrl)) + assertFalse("Queued frame log must not contain 'supersecret'", log.contains("supersecret")) + assertTrue("Queued frame log should mark as queued", log.contains("→ (queued)")) + assertTrue("Queued frame log should show method", log.contains("method=connectors.connect")) + } + + @Test + fun `formatSafeIncomingFrameLog does not leak connect_url or token from response`() { + val sensitiveAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=123&token=abc_secret_token" + val responseJson = + """ + { + "jsonrpc": "2.0", + "id": "req-99", + "result": { + "results": [ + { + "connector": "google", + "status": "initiated", + "connect_url": "$sensitiveAuthUrl", + "instruction": "Open this link in your browser to sign in" + } + ], + "summary": {"total": 1} + } + } + """.trimIndent() + + val log = HermesWsClient.formatSafeIncomingFrameLog(responseJson) + + assertFalse("Incoming frame log must not leak sensitive auth url", log.contains(sensitiveAuthUrl)) + assertFalse("Incoming frame log must not leak secret token", log.contains("abc_secret_token")) + assertFalse("Incoming frame log must not leak instructions", log.contains("Open this link")) + assertTrue("Incoming frame log should contain id", log.contains("id=req-99")) + assertTrue("Incoming frame log should summarize result keys", log.contains("result=[results,summary]")) + } + + @Test + fun `formatSafeIncomingFrameLog handles malformed or non-object json safely`() { + val log1 = HermesWsClient.formatSafeIncomingFrameLog("plain string not json") + assertTrue(log1.startsWith("← frame bytes=")) + + val log2 = HermesWsClient.formatSafeIncomingFrameLog("[1, 2, 3]") + assertTrue(log2.startsWith("← non-object frame bytes=")) + } + + // ── Real inbound WsListenerImpl.onMessage path regression tests ─────────── + + @Test + fun `inbound onMessage delivers result with connect_url while Log_d redacts secrets`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider")) + val pendingCalls = getPendingCalls() + val reqId = pendingCalls.keys.firstOrNull() + assertNotNull("Pending call ID must exist", reqId) + + val sensitiveAuthUrl = + "https://auth.synthetic-fixture.local/oauth/authorize?code=fixture-code-99&token=fixture-secret-token-xyz" + val responseJson = + """ + { + "jsonrpc": "2.0", + "id": "$reqId", + "result": { + "status": "initiated", + "connect_url": "$sensitiveAuthUrl" + } + } + """.trimIndent() + + listener.onMessage(socket, responseJson) + + assertTrue("Deferred request must be completed", deferred.isCompleted) + val result = runBlocking { deferred.await() } + assertTrue("Result must be delivered as JsonObject", result is JsonObject) + val resultObj = result as JsonObject + assertEquals( + sensitiveAuthUrl, + (resultObj["connect_url"] as? JsonPrimitive)?.content, + ) + + val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs + for (log in allLogs) { + assertFalse("Captured log must not leak sensitive auth URL: $log", log.contains(sensitiveAuthUrl)) + assertFalse("Captured log must not leak secret token: $log", log.contains("fixture-secret-token-xyz")) + assertFalse("Captured log must not leak fixture code: $log", log.contains("fixture-code-99")) + assertFalse( + "Captured log must not leak synthetic auth host: $log", + log.contains("https://auth.synthetic-fixture.local"), + ) + } + + assertTrue( + "Log.d must record safe sanitized incoming frame log with id and sanitized keys", + capturedDebugLogs.any { + it.contains("id=$reqId") && ( + it.contains("result=[status,connect_url]") || + it.contains("result=[connect_url,status]") + ) + }, + ) + } + + @Test + fun `inbound onMessage rpc error 4001 NOT_OWNER completes deferred exceptionally and preserves shared events`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val capturedEvents = Collections.synchronizedList(mutableListOf()) + val subscribedLatch = CountDownLatch(1) + val errorEventLatch = CountDownLatch(1) + var receivedErrorEvent: WsEvent.RpcError? = null + + val collectJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { subscribedLatch.countDown() } + .collect { + capturedEvents.add(it) + if (it is WsEvent.RpcError) { + receivedErrorEvent = it + errorEventLatch.countDown() + } + } + } + + assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) + + val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001")) + val pendingCalls = getPendingCalls() + val reqId = pendingCalls.keys.firstOrNull() + assertNotNull("Pending call ID must exist", reqId) + + val errorJson = + """ + { + "jsonrpc": "2.0", + "id": "$reqId", + "error": { + "code": 4001, + "message": "Access denied: session ownership required", + "data": { + "reason": "NOT_OWNER", + "session_id": "sess-ownership-4001" + } + } + } + """.trimIndent() + + listener.onMessage(socket, errorJson) + + assertTrue("Deferred must be completed", deferred.isCompleted) + val thrown = runCatching { runBlocking { deferred.await() } }.exceptionOrNull() + assertTrue( + "Deferred must complete exceptionally with HermesRpcException", + thrown is HermesWsClient.HermesRpcException, + ) + val rpcException = thrown as HermesWsClient.HermesRpcException + assertEquals(4001, rpcException.code) + assertEquals("Access denied: session ownership required", rpcException.message) + assertNotNull("Exception data must be preserved", rpcException.data) + assertTrue("Exception data must be JsonObject", rpcException.data is JsonObject) + val dataObj = rpcException.data as JsonObject + assertEquals("NOT_OWNER", (dataObj["reason"] as? JsonPrimitive)?.content) + assertEquals("sess-ownership-4001", (dataObj["session_id"] as? JsonPrimitive)?.content) + + assertTrue("Shared events flow must receive RpcError event", errorEventLatch.await(3, TimeUnit.SECONDS)) + collectJob.cancel() + + assertNotNull("Captured RpcError event must exist", receivedErrorEvent) + assertEquals(reqId, receivedErrorEvent!!.id) + assertEquals(4001, receivedErrorEvent!!.error.code) + assertEquals("Access denied: session ownership required", receivedErrorEvent!!.error.message) + val eventDataObj = receivedErrorEvent!!.error.data as? JsonObject + assertNotNull("Event error data must be JsonObject", eventDataObj) + assertEquals("NOT_OWNER", (eventDataObj!!["reason"] as? JsonPrimitive)?.content) + } + + @Test + fun `inbound onMessage malformed frame logs safely without leaking payload or secrets`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val subscribedLatch = CountDownLatch(1) + val unknownEventLatch = CountDownLatch(1) + var receivedUnknownEvent: WsEvent.Unknown? = null + + val collectJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { subscribedLatch.countDown() } + .collect { + if (it is WsEvent.Unknown) { + receivedUnknownEvent = it + unknownEventLatch.countDown() + } + } + } + + assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) + capturedDebugLogs.clear() + capturedErrorLogs.clear() + + val sensitiveSecret = "synthetic-secret-token-malformed-frame-777" + val sensitiveUrl = "https://auth.synthetic-trap.local/oauth/secret" + val malformedPayload = """{"token": "$sensitiveSecret", "url": "$sensitiveUrl", [syntax-broken-json]}""" + + listener.onMessage(socket, malformedPayload) + + assertTrue("Unknown event must be emitted for malformed frame", unknownEventLatch.await(3, TimeUnit.SECONDS)) + collectJob.cancel() + + assertNotNull(receivedUnknownEvent) + assertEquals(malformedPayload, receivedUnknownEvent!!.raw) + + val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs + for (log in allLogs) { + assertFalse("Logs must not contain sensitive secret token: $log", log.contains(sensitiveSecret)) + assertFalse("Logs must not contain sensitive auth URL: $log", log.contains(sensitiveUrl)) + } + + assertTrue( + "Log.e must record parse failure without leaking secrets", + capturedErrorLogs.any { it.startsWith("Failed to parse message:") }, + ) + } + + @Test + fun `cancelling pending deferred cleans pending requests and cancels timeout job`() { + attachMockSocket() + val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture")) + val pendingCalls = getPendingCalls() + val entry = + pendingCalls.entries.firstOrNull { + it.value.javaClass.name + .contains("PendingCall") + } + assertNotNull("Pending call must be registered in pendingCalls map", entry) + val reqId = entry!!.key + val timeoutJob = getTimeoutJob(entry.value) + assertNotNull("Timeout job must be initialized", timeoutJob) + assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive) + + deferred.cancel() + + assertTrue("Deferred must be cancelled", deferred.isCancelled) + assertFalse( + "Pending call must be removed from pendingCalls map on cancellation", + pendingCalls.containsKey(reqId), + ) + assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index 0259ea7d..a0825212 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -161,6 +161,26 @@ class WsProfileParamsTest { assertSame(original, result) } + @Test + fun `connectors_list is not profile scoped`() { + AuthManager.setActiveProfileId("meow") + + val original = mapOf("session_id" to "sess-123") + val result = WsProfileParams.decorate(WsMethods.CONNECTORS_LIST, original) + + assertSame(original, result) + } + + @Test + fun `connectors_connect is not profile scoped`() { + AuthManager.setActiveProfileId("meow") + + val original = mapOf("session_id" to "sess-123", "connectors" to listOf("linear"), "reconnect" to false) + val result = WsProfileParams.decorate(WsMethods.CONNECTORS_CONNECT, original) + + assertSame(original, result) + } + @Test fun `unknown method is not profile scoped`() { AuthManager.setActiveProfileId("meow") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt new file mode 100644 index 00000000..753e0a94 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt @@ -0,0 +1,1403 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorConnectSummary +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.ws.ConnectorRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectorsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val mockRepository = mockk() + private var currentSessionId: String? = "session-A" + + private lateinit var delegate: ChatConnectorsDelegate + + @Before + fun setup() { + currentSessionId = "session-A" + coEvery { mockRepository.listConnectors(any()) } returns + ConnectorListResult.Success(available = true, connectors = emptyList()) + coEvery { mockRepository.connect(any(), any(), any()) } returns + ConnectorConnectResult.Success(results = emptyList(), summary = ConnectorConnectSummary()) + + delegate = + ChatConnectorsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + repository = mockRepository, + runtimeSessionId = { currentSessionId }, + ) + } + + private fun mockCatalog(vararg items: ConnectorItem) { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success(available = true, connectors = items.toList()) + } + + @Test + fun testInitialState_isNotVisible_andIdle() { + val state = delegate.uiState.value + assertFalse(state.isVisible) + assertEquals(ConnectorsLoadPhase.Initial, state.loadPhase) + assertTrue(state.items.isEmpty()) + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertNull(state.errorMessage) + assertFalse(state.isOldBackend) + } + + @Test + fun testShow_triggersRefreshWhenForeground() = + testScope.runTest { + mockCatalog( + ConnectorItem( + connector = "github", + name = "GitHub", + connected = false, + enabled = true, + ), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.isVisible) + assertTrue(state.loadPhase is ConnectorsLoadPhase.Loaded) + assertEquals(1, state.items.size) + assertEquals("github", state.items[0].slug) + assertEquals("GitHub", state.items[0].name) + assertFalse(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testNoSession_doesNotIssueRpc() = + testScope.runTest { + currentSessionId = null + delegate.onResume() + delegate.show() + delegate.refresh(force = true) + delegate.connect("github") + advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.listConnectors(any()) } + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + } + + @Test + fun testHide_cancelsJobs_andResetsActionState() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Loading) + + delegate.hide() + advanceUntilIdle() + + assertFalse(delegate.uiState.value.isVisible) + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testSessionReload_generationGuard_ignoresStaleSessionAResult() = + testScope.runTest { + val sessionADeferred1 = CompletableDeferred() + val sessionADeferred2 = CompletableDeferred() + var callCount = 0 + + coEvery { mockRepository.listConnectors("session-A") } coAnswers { + callCount++ + if (callCount == 1) { + sessionADeferred1.await() + } else { + sessionADeferred2.await() + } + } + coEvery { mockRepository.listConnectors("session-B") } returns + ConnectorListResult.Success( + available = true, + connectors = + listOf( + ConnectorItem(connector = "session-b-connector", name = "B"), + ), + ) + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + // Transition: session A -> session B + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + advanceUntilIdle() + + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "session-b-connector", + delegate.uiState.value.items[0] + .slug, + ) + + // Transition: session B -> session A (new generation) + currentSessionId = "session-A" + delegate.onActiveSessionChanged("session-A") + testDispatcher.scheduler.runCurrent() + + // First session A call completes late! + sessionADeferred1.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "stale-item", name = "Stale")), + ), + ) + testDispatcher.scheduler.runCurrent() + + // Second session A call completes + sessionADeferred2.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "fresh-item", name = "Fresh")), + ), + ) + advanceUntilIdle() + + // Should have fresh-item, stale-item from previous generation must be dropped + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "fresh-item", + delegate.uiState.value.items[0] + .slug, + ) + } + + @Test + fun testConnectUrl_validHttps_emitsBrowserEvent_awaitsAuthorization() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("slack"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "slack", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://slack.com/oauth/v2/authorize?client_id=123", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("slack") + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.actionState is ConnectorActionState.AwaitingAuthorization) + assertEquals("slack", (state.actionState as ConnectorActionState.AwaitingAuthorization).slug) + + assertNotNull(state.browserLaunchEvent) + val event = state.browserLaunchEvent!! + assertEquals("slack", event.slug) + assertEquals("https://slack.com/oauth/v2/authorize?client_id=123", event.url) + assertTrue(event.toString().contains("[REDACTED]")) + assertFalse(event.toString().contains("client_id=123")) + + // Consume event + delegate.consumeBrowserEvent() + assertNull(delegate.uiState.value.browserLaunchEvent) + // Action state stays AwaitingAuthorization + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + } + + @Test + fun testConnectUrl_unsafeOrInvalid_resultsInRetryableFailure() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "bad", name = "Bad", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("bad"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "bad", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "http://insecure.com/oauth", // Insecure HTTP + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("bad") + advanceUntilIdle() + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("Invalid or unsafe authorization URL received.", state.errorMessage) + } + + @Test + fun testResume_whileAwaitingAuthorization_triggersRefreshOnce() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.takeBrowserEvent()) + + // Simulate catalog returning connected = true on next fetch + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = true), + ) + + // Simulate user backgrounding app to browser + delegate.onPause() + // Returning from browser + delegate.onResume() + advanceUntilIdle() + + // List refresh confirmed it is now connected! Action state resets to Idle + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConnectActive_immediatelyRefreshesList() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("local"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "local", + status = ConnectorConnectStatus.ACTIVE, + ), + ), + summary = ConnectorConnectSummary(total = 1, active = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Update catalog to show connected = true after active connect + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = true), + ) + + delegate.connect("local") + advanceUntilIdle() + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testFailures_retainCurrentItems_andCanRetry() = + testScope.runTest { + val initialItems = + listOf( + ConnectorItem( + connector = "existing", + name = "Existing Connector", + connected = false, + ), + ) + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success(available = true, connectors = initialItems) andThen + ConnectorListResult.Error(ConnectorError.NetworkError()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertEquals(1, delegate.uiState.value.items.size) + + // Second refresh fails with network error + delegate.refresh(force = true) + advanceUntilIdle() + + val state = delegate.uiState.value + // Existing items are preserved + assertEquals(1, state.items.size) + assertEquals("existing", state.items[0].slug) + assertTrue(state.loadPhase is ConnectorsLoadPhase.Error) + val errorPhase = state.loadPhase as ConnectorsLoadPhase.Error + assertTrue(errorPhase.canRetry) + assertTrue(errorPhase.error is ConnectorError.NetworkError) + } + + @Test + fun testOldBackend_setsFlag_andPreventsAutoRetries() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.UnsupportedBackend()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.isOldBackend) + assertTrue(state.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) + + // Auto-retries (like onResume or onTransportReconnected) must NOT issue RPC + delegate.onPause() + delegate.onResume() + delegate.onTransportReconnected() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testUnavailableAndUnsupportedRuntime_distinctPhases() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.Unavailable("Unavailable for this session.")) andThen + ConnectorListResult.Error(ConnectorError.UnsupportedRuntime("Must run on host.")) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Unavailable) + assertEquals( + "Unavailable for this session.", + (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.Unavailable).message, + ) + + delegate.refresh(force = true) + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedRuntime) + assertEquals( + "Must run on host.", + (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.UnsupportedRuntime).message, + ) + } + + @Test + fun testNotOwner_explainsReconnectSession() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.NotOwner) + val msg = (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.NotOwner).message + assertTrue(msg.contains("Please reconnect the session")) + } + + @Test + fun testRepeatTaps_connectDeduplication() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + val deferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Second tap during Connecting should be ignored + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + coVerify(exactly = 1) { mockRepository.connect("session-A", listOf("github"), false) } + + deferred.complete( + ConnectorConnectResult.Success( + results = listOf(ConnectorConnectItem("github", ConnectorConnectStatus.FAILED)), + summary = ConnectorConnectSummary(1, failed = 1), + ), + ) + advanceUntilIdle() + } + + @Test + fun testRefreshDuringMutation_isDeferredUntilMutationCompletes() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers + { connectDeferred.await() } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // 1 call from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Call refresh while connecting + delegate.refresh() + testDispatcher.scheduler.runCurrent() + + // Refresh should not have run yet while mutation is active (still exactly 1) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Finish connect + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ), + ) + advanceUntilIdle() + + // Deferred refresh should have executed! (now 2) + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testLaunchError_resetsActionStateAndSetsError() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + delegate.launchError("No browser app found to handle OAuth.") + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("No browser app found to handle OAuth.", state.errorMessage) + } + + @Test + fun testOnPause_clearsPendingBrowserEvent() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testConnect_notOwner_neverReplaysMutationIntoSessionB() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Connect is invoked for session-A + delegate.connect("github") + // Active session shifts to session-B + currentSessionId = "session-B" + advanceUntilIdle() + + // Critical regression check: MUST NEVER execute connect mutation on session-B! + coVerify(exactly = 0) { mockRepository.connect("session-B", any(), any()) } + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + } + + @Test + fun testConnect_notOwner_setsNotOwnerPhaseAndErrorWithoutMutationRetry() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.connect("session-A", listOf("github"), false) } + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertTrue(state.loadPhase is ConnectorsLoadPhase.NotOwner) + assertTrue(state.errorMessage?.contains("reconnect the session") == true) + } + + @Test + fun testConnect_fallbackFirstUnrelatedConnectorRemoved_requiresExactSlugMatch() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + // Backend returns unrelated connector "other" instead of "slack" + coEvery { mockRepository.connect("session-A", listOf("slack"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "other", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://other.com/oauth", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("slack") + advanceUntilIdle() + + val state = delegate.uiState.value + // Fallback must NOT trigger authorization for "other" + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("Connector 'slack' not found in response.", state.errorMessage) + } + + @Test + fun testConnect_guardsDisabledAndNonCatalogSlug() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "enabled-item", name = "Enabled", enabled = true), + ConnectorItem(connector = "disabled-item", name = "Disabled", enabled = false), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Slug not in catalog + delegate.connect("unknown-item") + advanceUntilIdle() + assertEquals("Connector 'unknown-item' not found in catalog.", delegate.uiState.value.errorMessage) + coVerify(exactly = 0) { mockRepository.connect(any(), listOf("unknown-item"), any()) } + + // Slug disabled in catalog + delegate.connect("disabled-item") + advanceUntilIdle() + assertEquals("Connector 'disabled-item' is disabled.", delegate.uiState.value.errorMessage) + coVerify(exactly = 0) { mockRepository.connect(any(), listOf("disabled-item"), any()) } + } + + @Test + fun testConnect_guardsBackgroundAndOldBackend() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Background guard + delegate.onPause() + delegate.connect("github") + advanceUntilIdle() + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + + // UnsupportedBackend guard + delegate.onResume() + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.UnsupportedBackend("Old server")) + delegate.refresh(force = true) + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + assertEquals("Connectors are not supported by this backend.", delegate.uiState.value.errorMessage) + } + + @Test + fun testRefresh_coalescesAndDeduplicatesForceCalls() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + // Rapid repeat calls to refresh(force = true) while in flight + delegate.refresh(force = true) + delegate.refresh(force = true) + testDispatcher.scheduler.runCurrent() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + deferred.complete( + ConnectorListResult.Success(available = true, connectors = emptyList()), + ) + advanceUntilIdle() + } + + @Test + fun testOnResume_duplicateCallsDoNotRepeatForcedRefresh() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // 1 refresh from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Take browser event to simulate launching external browser + assertNotNull(delegate.takeBrowserEvent()) + + // App goes to background (user in browser) + delegate.onPause() + // App returns: true paused -> resume transition triggers refresh once + delegate.onResume() + advanceUntilIdle() + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + + // Duplicate onResume while already foreground MUST NOT fire forced refresh again + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConsumeBrowserEvent_validatesIdentitySessionGenerationAndForeground() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + val event = delegate.uiState.value.browserLaunchEvent + assertNotNull(event) + val eventId = event!!.eventId + + // 1. Wrong event ID returns null + assertNull(delegate.consumeBrowserEvent(999999L)) + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // 2. Consume while backgrounded rejects handoff and clears stale event + delegate.onPause() + assertNull(delegate.consumeBrowserEvent(eventId)) + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testCatalogRefresh_removedConnectorEndsAuthorization() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.takeBrowserEvent()) + + // Refresh returns catalog where "github" was completely removed + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + delegate.onPause() + delegate.onResume() + advanceUntilIdle() + + // Authorization should end cleanly, avoiding infinite spinner + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + } + + @Test + fun testRefreshSuccess_clearsPreviousErrorMessage() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.NetworkError("Network lost")) andThen + ConnectorListResult.Success(available = true, connectors = emptyList()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.launchError("Temporary failure") + assertEquals("Temporary failure", delegate.uiState.value.errorMessage) + + delegate.refresh(force = true) + advanceUntilIdle() + + assertNull(delegate.uiState.value.errorMessage) + } + + @Test + fun testAvailableFalse_mapsToUnavailablePhaseAndClearsItems() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success( + available = false, + connectors = listOf(ConnectorItem("github", "GitHub")), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.loadPhase is ConnectorsLoadPhase.Unavailable) + assertTrue(state.items.isEmpty()) + } + + @Test + fun testHide_incrementsGeneration_andIgnoresLateResponses() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + delegate.hide() + + deferred.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem("github", "GitHub")), + ), + ) + advanceUntilIdle() + + // State must remain hidden with initial/empty items + assertFalse(delegate.uiState.value.isVisible) + assertTrue( + delegate.uiState.value.items + .isEmpty(), + ) + } + + @Test + fun testReadCompletesPaused_deliversCatalog_noPermanentSpinnerOnResume() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Loading) + assertTrue(delegate.uiState.value.isLoading) + + // Background while read is in-flight + delegate.onPause() + + // Read completes while app is paused + deferred.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "github", name = "GitHub", enabled = true)), + ), + ) + advanceUntilIdle() + + // State delivered while paused; not stuck in permanent spinner! + val pausedState = delegate.uiState.value + assertTrue(pausedState.loadPhase is ConnectorsLoadPhase.Loaded) + assertFalse(pausedState.isLoading) + assertEquals(1, pausedState.items.size) + assertEquals("github", pausedState.items[0].slug) + + // Returning to foreground retains loaded catalog without infinite reload spinner + delegate.onResume() + advanceUntilIdle() + + val resumedState = delegate.uiState.value + assertTrue(resumedState.loadPhase is ConnectorsLoadPhase.Loaded) + assertFalse(resumedState.isLoading) + assertEquals(1, resumedState.items.size) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConnectActive_completedWhilePaused_defersRefreshUntilResume() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = false), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("local"), false) } coAnswers { + connectDeferred.await() + } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("local") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // App is paused while connect mutation is pending + delegate.onPause() + + // Mutation completes with ACTIVE while app is in background + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "local", + status = ConnectorConnectStatus.ACTIVE, + ), + ), + summary = ConnectorConnectSummary(total = 1, active = 1), + ), + ) + advanceUntilIdle() + + // Critical: NO background refresh RPC issued while paused! + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + + // Update mock catalog so refresh returns connected = true + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = true), + ) + + // Resume triggers the deferred refresh + delegate.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + val state = delegate.uiState.value + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + assertEquals(ConnectorActionState.Idle, state.actionState) + } + + @Test + fun testMintedUrl_unconsumedOnPause_retainedTransiently_onlyActualBrowserReturnTriggersVerification() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // 1 call from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + advanceUntilIdle() + + val mintedEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(mintedEvent) + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + + // App paused BEFORE event is consumed/launched (e.g. system dialog or user home tap) + delegate.onPause() + + // In background: transient event is hidden from UI state + assertNull(delegate.uiState.value.browserLaunchEvent) + + // App resumed WITHOUT having launched the browser + delegate.onResume() + advanceUntilIdle() + + // Critical: MUST NOT trigger verification refresh before launch! + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Minted unconsumed event is restored on resume + val restoredEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(restoredEvent) + assertEquals(mintedEvent!!.eventId, restoredEvent!!.eventId) + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + + // Now UI actually consumes event to launch browser + val taken = delegate.takeBrowserEvent() + assertNotNull(taken) + assertEquals(mintedEvent.eventId, taken!!.eventId) + assertNull(delegate.uiState.value.browserLaunchEvent) + + // Taking again returns null (taken only once) + assertNull(delegate.takeBrowserEvent()) + + // Simulate catalog returning connected = true on next fetch + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = true), + ) + + // App paused as user transitions to browser + delegate.onPause() + + // App resumed upon returning from external browser authorization + delegate.onResume() + advanceUntilIdle() + + // Verification refresh IS triggered upon actual browser return! + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + val finalState = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, finalState.actionState) + assertTrue(finalState.items[0].isConnected) + } + + @Test + fun testForcedRefresh_coalescesToOneTrailingRead_notCancelRestartStorm() = + testScope.runTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + var listCallCount = 0 + + coEvery { mockRepository.listConnectors("session-A") } coAnswers { + listCallCount++ + if (listCallCount == 1) { + deferred1.await() + } else { + deferred2.await() + } + } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertEquals(1, listCallCount) + + // Rapid storm of forced refresh calls while read is in-flight + delegate.refresh(force = true) + delegate.refresh(force = true) + delegate.refresh(force = true) + testDispatcher.scheduler.runCurrent() + + // In-flight read was NOT cancelled or multiplied + assertEquals(1, listCallCount) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Complete first read + deferred1.complete( + ConnectorListResult.Success(available = true, connectors = emptyList()), + ) + testDispatcher.scheduler.runCurrent() + + // Exactly ONE trailing read was started, not 3! + assertEquals(2, listCallCount) + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + + // Complete second (trailing) read + deferred2.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "done", name = "Done")), + ), + ) + advanceUntilIdle() + + // No additional reads + assertEquals(2, listCallCount) + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "done", + delegate.uiState.value.items[0] + .slug, + ) + } + + @Test + fun testActivePendingMutation_sessionChanged_doesNotReplayIntoSessionB() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers { + connectDeferred.await() + } + coEvery { mockRepository.listConnectors("session-B") } returns + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "slack", name = "Slack", enabled = true)), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Start connect mutation on session-A + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Switch active session to session-B while mutation is still pending + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + testDispatcher.scheduler.runCurrent() + + // Now pending connect mutation for session-A finishes + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ), + ) + advanceUntilIdle() + + // Critical: MUST NEVER replay mutation into session-B! + coVerify(exactly = 0) { mockRepository.connect("session-B", any(), any()) } + + // State reflects session-B, session-A's pending mutation is completely discarded + val state = delegate.uiState.value + assertEquals("session-B", state.sessionId) + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals(1, state.items.size) + assertEquals("slack", state.items[0].slug) + } + + @Test + fun testPendingBrowserEvent_publishedResumedTakenOnlyOnce_sessionSwitchDiscards() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // 1. Published into UI state + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // 2. Background app: unconsumed event retained transiently + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + + // 3. Switch active session while backgrounded + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + + // 4. Resume in new session: old session's pending event MUST be discarded + delegate.onResume() + advanceUntilIdle() + + assertNull(delegate.uiState.value.browserLaunchEvent) + assertNull(delegate.takeBrowserEvent()) + + // Switch back to session-A + currentSessionId = "session-A" + delegate.onActiveSessionChanged("session-A") + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // Published again in session-A + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // Background & Resume + delegate.onPause() + delegate.onResume() + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // Taken only once + val taken = delegate.takeBrowserEvent() + assertNotNull(taken) + assertNull(delegate.uiState.value.browserLaunchEvent) + assertNull(delegate.takeBrowserEvent()) + + // Subsequent onResume does not re-publish already-taken event + delegate.onPause() + delegate.onResume() + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testLifecycle_repeatedCallsDoNotDuplicateEventsOrRefreshes() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + advanceUntilIdle() + + val originalEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(originalEvent) + + // Repeated onPause calls do not corrupt or lose the event + delegate.onPause() + delegate.onPause() + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + + // Repeated onResume calls do not duplicate event or trigger repeated refresh + delegate.onResume() + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + + assertEquals( + originalEvent!!.eventId, + delegate.uiState.value.browserLaunchEvent + ?.eventId, + ) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Take the event + assertNotNull(delegate.takeBrowserEvent()) + + // Repeated pause/resume cycle after launch triggers verification refresh exactly once + delegate.onPause() + delegate.onResume() + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt new file mode 100644 index 00000000..048ffbc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt @@ -0,0 +1,146 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectorsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockDelegate = mockk(relaxed = true) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + ActiveSessionHolder.clear() + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED) + } + + @After + fun tearDown() { + ActiveSessionHolder.clear() + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED) + AuthManager.resetAuthStateForTest() + Dispatchers.resetMain() + } + + @Test + fun testInitialSync_triggersOnActiveSessionChangedWithInitialSession() { + ActiveSessionHolder.set("sess-init") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onActiveSessionChanged("sess-init") } + } + + @Test + fun testSessionChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + ActiveSessionHolder.set("sess-2") + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onActiveSessionChanged("sess-2") } + } + + @Test + fun testProfileChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + AuthManager.setActiveProfileId("profile-new") + testDispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") } + } + + @Test + fun testServerChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + AuthManager.setBaseUrlForTest("http://192.168.1.199:8080") + testDispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") } + } + + @Test + fun testReconnect_triggersOnTransportReconnected() { + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onTransportReconnected() } + } + + @Test + fun testForwardsUiMethodsToDelegate() { + val uiStateFlow = MutableStateFlow(ChatConnectorsUiState(isVisible = true)) + val delegate = + mockk(relaxed = true) { + io.mockk.every { uiState } returns uiStateFlow + io.mockk.every { takeBrowserEvent(any()) } returns + ConnectBrowserEvent( + slug = "slug", + url = "https://auth.example.com", + ) + } + + val viewModel = ChatConnectorsViewModel(delegate = delegate) + + assertEquals(true, viewModel.uiState.value.isVisible) + + viewModel.show() + verify { delegate.show() } + + viewModel.hide() + verify { delegate.hide() } + + viewModel.onResume() + verify { delegate.onResume() } + + viewModel.onPause() + verify { delegate.onPause() } + + viewModel.refresh(force = true) + verify { delegate.refresh(force = true) } + + viewModel.connect("github", reconnect = true) + verify { delegate.connect("github", reconnect = true) } + + val event = viewModel.takeBrowserEvent(123L) + assertEquals("slug", event?.slug) + verify { delegate.takeBrowserEvent(123L) } + + viewModel.launchError("test error") + verify { delegate.launchError("test error") } + + viewModel.clearError() + verify { delegate.clearError() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt b/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt new file mode 100644 index 00000000..851e03b5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt @@ -0,0 +1,68 @@ +package com.m57.hermescontrol.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorUrlValidatorTest { + @Test + fun `valid https urls are accepted`() { + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://auth.hermes.nousresearch.com/oauth/google")) + assertTrue( + ConnectorUrlValidator.isValidHttpsUrl("https://github.com/login/oauth/authorize?client_id=123&scope=repo"), + ) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://linear.app/oauth/authorize?response_type=code")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://slack.com/oauth/v2/authorize")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:8443/auth?foo=bar#section")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("HTTPS://EXAMPLE.COM/AUTH")) + } + + @Test + fun `null, empty, and whitespace strings are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl(null)) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl(" ")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("\t\n")) + } + + @Test + fun `non-https schemes are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("http://example.com/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("ftp://example.com/file")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("javascript:alert(1)")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("data:text/html,")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("file:///etc/passwd")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("intent://example.com")) + } + + @Test + fun `urls with userinfo are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user:password@example.com/oauth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user@example.com/oauth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://admin:@example.com/oauth")) + } + + @Test + fun `urls with backslashes are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com\\evil.com")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/path\\something")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:\\\\example.com")) + } + + @Test + fun `urls with control characters or whitespace are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\u0000")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\r\nSet-Cookie:bad")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth test")) + } + + @Test + fun `urls with invalid host or port are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:///path")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:0/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:65536/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:99999/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://invalid_host_chars!/auth")) + } +}