feat(vault): credential vault status and mid-turn auth prompt handling (#1090) (#1097)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 00:20:07 +03:00
1 parent d774a63663
commit 69cd34d289
23 files changed
+2224 -53

No files matched your search

@@ -508,6 +508,151 @@ class EventParserTest {
assertEquals(false, approval.allowPermanent)
}
// ── Vault prompt events (issue #1090) ───────────────────────────────
@Test
fun testParseVaultUnlockRequest_andExpire() {
val reqResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.unlock.request",
"session_id" to "sess-123",
"payload" to
mapOf(
"request_id" to "req-unlock-1",
"backend" to "onepassword",
"display_name" to "1Password",
),
),
)
val reqEvent = EventParser.parse(reqResponse)
assertTrue(reqEvent is WsEvent.VaultUnlockRequest)
val unlock = reqEvent as WsEvent.VaultUnlockRequest
assertEquals("req-unlock-1", unlock.requestId)
assertEquals("sess-123", unlock.sessionId)
assertEquals("onepassword", unlock.backend)
assertEquals("1Password", unlock.displayName)
val expResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.unlock.expire",
"session_id" to "sess-123",
"payload" to mapOf("request_id" to "req-unlock-1"),
),
)
val expEvent = EventParser.parse(expResponse)
assertTrue(expEvent is WsEvent.VaultUnlockExpire)
assertEquals("req-unlock-1", (expEvent as WsEvent.VaultUnlockExpire).requestId)
}
@Test
fun testParseVaultSaveLoginRequest_andExpire() {
val reqResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.save_login.request",
"session_id" to "sess-123",
"payload" to
mapOf(
"request_id" to "req-save-1",
"origin" to "https://github.com",
"site" to "GitHub",
),
),
)
val reqEvent = EventParser.parse(reqResponse)
assertTrue(reqEvent is WsEvent.VaultSaveLoginRequest)
val save = reqEvent as WsEvent.VaultSaveLoginRequest
assertEquals("req-save-1", save.requestId)
assertEquals("https://github.com/login", "https://github.com/login")
assertEquals("https://github.com", save.origin)
assertEquals("GitHub", save.site)
val expResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.save_login.expire",
"session_id" to "sess-123",
"payload" to mapOf("request_id" to "req-save-1"),
),
)
val expEvent = EventParser.parse(expResponse)
assertTrue(expEvent is WsEvent.VaultSaveLoginExpire)
assertEquals("req-save-1", (expEvent as WsEvent.VaultSaveLoginExpire).requestId)
}
@Test
fun testParseVaultCodeRequest_andExpire() {
val reqResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.code.request",
"session_id" to "sess-123",
"payload" to
mapOf(
"request_id" to "req-code-1",
"site" to "GitHub",
"hint" to "SMS to +1234",
),
),
)
val reqEvent = EventParser.parse(reqResponse)
assertTrue(reqEvent is WsEvent.VaultCodeRequest)
val code = reqEvent as WsEvent.VaultCodeRequest
assertEquals("req-code-1", code.requestId)
assertEquals("GitHub", code.site)
assertEquals("SMS to +1234", code.hint)
val expResponse =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "vault.code.expire",
"session_id" to "sess-123",
"payload" to mapOf("request_id" to "req-code-1"),
),
)
val expEvent = EventParser.parse(expResponse)
assertTrue(expEvent is WsEvent.VaultCodeExpire)
assertEquals("req-code-1", (expEvent as WsEvent.VaultCodeExpire).requestId)
}
// ── TEST-07: Untested subtypes ─────────────────────────────────────
@Test
@@ -7,11 +7,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
@@ -19,69 +21,149 @@ class ChatCredentialPromptsDelegateTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1"))
private val sentMethods = mutableListOf<String>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val trackedRequests = mutableListOf<Pair<String, String>>()
private val delegate =
ChatCredentialPromptsDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
wsSend = { method, params, onSent ->
sentMethods.add(method)
sentParams.add(params)
onSent?.invoke("req-${sentMethods.size}")
},
trackRequest = { id, method -> trackedRequests.add(id to method) },
private lateinit var delegate: ChatCredentialPromptsDelegate
@Before
fun setUp() {
sentMethods.clear()
sentParams.clear()
uiState.value = ChatUiState(currentSessionId = "sess-1")
delegate =
ChatCredentialPromptsDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
wsSend = { method, params, _ ->
sentMethods.add(method)
sentParams.add(params)
},
trackRequest = { _, _ -> },
)
}
@Test
fun testVaultUnlock_request_and_respond() {
delegate.handleVaultUnlockRequest(
WsEvent.VaultUnlockRequest(
requestId = "req-1",
sessionId = "sess-1",
backend = "onepassword",
displayName = "1Password",
),
)
@Test
fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() =
testScope.runTest {
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1"))
assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId)
assertFalse(uiState.value.isAgentTyping)
assertNotNull(uiState.value.vaultUnlockPrompt)
assertEquals("req-1", uiState.value.vaultUnlockPrompt?.requestId)
assertEquals("1Password", uiState.value.vaultUnlockPrompt?.displayName)
delegate.respondToSudo("secret-pass")
assertNull(uiState.value.sudoPrompt)
delegate.respondToVaultUnlock("master-pass")
testDispatcher.scheduler.advanceUntilIdle()
advanceUntilIdle()
assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods)
assertEquals("secret-pass", sentParams.first()["password"])
assertEquals("r-sudo", sentParams.first()["request_id"])
assertEquals("s-1", sentParams.first()["session_id"])
}
assertNull(uiState.value.vaultUnlockPrompt)
assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods)
assertEquals("master-pass", sentParams.first()["password"])
assertEquals("req-1", sentParams.first()["request_id"])
}
@Test
fun handleSecretRequest_andDismiss_sendsEmptyValue() =
testScope.runTest {
delegate.handleSecretRequest(
WsEvent.SecretRequest(
requestId = "r-sec",
sessionId = "s-1",
envVar = "OPENAI_API_KEY",
prompt = "Enter key",
),
)
assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar)
fun testVaultUnlock_dismiss_sendsEmptyPassword() {
delegate.handleVaultUnlockRequest(
WsEvent.VaultUnlockRequest(
requestId = "req-1",
sessionId = "sess-1",
),
)
delegate.dismissSecret()
assertNull(uiState.value.secretPrompt)
delegate.dismissVaultUnlock()
testDispatcher.scheduler.advanceUntilIdle()
advanceUntilIdle()
assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods)
assertEquals("", sentParams.first()["value"])
assertEquals("r-sec", sentParams.first()["request_id"])
}
assertNull(uiState.value.vaultUnlockPrompt)
assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods)
assertEquals("", sentParams.first()["password"])
}
@Test
fun expireHandlers_ignoreMismatchedRequestId() {
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1"))
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1"))
assertNotNull(uiState.value.sudoPrompt)
fun testVaultSaveLogin_request_respond_and_expire() {
delegate.handleVaultSaveLoginRequest(
WsEvent.VaultSaveLoginRequest(
requestId = "req-save",
sessionId = "sess-1",
origin = "https://github.com",
site = "GitHub",
),
)
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1"))
assertNull(uiState.value.sudoPrompt)
assertNotNull(uiState.value.vaultSaveLoginPrompt)
assertEquals("GitHub", uiState.value.vaultSaveLoginPrompt?.site)
delegate.respondToVaultSaveLogin("user@example.com", "secret123")
testDispatcher.scheduler.advanceUntilIdle()
assertNull(uiState.value.vaultSaveLoginPrompt)
assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods)
val loginJsonStr = sentParams.first()["login"] as String
val json = Json.parseToJsonElement(loginJsonStr).jsonObject
assertEquals("user@example.com", json["identifier"]?.jsonPrimitive?.content)
assertEquals("secret123", json["password"]?.jsonPrimitive?.content)
}
@Test
fun testVaultSaveLogin_dismiss_sendsEmptyLogin() {
delegate.handleVaultSaveLoginRequest(
WsEvent.VaultSaveLoginRequest(
requestId = "req-save",
sessionId = "sess-1",
),
)
delegate.dismissVaultSaveLogin()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(uiState.value.vaultSaveLoginPrompt)
assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods)
assertEquals("", sentParams.first()["login"])
}
@Test
fun testVaultCode_request_respond_and_dismiss() {
delegate.handleVaultCodeRequest(
WsEvent.VaultCodeRequest(
requestId = "req-code",
sessionId = "sess-1",
site = "GitHub",
hint = "SMS to phone",
),
)
assertNotNull(uiState.value.vaultCodePrompt)
assertEquals("GitHub", uiState.value.vaultCodePrompt?.site)
delegate.respondToVaultCode("654321")
testDispatcher.scheduler.advanceUntilIdle()
assertNull(uiState.value.vaultCodePrompt)
assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods)
assertEquals("654321", sentParams.first()["code"])
}
@Test
fun testVaultCode_dismiss_sendsEmptyCode() {
delegate.handleVaultCodeRequest(
WsEvent.VaultCodeRequest(
requestId = "req-code",
sessionId = "sess-1",
),
)
delegate.dismissVaultCode()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(uiState.value.vaultCodePrompt)
assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods)
assertEquals("", sentParams.first()["code"])
}
}
@@ -0,0 +1,143 @@
package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.model.VaultItem
import com.m57.hermescontrol.data.model.VaultSource
import com.m57.hermescontrol.data.ws.VaultRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsVaultViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockRepo: VaultRepository
private val sampleSources =
listOf(
VaultSource(
name = "local",
displayName = "Hermes vault",
enabled = true,
unlocked = true,
installed = true,
),
VaultSource(
name = "onepassword",
displayName = "1Password",
enabled = true,
needsUnlock = true,
unlocked = false,
installed = true,
),
)
private val sampleItems =
listOf(
VaultItem(id = "item-1", backend = "local", label = "GitHub", identifier = "user@example.com"),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockRepo = mockk()
coEvery { mockRepo.getSources() } returns sampleSources
coEvery { mockRepo.listItems() } returns sampleItems
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `loadVaultData loads sources and items successfully`() {
val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(2, viewModel.uiState.value.sources.size)
assertEquals(1, viewModel.uiState.value.items.size)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun `toggleSource updates enabled state and reloads sources`() {
coEvery { mockRepo.setSourceEnabled("onepassword", false) } returns false
coEvery { mockRepo.getSources() } returns
listOf(
sampleSources[0],
sampleSources[1].copy(enabled = false),
)
val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.toggleSource(sampleSources[1], false)
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockRepo.setSourceEnabled("onepassword", false) }
assertFalse(
viewModel.uiState.value.sources[1]
.enabled,
)
}
@Test
fun `unlockSource calls repo and updates state`() {
coEvery { mockRepo.unlockSource("onepassword", "mypassword") } returns true
coEvery { mockRepo.getSources() } returns
listOf(
sampleSources[0],
sampleSources[1].copy(unlocked = true),
)
val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.showUnlockDialog(sampleSources[1])
assertEquals(
"onepassword",
viewModel.uiState.value.unlockDialogSource
?.name,
)
viewModel.unlockSource("onepassword", "mypassword")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockRepo.unlockSource("onepassword", "mypassword") }
assertNull(viewModel.uiState.value.unlockDialogSource)
assertTrue(
viewModel.uiState.value.sources[1]
.unlocked,
)
assertEquals("Unlocked onepassword", viewModel.uiState.value.toastMessage)
}
@Test
fun `lockSource locks all and notifies`() {
coEvery { mockRepo.lockSource(null) } returns true
coEvery { mockRepo.getSources() } returns sampleSources
val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.lockSource(null)
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockRepo.lockSource(null) }
assertEquals("All password managers locked", viewModel.uiState.value.toastMessage)
}
}