fix(#1282): require capability consent for plugin updates (#1295)

This commit is contained in:
m57 authored and GitHub committed 2026-09-26 21:03:53 +03:00
1 parent 89858fc35d
commit ee487e0087
6 files changed
+247 -16

No files matched your search

@@ -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<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
fun `switch tab to CATALOG automatically triggers catalog load`() {
coEvery { mockApi.getPluginCatalog() } returns