feat: add local-first gateway routing
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
|
||||
+115
-174
@@ -2,6 +2,9 @@ package cloud.molberg.hermesmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Bundle
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
@@ -85,6 +88,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -103,26 +107,22 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionCheckResult
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionForm
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionReevaluationTrigger
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionStatus
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionUiState
|
||||
import cloud.molberg.hermesmobile.connection.ConnectionValidator
|
||||
import cloud.molberg.hermesmobile.connection.GatewayHttpProbe
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteCoordinator
|
||||
import cloud.molberg.hermesmobile.connection.GatewayRouteSelector
|
||||
import cloud.molberg.hermesmobile.connection.SecureConnectionStorage
|
||||
import cloud.molberg.hermesmobile.connection.StoredConnectionRepository
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
enum class Screen(val label: String, val icon: ImageVector, val title: String) {
|
||||
Inbox("Chat", Icons.Filled.ChatBubble, "Chat"),
|
||||
@@ -173,21 +173,18 @@ data class HealthState(
|
||||
|
||||
class CompanionApi(context: Context) {
|
||||
val connectionStorage = SecureConnectionStorage(context)
|
||||
private val connectionRepository = StoredConnectionRepository(connectionStorage) { checkConnection() }
|
||||
private val routeCoordinator = GatewayRouteCoordinator(GatewayRouteSelector(GatewayHttpProbe()))
|
||||
private val connectionRepository = StoredConnectionRepository(connectionStorage, routeCoordinator)
|
||||
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
init {
|
||||
migrateLegacyConnectionPrefs()
|
||||
}
|
||||
|
||||
var baseUrl: String
|
||||
get() = connectionStorage.serverUrl
|
||||
get() = connectionStorage.remoteUrl
|
||||
set(value) {
|
||||
connectionStorage.serverUrl = value
|
||||
connectionStorage.remoteUrl = value
|
||||
}
|
||||
|
||||
var accessKey: String
|
||||
@@ -227,149 +224,48 @@ class CompanionApi(context: Context) {
|
||||
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
|
||||
connectionRepository.save(form)
|
||||
|
||||
suspend fun checkConnectionState(): ConnectionUiState =
|
||||
connectionRepository.check()
|
||||
suspend fun checkConnectionState(
|
||||
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
|
||||
): ConnectionUiState = connectionRepository.check(trigger)
|
||||
|
||||
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
|
||||
val normalizedUrl = ConnectionValidator.normalizeServerUrl(baseUrl)
|
||||
.getOrElse { throw IllegalStateException(it.message ?: "Enter a valid server URL in Settings.") }
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val builder = Request.Builder()
|
||||
.url(normalizedUrl + path)
|
||||
.header("Accept", "application/json")
|
||||
if (connectionStorage.authMode == ConnectionAuthMode.BearerToken && accessKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $accessKey")
|
||||
}
|
||||
val request = when (method) {
|
||||
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
|
||||
"PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
|
||||
else -> builder.get().build()
|
||||
}
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = cleanServerText(response.body?.string().orEmpty())
|
||||
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
throw UnauthorizedConnectionException("Authentication was rejected. Check the access key and retry.")
|
||||
}
|
||||
val message = json?.optString("reply")?.takeIf { it.isNotBlank() }
|
||||
?: json?.optString("message")?.takeIf { it.isNotBlank() }
|
||||
?: json?.optString("error")?.takeIf { it.isNotBlank() }
|
||||
?: text.take(600).ifBlank { "Server returned HTTP ${response.code}." }
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
return json ?: throw IllegalStateException("Server returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFirst(paths: List<String>): JSONObject {
|
||||
var lastError: Throwable? = null
|
||||
for (path in paths) {
|
||||
val result = runCatching { request(path) }
|
||||
result.onSuccess { return it }
|
||||
val error = result.exceptionOrNull()
|
||||
if (error is UnauthorizedConnectionException) throw error
|
||||
lastError = error
|
||||
}
|
||||
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
|
||||
}
|
||||
|
||||
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
|
||||
val json = requestFirst(listOf("/health", "/api/health"))
|
||||
val checks = json.optJSONObject("checks")?.optJSONObject("hermes")
|
||||
val config = json.optJSONObject("config")
|
||||
HealthState(
|
||||
reachable = true,
|
||||
status = json.optString("status", "Gateway is reachable.").ifBlank { "Gateway is reachable." },
|
||||
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.",
|
||||
workspace = config?.optString("workspaceRoot") ?: "Unknown",
|
||||
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
|
||||
mode = json.optString("mode", "-")
|
||||
suspend fun health(trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect): HealthState {
|
||||
val state = checkConnectionState(trigger)
|
||||
return HealthState(
|
||||
reachable = state.connected,
|
||||
status = state.noticeText,
|
||||
hermesCli = "Managed by upstream Hermes",
|
||||
workspace = "Not exposed by documented gateway routes",
|
||||
mode = state.activeRouteLabel
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun validate(): String = withContext(Dispatchers.IO) {
|
||||
requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.")
|
||||
}
|
||||
suspend fun validate(): String = checkConnectionState().message
|
||||
|
||||
suspend fun checkConnection(): ConnectionCheckResult = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val health = health()
|
||||
val authMessage = if (connectionStorage.authMode == ConnectionAuthMode.BearerToken) {
|
||||
validate()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
ConnectionCheckResult.Healthy(authMessage.ifBlank { health.status })
|
||||
}.getOrElse { error ->
|
||||
if (error is UnauthorizedConnectionException) {
|
||||
ConnectionCheckResult.Unauthorized(error.message ?: "Authentication was rejected.")
|
||||
} else {
|
||||
ConnectionCheckResult.Offline(error.message ?: "Server is offline or unreachable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun checkConnection(
|
||||
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
|
||||
): ConnectionUiState = checkConnectionState(trigger)
|
||||
|
||||
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) {
|
||||
val json = request("/api/chat", "POST", JSONObject().put("prompt", prompt))
|
||||
ChatResult(
|
||||
reply = json.optString("reply", ""),
|
||||
hermesAvailable = json.optBoolean("hermesAvailable", false),
|
||||
exitCode = if (json.isNull("exitCode")) null else json.optInt("exitCode"),
|
||||
stderr = json.optString("stderr", "")
|
||||
)
|
||||
}
|
||||
suspend fun chat(prompt: String): ChatResult =
|
||||
throw UnsupportedOperationException("Direct gateway chat integration is scheduled after the session contract is verified.")
|
||||
|
||||
suspend fun testHermes(): String {
|
||||
val result = chat("ping")
|
||||
return if (result.hermesAvailable) {
|
||||
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
|
||||
} else {
|
||||
result.reply.ifBlank { "Hermes is not available through this server." }
|
||||
}
|
||||
}
|
||||
suspend fun testHermes(): String = validate()
|
||||
|
||||
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = withContext(Dispatchers.IO) {
|
||||
val encoded = URLEncoder.encode(path, "UTF-8")
|
||||
val json = request("/api/files?path=$encoded")
|
||||
val entries = json.optJSONArray("entries") ?: JSONArray()
|
||||
json.optString("path", path) to List(entries.length()) { i ->
|
||||
val item = entries.getJSONObject(i)
|
||||
FileEntry(
|
||||
name = item.optString("name"),
|
||||
path = item.optString("path"),
|
||||
type = item.optString("type"),
|
||||
size = item.optLong("size", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = legacyRouteUnavailable()
|
||||
|
||||
suspend fun readFile(path: String): Pair<String, String> = withContext(Dispatchers.IO) {
|
||||
val encoded = URLEncoder.encode(path, "UTF-8")
|
||||
val json = request("/api/files/read?path=$encoded")
|
||||
json.optString("path", path) to json.optString("content", "")
|
||||
}
|
||||
suspend fun readFile(path: String): Pair<String, String> = legacyRouteUnavailable()
|
||||
|
||||
suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) {
|
||||
val json = request("/api/files/write", "POST", JSONObject().put("path", path).put("content", content))
|
||||
"Saved ${json.optLong("bytesWritten")} bytes"
|
||||
}
|
||||
suspend fun writeFile(path: String, content: String): String = legacyRouteUnavailable()
|
||||
|
||||
suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) {
|
||||
val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd))
|
||||
listOf(
|
||||
"$ ${json.optString("command", command)}",
|
||||
json.optString("stdout"),
|
||||
json.optString("stderr").takeIf { it.isNotBlank() }?.let { "stderr:\n$it" }.orEmpty(),
|
||||
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
|
||||
).filter { it.isNotBlank() }.joinToString("\n")
|
||||
}
|
||||
suspend fun runCommand(command: String, cwd: String): String = legacyRouteUnavailable()
|
||||
|
||||
private fun <T> legacyRouteUnavailable(): T =
|
||||
throw UnsupportedOperationException("This legacy companion route is not part of the direct upstream gateway contract.")
|
||||
|
||||
private fun migrateLegacyConnectionPrefs() {
|
||||
val legacyUrl = prefs.getString("baseUrl", null).orEmpty()
|
||||
val legacyAccessKey = prefs.getString("accessKey", null).orEmpty()
|
||||
if (connectionStorage.serverUrl.isBlank() && legacyUrl.isNotBlank()) {
|
||||
connectionStorage.serverUrl = legacyUrl
|
||||
if (connectionStorage.remoteUrl.isBlank() && legacyUrl.isNotBlank()) {
|
||||
connectionStorage.remoteUrl = legacyUrl
|
||||
}
|
||||
if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) {
|
||||
connectionStorage.bearerToken = legacyAccessKey
|
||||
@@ -380,8 +276,6 @@ class CompanionApi(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
|
||||
|
||||
fun cleanServerText(text: String): String =
|
||||
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
|
||||
.replace("\u0000", "")
|
||||
@@ -402,23 +296,24 @@ fun HermesNativeApp() {
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
var screen by remember { mutableStateOf(Screen.Inbox) }
|
||||
var online by remember { mutableStateOf(false) }
|
||||
var connectionState by remember { mutableStateOf(ConnectionStateReducer.initial(api.connectionConfig())) }
|
||||
var resetInboxToken by remember { mutableStateOf(0) }
|
||||
|
||||
fun notify(message: String) {
|
||||
scope.launch { snackbar.showSnackbar(message) }
|
||||
}
|
||||
|
||||
LaunchedEffect(screen) {
|
||||
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy }
|
||||
LaunchedEffect(Unit) {
|
||||
connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
|
||||
}
|
||||
GatewayReevaluationHooks(api) { connectionState = it }
|
||||
|
||||
HermesMobileTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = HermesTheme.colors.background,
|
||||
topBar = {
|
||||
NativeTopBar(screen, online, onAction = {
|
||||
NativeTopBar(screen, connectionState.connected, onAction = {
|
||||
if (screen == Screen.Inbox) {
|
||||
resetInboxToken++
|
||||
notify("New chat started.")
|
||||
@@ -451,13 +346,44 @@ fun HermesNativeApp() {
|
||||
screen = Screen.Inbox
|
||||
notify("New chat started.")
|
||||
})
|
||||
Screen.Settings -> SettingsScreen(api, ::notify)
|
||||
Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
DisposableEffect(api, lifecycleOwner) {
|
||||
fun reevaluate(trigger: ConnectionReevaluationTrigger) {
|
||||
scope.launch { onState(api.checkConnection(trigger)) }
|
||||
}
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_START) reevaluate(ConnectionReevaluationTrigger.Foreground)
|
||||
}
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun slideFor(from: Int, to: Int): ContentTransform {
|
||||
val direction = if (to > from) 1 else -1
|
||||
return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith
|
||||
@@ -867,12 +793,17 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
|
||||
fun SettingsScreen(
|
||||
api: CompanionApi,
|
||||
connectionState: ConnectionUiState,
|
||||
onConnectionState: (ConnectionUiState) -> Unit,
|
||||
notify: (String) -> Unit
|
||||
) {
|
||||
var panel by remember { mutableStateOf(SettingsPanel.Home) }
|
||||
BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home }
|
||||
|
||||
when (panel) {
|
||||
SettingsPanel.Home -> SettingsHome(api, notify, onPanel = { panel = it })
|
||||
SettingsPanel.Home -> SettingsHome(api, connectionState, onConnectionState, notify, onPanel = { panel = it })
|
||||
SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home })
|
||||
SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home })
|
||||
SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home })
|
||||
@@ -881,29 +812,34 @@ fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) {
|
||||
fun SettingsHome(
|
||||
api: CompanionApi,
|
||||
connectionState: ConnectionUiState,
|
||||
onConnectionState: (ConnectionUiState) -> Unit,
|
||||
notify: (String) -> Unit,
|
||||
onPanel: (SettingsPanel) -> Unit
|
||||
) {
|
||||
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val initialConfig = remember { api.connectionConfig() }
|
||||
var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) }
|
||||
var localUrl by remember { mutableStateOf(initialConfig.localUrl) }
|
||||
var remoteUrl by remember { mutableStateOf(initialConfig.remoteUrl) }
|
||||
var authMode by remember { mutableStateOf(initialConfig.authMode) }
|
||||
var accessKey by remember { mutableStateOf("") }
|
||||
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
|
||||
var connectionState by remember {
|
||||
mutableStateOf(ConnectionStateReducer.initial(initialConfig))
|
||||
}
|
||||
|
||||
fun saveAndCheck() {
|
||||
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
|
||||
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
|
||||
val saved = api.saveConnection(form).getOrElse {
|
||||
connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
|
||||
notify(connectionState.message)
|
||||
val invalid = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
|
||||
onConnectionState(invalid)
|
||||
notify(invalid.message)
|
||||
return
|
||||
}
|
||||
connectionState = saved
|
||||
onConnectionState(saved)
|
||||
accessKey = ""
|
||||
scope.launch {
|
||||
val checked = api.checkConnectionState()
|
||||
connectionState = checked
|
||||
onConnectionState(checked)
|
||||
notify(checked.message)
|
||||
}
|
||||
}
|
||||
@@ -912,8 +848,10 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
|
||||
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
|
||||
FlatCard {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
FieldLabel("Hermes gateway URL")
|
||||
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri)
|
||||
FieldLabel("Remote HTTPS URL")
|
||||
AppTextField(remoteUrl, { remoteUrl = it }, "https://hermes.example.com", keyboardType = KeyboardType.Uri)
|
||||
FieldLabel("Local HTTPS URL (optional)")
|
||||
AppTextField(localUrl, { localUrl = it }, "https://hermes.home.arpa", keyboardType = KeyboardType.Uri)
|
||||
FieldLabel("Authentication")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) {
|
||||
@@ -936,15 +874,16 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
|
||||
AppTextField(sessionLabel, { sessionLabel = it }, "Default session")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
|
||||
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
|
||||
connectionState = api.saveConnection(form).getOrElse {
|
||||
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
|
||||
var saved = api.saveConnection(form).getOrElse {
|
||||
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
|
||||
}
|
||||
if (connectionState.status == ConnectionStatus.Checking) {
|
||||
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config)
|
||||
if (saved.status == ConnectionStatus.Checking) {
|
||||
saved = ConnectionStateReducer.savedForManualCheck(saved.config)
|
||||
accessKey = ""
|
||||
}
|
||||
notify(connectionState.message)
|
||||
onConnectionState(saved)
|
||||
notify(saved.message)
|
||||
}
|
||||
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
|
||||
saveAndCheck()
|
||||
@@ -953,6 +892,7 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
|
||||
}
|
||||
}
|
||||
ConnectionNotice(connectionState)
|
||||
MetricRow("Active route", connectionState.activeRouteLabel)
|
||||
SectionHeader("Gateway", "Inspect server health and authentication.")
|
||||
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) }
|
||||
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) }
|
||||
@@ -968,7 +908,8 @@ fun ConnectionNotice(state: ConnectionUiState) {
|
||||
state.noticeText,
|
||||
danger = state.status == ConnectionStatus.Offline ||
|
||||
state.status == ConnectionStatus.Unauthorized ||
|
||||
state.urlError != null
|
||||
state.localUrlError != null ||
|
||||
state.remoteUrlError != null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1032,7 +973,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
|
||||
|
||||
DetailList("Hermes", onBack) {
|
||||
NoticeCard(health.status, danger = !health.reachable)
|
||||
MetricRow("Gateway URL", api.baseUrl.ifBlank { "Not set" })
|
||||
MetricRow("Active route", health.mode)
|
||||
MetricRow("Hermes CLI", health.hermesCli)
|
||||
MetricRow("Workspace", health.workspace)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
+227
-101
@@ -2,20 +2,28 @@ package cloud.molberg.hermesmobile.connection
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
enum class ConnectionAuthMode { None, BearerToken }
|
||||
|
||||
enum class ConnectionRoute { Local, Remote }
|
||||
|
||||
enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized }
|
||||
|
||||
enum class ConnectionReevaluationTrigger { Foreground, ExplicitReconnect, ConnectivityChange, RequestFailure }
|
||||
|
||||
data class ConnectionConfig(
|
||||
val serverUrl: String = "",
|
||||
val localUrl: String = "",
|
||||
val remoteUrl: String = "",
|
||||
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
|
||||
val hasBearerToken: Boolean = false,
|
||||
val sessionLabel: String = "Default session"
|
||||
)
|
||||
|
||||
data class ConnectionForm(
|
||||
val serverUrl: String = "",
|
||||
val localUrl: String = "",
|
||||
val remoteUrl: String = "",
|
||||
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
|
||||
val bearerToken: String = "",
|
||||
val sessionLabel: String = "Default session"
|
||||
@@ -24,17 +32,20 @@ data class ConnectionForm(
|
||||
data class ConnectionUiState(
|
||||
val config: ConnectionConfig = ConnectionConfig(),
|
||||
val status: ConnectionStatus = ConnectionStatus.Unconfigured,
|
||||
val message: String = "Enter a server URL to connect.",
|
||||
val urlError: String? = null,
|
||||
val activeRoute: ConnectionRoute? = null,
|
||||
val message: String = "Enter a remote gateway URL to connect.",
|
||||
val localUrlError: String? = null,
|
||||
val remoteUrlError: String? = null,
|
||||
val canRetry: Boolean = false
|
||||
) {
|
||||
val connected: Boolean get() = status == ConnectionStatus.Connected
|
||||
val activeRouteLabel: String get() = activeRoute?.name ?: "None"
|
||||
val noticeText: String
|
||||
get() {
|
||||
val label = when (status) {
|
||||
ConnectionStatus.Unconfigured -> "Not configured"
|
||||
ConnectionStatus.Checking -> "Checking"
|
||||
ConnectionStatus.Connected -> "Connected"
|
||||
ConnectionStatus.Connected -> "Connected · $activeRouteLabel"
|
||||
ConnectionStatus.Offline -> "Offline"
|
||||
ConnectionStatus.Unauthorized -> "Unauthorized"
|
||||
}
|
||||
@@ -43,137 +54,234 @@ data class ConnectionUiState(
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ConnectionCheckResult {
|
||||
data class Healthy(val message: String) : ConnectionCheckResult
|
||||
data class Offline(val message: String) : ConnectionCheckResult
|
||||
data class Unauthorized(val message: String) : ConnectionCheckResult
|
||||
sealed interface GatewayProbeResult {
|
||||
data class Healthy(val message: String = "") : GatewayProbeResult
|
||||
data class Offline(val message: String) : GatewayProbeResult
|
||||
data class Unauthorized(val message: String) : GatewayProbeResult
|
||||
data class Incompatible(val message: String) : GatewayProbeResult
|
||||
}
|
||||
|
||||
fun interface GatewayProbe {
|
||||
suspend fun probe(baseUrl: String, authMode: ConnectionAuthMode, bearerToken: String): GatewayProbeResult
|
||||
}
|
||||
|
||||
sealed interface RouteSelectionResult {
|
||||
data class Connected(
|
||||
val route: ConnectionRoute,
|
||||
val baseUrl: String,
|
||||
val message: String
|
||||
) : RouteSelectionResult
|
||||
|
||||
data class Offline(val message: String) : RouteSelectionResult
|
||||
data class Unauthorized(val message: String) : RouteSelectionResult
|
||||
}
|
||||
|
||||
data class GatewayRouteLease(
|
||||
val route: ConnectionRoute,
|
||||
val baseUrl: String,
|
||||
val generation: Long
|
||||
)
|
||||
|
||||
object ConnectionValidator {
|
||||
fun normalizeServerUrl(input: String): Result<String> {
|
||||
fun normalizeGatewayUrl(input: String, required: Boolean): Result<String> {
|
||||
val trimmed = input.trim().trimEnd('/')
|
||||
if (trimmed.isBlank()) return Result.failure(IllegalArgumentException("Server URL is required."))
|
||||
if (trimmed.isBlank()) {
|
||||
return if (required) {
|
||||
Result.failure(IllegalArgumentException("Remote gateway URL is required."))
|
||||
} else {
|
||||
Result.success("")
|
||||
}
|
||||
}
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: URISyntaxException) {
|
||||
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
|
||||
return Result.failure(IllegalArgumentException("Enter a valid HTTPS URL."))
|
||||
}
|
||||
if (uri.scheme !in listOf("http", "https") || uri.host.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
|
||||
if (uri.scheme != "https" || uri.host.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("Gateway URLs must use HTTPS."))
|
||||
}
|
||||
if (uri.rawUserInfo != null) {
|
||||
return Result.failure(IllegalArgumentException("Do not include credentials in the server URL."))
|
||||
return Result.failure(IllegalArgumentException("Do not include credentials in a gateway URL."))
|
||||
}
|
||||
if (uri.rawQuery != null || uri.rawFragment != null || uri.path.orEmpty() !in listOf("", "/")) {
|
||||
return Result.failure(IllegalArgumentException("Gateway URLs cannot include a path, query, or fragment."))
|
||||
}
|
||||
return Result.success(trimmed)
|
||||
}
|
||||
|
||||
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> =
|
||||
normalizeServerUrl(form.serverUrl).map { normalized ->
|
||||
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
|
||||
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> {
|
||||
val local = normalizeGatewayUrl(form.localUrl, required = false).getOrElse { return Result.failure(it) }
|
||||
val remote = normalizeGatewayUrl(form.remoteUrl, required = true).getOrElse { return Result.failure(it) }
|
||||
if (local.isNotBlank() && local == remote) {
|
||||
return Result.failure(IllegalArgumentException("Local and remote gateway URLs must be different."))
|
||||
}
|
||||
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
|
||||
if (form.authMode == ConnectionAuthMode.BearerToken && !hasBearerToken) {
|
||||
return Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
|
||||
}
|
||||
return Result.success(
|
||||
ConnectionConfig(
|
||||
serverUrl = normalized,
|
||||
localUrl = local,
|
||||
remoteUrl = remote,
|
||||
authMode = form.authMode,
|
||||
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
|
||||
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { config ->
|
||||
if (form.authMode == ConnectionAuthMode.BearerToken && !config.hasBearerToken) {
|
||||
Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
|
||||
} else {
|
||||
Result.success(config)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayRouteSelector(private val probe: GatewayProbe) {
|
||||
suspend fun select(config: ConnectionConfig, bearerToken: String): RouteSelectionResult {
|
||||
val failures = mutableListOf<GatewayProbeResult>()
|
||||
if (config.localUrl.isNotBlank()) {
|
||||
when (val local = probe.probe(config.localUrl, config.authMode, bearerToken)) {
|
||||
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
|
||||
ConnectionRoute.Local,
|
||||
config.localUrl,
|
||||
local.message.ifBlank { "Local gateway is reachable and compatible." }
|
||||
)
|
||||
else -> failures += local
|
||||
}
|
||||
}
|
||||
when (val remote = probe.probe(config.remoteUrl, config.authMode, bearerToken)) {
|
||||
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
|
||||
ConnectionRoute.Remote,
|
||||
config.remoteUrl,
|
||||
remote.message.ifBlank { "Remote gateway is reachable and compatible." }
|
||||
)
|
||||
else -> failures += remote
|
||||
}
|
||||
val unauthorized = failures.filterIsInstance<GatewayProbeResult.Unauthorized>().lastOrNull()
|
||||
return if (unauthorized != null) {
|
||||
RouteSelectionResult.Unauthorized(unauthorized.message.ifBlank { "Authentication was rejected." })
|
||||
} else {
|
||||
RouteSelectionResult.Offline("Neither configured gateway route is reachable and compatible.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
|
||||
private val mutex = Mutex()
|
||||
@Volatile
|
||||
private var activeLease: GatewayRouteLease? = null
|
||||
private var generation = 0L
|
||||
|
||||
suspend fun reevaluate(config: ConnectionConfig, bearerToken: String): RouteSelectionResult = mutex.withLock {
|
||||
val result = selector.select(config, bearerToken)
|
||||
activeLease = if (result is RouteSelectionResult.Connected) {
|
||||
GatewayRouteLease(result.route, result.baseUrl, ++generation)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
suspend fun routeForRequest(config: ConnectionConfig, bearerToken: String): GatewayRouteLease {
|
||||
activeLease?.takeIf { lease ->
|
||||
lease.baseUrl == config.localUrl || lease.baseUrl == config.remoteUrl
|
||||
}?.let { return it }
|
||||
return when (val selected = reevaluate(config, bearerToken)) {
|
||||
is RouteSelectionResult.Connected -> activeLease!!
|
||||
is RouteSelectionResult.Unauthorized -> throw UnauthorizedConnectionException(selected.message)
|
||||
is RouteSelectionResult.Offline -> throw IllegalStateException(selected.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeRoute(): ConnectionRoute? = activeLease?.route
|
||||
|
||||
fun reportFailure(lease: GatewayRouteLease) {
|
||||
if (activeLease?.generation == lease.generation) activeLease = null
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
activeLease = null
|
||||
}
|
||||
}
|
||||
|
||||
object ConnectionStateReducer {
|
||||
fun initial(config: ConnectionConfig): ConnectionUiState =
|
||||
ConnectionUiState(
|
||||
config = config,
|
||||
status = if (config.serverUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
|
||||
message = if (config.serverUrl.isBlank()) {
|
||||
"Server URL is blank until you enter your Hermes gateway address."
|
||||
} else {
|
||||
"Saved connection is ready to check."
|
||||
},
|
||||
canRetry = config.serverUrl.isNotBlank()
|
||||
)
|
||||
fun initial(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
|
||||
config = config,
|
||||
status = if (config.remoteUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
|
||||
message = if (config.remoteUrl.isBlank()) {
|
||||
"Remote gateway URL is blank until you configure this Hermes profile."
|
||||
} else {
|
||||
"Saved connection is ready to check."
|
||||
},
|
||||
canRetry = config.remoteUrl.isNotBlank()
|
||||
)
|
||||
|
||||
fun saved(config: ConnectionConfig): ConnectionUiState =
|
||||
ConnectionUiState(
|
||||
fun saved(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Checking,
|
||||
message = "Checking local then remote gateway routes..."
|
||||
)
|
||||
|
||||
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Offline,
|
||||
message = "Settings saved. Run Test to select the active route.",
|
||||
canRetry = true
|
||||
)
|
||||
|
||||
fun result(config: ConnectionConfig, result: RouteSelectionResult): ConnectionUiState = when (result) {
|
||||
is RouteSelectionResult.Connected -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Checking,
|
||||
message = "Checking server health...",
|
||||
status = ConnectionStatus.Connected,
|
||||
activeRoute = result.route,
|
||||
message = result.message,
|
||||
canRetry = false
|
||||
)
|
||||
|
||||
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState =
|
||||
ConnectionUiState(
|
||||
is RouteSelectionResult.Offline -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Offline,
|
||||
message = "Settings saved. Run Test to check the server.",
|
||||
message = result.message,
|
||||
canRetry = true
|
||||
)
|
||||
|
||||
fun result(config: ConnectionConfig, result: ConnectionCheckResult): ConnectionUiState =
|
||||
when (result) {
|
||||
is ConnectionCheckResult.Healthy -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Connected,
|
||||
message = result.message.ifBlank { "Server is reachable." },
|
||||
canRetry = false
|
||||
)
|
||||
|
||||
is ConnectionCheckResult.Offline -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Offline,
|
||||
message = result.message.ifBlank { "Server is offline or unreachable." },
|
||||
canRetry = true
|
||||
)
|
||||
|
||||
is ConnectionCheckResult.Unauthorized -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Unauthorized,
|
||||
message = result.message.ifBlank { "Authentication was rejected." },
|
||||
canRetry = true
|
||||
)
|
||||
}
|
||||
|
||||
fun invalid(form: ConnectionForm, message: String): ConnectionUiState =
|
||||
ConnectionUiState(
|
||||
config = ConnectionConfig(
|
||||
serverUrl = form.serverUrl.trim(),
|
||||
authMode = form.authMode,
|
||||
hasBearerToken = form.bearerToken.isNotBlank(),
|
||||
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
|
||||
),
|
||||
status = ConnectionStatus.Unconfigured,
|
||||
message = message,
|
||||
urlError = message,
|
||||
canRetry = false
|
||||
is RouteSelectionResult.Unauthorized -> ConnectionUiState(
|
||||
config = config,
|
||||
status = ConnectionStatus.Unauthorized,
|
||||
message = result.message,
|
||||
canRetry = true
|
||||
)
|
||||
}
|
||||
|
||||
fun invalid(form: ConnectionForm, message: String): ConnectionUiState = ConnectionUiState(
|
||||
config = ConnectionConfig(
|
||||
localUrl = form.localUrl.trim(),
|
||||
remoteUrl = form.remoteUrl.trim(),
|
||||
authMode = form.authMode,
|
||||
hasBearerToken = form.bearerToken.isNotBlank(),
|
||||
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
|
||||
),
|
||||
status = ConnectionStatus.Unconfigured,
|
||||
message = message,
|
||||
localUrlError = message.takeIf { form.localUrl.isNotBlank() },
|
||||
remoteUrlError = message,
|
||||
canRetry = false
|
||||
)
|
||||
}
|
||||
|
||||
interface ConnectionStore {
|
||||
var serverUrl: String
|
||||
var localUrl: String
|
||||
var remoteUrl: String
|
||||
var sessionLabel: String
|
||||
var authMode: ConnectionAuthMode
|
||||
var bearerToken: String
|
||||
|
||||
fun config(): ConnectionConfig =
|
||||
ConnectionConfig(
|
||||
serverUrl = serverUrl,
|
||||
authMode = authMode,
|
||||
hasBearerToken = bearerToken.isNotBlank(),
|
||||
sessionLabel = sessionLabel
|
||||
)
|
||||
fun config(): ConnectionConfig = ConnectionConfig(
|
||||
localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
|
||||
remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
|
||||
authMode = authMode,
|
||||
hasBearerToken = bearerToken.isNotBlank(),
|
||||
sessionLabel = sessionLabel
|
||||
)
|
||||
|
||||
fun save(form: ConnectionForm, normalizedUrl: String) {
|
||||
serverUrl = normalizedUrl
|
||||
fun save(form: ConnectionForm, config: ConnectionConfig) {
|
||||
localUrl = config.localUrl
|
||||
remoteUrl = config.remoteUrl
|
||||
authMode = form.authMode
|
||||
sessionLabel = form.sessionLabel
|
||||
sessionLabel = config.sessionLabel
|
||||
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
|
||||
}
|
||||
}
|
||||
@@ -181,12 +289,14 @@ interface ConnectionStore {
|
||||
interface ConnectionRepository {
|
||||
fun config(): ConnectionConfig
|
||||
fun save(form: ConnectionForm): Result<ConnectionUiState>
|
||||
suspend fun check(): ConnectionUiState
|
||||
suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState
|
||||
suspend fun routeForRequest(): GatewayRouteLease
|
||||
fun reportRequestFailure(lease: GatewayRouteLease)
|
||||
}
|
||||
|
||||
class StoredConnectionRepository(
|
||||
private val store: ConnectionStore,
|
||||
private val healthCheck: suspend () -> ConnectionCheckResult
|
||||
private val coordinator: GatewayRouteCoordinator
|
||||
) : ConnectionRepository {
|
||||
override fun config(): ConnectionConfig = store.config()
|
||||
|
||||
@@ -200,14 +310,30 @@ class StoredConnectionRepository(
|
||||
} else {
|
||||
form
|
||||
}
|
||||
return ConnectionValidator.validateForm(effectiveForm).mapCatching { config ->
|
||||
store.save(effectiveForm, config.serverUrl)
|
||||
return ConnectionValidator.validateForm(effectiveForm, store.bearerToken.isNotBlank()).mapCatching { config ->
|
||||
store.save(effectiveForm, config)
|
||||
coordinator.clear()
|
||||
ConnectionStateReducer.saved(store.config())
|
||||
}.recoverCatching {
|
||||
throw IllegalArgumentException(it.message ?: "Connection settings are invalid.")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun check(): ConnectionUiState =
|
||||
ConnectionStateReducer.result(store.config(), healthCheck())
|
||||
override suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState =
|
||||
store.config().let { config ->
|
||||
if (config.remoteUrl.isBlank()) {
|
||||
ConnectionStateReducer.initial(config)
|
||||
} else {
|
||||
ConnectionStateReducer.result(config, coordinator.reevaluate(config, store.bearerToken))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun routeForRequest(): GatewayRouteLease =
|
||||
coordinator.routeForRequest(store.config(), store.bearerToken)
|
||||
|
||||
override fun reportRequestFailure(lease: GatewayRouteLease) {
|
||||
coordinator.reportFailure(lease)
|
||||
}
|
||||
}
|
||||
|
||||
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package cloud.molberg.hermesmobile.connection
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class GatewayHttpProbe(
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
) : GatewayProbe {
|
||||
override suspend fun probe(
|
||||
baseUrl: String,
|
||||
authMode: ConnectionAuthMode,
|
||||
bearerToken: String
|
||||
): GatewayProbeResult {
|
||||
val health = getJson("$baseUrl/health")
|
||||
if (health != HttpProbeResult.Success) return health.toGatewayResult("Health probe")
|
||||
if (authMode == ConnectionAuthMode.None) {
|
||||
return GatewayProbeResult.Healthy("Gateway health check passed.")
|
||||
}
|
||||
val compatibility = getJson("$baseUrl/v1/models", bearerToken)
|
||||
return when (compatibility) {
|
||||
HttpProbeResult.Success -> GatewayProbeResult.Healthy("Gateway health and bearer authentication checks passed.")
|
||||
else -> compatibility.toGatewayResult("Authenticated compatibility check")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJson(url: String, bearerToken: String = ""): HttpProbeResult {
|
||||
val builder = Request.Builder().url(url).get().header("Accept", "application/json")
|
||||
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
|
||||
return try {
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
when {
|
||||
response.code == 401 || response.code == 403 -> HttpProbeResult.Unauthorized
|
||||
!response.isSuccessful -> HttpProbeResult.Unavailable
|
||||
body.isBlank() || !isJson(body) -> HttpProbeResult.Incompatible
|
||||
else -> HttpProbeResult.Success
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
HttpProbeResult.Unavailable
|
||||
} catch (_: IllegalArgumentException) {
|
||||
HttpProbeResult.Incompatible
|
||||
}
|
||||
}
|
||||
|
||||
private fun isJson(body: String): Boolean =
|
||||
runCatching { JSONObject(body) }.isSuccess || runCatching { JSONArray(body) }.isSuccess
|
||||
|
||||
private fun HttpProbeResult.toGatewayResult(check: String): GatewayProbeResult = when (this) {
|
||||
HttpProbeResult.Success -> GatewayProbeResult.Healthy()
|
||||
HttpProbeResult.Unauthorized -> GatewayProbeResult.Unauthorized("$check was unauthorized.")
|
||||
HttpProbeResult.Incompatible -> GatewayProbeResult.Incompatible("$check returned an incompatible response.")
|
||||
HttpProbeResult.Unavailable -> GatewayProbeResult.Offline("$check could not reach the gateway.")
|
||||
}
|
||||
|
||||
private enum class HttpProbeResult { Success, Unauthorized, Incompatible, Unavailable }
|
||||
|
||||
private companion object {
|
||||
const val PROBE_TIMEOUT_SECONDS = 3L
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -15,10 +15,16 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("connection", Context.MODE_PRIVATE)
|
||||
|
||||
override var serverUrl: String
|
||||
get() = prefs.getString(KEY_URL, "") ?: ""
|
||||
override var localUrl: String
|
||||
get() = prefs.getString(KEY_LOCAL_URL, "") ?: ""
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply()
|
||||
prefs.edit().putString(KEY_LOCAL_URL, value.trim().trimEnd('/')).apply()
|
||||
}
|
||||
|
||||
override var remoteUrl: String
|
||||
get() = prefs.getString(KEY_REMOTE_URL, prefs.getString(KEY_LEGACY_URL, "")) ?: ""
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_REMOTE_URL, value.trim().trimEnd('/')).remove(KEY_LEGACY_URL).apply()
|
||||
}
|
||||
|
||||
override var sessionLabel: String
|
||||
@@ -84,7 +90,9 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
|
||||
const val KEY_ALIAS = "hermes_mobile_connection"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val GCM_TAG_BITS = 128
|
||||
const val KEY_URL = "serverUrl"
|
||||
const val KEY_LOCAL_URL = "localUrl"
|
||||
const val KEY_REMOTE_URL = "remoteUrl"
|
||||
const val KEY_LEGACY_URL = "serverUrl"
|
||||
const val KEY_SESSION = "sessionLabel"
|
||||
const val KEY_AUTH_MODE = "authMode"
|
||||
const val KEY_BEARER_TOKEN = "bearerToken"
|
||||
|
||||
+159
-113
@@ -1,163 +1,209 @@
|
||||
package cloud.molberg.hermesmobile.connection
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionStateTest {
|
||||
@Test
|
||||
fun validatorNormalizesHttpUrlsAndRejectsUnsafeCredentials() {
|
||||
val normalized = ConnectionValidator.normalizeServerUrl(" https://hermes.example.test/ ").getOrThrow()
|
||||
val embeddedCredentials = ConnectionValidator.normalizeServerUrl("https://user:secret@hermes.example.test")
|
||||
|
||||
assertEquals("https://hermes.example.test", normalized)
|
||||
assertTrue(embeddedCredentials.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatorRequiresTokenForBearerAuthentication() {
|
||||
val missingToken = ConnectionValidator.validateForm(
|
||||
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.BearerToken)
|
||||
)
|
||||
val blankToken = ConnectionValidator.validateForm(
|
||||
fun validatorRequiresHttpsRemoteAndRejectsEmbeddedCredentialsOrPaths() {
|
||||
val valid = ConnectionValidator.validateForm(
|
||||
ConnectionForm(
|
||||
serverUrl = "http://10.0.2.2:8787",
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
bearerToken = " "
|
||||
localUrl = " https://hermes.home.arpa/ ",
|
||||
remoteUrl = " https://hermes.example.test/ ",
|
||||
authMode = ConnectionAuthMode.None
|
||||
)
|
||||
)
|
||||
val anonymous = ConnectionValidator.validateForm(
|
||||
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.None)
|
||||
)
|
||||
|
||||
assertTrue(missingToken.isFailure)
|
||||
assertTrue(blankToken.isFailure)
|
||||
assertTrue(anonymous.isSuccess)
|
||||
assertFalse(anonymous.getOrThrow().hasBearerToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatorAcceptsStoredBearerTokenWithoutExposingItInConfig() {
|
||||
val config = ConnectionValidator.validateForm(
|
||||
ConnectionForm(
|
||||
serverUrl = "http://10.0.2.2:8787",
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
bearerToken = ""
|
||||
),
|
||||
hasStoredBearerToken = true
|
||||
).getOrThrow()
|
||||
|
||||
assertTrue(config.hasBearerToken)
|
||||
assertEquals("http://10.0.2.2:8787", config.serverUrl)
|
||||
assertEquals("https://hermes.home.arpa", valid.localUrl)
|
||||
assertEquals("https://hermes.example.test", valid.remoteUrl)
|
||||
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "http://example.test", authMode = ConnectionAuthMode.None)).isFailure)
|
||||
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://user:secret@example.test", authMode = ConnectionAuthMode.None)).isFailure)
|
||||
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://example.test/base", authMode = ConnectionAuthMode.None)).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatorRequiresRemoteUrlTokenAndDistinctRoutes() {
|
||||
assertTrue(ConnectionValidator.validateForm(ConnectionForm(authMode = ConnectionAuthMode.None)).isFailure)
|
||||
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE)).isFailure)
|
||||
assertTrue(
|
||||
ConnectionValidator.validateForm(
|
||||
ConnectionForm(localUrl = REMOTE, remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)
|
||||
).isFailure
|
||||
)
|
||||
assertTrue(
|
||||
ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE), hasStoredBearerToken = true).isSuccess
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() {
|
||||
val config = ConnectionConfig(
|
||||
serverUrl = "http://10.0.2.2:8787",
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
hasBearerToken = true
|
||||
fun selectorPrefersCompatibleLocalWithoutProbingRemote() = runBlocking {
|
||||
val probe = RecordingProbe(mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")))
|
||||
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
|
||||
|
||||
assertEquals(ConnectionRoute.Local, (result as RouteSelectionResult.Connected).route)
|
||||
assertEquals(listOf(LOCAL), probe.urls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectorFallsBackToRemoteAfterLocalFailure() = runBlocking {
|
||||
val probe = RecordingProbe(
|
||||
mapOf(
|
||||
LOCAL to GatewayProbeResult.Offline("local offline"),
|
||||
REMOTE to GatewayProbeResult.Healthy("remote ok")
|
||||
)
|
||||
)
|
||||
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
|
||||
|
||||
val offline = ConnectionStateReducer.result(config, ConnectionCheckResult.Offline("No route to host."))
|
||||
val unauthorized = ConnectionStateReducer.result(config, ConnectionCheckResult.Unauthorized("Rejected."))
|
||||
val connected = ConnectionStateReducer.result(config, ConnectionCheckResult.Healthy("Server is reachable."))
|
||||
assertEquals(ConnectionRoute.Remote, (result as RouteSelectionResult.Connected).route)
|
||||
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectorSurfacesUnauthorizedAfterBothRoutesFail() = runBlocking {
|
||||
val probe = RecordingProbe(
|
||||
mapOf(
|
||||
LOCAL to GatewayProbeResult.Unauthorized("local rejected"),
|
||||
REMOTE to GatewayProbeResult.Offline("remote offline")
|
||||
)
|
||||
)
|
||||
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
|
||||
|
||||
assertTrue(result is RouteSelectionResult.Unauthorized)
|
||||
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coordinatorKeepsInflightLeaseStableAndReevaluatesNextRequest() = runBlocking {
|
||||
val probe = SequencedProbe(
|
||||
mutableListOf(
|
||||
mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")),
|
||||
mapOf(
|
||||
LOCAL to GatewayProbeResult.Offline("local offline"),
|
||||
REMOTE to GatewayProbeResult.Healthy("remote ok")
|
||||
)
|
||||
)
|
||||
)
|
||||
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(probe))
|
||||
val first = coordinator.routeForRequest(config(), TOKEN)
|
||||
|
||||
coordinator.reportFailure(first)
|
||||
val second = coordinator.routeForRequest(config(), TOKEN)
|
||||
|
||||
assertEquals(ConnectionRoute.Local, first.route)
|
||||
assertEquals(LOCAL, first.baseUrl)
|
||||
assertEquals(ConnectionRoute.Remote, second.route)
|
||||
assertEquals(REMOTE, second.baseUrl)
|
||||
assertNotEquals(first.generation, second.generation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reducerReportsRouteOfflineUnauthorizedAndRetryState() {
|
||||
val connected = ConnectionStateReducer.result(
|
||||
config(),
|
||||
RouteSelectionResult.Connected(ConnectionRoute.Local, LOCAL, "ready")
|
||||
)
|
||||
val offline = ConnectionStateReducer.result(config(), RouteSelectionResult.Offline("offline"))
|
||||
val unauthorized = ConnectionStateReducer.result(config(), RouteSelectionResult.Unauthorized("rejected"))
|
||||
|
||||
assertEquals(ConnectionStatus.Connected, connected.status)
|
||||
assertEquals("Local", connected.activeRouteLabel)
|
||||
assertFalse(connected.canRetry)
|
||||
assertEquals(ConnectionStatus.Offline, offline.status)
|
||||
assertTrue(offline.canRetry)
|
||||
assertEquals(ConnectionStatus.Unauthorized, unauthorized.status)
|
||||
assertTrue(unauthorized.canRetry)
|
||||
assertEquals(ConnectionStatus.Connected, connected.status)
|
||||
assertFalse(connected.canRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidFormKeepsDraftValuesWithoutClaimingConnection() {
|
||||
val state = ConnectionStateReducer.invalid(
|
||||
ConnectionForm(serverUrl = "ftp://example.test", bearerToken = "redacted-test-token"),
|
||||
"Enter a valid http or https URL."
|
||||
)
|
||||
|
||||
assertEquals(ConnectionStatus.Unconfigured, state.status)
|
||||
assertEquals("ftp://example.test", state.config.serverUrl)
|
||||
assertTrue(state.config.hasBearerToken)
|
||||
assertEquals("Enter a valid http or https URL.", state.urlError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoryPreservesExistingBearerTokenWhenSavingMetadataOnly() {
|
||||
val store = InMemoryConnectionStore().apply {
|
||||
serverUrl = "https://old.example.test"
|
||||
authMode = ConnectionAuthMode.BearerToken
|
||||
bearerToken = "stored-token"
|
||||
}
|
||||
val repository = StoredConnectionRepository(store) {
|
||||
ConnectionCheckResult.Healthy("ok")
|
||||
}
|
||||
|
||||
val saved = repository.save(
|
||||
ConnectionForm(
|
||||
serverUrl = " https://hermes.example.test/ ",
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
bearerToken = "",
|
||||
sessionLabel = "Mobile"
|
||||
)
|
||||
).getOrThrow()
|
||||
|
||||
assertEquals(ConnectionStatus.Checking, saved.status)
|
||||
assertEquals("https://hermes.example.test", store.serverUrl)
|
||||
assertEquals("stored-token", store.bearerToken)
|
||||
assertEquals("Mobile", store.sessionLabel)
|
||||
assertTrue(saved.config.hasBearerToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoryClearsBearerTokenForAnonymousConnection() {
|
||||
val store = InMemoryConnectionStore().apply {
|
||||
bearerToken = "stored-token"
|
||||
authMode = ConnectionAuthMode.BearerToken
|
||||
}
|
||||
val repository = StoredConnectionRepository(store) {
|
||||
ConnectionCheckResult.Healthy("ok")
|
||||
}
|
||||
fun repositoryStoresOneProfileAndPreservesOrClearsToken() {
|
||||
val store = InMemoryConnectionStore().apply { bearerToken = TOKEN }
|
||||
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(RecordingProbe(emptyMap())))
|
||||
val repository = StoredConnectionRepository(store, coordinator)
|
||||
|
||||
repository.save(
|
||||
ConnectionForm(
|
||||
serverUrl = "http://10.0.2.2:8787",
|
||||
authMode = ConnectionAuthMode.None,
|
||||
bearerToken = "ignored-token"
|
||||
localUrl = LOCAL,
|
||||
remoteUrl = REMOTE,
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
sessionLabel = " Mobile "
|
||||
)
|
||||
).getOrThrow()
|
||||
|
||||
assertEquals(ConnectionAuthMode.None, store.authMode)
|
||||
assertEquals(LOCAL, store.localUrl)
|
||||
assertEquals(REMOTE, store.remoteUrl)
|
||||
assertEquals(TOKEN, store.bearerToken)
|
||||
assertEquals("Mobile", store.sessionLabel)
|
||||
|
||||
repository.save(ConnectionForm(remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)).getOrThrow()
|
||||
assertEquals("", store.bearerToken)
|
||||
assertFalse(repository.config().hasBearerToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoryCheckMapsHealthResultToUiState() {
|
||||
fun storedLegacyCleartextUrlIsNotReturnedAsAUsableRoute() {
|
||||
val store = InMemoryConnectionStore().apply {
|
||||
serverUrl = "http://10.0.2.2:8787"
|
||||
authMode = ConnectionAuthMode.BearerToken
|
||||
bearerToken = "stored-token"
|
||||
}
|
||||
val repository = StoredConnectionRepository(store) {
|
||||
ConnectionCheckResult.Unauthorized("Authentication was rejected.")
|
||||
remoteUrl = "http://legacy.example.test"
|
||||
authMode = ConnectionAuthMode.None
|
||||
}
|
||||
|
||||
val state = kotlinx.coroutines.runBlocking { repository.check() }
|
||||
assertEquals("", store.config().remoteUrl)
|
||||
assertEquals(ConnectionStatus.Unconfigured, ConnectionStateReducer.initial(store.config()).status)
|
||||
}
|
||||
|
||||
assertEquals(ConnectionStatus.Unauthorized, state.status)
|
||||
assertTrue(state.canRetry)
|
||||
assertTrue(state.config.hasBearerToken)
|
||||
private fun config() = ConnectionConfig(
|
||||
localUrl = LOCAL,
|
||||
remoteUrl = REMOTE,
|
||||
authMode = ConnectionAuthMode.BearerToken,
|
||||
hasBearerToken = true
|
||||
)
|
||||
|
||||
private class RecordingProbe(private val results: Map<String, GatewayProbeResult>) : GatewayProbe {
|
||||
val urls = mutableListOf<String>()
|
||||
|
||||
override suspend fun probe(
|
||||
baseUrl: String,
|
||||
authMode: ConnectionAuthMode,
|
||||
bearerToken: String
|
||||
): GatewayProbeResult {
|
||||
urls += baseUrl
|
||||
assertEquals(ConnectionAuthMode.BearerToken, authMode)
|
||||
assertEquals(TOKEN, bearerToken)
|
||||
return results[baseUrl] ?: GatewayProbeResult.Offline("offline")
|
||||
}
|
||||
}
|
||||
|
||||
private class SequencedProbe(
|
||||
private val attempts: MutableList<Map<String, GatewayProbeResult>>
|
||||
) : GatewayProbe {
|
||||
private var attemptIndex = 0
|
||||
|
||||
override suspend fun probe(
|
||||
baseUrl: String,
|
||||
authMode: ConnectionAuthMode,
|
||||
bearerToken: String
|
||||
): GatewayProbeResult {
|
||||
val attempt = attempts[attemptIndex]
|
||||
val result = attempt[baseUrl] ?: GatewayProbeResult.Offline("offline")
|
||||
if (baseUrl == REMOTE || result is GatewayProbeResult.Healthy) attemptIndex++
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryConnectionStore : ConnectionStore {
|
||||
override var serverUrl: String = ""
|
||||
override var localUrl: String = ""
|
||||
override var remoteUrl: String = ""
|
||||
override var sessionLabel: String = "Default session"
|
||||
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
|
||||
override var bearerToken: String = ""
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOCAL = "https://hermes.home.arpa"
|
||||
const val REMOTE = "https://hermes.example.test"
|
||||
const val TOKEN = "test-token"
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package cloud.molberg.hermesmobile.connection
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayHttpProbeTest {
|
||||
@Test
|
||||
fun bearerProbeChecksHealthWithoutCredentialsBeforeModelsWithBearer() = runBlocking {
|
||||
val requests = mutableListOf<Pair<String, String?>>()
|
||||
val probe = GatewayHttpProbe(client { chain ->
|
||||
requests += chain.request().url.encodedPath to chain.request().header("Authorization")
|
||||
jsonResponse(chain, 200)
|
||||
})
|
||||
|
||||
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
|
||||
|
||||
assertTrue(result is GatewayProbeResult.Healthy)
|
||||
assertEquals(listOf("/health" to null, "/v1/models" to "Bearer $TOKEN"), requests)
|
||||
assertFalse((result as GatewayProbeResult.Healthy).message.contains(TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anonymousProbeStopsAfterHealth() = runBlocking {
|
||||
val paths = mutableListOf<String>()
|
||||
val probe = GatewayHttpProbe(client { chain ->
|
||||
paths += chain.request().url.encodedPath
|
||||
jsonResponse(chain, 200)
|
||||
})
|
||||
|
||||
val result = probe.probe(BASE_URL, ConnectionAuthMode.None, "")
|
||||
|
||||
assertTrue(result is GatewayProbeResult.Healthy)
|
||||
assertEquals(listOf("/health"), paths)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedCompatibilityRejectionIsUnauthorized() = runBlocking {
|
||||
val probe = GatewayHttpProbe(client { chain ->
|
||||
if (chain.request().url.encodedPath == "/health") jsonResponse(chain, 200) else jsonResponse(chain, 401)
|
||||
})
|
||||
|
||||
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
|
||||
|
||||
assertTrue(result is GatewayProbeResult.Unauthorized)
|
||||
assertFalse((result as GatewayProbeResult.Unauthorized).message.contains(BASE_URL))
|
||||
assertFalse(result.message.contains(TOKEN))
|
||||
}
|
||||
|
||||
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.addInterceptor(Interceptor { chain -> interceptor(chain) })
|
||||
.build()
|
||||
|
||||
private fun jsonResponse(chain: Interceptor.Chain, code: Int): Response = Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("test")
|
||||
.body("{}".toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "https://hermes.example.test"
|
||||
const val TOKEN = "secret-test-token"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user