fix(security): credential wipe on endpoint change, PairingProbeState gating, and parser hardening

This commit is contained in:
Ochenstarik 2026-08-24 14:38:49 +07:00
parent c27d902d3c
commit 7c90da222c
8 changed files with 305 additions and 122 deletions

View file

@ -11,6 +11,10 @@ object HermesPairingParser {
fun parse(rawUri: String): PairingValidationResult {
try {
if (rawUri.toByteArray(Charsets.UTF_8).size > 4096) {
return PairingValidationResult.InvalidPayload("URI exceeds maximum length of 4096 bytes")
}
val uri = URI(rawUri)
if (uri.scheme != "hermes" || uri.host != "pair") {
return PairingValidationResult.InvalidPayload("Invalid scheme or host")
@ -29,6 +33,10 @@ object HermesPairingParser {
return PairingValidationResult.InvalidPayload("Malformed Base64")
}
if (decodedBytes.size > 2048) {
return PairingValidationResult.InvalidPayload("Decoded payload exceeds 2048 bytes")
}
val jsonString = String(decodedBytes, Charsets.UTF_8)
val payload = try {
json.decodeFromString<HermesPairingPayload>(jsonString)
@ -43,13 +51,32 @@ object HermesPairingParser {
return PairingValidationResult.InvalidPayload("Invalid type")
}
try {
UUID.fromString(payload.hostId)
val uuid = UUID.fromString(payload.hostId)
if (uuid.toString() != payload.hostId.lowercase()) {
return PairingValidationResult.InvalidPayload("host_id must be a valid UUID string")
}
} catch (e: IllegalArgumentException) {
return PairingValidationResult.InvalidPayload("Invalid host_id")
}
if (payload.name.isBlank()) {
return PairingValidationResult.InvalidPayload("Name is empty")
}
val trimmedName = payload.name.trim()
if (trimmedName.length > 128) {
return PairingValidationResult.InvalidPayload("Name exceeds 128 characters")
}
if (payload.name.any { it in '\u0000'..'\u001F' || it in '\u007F'..'\u009F' }) {
return PairingValidationResult.InvalidPayload("Name contains control characters")
}
if (payload.host.isBlank()) {
return PairingValidationResult.InvalidPayload("Host is empty")
}
if (payload.host.any { it.isWhitespace() || it == '/' || it == '\\' || it == '?' || it == '#' || it == '@' || it == ':' || it in '\u0000'..'\u001F' || it in '\u007F'..'\u009F' }) {
return PairingValidationResult.InvalidPayload("Host contains invalid characters")
}
if (payload.port !in 1..65535) {
return PairingValidationResult.InvalidPayload("Invalid port")
}
@ -57,9 +84,25 @@ object HermesPairingParser {
return PairingValidationResult.InvalidScheme("Scheme must be http or https")
}
if (payload.expiresAt <= System.currentTimeMillis() / 1000) {
if (payload.nonce.isBlank()) {
return PairingValidationResult.InvalidPayload("Nonce is empty")
}
val nonceBytes = try {
Base64.getUrlDecoder().decode(payload.nonce)
} catch (e: IllegalArgumentException) {
return PairingValidationResult.InvalidPayload("Nonce must be valid Base64URL")
}
if (nonceBytes.size < 16 || nonceBytes.size > 64) {
return PairingValidationResult.InvalidPayload("Nonce must decode to between 16 and 64 bytes")
}
val now = System.currentTimeMillis() / 1000
if (payload.expiresAt < now - 30) {
return PairingValidationResult.Expired(payload.expiresAt)
}
if (payload.expiresAt > now + 600) {
return PairingValidationResult.InvalidPayload("Expiry exceeds maximum TTL of 600 seconds")
}
return PairingValidationResult.Success(payload)
} catch (e: Exception) {

View file

@ -14,7 +14,34 @@ data class HermesPairingPayload(
val scheme: String = "http",
@SerialName("expires_at") val expiresAt: Long,
val nonce: String
)
) {
val canonicalEndpoint: CanonicalEndpoint
get() = CanonicalEndpoint(
scheme = scheme.lowercase(),
host = host.lowercase(),
port = if (port == 0) {
if (scheme.lowercase() == "https") 443 else 80
} else port
)
}
data class CanonicalEndpoint(val scheme: String, val host: String, val port: Int) {
companion object {
fun fromBaseUrl(baseUrl: String): CanonicalEndpoint {
return try {
val uri = java.net.URI(baseUrl)
val scheme = uri.scheme?.lowercase() ?: "http"
val host = uri.host?.lowercase() ?: ""
val port = if (uri.port == -1 || uri.port == 0) {
if (scheme == "https") 443 else 80
} else uri.port
CanonicalEndpoint(scheme, host, port)
} catch (e: Exception) {
CanonicalEndpoint("http", "", 80)
}
}
}
}
sealed interface PairingValidationResult {
data class Success(val payload: HermesPairingPayload) : PairingValidationResult

View file

@ -200,10 +200,15 @@ fun HostsScreen(
uiState.scannedPayload?.let { payload ->
val existingHost = hosts.find { it.id.value == payload.hostId }
val isEndpointChanged = existingHost != null &&
app.hermes.mobile.core.pairing.CanonicalEndpoint.fromBaseUrl(existingHost.baseUrl) != payload.canonicalEndpoint
PairingPreviewDialog(
payload = payload,
isExistingHost = existingHost != null,
existingHostName = existingHost?.displayName,
isEndpointChanged = isEndpointChanged,
oldEndpointUrl = existingHost?.baseUrl,
onConfirm = { allowCleartext ->
viewModel.confirmPairing(payload, allowCleartext)
},

View file

@ -151,6 +151,11 @@ class HostsViewModel(
viewModelScope.launch {
val existingHost = connectionManager.hostDao.getHost(payload.hostId)
val hostToConnect = if (existingHost != null) {
val oldCanonical = app.hermes.mobile.core.pairing.CanonicalEndpoint.fromBaseUrl(existingHost.baseUrl)
if (oldCanonical != payload.canonicalEndpoint) {
connectionManager.disconnectHost(HermesHostId(payload.hostId))
tokenVault.clearTokens(payload.hostId)
}
val updatedHost = HermesHost(
id = HermesHostId(payload.hostId),
displayName = payload.name,

View file

@ -7,9 +7,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.hermes.mobile.core.pairing.HermesPairingPayload
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.model.HermesServerStatus
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
sealed interface PairingProbeState {
data object Idle : PairingProbeState
data object Probing : PairingProbeState
data class Success(val status: HermesServerStatus) : PairingProbeState
data class Failed(val message: String) : PairingProbeState
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -17,15 +26,18 @@ fun PairingPreviewDialog(
payload: HermesPairingPayload,
isExistingHost: Boolean,
existingHostName: String?,
isEndpointChanged: Boolean,
oldEndpointUrl: String?,
onConfirm: (Boolean) -> Unit,
onCancel: () -> Unit
) {
var probeStatus by remember { mutableStateOf("Probing...") }
var probeState by remember { mutableStateOf<PairingProbeState>(PairingProbeState.Probing) }
var allowCleartext by remember { mutableStateOf(payload.scheme == "http") }
val coroutineScope = rememberCoroutineScope()
val restClient = remember { HermesRestClient() }
LaunchedEffect(payload) {
fun doProbe() {
probeState = PairingProbeState.Probing
coroutineScope.launch {
try {
val url = "${payload.scheme}://${payload.host}:${payload.port}"
@ -35,17 +47,23 @@ fun PairingPreviewDialog(
if (result.isSuccess) {
val status = result.getOrNull()
if (status != null) {
probeStatus = "Hermes v${status.version} | Auth: ${if (status.authRequired) "Required" else "None"}"
probeState = PairingProbeState.Success(status)
} else {
probeState = PairingProbeState.Failed("Empty status")
}
} else {
probeStatus = "Probe failed: ${result.exceptionOrNull()?.message}"
probeState = PairingProbeState.Failed(result.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
probeStatus = "Probe failed: ${e.message}"
probeState = PairingProbeState.Failed(e.message ?: "Unknown error")
}
}
}
LaunchedEffect(payload) {
doProbe()
}
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Pairing Preview") },
@ -54,20 +72,61 @@ fun PairingPreviewDialog(
Text("Host: ${payload.name}", style = MaterialTheme.typography.bodyLarge)
Text("Address: ${payload.scheme}://${payload.host}:${payload.port}", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text("Status: $probeStatus", style = MaterialTheme.typography.bodySmall)
when (val state = probeState) {
is PairingProbeState.Probing -> {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("Probing status...", style = MaterialTheme.typography.bodySmall)
}
}
is PairingProbeState.Failed -> {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.small) {
Column(modifier = Modifier.padding(8.dp)) {
Text("Probe failed: ${state.message}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer)
Spacer(modifier = Modifier.height(4.dp))
Button(onClick = { doProbe() }, modifier = Modifier.height(32.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
Text("Retry", style = MaterialTheme.typography.labelSmall)
}
}
}
}
is PairingProbeState.Success -> {
Surface(color = androidx.compose.ui.graphics.Color(0xFFD1FAE5), shape = MaterialTheme.shapes.small) {
Text(
"Hermes v${state.status.version} | Auth: ${if (state.status.authRequired) "Required" else "None"}",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFF065F46)
)
}
}
PairingProbeState.Idle -> {}
}
if (isExistingHost) {
Spacer(modifier = Modifier.height(8.dp))
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) {
Text(
"Will update endpoint for existing host '${existingHostName ?: payload.name}'",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.labelMedium
)
if (isEndpointChanged) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.small) {
Text(
"Endpoint for this host has changed from $oldEndpointUrl to ${payload.scheme}://${payload.host}:${payload.port}. For security, saved login credentials will be cleared. Fresh login will be required.",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
} else {
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) {
Text(
"Will update endpoint for existing host '${existingHostName ?: payload.name}'",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
}
if (payload.scheme == "http") {
Spacer(modifier = Modifier.height(8.dp))
Row(
@ -88,7 +147,10 @@ fun PairingPreviewDialog(
}
},
confirmButton = {
TextButton(onClick = { onConfirm(allowCleartext) }) {
Button(
onClick = { onConfirm(allowCleartext) },
enabled = probeState is PairingProbeState.Success
) {
Text(if (isExistingHost) "Update Host" else "Add Host")
}
},

View file

@ -95,7 +95,12 @@ class JsonRpcGatewayClientTest {
client.connect(wsUrl, allowCleartext = true)
// Give WS a moment to open transport
kotlinx.coroutines.delay(100)
var retries = 0
while (serverWebSocket == null && retries < 50) {
kotlinx.coroutines.delay(50)
retries++
}
// Must still be Connecting before gateway.ready is received
assertEquals(ConnectionState.Connecting, client.connectionState.value)

View file

@ -14,7 +14,7 @@ class HermesPairingParserTest {
@Test
fun testValidPairingPayloadParsing() {
val futureTime = (System.currentTimeMillis() / 1000) + 3600
val futureTime = (System.currentTimeMillis() / 1000) + 300
val hostId = UUID.randomUUID().toString()
val json = """
{
@ -26,7 +26,7 @@ class HermesPairingParserTest {
"port": 9119,
"scheme": "http",
"expires_at": $futureTime,
"nonce": "random-nonce"
"nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"
}
""".trimIndent()
@ -44,128 +44,127 @@ class HermesPairingParserTest {
}
@Test
fun testExpiredPayloadRejection() {
val pastTime = (System.currentTimeMillis() / 1000) - 3600
val hostId = UUID.randomUUID().toString()
fun testCanonicalCrossContractFixture() {
val futureTime = (System.currentTimeMillis() / 1000) + 300
val json = """
{
"v": 1,
"type": "hermes-pair",
"host_id": "$hostId",
"name": "My Server",
"host": "192.168.1.5",
"host_id": "58af1471-a0a2-4e2b-9426-5068f2a2deab",
"name": "Office-PC",
"host": "192.168.1.150",
"port": 9119,
"scheme": "http",
"expires_at": $pastTime,
"nonce": "random-nonce"
"expires_at": $futureTime,
"nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"
}
""".trimIndent()
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.Success)
val payload = (result as PairingValidationResult.Success).payload
assertEquals("58af1471-a0a2-4e2b-9426-5068f2a2deab", payload.hostId)
assertEquals("Office-PC", payload.name)
assertEquals("192.168.1.150", payload.host)
assertEquals(9119, payload.port)
assertEquals("http", payload.scheme)
assertEquals("QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY", payload.nonce)
}
@Test
fun testExpiredPayloadRejection() {
val pastTime = (System.currentTimeMillis() / 1000) - 35
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":$pastTime,"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.Expired)
}
@Test
fun testExcessiveTTLRejection() {
val farFuture = (System.currentTimeMillis() / 1000) + 605
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":$farFuture,"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
val result = HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}")
assertTrue(result is PairingValidationResult.InvalidPayload)
}
@Test
fun testInvalidVersionRejection() {
val futureTime = (System.currentTimeMillis() / 1000) + 3600
val hostId = UUID.randomUUID().toString()
val json = """
{
"v": 2,
"type": "hermes-pair",
"host_id": "$hostId",
"name": "My Server",
"host": "192.168.1.5",
"port": 9119,
"scheme": "http",
"expires_at": $futureTime,
"nonce": "random-nonce"
}
""".trimIndent()
val json = """{"v":2,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidVersion)
}
@Test
fun testInvalidTypeRejection() {
val json = """{"v":1,"type":"other","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
}
@Test
fun testInvalidHostIdRejection() {
val json = """{"v":1,"type":"hermes-pair","host_id":"not-a-uuid","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
}
@Test
fun testInvalidNameRejections() {
val futures = listOf("", " ", "Name\u0000", "A".repeat(129))
futures.forEach { name ->
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"$name","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
}
}
@Test
fun testInvalidHostRejections() {
val hosts = listOf("1.1.1.1/path", "user@1.1.1.1", "1.1.1.1:9119", "1.1.1.1?q=1", "1.1.1.1#frag", "1 1")
hosts.forEach { host ->
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"$host","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
}
}
@Test
fun testInvalidPortRejections() {
val ports = listOf(0, 70000)
ports.forEach { port ->
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":$port,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
}
}
@Test
fun testInvalidSchemeRejection() {
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"ftp","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidScheme)
}
@Test
fun testInvalidNonceRejections() {
val shortNonce = Base64.getUrlEncoder().withoutPadding().encodeToString("12345".toByteArray())
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"$shortNonce"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload)
val invalidBase64 = "ThisIs!Not!Valid!Base64"
val json2 = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"$invalidBase64"}"""
assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json2)}") is PairingValidationResult.InvalidPayload)
}
@Test
fun testOversizedPayloads() {
val longString = "A".repeat(3000)
val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"$longString","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}"""
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(HermesPairingParser.parse(uri) is PairingValidationResult.InvalidPayload)
assertTrue(result is PairingValidationResult.InvalidVersion)
val hugeUri = "hermes://pair?data=" + "A".repeat(5000)
assertTrue(HermesPairingParser.parse(hugeUri) is PairingValidationResult.InvalidPayload)
}
@Test
fun testMalformedBase64Rejection() {
val uri = "hermes://pair?data=ThisIs!Not!Valid!Base64"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.InvalidPayload)
}
@Test
fun testInvalidPortRejection() {
val futureTime = (System.currentTimeMillis() / 1000) + 3600
val hostId = UUID.randomUUID().toString()
val json = """
{
"v": 1,
"type": "hermes-pair",
"host_id": "$hostId",
"name": "My Server",
"host": "192.168.1.5",
"port": 70000,
"scheme": "http",
"expires_at": $futureTime,
"nonce": "random-nonce"
}
""".trimIndent()
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.InvalidPayload)
}
@Test
fun testInvalidSchemeRejection() {
val futureTime = (System.currentTimeMillis() / 1000) + 3600
val hostId = UUID.randomUUID().toString()
val json = """
{
"v": 1,
"type": "hermes-pair",
"host_id": "$hostId",
"name": "My Server",
"host": "192.168.1.5",
"port": 9119,
"scheme": "ftp",
"expires_at": $futureTime,
"nonce": "random-nonce"
}
""".trimIndent()
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.InvalidScheme)
}
@Test
fun testMissingHostIdRejection() {
val futureTime = (System.currentTimeMillis() / 1000) + 3600
val json = """
{
"v": 1,
"type": "hermes-pair",
"name": "My Server",
"host": "192.168.1.5",
"port": 9119,
"scheme": "http",
"expires_at": $futureTime,
"nonce": "random-nonce"
}
""".trimIndent()
val uri = "hermes://pair?data=${encodePayload(json)}"
val result = HermesPairingParser.parse(uri)
assertTrue(result is PairingValidationResult.InvalidPayload)
}
}

View file

@ -125,7 +125,41 @@ class HostsPairingTest {
@Test
fun testExistingTokensPreservedOnHostUpdate() = runTest {
// Just verify updateHost is called and tokenVault clear isn't.
val hostId = UUID.randomUUID().toString()
val existingEntity = HostEntity(
id = hostId,
displayName = "Old Name",
baseUrl = "http://192.168.1.5:9119",
allowCleartext = true,
enabled = true,
lastSeenAt = 1000L,
lastKnownStatus = "OFFLINE"
)
val payload = HermesPairingPayload(
v = 1,
type = "hermes-pair",
hostId = hostId,
name = "Updated Server Name",
host = "192.168.1.5",
port = 9119,
scheme = "http",
expiresAt = (System.currentTimeMillis() / 1000) + 3600,
nonce = "nonce"
)
coEvery { hostDao.getHost(hostId) } returns existingEntity
coEvery { connectionManager.updateHost(any()) } returns Unit
coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit)
viewModel.confirmPairing(payload, allowCleartext = true)
coVerify(exactly = 0) { tokenVault.clearTokens(any()) }
coVerify(exactly = 0) { connectionManager.disconnectHost(any()) }
}
@Test
fun testExistingTokensClearedOnEndpointChange() = runTest {
val hostId = UUID.randomUUID().toString()
val existingEntity = HostEntity(
id = hostId,
@ -150,9 +184,12 @@ class HostsPairingTest {
)
coEvery { hostDao.getHost(hostId) } returns existingEntity
coEvery { connectionManager.updateHost(any()) } returns Unit
coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit)
viewModel.confirmPairing(payload, allowCleartext = true)
coVerify(exactly = 0) { tokenVault.clearTokens(any()) }
coVerify { tokenVault.clearTokens(hostId) }
coVerify { connectionManager.disconnectHost(app.hermes.mobile.core.model.HermesHostId(hostId)) }
}
}