23 files changed
+6579
-6
No files matched your search
@@ -0,0 +1,742 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorStatus
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectorParserTest {
|
||||
@Test
|
||||
fun `parseListResult parses standard valid envelope`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"available" to true,
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "linear",
|
||||
"name" to "Linear",
|
||||
"connected" to true,
|
||||
"enabled" to true,
|
||||
"connectionStatus" to "active",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertTrue(success.available)
|
||||
assertEquals(1, success.connectors.size)
|
||||
val item = success.connectors[0]
|
||||
assertEquals("linear", item.connector)
|
||||
assertEquals("Linear", item.name)
|
||||
assertTrue(item.connected)
|
||||
assertTrue(item.enabled)
|
||||
assertEquals(ConnectorStatus.CONNECTED, item.status)
|
||||
assertTrue(item.isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult handles unavailable state`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"available" to false,
|
||||
"connectors" to emptyList<Map<String, Any>>(),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertFalse(success.available)
|
||||
assertTrue(success.connectors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult handles empty connectors list`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"available" to true,
|
||||
"connectors" to emptyList<Map<String, Any>>(),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertTrue(success.available)
|
||||
assertTrue(success.connectors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult deliberate boolean connected precedence over stale backend statuses`() {
|
||||
// When connected is true, status is ALWAYS CONNECTED even if backend reports expired or revoked
|
||||
val payloadExpired =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf("connector" to "c1", "connected" to true, "connectionStatus" to "expired"),
|
||||
mapOf("connector" to "c2", "connected" to true, "connectionStatus" to "revoked"),
|
||||
),
|
||||
)
|
||||
val resultExpired = ConnectorParser.parseListResult(payloadExpired) as ConnectorListResult.Success
|
||||
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[0].status)
|
||||
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[1].status)
|
||||
|
||||
// When connected is false, status reflects expired or revoked, and stale "active" resolves to DISCONNECTED
|
||||
val payloadFalse =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "expired"),
|
||||
mapOf("connector" to "c4", "connected" to false, "connectionStatus" to "revoked"),
|
||||
mapOf("connector" to "c5", "connected" to false, "connectionStatus" to "active"),
|
||||
mapOf("connector" to "c6", "connected" to false, "connectionStatus" to "initiated"),
|
||||
mapOf("connector" to "c7", "connected" to false, "connectionStatus" to "error"),
|
||||
),
|
||||
)
|
||||
val resultFalse = ConnectorParser.parseListResult(payloadFalse) as ConnectorListResult.Success
|
||||
assertEquals(ConnectorStatus.EXPIRED, resultFalse.connectors[0].status)
|
||||
assertEquals(ConnectorStatus.REVOKED, resultFalse.connectors[1].status)
|
||||
assertEquals(ConnectorStatus.DISCONNECTED, resultFalse.connectors[2].status)
|
||||
assertEquals(ConnectorStatus.INITIATED, resultFalse.connectors[3].status)
|
||||
assertEquals(ConnectorStatus.FAILED, resultFalse.connectors[4].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult supports legacy aliases`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "github",
|
||||
"displayName" to "GitHub App",
|
||||
"status" to "expired",
|
||||
"auth_url" to "https://github.com/login/oauth/authorize",
|
||||
),
|
||||
mapOf(
|
||||
"id" to "jira",
|
||||
"title" to "Jira Cloud",
|
||||
"connection_status" to "revoked",
|
||||
"connectUrl" to "https://auth.atlassian.com/oauth",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertEquals(2, success.connectors.size)
|
||||
|
||||
val c1 = success.connectors[0]
|
||||
assertEquals("github", c1.connector)
|
||||
assertEquals("GitHub App", c1.name)
|
||||
assertEquals(ConnectorStatus.EXPIRED, c1.status)
|
||||
assertEquals("https://github.com/login/oauth/authorize", c1.safeConnectUrl)
|
||||
|
||||
val c2 = success.connectors[1]
|
||||
assertEquals("jira", c2.connector)
|
||||
assertEquals("Jira Cloud", c2.name)
|
||||
assertEquals(ConnectorStatus.REVOKED, c2.status)
|
||||
assertEquals("https://auth.atlassian.com/oauth", c2.safeConnectUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult safely handles wrong optional field types without crash`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"available" to "true",
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "slack",
|
||||
"name" to 12345, // Number instead of string
|
||||
"connected" to "true", // String instead of boolean
|
||||
"enabled" to "false", // String instead of boolean
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
val item = success.connectors[0]
|
||||
assertEquals("slack", item.connector)
|
||||
assertEquals("12345", item.name)
|
||||
assertTrue(item.connected)
|
||||
assertFalse(item.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult maps unknown status to UNKNOWN`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "notion",
|
||||
"connected" to false,
|
||||
"connectionStatus" to "pending_approval_future_state",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload) as ConnectorListResult.Success
|
||||
assertEquals(ConnectorStatus.UNKNOWN, result.connectors[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult fails on malformed required envelopes without fabricating empty success`() {
|
||||
// Missing "connectors" key
|
||||
val missingConnectors = mapOf("available" to true)
|
||||
val r1 = ConnectorParser.parseListResult(missingConnectors)
|
||||
assertTrue(r1 is ConnectorListResult.Error)
|
||||
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// "connectors" is a string instead of array
|
||||
val stringConnectors = mapOf("connectors" to "invalid")
|
||||
val r2 = ConnectorParser.parseListResult(stringConnectors)
|
||||
assertTrue(r2 is ConnectorListResult.Error)
|
||||
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Raw array at root instead of object
|
||||
val rawList = listOf("c1", "c2")
|
||||
val r3 = ConnectorParser.parseListResult(rawList)
|
||||
assertTrue(r3 is ConnectorListResult.Error)
|
||||
assertTrue((r3 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Primitive string at root
|
||||
val r4 = ConnectorParser.parseListResult("some random string")
|
||||
assertTrue(r4 is ConnectorListResult.Error)
|
||||
assertTrue((r4 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Null root
|
||||
val r5 = ConnectorParser.parseListResult(null)
|
||||
assertTrue(r5 is ConnectorListResult.Error)
|
||||
assertTrue((r5 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult parses standard connect initiated response`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "linear",
|
||||
"status" to "initiated",
|
||||
"connect_url" to "https://linear.app/oauth/authorize?client_id=123",
|
||||
"instruction" to "Arbitrary backend instruction that must not be exposed as UI text",
|
||||
),
|
||||
),
|
||||
"summary" to
|
||||
mapOf(
|
||||
"total" to 1,
|
||||
"active" to 0,
|
||||
"initiated" to 1,
|
||||
"failed" to 0,
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseConnectResult(payload)
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
val success = result as ConnectorConnectResult.Success
|
||||
assertEquals(1, success.results.size)
|
||||
val item = success.results[0]
|
||||
assertEquals("linear", item.connector)
|
||||
assertEquals(ConnectorConnectStatus.INITIATED, item.status)
|
||||
assertTrue(item.isConnectUrlValid)
|
||||
assertEquals("https://linear.app/oauth/authorize?client_id=123", item.safeConnectUrl)
|
||||
assertEquals(1, success.summary.total)
|
||||
assertEquals(1, success.summary.initiated)
|
||||
assertEquals(0, success.summary.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult parses connect already active response`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "linear",
|
||||
"status" to "active",
|
||||
),
|
||||
),
|
||||
"summary" to
|
||||
mapOf(
|
||||
"total" to 1,
|
||||
"active" to 1,
|
||||
"initiated" to 0,
|
||||
"failed" to 0,
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseConnectResult(payload)
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
val success = result as ConnectorConnectResult.Success
|
||||
assertEquals(1, success.results.size)
|
||||
val item = success.results[0]
|
||||
assertEquals("linear", item.connector)
|
||||
assertEquals(ConnectorConnectStatus.ACTIVE, item.status)
|
||||
assertTrue(item.status.isActive)
|
||||
assertNull(item.safeConnectUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult supports legacy aliases and unknown status`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "github",
|
||||
"connectionStatus" to "failed",
|
||||
"auth_url" to "https://github.com/login",
|
||||
),
|
||||
mapOf(
|
||||
"connector" to "notion",
|
||||
"status" to "custom_unknown_status",
|
||||
),
|
||||
),
|
||||
"summary" to
|
||||
mapOf(
|
||||
"total" to "2", // String count coerced safely
|
||||
"failed" to "1",
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
|
||||
assertEquals(2, result.results.size)
|
||||
assertEquals("github", result.results[0].connector)
|
||||
assertEquals(ConnectorConnectStatus.FAILED, result.results[0].status)
|
||||
assertEquals("https://github.com/login", result.results[0].safeConnectUrl)
|
||||
|
||||
assertEquals("notion", result.results[1].connector)
|
||||
assertEquals(ConnectorConnectStatus.UNKNOWN, result.results[1].status)
|
||||
|
||||
assertEquals(2, result.summary.total)
|
||||
assertEquals(1, result.summary.failed)
|
||||
assertEquals(0, result.summary.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult rejects insecure or invalid auth URLs`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connector" to "insecure_http",
|
||||
"status" to "initiated",
|
||||
"connect_url" to "http://example.com/oauth",
|
||||
),
|
||||
mapOf(
|
||||
"connector" to "js_proto",
|
||||
"status" to "initiated",
|
||||
"connect_url" to "javascript:alert(1)",
|
||||
),
|
||||
mapOf(
|
||||
"connector" to "user_creds",
|
||||
"status" to "initiated",
|
||||
"connect_url" to "https://user:pass@evil.com/",
|
||||
),
|
||||
),
|
||||
"summary" to
|
||||
mapOf(
|
||||
"total" to 3,
|
||||
"initiated" to 3,
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
|
||||
assertEquals(3, result.results.size)
|
||||
for (item in result.results) {
|
||||
assertFalse(item.isConnectUrlValid)
|
||||
assertNull(item.safeConnectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult fails on malformed required envelope without fabricating success`() {
|
||||
// Missing results
|
||||
val r1 = ConnectorParser.parseConnectResult(mapOf("summary" to mapOf("total" to 0)))
|
||||
assertTrue(r1 is ConnectorConnectResult.Error)
|
||||
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Results is a string
|
||||
val r2 = ConnectorParser.parseConnectResult(mapOf("results" to "not a list"))
|
||||
assertTrue(r2 is ConnectorConnectResult.Error)
|
||||
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Primitive string
|
||||
val r3 = ConnectorParser.parseConnectResult("random text")
|
||||
assertTrue(r3 is ConnectorConnectResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toString redacts auth URLs on ConnectorItem and ConnectorConnectItem`() {
|
||||
val sensitiveUrl = "https://example.com/oauth?client_secret=super_secret_token_12345"
|
||||
val item =
|
||||
ConnectorItem(
|
||||
connector = "linear",
|
||||
name = "Linear",
|
||||
connectUrl = sensitiveUrl,
|
||||
)
|
||||
assertFalse(item.toString().contains("super_secret_token_12345"))
|
||||
assertTrue(item.toString().contains("[REDACTED]"))
|
||||
|
||||
val connectItem =
|
||||
ConnectorConnectItem(
|
||||
connector = "linear",
|
||||
status = ConnectorConnectStatus.INITIATED,
|
||||
connectUrl = sensitiveUrl,
|
||||
)
|
||||
assertFalse(connectItem.toString().contains("super_secret_token_12345"))
|
||||
assertTrue(connectItem.toString().contains("[REDACTED]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapRpcError correctly maps backend codes and reasons`() {
|
||||
// 4000 INVALID_PARAMS
|
||||
val e4000 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 4000,
|
||||
message = "session_id required",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
|
||||
)
|
||||
assertTrue(e4000 is ConnectorError.InvalidParams)
|
||||
|
||||
// 4001 NOT_OWNER
|
||||
val e4001 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 4001,
|
||||
message = "session not owned",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
|
||||
)
|
||||
assertTrue(e4001 is ConnectorError.NotOwner)
|
||||
|
||||
// 4031 CONNECTORS_UNAVAILABLE
|
||||
val e4031 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 4031,
|
||||
message = "connectors unavailable",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
|
||||
)
|
||||
assertTrue(e4031 is ConnectorError.Unavailable)
|
||||
|
||||
// 5033 UNSUPPORTED_RUNTIME
|
||||
val e5033 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 5033,
|
||||
message = "compute host required",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
)
|
||||
assertTrue(e5033 is ConnectorError.UnsupportedRuntime)
|
||||
|
||||
// 5034 INVALID_CONNECTOR_RESPONSE
|
||||
val e5034a =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 5034,
|
||||
message = "invalid response",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
|
||||
)
|
||||
assertTrue(e5034a is ConnectorError.InvalidResponse)
|
||||
|
||||
// 5034 CONNECTOR_REQUEST_FAILED
|
||||
val e5034b =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 5034,
|
||||
message = "request failed",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTOR_REQUEST_FAILED"))),
|
||||
)
|
||||
assertTrue(e5034b is ConnectorError.RequestFailed)
|
||||
|
||||
// -32601 Method not found
|
||||
val e32601 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = -32601,
|
||||
message = "Method not found",
|
||||
data = null,
|
||||
)
|
||||
assertTrue(e32601 is ConnectorError.UnsupportedBackend)
|
||||
|
||||
// Other code
|
||||
val eOther =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 9999,
|
||||
message = "Custom failure",
|
||||
data = null,
|
||||
)
|
||||
assertTrue(eOther is ConnectorError.Other)
|
||||
assertEquals(9999, (eOther as ConnectorError.Other).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapRpcError never leaks raw backend messages or secrets into typed error or toString`() {
|
||||
val secretApiKey = "sk-live-secret-super-sensitive-api-key-987654321"
|
||||
val secretDbPass = "postgres://user:super_secret_db_pass@10.0.0.1:5432/db"
|
||||
|
||||
// Known error code with secret in message
|
||||
val e4000 =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 4000,
|
||||
message = "Invalid auth header: $secretApiKey",
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
|
||||
)
|
||||
assertFalse("Error message must not leak secret", e4000.message.contains(secretApiKey))
|
||||
assertFalse("Error toString must not leak secret", e4000.toString().contains(secretApiKey))
|
||||
assertEquals("Invalid connector parameters.", e4000.message)
|
||||
|
||||
// Unknown error code with secret in message
|
||||
val eUnknown =
|
||||
ConnectorParser.mapRpcError(
|
||||
code = 8888,
|
||||
message = "Internal connection failed to $secretDbPass",
|
||||
data = null,
|
||||
)
|
||||
assertFalse("Unknown error message must not leak secret", eUnknown.message.contains(secretDbPass))
|
||||
assertFalse("Unknown error toString must not leak secret", eUnknown.toString().contains(secretDbPass))
|
||||
assertEquals("Connector operation failed.", eUnknown.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectorItem strictly rejects non-string identifiers and invalid slug formats`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf("connector" to true, "name" to "Boolean Slug"),
|
||||
mapOf("id" to 12345, "name" to "Number ID"),
|
||||
mapOf("slug" to "INVALID UPPERCASE", "name" to "Uppercase"),
|
||||
mapOf("connector" to "-leading-hyphen", "name" to "Leading Hyphen"),
|
||||
mapOf("connector" to "slug with spaces", "name" to "Spaces"),
|
||||
mapOf("connector" to "valid_slug-1", "name" to "Valid Item"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertEquals(1, success.connectors.size)
|
||||
assertEquals("valid_slug-1", success.connectors[0].connector)
|
||||
assertEquals("Valid Item", success.connectors[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult deduplicates items by slug to prevent duplicate keys in lazy lists`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf("connector" to "github", "name" to "First GitHub"),
|
||||
mapOf("connector" to "github", "name" to "Duplicate GitHub"),
|
||||
mapOf("connector" to "slack", "name" to "Slack"),
|
||||
mapOf("connector" to "slack", "name" to "Duplicate Slack"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertEquals(2, success.connectors.size)
|
||||
assertEquals("github", success.connectors[0].connector)
|
||||
assertEquals("First GitHub", success.connectors[0].name)
|
||||
assertEquals("slack", success.connectors[1].connector)
|
||||
assertEquals("Slack", success.connectors[1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult fails with MalformedEnvelope when nonempty connectors array contains only invalid items`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
"not an object",
|
||||
12345,
|
||||
mapOf("connector" to true),
|
||||
mapOf("slug" to "INVALID SLUG"),
|
||||
mapOf("bogus" to "no identifier"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Error)
|
||||
assertTrue((result as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseListResult tolerates mixed junk items when valid items exist`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
"random junk string",
|
||||
mapOf("connector" to "linear", "name" to "Linear"),
|
||||
12345,
|
||||
mapOf("connector" to true),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertEquals(1, success.connectors.size)
|
||||
assertEquals("linear", success.connectors[0].connector)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult fails when results is empty or all items invalid`() {
|
||||
// Empty results array
|
||||
val payloadEmpty =
|
||||
mapOf(
|
||||
"results" to emptyList<Map<String, Any>>(),
|
||||
"summary" to mapOf("total" to 0),
|
||||
)
|
||||
val rEmpty = ConnectorParser.parseConnectResult(payloadEmpty)
|
||||
assertTrue(rEmpty is ConnectorConnectResult.Error)
|
||||
assertTrue((rEmpty as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
|
||||
|
||||
// All invalid items
|
||||
val payloadInvalid =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to true),
|
||||
mapOf("id" to 12345),
|
||||
mapOf("connector" to "INVALID UPPER"),
|
||||
),
|
||||
"summary" to mapOf("total" to 3),
|
||||
)
|
||||
val rInvalid = ConnectorParser.parseConnectResult(payloadInvalid)
|
||||
assertTrue(rInvalid is ConnectorConnectResult.Error)
|
||||
assertTrue((rInvalid as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult fails when summary object is missing or non-object in standard envelope`() {
|
||||
// Missing summary
|
||||
val payloadMissingSummary =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "active"),
|
||||
),
|
||||
)
|
||||
val r1 = ConnectorParser.parseConnectResult(payloadMissingSummary)
|
||||
assertTrue(r1 is ConnectorConnectResult.Error)
|
||||
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
|
||||
// Non-object summary (string)
|
||||
val payloadStringSummary =
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "active"),
|
||||
),
|
||||
"summary" to "invalid_string_summary",
|
||||
)
|
||||
val r2 = ConnectorParser.parseConnectResult(payloadStringSummary)
|
||||
assertTrue(r2 is ConnectorConnectResult.Error)
|
||||
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseConnectResult tolerates purposeful legacy bare array form and derives summary`() {
|
||||
val legacyPayload =
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "active"),
|
||||
mapOf("connector" to "github", "status" to "initiated", "connect_url" to "https://github.com/login"),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseConnectResult(legacyPayload)
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
val success = result as ConnectorConnectResult.Success
|
||||
assertEquals(2, success.results.size)
|
||||
assertEquals("linear", success.results[0].connector)
|
||||
assertEquals(ConnectorConnectStatus.ACTIVE, success.results[0].status)
|
||||
assertEquals("github", success.results[1].connector)
|
||||
assertEquals(ConnectorConnectStatus.INITIATED, success.results[1].status)
|
||||
|
||||
// Summary should be synthesized
|
||||
assertEquals(2, success.summary.total)
|
||||
assertEquals(1, success.summary.active)
|
||||
assertEquals(1, success.summary.initiated)
|
||||
assertEquals(0, success.summary.failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy connected consistency - status authorized yields effective connected when boolean is missing`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
// Missing "connected" boolean, status "authorized"
|
||||
mapOf("connector" to "c1", "connectionStatus" to "authorized"),
|
||||
// Missing "connected" boolean, status "active"
|
||||
mapOf("connector" to "c2", "connectionStatus" to "active"),
|
||||
// Missing "connected" boolean, status "connected"
|
||||
mapOf("connector" to "c3", "connectionStatus" to "connected"),
|
||||
// Missing "connected" boolean, status "expired"
|
||||
mapOf("connector" to "c4", "connectionStatus" to "expired"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
assertEquals(4, success.connectors.size)
|
||||
|
||||
val c1 = success.connectors[0]
|
||||
assertEquals(ConnectorStatus.CONNECTED, c1.status)
|
||||
assertTrue(c1.connected)
|
||||
assertTrue(c1.isConnected)
|
||||
|
||||
val c2 = success.connectors[1]
|
||||
assertEquals(ConnectorStatus.CONNECTED, c2.status)
|
||||
assertTrue(c2.connected)
|
||||
assertTrue(c2.isConnected)
|
||||
|
||||
val c3 = success.connectors[2]
|
||||
assertEquals(ConnectorStatus.CONNECTED, c3.status)
|
||||
assertTrue(c3.connected)
|
||||
assertTrue(c3.isConnected)
|
||||
|
||||
val c4 = success.connectors[3]
|
||||
assertEquals(ConnectorStatus.EXPIRED, c4.status)
|
||||
assertFalse(c4.connected)
|
||||
assertFalse(c4.isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit connected false is authoritative and not overridden by status active or authorized`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf("connector" to "c1", "connected" to false, "connectionStatus" to "active"),
|
||||
mapOf("connector" to "c2", "connected" to false, "connectionStatus" to "authorized"),
|
||||
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "connected"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConnectorParser.parseListResult(payload)
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
val success = result as ConnectorListResult.Success
|
||||
for (item in success.connectors) {
|
||||
assertFalse(item.connected)
|
||||
assertFalse(item.isConnected)
|
||||
assertEquals(ConnectorStatus.DISCONNECTED, item.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectorRepositoryTest {
|
||||
@Test
|
||||
fun `exact method constants match upstream gateway expectations`() {
|
||||
assertEquals("connectors.list", WsMethods.CONNECTORS_LIST)
|
||||
assertEquals("connectors.connect", WsMethods.CONNECTORS_CONNECT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listConnectors sends exact parameters without extra profile injection`() =
|
||||
runBlocking {
|
||||
var recordedMethod: String? = null
|
||||
var recordedParams: Map<String, Any>? = null
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
|
||||
},
|
||||
)
|
||||
|
||||
val result = repo.listConnectors("sess-abc-123")
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
|
||||
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
|
||||
// Verify no profile or singular slug mutations
|
||||
assertFalse(recordedParams?.containsKey("profile") == true)
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect sends exact parameters with connectors array and reconnect boolean`() =
|
||||
runBlocking {
|
||||
var recordedMethod: String? = null
|
||||
var recordedParams: Map<String, Any>? = null
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "initiated"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "initiated" to 1),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result =
|
||||
repo.connect(
|
||||
sessionId = "sess-456",
|
||||
connectors = listOf("linear", "github_app-1"),
|
||||
reconnect = true,
|
||||
)
|
||||
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
|
||||
assertEquals("sess-456", recordedParams?.get("session_id"))
|
||||
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
|
||||
assertEquals(true, recordedParams?.get("reconnect"))
|
||||
|
||||
// Verify no singular slug key mutation
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect preserves reconnect false default`() =
|
||||
runBlocking {
|
||||
var recordedParams: Map<String, Any>? = null
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, params ->
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "slack", "status" to "active"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "active" to 1),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
repo.connect(sessionId = "sess-789", connectors = listOf("slack"))
|
||||
assertEquals(false, recordedParams?.get("reconnect"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listConnectors validates non-blank session_id before network`() =
|
||||
runBlocking {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
val r1 = repo.listConnectors("")
|
||||
val r2 = repo.listConnectors(" ")
|
||||
|
||||
assertFalse(rpcCalled)
|
||||
assertTrue(r1 is ConnectorListResult.Error)
|
||||
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
|
||||
assertTrue(r2 is ConnectorListResult.Error)
|
||||
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect validates session_id and connectors list before network`() =
|
||||
runBlocking {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
// Blank session_id
|
||||
val r1 = repo.connect(sessionId = "", connectors = listOf("linear"))
|
||||
assertFalse(rpcCalled)
|
||||
assertTrue(r1 is ConnectorConnectResult.Error)
|
||||
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
|
||||
|
||||
// Empty connectors
|
||||
val r2 = repo.connect(sessionId = "sess-1", connectors = emptyList())
|
||||
assertFalse(rpcCalled)
|
||||
assertTrue(r2 is ConnectorConnectResult.Error)
|
||||
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect validates slug pattern matches backend expectations`() =
|
||||
runBlocking {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
// Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols
|
||||
val invalidSlugs =
|
||||
listOf(
|
||||
"Linear", // uppercase
|
||||
"-linear", // leading hyphen
|
||||
"_linear", // leading underscore
|
||||
"linear app", // space
|
||||
"linear/app", // slash
|
||||
"linear@app", // symbol
|
||||
"", // empty
|
||||
)
|
||||
|
||||
for (invalidSlug in invalidSlugs) {
|
||||
rpcCalled = false
|
||||
val result = repo.connect(sessionId = "sess-1", connectors = listOf(invalidSlug))
|
||||
assertFalse("Expected RPC not to be called for invalid slug '$invalidSlug'", rpcCalled)
|
||||
assertTrue("Expected InvalidParams error for '$invalidSlug'", result is ConnectorConnectResult.Error)
|
||||
assertTrue((result as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
|
||||
}
|
||||
|
||||
// Valid slugs
|
||||
assertTrue(ConnectorRepository.isValidSlug("gmail"))
|
||||
assertTrue(ConnectorRepository.isValidSlug("google_drive"))
|
||||
assertTrue(ConnectorRepository.isValidSlug("jira-service-desk"))
|
||||
assertTrue(ConnectorRepository.isValidSlug("0auth_provider"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception rethrows and is not swallowed`() =
|
||||
runBlocking {
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw CancellationException("coroutine cancelled")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
repo.listConnectors("sess-1")
|
||||
fail("Expected CancellationException to be rethrown on listConnectors")
|
||||
} catch (e: CancellationException) {
|
||||
assertEquals("coroutine cancelled", e.message)
|
||||
}
|
||||
|
||||
try {
|
||||
repo.connect("sess-1", listOf("linear"))
|
||||
fail("Expected CancellationException to be rethrown on connect")
|
||||
} catch (e: CancellationException) {
|
||||
assertEquals("coroutine cancelled", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maps HermesRpcException to typed errors`() =
|
||||
runBlocking {
|
||||
// 4031 CONNECTORS_UNAVAILABLE
|
||||
val repo4031 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Connectors unavailable",
|
||||
code = 4031,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r4031 = repo4031.listConnectors("sess-1")
|
||||
assertTrue(r4031 is ConnectorListResult.Error)
|
||||
assertTrue((r4031 as ConnectorListResult.Error).error is ConnectorError.Unavailable)
|
||||
|
||||
// 4001 NOT_OWNER
|
||||
val repo4001 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Session not owned",
|
||||
code = 4001,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r4001 = repo4001.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r4001 is ConnectorConnectResult.Error)
|
||||
assertTrue((r4001 as ConnectorConnectResult.Error).error is ConnectorError.NotOwner)
|
||||
|
||||
// 5033 UNSUPPORTED_RUNTIME
|
||||
val repo5033 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Compute host required",
|
||||
code = 5033,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r5033 = repo5033.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r5033 is ConnectorConnectResult.Error)
|
||||
assertTrue((r5033 as ConnectorConnectResult.Error).error is ConnectorError.UnsupportedRuntime)
|
||||
|
||||
// 5034 INVALID_CONNECTOR_RESPONSE
|
||||
val repo5034 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Invalid connector response",
|
||||
code = 5034,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r5034 = repo5034.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r5034 is ConnectorConnectResult.Error)
|
||||
assertTrue((r5034 as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
|
||||
|
||||
// -32601 Unsupported backend
|
||||
val repo32601 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Method not found",
|
||||
code = -32601,
|
||||
data = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
val r32601 = repo32601.listConnectors("sess-1")
|
||||
assertTrue(r32601 is ConnectorListResult.Error)
|
||||
assertTrue((r32601 as ConnectorListResult.Error).error is ConnectorError.UnsupportedBackend)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic network exception maps to typed NetworkError`() =
|
||||
runBlocking {
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw java.io.IOException("Connection reset by peer")
|
||||
},
|
||||
)
|
||||
|
||||
val rList = repo.listConnectors("sess-1")
|
||||
assertTrue(rList is ConnectorListResult.Error)
|
||||
assertTrue((rList as ConnectorListResult.Error).error is ConnectorError.NetworkError)
|
||||
|
||||
val rConnect = repo.connect("sess-1", listOf("linear"))
|
||||
assertTrue(rConnect is ConnectorConnectResult.Error)
|
||||
assertTrue((rConnect as ConnectorConnectResult.Error).error is ConnectorError.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default rpcRequest cancels underlying deferred on caller cancellation for listConnectors`() =
|
||||
runBlocking {
|
||||
mockkObject(HermesWsClient)
|
||||
try {
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any(), any()) } returns deferred
|
||||
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any()) } returns deferred
|
||||
|
||||
val repo = HermesConnectorRepository()
|
||||
val job =
|
||||
launch {
|
||||
repo.listConnectors("sess-cancel-test")
|
||||
}
|
||||
|
||||
yield()
|
||||
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
|
||||
|
||||
job.cancel()
|
||||
job.join()
|
||||
|
||||
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
|
||||
} finally {
|
||||
unmockkObject(HermesWsClient)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default rpcRequest cancels underlying deferred on caller cancellation for connect`() =
|
||||
runBlocking {
|
||||
mockkObject(HermesWsClient)
|
||||
try {
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any(), any()) } returns deferred
|
||||
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any()) } returns deferred
|
||||
|
||||
val repo = HermesConnectorRepository()
|
||||
val job =
|
||||
launch {
|
||||
repo.connect("sess-cancel-test", listOf("linear"))
|
||||
}
|
||||
|
||||
yield()
|
||||
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
|
||||
|
||||
job.cancel()
|
||||
job.join()
|
||||
|
||||
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
|
||||
} finally {
|
||||
unmockkObject(HermesWsClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class HermesWsClientLoggingSecurityTest {
|
||||
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
private val capturedInfoLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
private val capturedWarnLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
private val capturedErrorLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
capturedDebugLogs.clear()
|
||||
capturedInfoLogs.clear()
|
||||
capturedWarnLogs.clear()
|
||||
capturedErrorLogs.clear()
|
||||
|
||||
mockkStatic(Log::class)
|
||||
every { Log.v(any<String>(), any<String>()) } answers {
|
||||
capturedDebugLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.d(any<String>(), any<String>()) } answers {
|
||||
capturedDebugLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.i(any<String>(), any<String>()) } answers {
|
||||
capturedInfoLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.w(any<String>(), any<String>()) } answers {
|
||||
capturedWarnLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } answers {
|
||||
capturedWarnLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.e(any<String>(), any<String>()) } answers {
|
||||
capturedErrorLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } answers {
|
||||
capturedErrorLogs.add(secondArg())
|
||||
0
|
||||
}
|
||||
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.wsUrl() } returns "ws://127.0.0.1:9119/"
|
||||
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.rejectAllPending()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun createWsListener(generation: Int? = null): WebSocketListener {
|
||||
val targetGen =
|
||||
generation ?: run {
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as AtomicInteger).get()
|
||||
}
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(targetGen) as WebSocketListener
|
||||
}
|
||||
|
||||
private fun attachMockSocket(): WebSocket {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
|
||||
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
|
||||
field.isAccessible = true
|
||||
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
|
||||
}
|
||||
|
||||
private fun getTimeoutJob(pendingCall: Any): Job? {
|
||||
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
|
||||
field.isAccessible = true
|
||||
return field.get(pendingCall) as? Job
|
||||
}
|
||||
|
||||
// ── Formatter unit tests ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `formatSafeOutgoingFrameLog does not leak payload parameters or urls`() {
|
||||
val sensitiveUrl = "https://example.com/oauth/authorize?token=secret123"
|
||||
val log =
|
||||
HermesWsClient.formatSafeOutgoingFrameLog(
|
||||
id = "req-42",
|
||||
method = WsMethods.CONNECTORS_CONNECT,
|
||||
paramsKeys = listOf("session_id", "connectors", "reconnect"),
|
||||
byteLength = 256,
|
||||
queued = false,
|
||||
)
|
||||
|
||||
assertFalse("Log must not contain sensitive URL", log.contains(sensitiveUrl))
|
||||
assertFalse("Log must not contain 'secret'", log.contains("secret"))
|
||||
assertTrue("Log should contain id", log.contains("id=req-42"))
|
||||
assertTrue("Log should contain method", log.contains("method=connectors.connect"))
|
||||
assertTrue(
|
||||
"Log should contain parameter keys only",
|
||||
log.contains("paramsKeys=[session_id,connectors,reconnect]"),
|
||||
)
|
||||
assertTrue("Log should contain byte length", log.contains("bytes=256"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatSafeQueuedFrameLog does not leak sensitive payload`() {
|
||||
val sensitiveUrl = "https://auth.provider.com/login?key=supersecret"
|
||||
val rawJson =
|
||||
buildJsonObject {
|
||||
put("id", "q-1")
|
||||
put("method", "connectors.connect")
|
||||
put(
|
||||
"params",
|
||||
buildJsonObject {
|
||||
put("session_id", "sid-1")
|
||||
put("secret_url", sensitiveUrl)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
|
||||
val log = HermesWsClient.formatSafeQueuedFrameLog(rawJson)
|
||||
|
||||
assertFalse("Queued frame log must not leak sensitive url", log.contains(sensitiveUrl))
|
||||
assertFalse("Queued frame log must not contain 'supersecret'", log.contains("supersecret"))
|
||||
assertTrue("Queued frame log should mark as queued", log.contains("→ (queued)"))
|
||||
assertTrue("Queued frame log should show method", log.contains("method=connectors.connect"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatSafeIncomingFrameLog does not leak connect_url or token from response`() {
|
||||
val sensitiveAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=123&token=abc_secret_token"
|
||||
val responseJson =
|
||||
"""
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "req-99",
|
||||
"result": {
|
||||
"results": [
|
||||
{
|
||||
"connector": "google",
|
||||
"status": "initiated",
|
||||
"connect_url": "$sensitiveAuthUrl",
|
||||
"instruction": "Open this link in your browser to sign in"
|
||||
}
|
||||
],
|
||||
"summary": {"total": 1}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val log = HermesWsClient.formatSafeIncomingFrameLog(responseJson)
|
||||
|
||||
assertFalse("Incoming frame log must not leak sensitive auth url", log.contains(sensitiveAuthUrl))
|
||||
assertFalse("Incoming frame log must not leak secret token", log.contains("abc_secret_token"))
|
||||
assertFalse("Incoming frame log must not leak instructions", log.contains("Open this link"))
|
||||
assertTrue("Incoming frame log should contain id", log.contains("id=req-99"))
|
||||
assertTrue("Incoming frame log should summarize result keys", log.contains("result=[results,summary]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatSafeIncomingFrameLog handles malformed or non-object json safely`() {
|
||||
val log1 = HermesWsClient.formatSafeIncomingFrameLog("plain string not json")
|
||||
assertTrue(log1.startsWith("← frame bytes="))
|
||||
|
||||
val log2 = HermesWsClient.formatSafeIncomingFrameLog("[1, 2, 3]")
|
||||
assertTrue(log2.startsWith("← non-object frame bytes="))
|
||||
}
|
||||
|
||||
// ── Real inbound WsListenerImpl.onMessage path regression tests ───────────
|
||||
|
||||
@Test
|
||||
fun `inbound onMessage delivers result with connect_url while Log_d redacts secrets`() {
|
||||
val socket = attachMockSocket()
|
||||
val listener = createWsListener()
|
||||
|
||||
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val sensitiveAuthUrl =
|
||||
"https://auth.synthetic-fixture.local/oauth/authorize?code=fixture-code-99&token=fixture-secret-token-xyz"
|
||||
val responseJson =
|
||||
"""
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "$reqId",
|
||||
"result": {
|
||||
"status": "initiated",
|
||||
"connect_url": "$sensitiveAuthUrl"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
listener.onMessage(socket, responseJson)
|
||||
|
||||
assertTrue("Deferred request must be completed", deferred.isCompleted)
|
||||
val result = runBlocking { deferred.await() }
|
||||
assertTrue("Result must be delivered as JsonObject", result is JsonObject)
|
||||
val resultObj = result as JsonObject
|
||||
assertEquals(
|
||||
sensitiveAuthUrl,
|
||||
(resultObj["connect_url"] as? JsonPrimitive)?.content,
|
||||
)
|
||||
|
||||
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
|
||||
for (log in allLogs) {
|
||||
assertFalse("Captured log must not leak sensitive auth URL: $log", log.contains(sensitiveAuthUrl))
|
||||
assertFalse("Captured log must not leak secret token: $log", log.contains("fixture-secret-token-xyz"))
|
||||
assertFalse("Captured log must not leak fixture code: $log", log.contains("fixture-code-99"))
|
||||
assertFalse(
|
||||
"Captured log must not leak synthetic auth host: $log",
|
||||
log.contains("https://auth.synthetic-fixture.local"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"Log.d must record safe sanitized incoming frame log with id and sanitized keys",
|
||||
capturedDebugLogs.any {
|
||||
it.contains("id=$reqId") && (
|
||||
it.contains("result=[status,connect_url]") ||
|
||||
it.contains("result=[connect_url,status]")
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbound onMessage rpc error 4001 NOT_OWNER completes deferred exceptionally and preserves shared events`() {
|
||||
val socket = attachMockSocket()
|
||||
val listener = createWsListener()
|
||||
|
||||
val capturedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
|
||||
val subscribedLatch = CountDownLatch(1)
|
||||
val errorEventLatch = CountDownLatch(1)
|
||||
var receivedErrorEvent: WsEvent.RpcError? = null
|
||||
|
||||
val collectJob =
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
HermesWsClient.events
|
||||
.onSubscription { subscribedLatch.countDown() }
|
||||
.collect {
|
||||
capturedEvents.add(it)
|
||||
if (it is WsEvent.RpcError) {
|
||||
receivedErrorEvent = it
|
||||
errorEventLatch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
||||
|
||||
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val errorJson =
|
||||
"""
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "$reqId",
|
||||
"error": {
|
||||
"code": 4001,
|
||||
"message": "Access denied: session ownership required",
|
||||
"data": {
|
||||
"reason": "NOT_OWNER",
|
||||
"session_id": "sess-ownership-4001"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
listener.onMessage(socket, errorJson)
|
||||
|
||||
assertTrue("Deferred must be completed", deferred.isCompleted)
|
||||
val thrown = runCatching { runBlocking { deferred.await() } }.exceptionOrNull()
|
||||
assertTrue(
|
||||
"Deferred must complete exceptionally with HermesRpcException",
|
||||
thrown is HermesWsClient.HermesRpcException,
|
||||
)
|
||||
val rpcException = thrown as HermesWsClient.HermesRpcException
|
||||
assertEquals(4001, rpcException.code)
|
||||
assertEquals("Access denied: session ownership required", rpcException.message)
|
||||
assertNotNull("Exception data must be preserved", rpcException.data)
|
||||
assertTrue("Exception data must be JsonObject", rpcException.data is JsonObject)
|
||||
val dataObj = rpcException.data as JsonObject
|
||||
assertEquals("NOT_OWNER", (dataObj["reason"] as? JsonPrimitive)?.content)
|
||||
assertEquals("sess-ownership-4001", (dataObj["session_id"] as? JsonPrimitive)?.content)
|
||||
|
||||
assertTrue("Shared events flow must receive RpcError event", errorEventLatch.await(3, TimeUnit.SECONDS))
|
||||
collectJob.cancel()
|
||||
|
||||
assertNotNull("Captured RpcError event must exist", receivedErrorEvent)
|
||||
assertEquals(reqId, receivedErrorEvent!!.id)
|
||||
assertEquals(4001, receivedErrorEvent!!.error.code)
|
||||
assertEquals("Access denied: session ownership required", receivedErrorEvent!!.error.message)
|
||||
val eventDataObj = receivedErrorEvent!!.error.data as? JsonObject
|
||||
assertNotNull("Event error data must be JsonObject", eventDataObj)
|
||||
assertEquals("NOT_OWNER", (eventDataObj!!["reason"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbound onMessage malformed frame logs safely without leaking payload or secrets`() {
|
||||
val socket = attachMockSocket()
|
||||
val listener = createWsListener()
|
||||
|
||||
val subscribedLatch = CountDownLatch(1)
|
||||
val unknownEventLatch = CountDownLatch(1)
|
||||
var receivedUnknownEvent: WsEvent.Unknown? = null
|
||||
|
||||
val collectJob =
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
HermesWsClient.events
|
||||
.onSubscription { subscribedLatch.countDown() }
|
||||
.collect {
|
||||
if (it is WsEvent.Unknown) {
|
||||
receivedUnknownEvent = it
|
||||
unknownEventLatch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
||||
capturedDebugLogs.clear()
|
||||
capturedErrorLogs.clear()
|
||||
|
||||
val sensitiveSecret = "synthetic-secret-token-malformed-frame-777"
|
||||
val sensitiveUrl = "https://auth.synthetic-trap.local/oauth/secret"
|
||||
val malformedPayload = """{"token": "$sensitiveSecret", "url": "$sensitiveUrl", [syntax-broken-json]}"""
|
||||
|
||||
listener.onMessage(socket, malformedPayload)
|
||||
|
||||
assertTrue("Unknown event must be emitted for malformed frame", unknownEventLatch.await(3, TimeUnit.SECONDS))
|
||||
collectJob.cancel()
|
||||
|
||||
assertNotNull(receivedUnknownEvent)
|
||||
assertEquals(malformedPayload, receivedUnknownEvent!!.raw)
|
||||
|
||||
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
|
||||
for (log in allLogs) {
|
||||
assertFalse("Logs must not contain sensitive secret token: $log", log.contains(sensitiveSecret))
|
||||
assertFalse("Logs must not contain sensitive auth URL: $log", log.contains(sensitiveUrl))
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"Log.e must record parse failure without leaking secrets",
|
||||
capturedErrorLogs.any { it.startsWith("Failed to parse message:") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
|
||||
attachMockSocket()
|
||||
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val entry =
|
||||
pendingCalls.entries.firstOrNull {
|
||||
it.value.javaClass.name
|
||||
.contains("PendingCall")
|
||||
}
|
||||
assertNotNull("Pending call must be registered in pendingCalls map", entry)
|
||||
val reqId = entry!!.key
|
||||
val timeoutJob = getTimeoutJob(entry.value)
|
||||
assertNotNull("Timeout job must be initialized", timeoutJob)
|
||||
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
|
||||
|
||||
deferred.cancel()
|
||||
|
||||
assertTrue("Deferred must be cancelled", deferred.isCancelled)
|
||||
assertFalse(
|
||||
"Pending call must be removed from pendingCalls map on cancellation",
|
||||
pendingCalls.containsKey(reqId),
|
||||
)
|
||||
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,26 @@ class WsProfileParamsTest {
|
||||
assertSame(original, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectors_list is not profile scoped`() {
|
||||
AuthManager.setActiveProfileId("meow")
|
||||
|
||||
val original = mapOf("session_id" to "sess-123")
|
||||
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_LIST, original)
|
||||
|
||||
assertSame(original, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connectors_connect is not profile scoped`() {
|
||||
AuthManager.setActiveProfileId("meow")
|
||||
|
||||
val original = mapOf("session_id" to "sess-123", "connectors" to listOf("linear"), "reconnect" to false)
|
||||
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_CONNECT, original)
|
||||
|
||||
assertSame(original, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown method is not profile scoped`() {
|
||||
AuthManager.setActiveProfileId("meow")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,146 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatConnectorsViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockDelegate = mockk<ChatConnectorsDelegate>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
ActiveSessionHolder.clear()
|
||||
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ActiveSessionHolder.clear()
|
||||
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialSync_triggersOnActiveSessionChangedWithInitialSession() {
|
||||
ActiveSessionHolder.set("sess-init")
|
||||
|
||||
ChatConnectorsViewModel(delegate = mockDelegate)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { mockDelegate.onActiveSessionChanged("sess-init") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSessionChange_triggersOnActiveSessionChanged() {
|
||||
ActiveSessionHolder.set("sess-1")
|
||||
|
||||
ChatConnectorsViewModel(delegate = mockDelegate)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
ActiveSessionHolder.set("sess-2")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { mockDelegate.onActiveSessionChanged("sess-2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProfileChange_triggersOnActiveSessionChanged() {
|
||||
ActiveSessionHolder.set("sess-1")
|
||||
|
||||
ChatConnectorsViewModel(delegate = mockDelegate)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
AuthManager.setActiveProfileId("profile-new")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testServerChange_triggersOnActiveSessionChanged() {
|
||||
ActiveSessionHolder.set("sess-1")
|
||||
|
||||
ChatConnectorsViewModel(delegate = mockDelegate)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
AuthManager.setBaseUrlForTest("http://192.168.1.199:8080")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReconnect_triggersOnTransportReconnected() {
|
||||
ChatConnectorsViewModel(delegate = mockDelegate)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { mockDelegate.onTransportReconnected() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testForwardsUiMethodsToDelegate() {
|
||||
val uiStateFlow = MutableStateFlow(ChatConnectorsUiState(isVisible = true))
|
||||
val delegate =
|
||||
mockk<ChatConnectorsDelegate>(relaxed = true) {
|
||||
io.mockk.every { uiState } returns uiStateFlow
|
||||
io.mockk.every { takeBrowserEvent(any()) } returns
|
||||
ConnectBrowserEvent(
|
||||
slug = "slug",
|
||||
url = "https://auth.example.com",
|
||||
)
|
||||
}
|
||||
|
||||
val viewModel = ChatConnectorsViewModel(delegate = delegate)
|
||||
|
||||
assertEquals(true, viewModel.uiState.value.isVisible)
|
||||
|
||||
viewModel.show()
|
||||
verify { delegate.show() }
|
||||
|
||||
viewModel.hide()
|
||||
verify { delegate.hide() }
|
||||
|
||||
viewModel.onResume()
|
||||
verify { delegate.onResume() }
|
||||
|
||||
viewModel.onPause()
|
||||
verify { delegate.onPause() }
|
||||
|
||||
viewModel.refresh(force = true)
|
||||
verify { delegate.refresh(force = true) }
|
||||
|
||||
viewModel.connect("github", reconnect = true)
|
||||
verify { delegate.connect("github", reconnect = true) }
|
||||
|
||||
val event = viewModel.takeBrowserEvent(123L)
|
||||
assertEquals("slug", event?.slug)
|
||||
verify { delegate.takeBrowserEvent(123L) }
|
||||
|
||||
viewModel.launchError("test error")
|
||||
verify { delegate.launchError("test error") }
|
||||
|
||||
viewModel.clearError()
|
||||
verify { delegate.clearError() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.m57.hermescontrol.util
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectorUrlValidatorTest {
|
||||
@Test
|
||||
fun `valid https urls are accepted`() {
|
||||
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://auth.hermes.nousresearch.com/oauth/google"))
|
||||
assertTrue(
|
||||
ConnectorUrlValidator.isValidHttpsUrl("https://github.com/login/oauth/authorize?client_id=123&scope=repo"),
|
||||
)
|
||||
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://linear.app/oauth/authorize?response_type=code"))
|
||||
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://slack.com/oauth/v2/authorize"))
|
||||
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:8443/auth?foo=bar#section"))
|
||||
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("HTTPS://EXAMPLE.COM/AUTH"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null, empty, and whitespace strings are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(null))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(""))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(" "))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("\t\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-https schemes are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("http://example.com/auth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("ftp://example.com/file"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("javascript:alert(1)"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("data:text/html,<html>"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("file:///etc/passwd"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("intent://example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `urls with userinfo are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user:password@example.com/oauth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user@example.com/oauth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://admin:@example.com/oauth"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `urls with backslashes are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com\\evil.com"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/path\\something"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:\\\\example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `urls with control characters or whitespace are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\u0000"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\r\nSet-Cookie:bad"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `urls with invalid host or port are rejected`() {
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:///path"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:0/auth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:65536/auth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:99999/auth"))
|
||||
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://invalid_host_chars!/auth"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user