diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 1e00957e..b6f47a46 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -81,6 +81,26 @@ data class PluginsHubResponse( val providers: PluginsHubProviders? = null, ) +@Serializable +data class PluginUpdateRequest( + @SerialName("accept_capabilities") val acceptCapabilities: Boolean, +) + +@Serializable +data class PluginUpdateResult( + val ok: Boolean = false, + val unchanged: Boolean = false, + @SerialName("consent_required") val consentRequired: Boolean = false, + val delta: Map>? = null, + @SerialName("delta_lines") val deltaLines: List? = null, + val error: String? = null, +) { + val capabilityDelta: List + get() = + deltaLines?.takeIf { it.isNotEmpty() } + ?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" } +} + @Serializable data class AgentPluginInstallBody( val identifier: String = "", diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 55e6cddf..c51156a9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -670,7 +670,8 @@ interface HermesApiService : KanbanApiService { @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name", encoded = true) name: String, - ): Response + @Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest, + ): Response @POST("api/dashboard/agent-plugins/{name}/enable") suspend fun enablePlugin( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 5cbd407a..d691fc92 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -13,6 +13,8 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.InstallDesktop @@ -120,6 +122,37 @@ fun PluginsScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + state.updateConsent?.let { consent -> + AlertDialog( + onDismissRequest = viewModel::cancelPluginUpdate, + title = { Text(stringResource(R.string.plugins_update_consent_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.plugins_update_consent_text, consent.name)) + val lines = consent.result.capabilityDelta + if (lines.isEmpty()) { + Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing)) + } else { + lines.forEach { Text(it) } + } + } + }, + confirmButton = { + TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) { + Text(stringResource(R.string.plugins_update_accept)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelPluginUpdate) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + // Remove confirmation dialog if (state.removeConfirmPlugin != null) { AlertDialog( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 10d50bde..a05daba1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -9,6 +9,8 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient @@ -33,6 +35,12 @@ enum class PluginsTab { CATALOG, } +data class PluginUpdateConsent( + val name: String, + val result: PluginUpdateResult, + val scope: DataScope?, +) + data class PluginsUiState( val selectedTab: PluginsTab = PluginsTab.INSTALLED, val isLoading: Boolean = false, @@ -55,6 +63,7 @@ data class PluginsUiState( val rowBusy: String? = null, // Confirm remove dialog val removeConfirmPlugin: String? = null, + val updateConsent: PluginUpdateConsent? = null, // Rescan val rescanBusy: Boolean = false, // Catalog state @@ -99,6 +108,7 @@ class PluginsViewModel( providerBusy = false, rowBusy = null, removeConfirmPlugin = null, + updateConsent = null, rescanBusy = false, ) } @@ -450,23 +460,83 @@ class PluginsViewModel( } fun updatePlugin(name: String) { - viewModelScope.launch { - setRowBusy(name) - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updatePlugin(name) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } - clearRowBusy(name) - loadPlugins(forceRefresh = true) - } + if (_uiState.value.updateConsent != null) return + performPluginUpdate(name, acceptCapabilities = false) + } - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } - clearRowBusy(name) + fun cancelPluginUpdate() { + _uiState.update { it.copy(updateConsent = null) } + } + + fun confirmPluginUpdate() { + val consent = _uiState.value.updateConsent ?: return + if (_uiState.value.rowBusy != null) return + cancelPluginUpdate() + if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return + performPluginUpdate(consent.name, acceptCapabilities = true) + } + + private fun performPluginUpdate( + name: String, + acceptCapabilities: Boolean, + ) { + if (_uiState.value.rowBusy != null) return + val generation = scopeGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + val api = ApiClient.hermesApi + setRowBusy(name) + _uiState.update { it.copy(toastMessage = null) } + viewModelScope.launch { + try { + val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) } + if (generation != scopeGeneration || + requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull() + ) { + return@launch } + when (result) { + is NetworkResult.Success -> { + val update = result.data + // #1282: HTTP success is not update success; consent must be explicit. + when { + update.consentRequired -> { + _uiState.update { + it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope)) + } + } + + !update.ok -> { + _uiState.update { + it.copy( + toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}", + ) + } + } + + else -> { + _uiState.update { + it.copy( + toastMessage = + if (update.unchanged) { + "Plugin is already up to date" + } else { + "Plugin updated successfully" + }, + ) + } + loadPlugins(forceRefresh = true) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") + } + } + } + } finally { + if (generation == scopeGeneration) clearRowBusy(name) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 968d3120..df298c62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1779,4 +1779,8 @@ Hide header values Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app. Could not save custom headers. Try again. + Review new capabilities + Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update? + The server did not provide capability details. + Accept and update diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt index 7c8c759f..92c981d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginCatalogResponse import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -72,6 +74,107 @@ class PluginsViewModelTest { unmockkAll() } + @Test + fun `update waits for consent and only accepts on explicit confirmation`() { + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } returns + Response.success(PluginUpdateResult(consentRequired = true, deltaLines = listOf("tools: scan"))) + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } returns + Response.success(PluginUpdateResult(ok = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("tools: scan"), + viewModel.uiState.value.updateConsent + ?.result + ?.capabilityDelta, + ) + assertNull(viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.updatePlugin(any(), PluginUpdateRequest(true)) } + coVerify(exactly = 0) { mockApi.getPlugins() } + viewModel.confirmPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertEquals("Plugin updated successfully", viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } + coVerify(exactly = 1) { mockApi.getPlugins() } + } + + @Test + fun `cancel consent never retries the update`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(consentRequired = true)) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + viewModel.cancelPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `ok false is a failure even with HTTP 200 and unchanged true`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(unchanged = true, error = "Update refused")) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Failed to update plugin: Update refused", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + + @Test + fun `unchanged success reports already up to date`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(ok = true, unchanged = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Plugin is already up to date", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + } + + @Test + fun `scope switch discards late update consent`() { + val reply = CompletableDeferred>() + coEvery { mockApi.updatePlugin(any(), any()) } coAnswers { reply.await() } + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.runCurrent() + viewModel.clearScopeOwnedState() + reply.complete(Response.success(PluginUpdateResult(consentRequired = true))) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.confirmPluginUpdate() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.rowBusy) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `HTTP update failure releases busy state`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns Response.error(503, "offline".toResponseBody()) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("Failed to update"), + ) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + @Test fun `switch tab to CATALOG automatically triggers catalog load`() { coEvery { mockApi.getPluginCatalog() } returns