@@ -81,6 +81,26 @@ data class PluginsHubResponse(
|
|||||||
val providers: PluginsHubProviders? = null,
|
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<String, List<String>>? = null,
|
||||||
|
@SerialName("delta_lines") val deltaLines: List<String>? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val capabilityDelta: List<String>
|
||||||
|
get() =
|
||||||
|
deltaLines?.takeIf { it.isNotEmpty() }
|
||||||
|
?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" }
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AgentPluginInstallBody(
|
data class AgentPluginInstallBody(
|
||||||
val identifier: String = "",
|
val identifier: String = "",
|
||||||
|
|||||||
@@ -670,7 +670,8 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@POST("api/dashboard/agent-plugins/{name}/update")
|
@POST("api/dashboard/agent-plugins/{name}/update")
|
||||||
suspend fun updatePlugin(
|
suspend fun updatePlugin(
|
||||||
@Path("name", encoded = true) name: String,
|
@Path("name", encoded = true) name: String,
|
||||||
): Response<Unit>
|
@Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest,
|
||||||
|
): Response<com.m57.hermescontrol.data.model.PluginUpdateResult>
|
||||||
|
|
||||||
@POST("api/dashboard/agent-plugins/{name}/enable")
|
@POST("api/dashboard/agent-plugins/{name}/enable")
|
||||||
suspend fun enablePlugin(
|
suspend fun enablePlugin(
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.InstallDesktop
|
import androidx.compose.material.icons.filled.InstallDesktop
|
||||||
@@ -120,6 +122,37 @@ fun PluginsScreen(
|
|||||||
|
|
||||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
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
|
// Remove confirmation dialog
|
||||||
if (state.removeConfirmPlugin != null) {
|
if (state.removeConfirmPlugin != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
|||||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||||
import com.m57.hermescontrol.data.model.PluginInfo
|
import com.m57.hermescontrol.data.model.PluginInfo
|
||||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
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.PluginsHubResponse
|
||||||
import com.m57.hermescontrol.data.model.ProviderOption
|
import com.m57.hermescontrol.data.model.ProviderOption
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -33,6 +35,12 @@ enum class PluginsTab {
|
|||||||
CATALOG,
|
CATALOG,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PluginUpdateConsent(
|
||||||
|
val name: String,
|
||||||
|
val result: PluginUpdateResult,
|
||||||
|
val scope: DataScope?,
|
||||||
|
)
|
||||||
|
|
||||||
data class PluginsUiState(
|
data class PluginsUiState(
|
||||||
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -55,6 +63,7 @@ data class PluginsUiState(
|
|||||||
val rowBusy: String? = null,
|
val rowBusy: String? = null,
|
||||||
// Confirm remove dialog
|
// Confirm remove dialog
|
||||||
val removeConfirmPlugin: String? = null,
|
val removeConfirmPlugin: String? = null,
|
||||||
|
val updateConsent: PluginUpdateConsent? = null,
|
||||||
// Rescan
|
// Rescan
|
||||||
val rescanBusy: Boolean = false,
|
val rescanBusy: Boolean = false,
|
||||||
// Catalog state
|
// Catalog state
|
||||||
@@ -99,6 +108,7 @@ class PluginsViewModel(
|
|||||||
providerBusy = false,
|
providerBusy = false,
|
||||||
rowBusy = null,
|
rowBusy = null,
|
||||||
removeConfirmPlugin = null,
|
removeConfirmPlugin = null,
|
||||||
|
updateConsent = null,
|
||||||
rescanBusy = false,
|
rescanBusy = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -450,25 +460,85 @@ class PluginsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updatePlugin(name: String) {
|
fun updatePlugin(name: String) {
|
||||||
viewModelScope.launch {
|
if (_uiState.value.updateConsent != null) return
|
||||||
|
performPluginUpdate(name, acceptCapabilities = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
setRowBusy(name)
|
||||||
val result =
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
withContext(Dispatchers.IO) {
|
viewModelScope.launch {
|
||||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
try {
|
||||||
|
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
|
||||||
|
if (generation != scopeGeneration ||
|
||||||
|
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||||
|
) {
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
val update = result.data
|
||||||
clearRowBusy(name)
|
// #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)
|
loadPlugins(forceRefresh = true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
_uiState.update {
|
||||||
clearRowBusy(name)
|
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation == scopeGeneration) clearRowBusy(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePluginVisibility(plugin: PluginInfo) {
|
fun togglePluginVisibility(plugin: PluginInfo) {
|
||||||
|
|||||||
@@ -1779,4 +1779,8 @@
|
|||||||
<string name="custom_headers_hide">Hide header values</string>
|
<string name="custom_headers_hide">Hide header values</string>
|
||||||
<string name="custom_headers_invalid">Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app.</string>
|
<string name="custom_headers_invalid">Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app.</string>
|
||||||
<string name="custom_headers_save_failed">Could not save custom headers. Try again.</string>
|
<string name="custom_headers_save_failed">Could not save custom headers. Try again.</string>
|
||||||
|
<string name="plugins_update_consent_title">Review new capabilities</string>
|
||||||
|
<string name="plugins_update_consent_text">Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update?</string>
|
||||||
|
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
|
||||||
|
<string name="plugins_update_accept">Accept and update</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
|
|||||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||||
import com.m57.hermescontrol.data.model.PluginCatalogResponse
|
import com.m57.hermescontrol.data.model.PluginCatalogResponse
|
||||||
import com.m57.hermescontrol.data.model.PluginInfo
|
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.model.PluginsHubResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
@@ -72,6 +74,107 @@ class PluginsViewModelTest {
|
|||||||
unmockkAll()
|
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<Response<PluginUpdateResult>>()
|
||||||
|
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
|
@Test
|
||||||
fun `switch tab to CATALOG automatically triggers catalog load`() {
|
fun `switch tab to CATALOG automatically triggers catalog load`() {
|
||||||
coEvery { mockApi.getPluginCatalog() } returns
|
coEvery { mockApi.getPluginCatalog() } returns
|
||||||
|
|||||||
Reference in New Issue
Block a user