[feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)

* [feature] issue #634: HTTPS + reverse-proxy server endpoint support

- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API

Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>

* fix(authlogin): basic-auth derives token automatically, no manual entry

connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.

Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).

Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).

Fixes the basic-auth token prompt reported against issue #634.

* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)

* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)

* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login

- Replace 302-sniff probe with authoritative GET /api/status read
  (auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
  (basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.

Closes nothing; OAuth tracked in #639.

* fix(chat): don't block WS connect on empty token in gated (ticket) mode

connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.

Only require a non-blank token in non-gated mode.

Fixes #640.

* test: remove onPairingString test cases for deleted pairing-login path

The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.

Fixes the Unit & Integration Tests CI failure on PR #638.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-07-20 17:26:10 +03:00
committed by GitHub
co-authored by hy4ri
parent d839c5e9ad
commit 26b104b2bb
40 changed files with 1299 additions and 688 deletions
+15 -2
View File
@@ -1,6 +1,19 @@
# GitGuardian configuration # GitGuardian configuration
# Ignore test files — they contain fixture tokens, not real secrets # Ignore test files — they contain fixture tokens / base64 test payloads, not real secrets.
paths-ignore: paths-ignore:
# Full test source trees (unit + instrumented + variant-specific)
- 'app/src/test/**' - 'app/src/test/**'
- 'app/src/androidTest/**'
- 'app/src/testDebug/**'
- 'app/src/testRelease/**'
- 'app/src/androidTestDebug/**'
- 'app/src/androidTestRelease/**'
- '**/testFixtures/**'
# Kotlin test files by naming convention
- '**/*Test.kt' - '**/*Test.kt'
- '**/*test*' - '**/*Tests.kt'
- '**/*Test*.kt'
- '**/*Spec.kt'
# AndroidManifest / resources under test
- '**/androidTest/**'
- '**/test/**'
+6
View File
@@ -71,11 +71,17 @@ android {
} }
buildTypes { buildTypes {
debug {
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs["release"] signingConfig = signingConfigs["release"]
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
} }
} }
compileOptions { compileOptions {
+1 -1
View File
@@ -16,7 +16,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="${usesCleartextTraffic}"
android:theme="@style/Theme.HermesControl"> android:theme="@style/Theme.HermesControl">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.config package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -7,6 +9,21 @@ import java.util.UUID
data class ConnectionProfile( data class ConnectionProfile(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val name: String, val name: String,
val host: String, /** Legacy fields retained only so pre-base-URL profiles can migrate. */
val port: Int, val host: String = "127.0.0.1",
) val port: Int = 9119,
val baseUrl: String? = null,
) {
val resolvedBaseUrl: String
get() =
baseUrl
?.let {
ServerEndpoint
.parse(
it,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
}
@@ -1,5 +1,8 @@
package com.m57.hermescontrol.data.config package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStoreState { fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStoreState {
val updated = connectionProfiles.filter { it.id != profile.id } + profile val updated = connectionProfiles.filter { it.id != profile.id } + profile
return copy(connectionProfiles = updated) return copy(connectionProfiles = updated)
@@ -31,14 +34,36 @@ fun ServerStoreState.selfHealed(): ServerStoreState {
) )
} }
val ServerStoreState.resolvedHost: String val ServerStoreState.resolvedBaseUrl: String
get() { get() {
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId } val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId }
return selected?.host ?: host if (selected != null) return selected.resolvedBaseUrl
return baseUrl
?.let {
ServerEndpoint
.parse(
it,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
} }
val ServerStoreState.resolvedHost: String
get() =
ServerEndpoint
.parse(
resolvedBaseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.host
val ServerStoreState.resolvedPort: Int val ServerStoreState.resolvedPort: Int
get() { get() =
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId } ServerEndpoint
return selected?.port ?: port .parse(
} resolvedBaseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.port
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.config
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import com.m57.hermescontrol.data.remote.ServerEndpoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -29,7 +30,7 @@ class ServerStore(
try { try {
dataStore.data.first().selfHealed() dataStore.data.first().selfHealed()
} catch (e: Exception) { } catch (e: Exception) {
ServerStoreState() ServerStoreSerializer.defaultValue
} }
} }
_stateFlow = MutableStateFlow(initial) _stateFlow = MutableStateFlow(initial)
@@ -60,7 +61,8 @@ object ServerStoreSerializer : Serializer<ServerStoreState> {
prettyPrint = false prettyPrint = false
} }
override val defaultValue: ServerStoreState = ServerStoreState() override val defaultValue: ServerStoreState =
ServerStoreState(baseUrl = ServerEndpoint.DEFAULT_BASE_URL)
override suspend fun readFrom(input: InputStream): ServerStoreState = override suspend fun readFrom(input: InputStream): ServerStoreState =
try { try {
@@ -7,6 +7,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
@@ -18,11 +19,30 @@ class ServerStoreMigration(
companion object { companion object {
private const val PREFS_FILE = "hermes_secure_prefs" private const val PREFS_FILE = "hermes_secure_prefs"
private const val KEY_MIGRATED = "migrated_to_datastore" private const val KEY_MIGRATED = "migrated_to_datastore"
private val LEGACY_KEYS =
setOf(
"host",
"port",
"auto_reconnect",
"theme_preference",
"use_dynamic_colors",
"theme_preset",
"bottom_nav_display_mode",
"bottom_nav_items",
"connection_profiles",
"selected_profile_id",
"pinned_models",
"ws_auth_param",
"typing_effect_enabled",
"typing_effect_delay_ms",
"auth_token",
)
} }
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean { override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean {
val prefs = getPrefs() ?: return false val prefs = getPrefs() ?: return false
return !prefs.getBoolean(KEY_MIGRATED, false) if (prefs.getBoolean(KEY_MIGRATED, false)) return false
return LEGACY_KEYS.any(prefs::contains)
} }
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState { override suspend fun migrate(currentData: ServerStoreState): ServerStoreState {
@@ -92,13 +112,24 @@ class ServerStoreMigration(
return currentData.copy( return currentData.copy(
host = host, host = host,
port = port, port = port,
baseUrl = ServerEndpoint.fromLegacy(host, port).baseUrl.toString(),
autoReconnect = autoReconnect, autoReconnect = autoReconnect,
themePreference = themePreference, themePreference = themePreference,
useDynamicColors = useDynamicColors, useDynamicColors = useDynamicColors,
themePreset = themePreset, themePreset = themePreset,
bottomNavDisplayMode = bottomNavDisplayMode, bottomNavDisplayMode = bottomNavDisplayMode,
bottomNavItems = bottomNavItems, bottomNavItems = bottomNavItems,
connectionProfiles = connectionProfiles, connectionProfiles =
connectionProfiles.map { profile ->
profile.copy(
baseUrl =
profile.baseUrl
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
)
},
selectedProfileId = selectedProfileId, selectedProfileId = selectedProfileId,
pinnedModels = pinnedModels, pinnedModels = pinnedModels,
wsAuthParam = wsAuthParam, wsAuthParam = wsAuthParam,
@@ -8,8 +8,10 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ServerStoreState( data class ServerStoreState(
/** Legacy host and port retained for one-time migration. */
val host: String = "127.0.0.1", val host: String = "127.0.0.1",
val port: Int = 9119, val port: Int = 9119,
val baseUrl: String? = null,
val autoReconnect: Boolean = true, val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM, val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true, val useDynamicColors: Boolean = true,
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.data.config
import androidx.datastore.core.DataMigration
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.ServerEndpoint
/** Migrates the former host-and-port model to canonical complete base URLs. */
class ServerUrlMigration : DataMigration<ServerStoreState> {
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean =
currentData.baseUrl.isNullOrBlank() ||
currentData.connectionProfiles.any { it.baseUrl.isNullOrBlank() }
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState = migrateState(currentData)
override suspend fun cleanUp() = Unit
companion object {
internal fun migrateState(state: ServerStoreState): ServerStoreState =
state.copy(
baseUrl =
normalizeExisting(state.baseUrl)
?: ServerEndpoint.fromLegacy(
state.host,
state.port,
).baseUrl.toString(),
connectionProfiles =
state.connectionProfiles.map { profile ->
profile.copy(
baseUrl =
normalizeExisting(profile.baseUrl)
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
)
},
)
private fun normalizeExisting(raw: String?): String? {
if (raw.isNullOrBlank()) return null
return ServerEndpoint
.parse(
raw,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
}
}
}
@@ -8,10 +8,14 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.ServerStore import com.m57.hermescontrol.data.config.ServerStore
import com.m57.hermescontrol.data.config.ServerStoreMigration import com.m57.hermescontrol.data.config.ServerStoreMigration
import com.m57.hermescontrol.data.config.ServerStoreSerializer import com.m57.hermescontrol.data.config.ServerStoreSerializer
import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
@@ -87,7 +91,11 @@ object AuthManager {
val dataStore = val dataStore =
androidx.datastore.core.DataStoreFactory.create( androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer, serializer = ServerStoreSerializer,
migrations = listOf(ServerStoreMigration(context)), migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) { ) {
context.filesDir.resolve("server_store.json") context.filesDir.resolve("server_store.json")
} }
@@ -174,9 +182,7 @@ object AuthManager {
fun getSessionCookie(): String? = CookieManager.getSessionCookie() fun getSessionCookie(): String? = CookieManager.getSessionCookie()
fun setSessionCookie(cookie: String?) { fun setSessionCookie(cookie: String?) {
// The session cookie is host-scoped to the current dashboard host so CookieManager.setSessionCookie(cookie, endpoint())
// the CookieJar only attaches it to matching requests.
CookieManager.setSessionCookie(cookie, AuthManager.getHost())
} }
/** /**
@@ -237,8 +243,7 @@ object AuthManager {
ConnectionProfile( ConnectionProfile(
id = DEFAULT_PROFILE_ID, id = DEFAULT_PROFILE_ID,
name = DEFAULT_PROFILE_NAME, name = DEFAULT_PROFILE_NAME,
host = s.host, baseUrl = s.resolvedBaseUrl,
port = s.port,
), ),
), ),
selectedProfileId = DEFAULT_PROFILE_ID, selectedProfileId = DEFAULT_PROFILE_ID,
@@ -359,7 +364,7 @@ object AuthManager {
appScope?.let { appScope?.let {
try { try {
it.cancel() it.cancel()
} catch (e: Exception) { } catch (_: Exception) {
} }
} }
appScope = null appScope = null
@@ -400,11 +405,24 @@ object AuthManager {
} }
} }
// ── Host ───────────────────────────────────────────────────────────── // ── Server endpoint ──────────────────────────────────────────────────
fun getHost(): String = serverStore.getLatestState().resolvedHost fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun setHost(host: String) { fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
fun endpointForBuild(): ServerEndpoint = ServerEndpoint.parseForBuild(getBaseUrl())
fun setBaseUrl(baseUrl: String) {
val normalized =
ServerEndpoint.parse(
baseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl.toString()
val selectedId = val selectedId =
getSelectedProfileId() ?: run { getSelectedProfileId() ?: run {
ensureDefaultSelected() ensureDefaultSelected()
@@ -412,30 +430,36 @@ object AuthManager {
} }
serverStore.update { state -> serverStore.update { state ->
val profiles = val profiles =
state.connectionProfiles.map { state.connectionProfiles.map { profile ->
if (it.id == selectedId) it.copy(host = host) else it if (profile.id == selectedId) {
profile.copy(
host = "",
port = 0,
baseUrl = normalized,
)
} else {
profile
}
} }
state.copy(connectionProfiles = profiles) state.copy(connectionProfiles = profiles)
} }
} }
// ── Port ───────────────────────────────────────────────────────────── /** Compatibility accessors for legacy pairing payloads. */
fun getHost(): String = serverStore.getLatestState().resolvedHost
fun getPort(): Int = serverStore.getLatestState().resolvedPort fun getPort(): Int = serverStore.getLatestState().resolvedPort
fun setHost(host: String) {
val endpoint = endpoint()
val normalizedHost = host.trim().removePrefix("[").removeSuffix("]")
setBaseUrl(endpoint.baseUrl.newBuilder().host(normalizedHost).build().toString())
}
fun setPort(port: Int) { fun setPort(port: Int) {
val selectedId = require(port in 1..65535) { "Port is out of range" }
getSelectedProfileId() ?: run { val endpoint = endpoint()
ensureDefaultSelected() setBaseUrl(endpoint.baseUrl.newBuilder().port(port).build().toString())
DEFAULT_PROFILE_ID
}
serverStore.update { state ->
val profiles =
state.connectionProfiles.map {
if (it.id == selectedId) it.copy(port = port) else it
}
state.copy(connectionProfiles = profiles)
}
} }
// ── Auto-reconnect ─────────────────────────────────────────────────── // ── Auto-reconnect ───────────────────────────────────────────────────
@@ -466,18 +490,17 @@ object AuthManager {
serverStore.update { it.copy(themePreset = preset) } serverStore.update { it.copy(themePreset = preset) }
} }
/** Convenience: build the base URL from current host + port. /** Canonical build-allowed Retrofit base URL. */
* NOTE: Uses plain http:// — intended for trusted local network only. fun baseUrl(): String = endpointForBuild().baseUrl.toString()
* Exposing the host to a hostile LAN risks token interception. */
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
/** Convenience: build the WebSocket URL with token query param. /** Canonical WebSocket URL with an encoded token or short-lived ticket. */
* NOTE: Token in query string — trusted local network only. */
fun wsUrl(): String { fun wsUrl(): String {
val raw = serverStore.getLatestState().wsAuthParam val raw = serverStore.getLatestState().wsAuthParam
val authParam = if (raw.isNullOrBlank()) "token" else raw val authParam = if (raw.isBlank()) "token" else raw
val credential = getToken().orEmpty() return endpointForBuild().webSocketUrl(
return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential" authParameter = authParam,
credential = getToken().orEmpty(),
)
} }
// ── Bottom nav bar items ────────────────────────────────────────────── // ── Bottom nav bar items ──────────────────────────────────────────────
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -41,10 +40,10 @@ object ApiClient {
/** Creates a standalone, temporary [HermesApiService] without modifying the global instance. */ /** Creates a standalone, temporary [HermesApiService] without modifying the global instance. */
fun createTempService( fun createTempService(
host: String, baseUrl: String,
port: Int,
token: String, token: String,
): HermesApiService { ): HermesApiService {
val endpoint = ServerEndpoint.parseForBuild(baseUrl)
val tempAuthInterceptor = val tempAuthInterceptor =
Interceptor { chain -> Interceptor { chain ->
val request = val request =
@@ -70,7 +69,7 @@ object ApiClient {
val tempRetrofit = val tempRetrofit =
Retrofit Retrofit
.Builder() .Builder()
.baseUrl("http://$host:$port/") .baseUrl(endpoint.baseUrl)
.client(tempOkHttp) .client(tempOkHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
@@ -85,7 +84,7 @@ object ApiClient {
HttpLoggingInterceptor().apply { HttpLoggingInterceptor().apply {
level = level =
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY HttpLoggingInterceptor.Level.BASIC
} else { } else {
HttpLoggingInterceptor.Level.NONE HttpLoggingInterceptor.Level.NONE
} }
@@ -110,8 +109,6 @@ object ApiClient {
} }
} }
val certificatePinner = CertificatePinner.Builder().build()
val okHttp = val okHttp =
OkHttpProvider OkHttpProvider
.base .base
@@ -120,13 +117,12 @@ object ApiClient {
.addInterceptor(ProfileScopeInterceptor) .addInterceptor(ProfileScopeInterceptor)
.addInterceptor(logging) .addInterceptor(logging)
.authenticator(TokenRefreshAuthenticator) .authenticator(TokenRefreshAuthenticator)
.certificatePinner(certificatePinner)
.build() .build()
val rf = val rf =
Retrofit Retrofit
.Builder() .Builder()
.baseUrl(AuthManager.baseUrl()) .baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(okHttp) .client(okHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build() .build()
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@Serializable
private data class PasswordLoginPayload(
val provider: String,
val username: String,
val password: String,
val next: String,
)
@Serializable
private data class WebSocketTicketPayload(
val ticket: String,
)
/** JSON serialization for authentication requests and responses. */
object AuthPayloads {
fun passwordLogin(
username: String,
password: String,
): String =
OkHttpProvider.json.encodeToString(
PasswordLoginPayload(
provider = "basic",
username = username,
password = password,
next = "",
),
)
fun webSocketTicket(body: String): String? =
runCatching {
OkHttpProvider.json.decodeFromString<WebSocketTicketPayload>(body)
.ticket
.takeIf { it.isNotBlank() }
}.getOrNull()
}
@@ -9,7 +9,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
/** /**
* Coordinator for the issue #470 cookie stack. * Coordinator for the issue #470 cookie stack.
@@ -22,9 +21,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
* - [getSessionCookie] / [setSessionCookie] — backward-compatible accessors * - [getSessionCookie] / [setSessionCookie] — backward-compatible accessors
* used by [com.m57.hermescontrol.data.local.AuthManager] and the login flow. * used by [com.m57.hermescontrol.data.local.AuthManager] and the login flow.
* *
* The session cookie is still exposed as a single string because the WS client * A single-value accessor remains for compatibility with migrated sessions;
* and a few REST paths reference it directly; internally it is just the * internally it resolves the dashboard access cookie from the active scope,
* `hermes_session_at` cookie from the active scope. * including HTTPS prefix variants.
*/ */
object CookieManager { object CookieManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -58,37 +57,36 @@ object CookieManager {
runBlocking(scope.coroutineContext) { j.useStore(serverId) } runBlocking(scope.coroutineContext) { j.useStore(serverId) }
} }
/** Read the current `hermes_session_at` cookie value (or null). */ /** Read the current dashboard access-cookie value (or null). */
fun getSessionCookie(): String? = jar?.getSessionCookieValue() fun getSessionCookie(): String? = jar?.getSessionCookieValue()
/** /**
* Set the `hermes_session_at` session cookie for the active scope. * Set the session cookie for the active scope and canonical endpoint.
* [host] should be the dashboard host so the cookie matches REST requests. * HTTPS endpoints produce Secure cookies that cannot be sent over HTTP.
*/ */
fun setSessionCookie( fun setSessionCookie(
rawValue: String?, rawValue: String?,
host: String, endpoint: ServerEndpoint,
) { ) {
val j = jar ?: return val j = jar ?: return
if (rawValue.isNullOrBlank()) { if (rawValue.isNullOrBlank()) {
// Clearing: drop the session cookie (and any other cookies) for the
// active scope — equivalent to the old AuthManager nulling the
// session-cookie pref.
j.clearActive() j.clearActive()
return return
} }
val cookie = val builder =
Cookie Cookie
.Builder() .Builder()
.name(SESSION_COOKIE_NAME) .name(SESSION_COOKIE_NAME)
.value(rawValue) .value(rawValue)
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000) .expiresAt(
.hostOnlyDomain(host) System.currentTimeMillis() +
.path("/") 10L * 365 * 24 * 60 * 60 * 1000,
)
.hostOnlyDomain(endpoint.baseUrl.host)
.path(endpoint.baseUrl.encodedPath)
.httpOnly() .httpOnly()
.build() if (endpoint.baseUrl.isHttps) builder.secure()
val url = "http://$host/".toHttpUrl() j.saveFromResponse(endpoint.baseUrl, listOf(builder.build()))
j.saveFromResponse(url, listOf(cookie))
} }
/** Evict expired (non-session) cookies for the active scope. */ /** Evict expired (non-session) cookies for the active scope. */
@@ -0,0 +1,164 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** Policy applied when a complete dashboard base URL uses cleartext HTTP. */
enum class CleartextPolicy {
DENY,
ALLOW_WITH_WARNING,
}
/**
* Canonical representation of a Hermes dashboard endpoint.
*
* The base URL always has an HTTP(S) scheme and a trailing slash. All REST,
* authentication, cookie, and WebSocket URLs are derived from this value so a
* reverse-proxy path prefix cannot be dropped by an individual caller.
*/
class ServerEndpoint private constructor(
val baseUrl: HttpUrl,
val securityWarning: String?,
) {
val isCleartext: Boolean
get() = baseUrl.scheme == "http"
fun resolve(relativePath: String): HttpUrl {
val path = relativePath.trim().trimStart('/')
require(path.isNotEmpty()) { "Endpoint path must not be blank" }
require(path.split('/').none { it == ".." }) {
"Endpoint path must not escape the configured base path"
}
return baseUrl.newBuilder().addPathSegments(path).build()
}
fun webSocketUrl(
authParameter: String,
credential: String,
): String {
require(authParameter == "token" || authParameter == "ticket") {
"Unsupported WebSocket authentication parameter"
}
val socketHttpUrl =
resolve("api/ws")
.newBuilder()
.addQueryParameter(authParameter, credential)
.build()
val socketScheme = if (baseUrl.isHttps) "wss" else "ws"
return replaceScheme(socketHttpUrl.toString(), socketScheme)
}
/** Return a request path relative to this endpoint's proxy prefix. */
fun relativeRequestPath(requestUrl: HttpUrl): String {
if (!sameOrigin(requestUrl)) return requestUrl.encodedPath
val prefix = baseUrl.encodedPath
if (!requestUrl.encodedPath.startsWith(prefix)) {
return requestUrl.encodedPath
}
return "/" + requestUrl.encodedPath.removePrefix(prefix)
}
private fun sameOrigin(other: HttpUrl): Boolean =
baseUrl.scheme == other.scheme &&
baseUrl.host == other.host &&
baseUrl.port == other.port
companion object {
const val DEFAULT_BASE_URL = "https://127.0.0.1:9119/"
const val CLEARTEXT_WARNING =
"Cleartext HTTP exposes credentials and messages. " +
"Use HTTPS unless this is a trusted development network."
fun parse(
rawValue: String,
cleartextPolicy: CleartextPolicy = CleartextPolicy.DENY,
): ServerEndpoint {
val raw = rawValue.trim()
require(raw.isNotEmpty()) { "Base URL is required" }
require(
raw.startsWith("https://", ignoreCase = true) ||
raw.startsWith("http://", ignoreCase = true),
) { "Base URL must use https:// or http://" }
val parsed =
raw.toHttpUrlOrNull()
?: throw IllegalArgumentException("Malformed base URL")
require(parsed.username.isEmpty() && parsed.password.isEmpty()) {
"Base URL must not contain credentials"
}
require(parsed.encodedQuery == null) {
"Base URL must not contain a query"
}
require(parsed.encodedFragment == null) {
"Base URL must not contain a fragment"
}
val normalized =
if (parsed.encodedPath.endsWith('/')) {
parsed
} else {
parsed.newBuilder().addPathSegment("").build()
}
if (!normalized.isHttps && cleartextPolicy == CleartextPolicy.DENY) {
throw IllegalArgumentException(
"Cleartext HTTP is disabled in this build. Use HTTPS.",
)
}
val warning = if (normalized.isHttps) null else CLEARTEXT_WARNING
return ServerEndpoint(normalized, warning)
}
fun parseForBuild(rawValue: String): ServerEndpoint =
parse(
rawValue,
if (BuildConfig.ALLOW_CLEARTEXT) {
CleartextPolicy.ALLOW_WITH_WARNING
} else {
CleartextPolicy.DENY
},
)
/** Preserve the pre-migration HTTP behavior for an existing install. */
fun fromLegacy(
host: String,
port: Int,
): ServerEndpoint {
require(port in 1..65535) { "Legacy port is out of range" }
val normalizedHost =
host.trim().removePrefix("[").removeSuffix("]")
val url =
HttpUrl
.Builder()
.scheme("http")
.host(normalizedHost)
.port(port)
.build()
return parse(
url.toString(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
}
fun redactWebSocketUrlForLog(rawUrl: String): String {
val httpScheme =
when {
rawUrl.startsWith("wss://", ignoreCase = true) -> "https"
rawUrl.startsWith("ws://", ignoreCase = true) -> "http"
else -> return "<invalid-websocket-url>"
}
val socketScheme = if (httpScheme == "https") "wss" else "ws"
val converted =
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
?: return "<invalid-websocket-url>"
val redacted = converted.newBuilder().query(null).build().toString()
return replaceScheme(redacted, socketScheme)
}
private fun replaceScheme(
url: String,
scheme: String,
): String = url.replaceBefore("://", scheme)
}
}
@@ -265,7 +265,7 @@ object HermesWsClient {
val request = val request =
Request Request
.Builder() .Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket") .url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody()) .post("{}".toRequestBody())
.build() .build()
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -153,27 +154,35 @@ fun AuthLoginScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
// Host field // Server URL field
OutlinedTextField( OutlinedTextField(
value = state.host, value = state.baseUrl,
onValueChange = viewModel::onHostChange, onValueChange = viewModel::onBaseUrlChange,
label = { Text(stringResource(R.string.auth_login_host_label)) }, label = { Text(stringResource(R.string.auth_login_base_url_label)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
enabled = state.authMode == null, enabled = state.authMode == null,
) )
// Port field // Cleartext transport warning
OutlinedTextField( AnimatedVisibility(
value = state.port, visible = state.transportWarning != null,
onValueChange = viewModel::onPortChange, enter = fadeIn(),
label = { Text(stringResource(R.string.auth_login_port_label)) }, exit = fadeOut(),
singleLine = true, ) {
modifier = Modifier.fillMaxWidth(), state.transportWarning?.let { warning ->
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), Text(
enabled = state.authMode == null, text = warning,
) style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.error,
),
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
// Probe / probing indicator // Probe / probing indicator
if (state.probing) { if (state.probing) {
@@ -261,6 +270,27 @@ fun AuthLoginScreen(
) )
} }
// OAuth "coming soon" notice — shown for OAUTH mode (issue #639)
AnimatedVisibility(
visible = state.authMode == DashboardAuthMode.OAUTH,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.auth_login_oauth_coming_soon),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
textAlign = TextAlign.Start,
)
}
}
// Error message // Error message
AnimatedVisibility( AnimatedVisibility(
visible = state.errorMessage != null, visible = state.errorMessage != null,
@@ -290,7 +320,7 @@ fun AuthLoginScreen(
} }
}, },
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier.fillMaxWidth().height(52.dp),
enabled = !state.isLoading && !state.probing, enabled = !state.isLoading && !state.probing && state.authMode != DashboardAuthMode.OAUTH,
) { ) {
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -9,6 +9,10 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.AuthPayloads
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,28 +22,40 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/** /**
* What auth the dashboard requires. * What auth the dashboard requires. Derived authoritatively from
* `GET /api/status` (`auth_required` + `auth_providers`), not by sniffing
* redirects — the status JSON is the source of truth.
*/ */
enum class DashboardAuthMode { enum class DashboardAuthMode {
/** Dashboard has no auth gate — just needs a session token. */ /** Dashboard has no auth gate (loopback / `--insecure`) — just needs a session token. */
TOKEN_ONLY, TOKEN_ONLY,
/** Dashboard has basic auth — needs username + password (and possibly a token). */ /** Dashboard has basic auth (gated `0.0.0.0` bind) — needs username + password. */
BASIC_AUTH, BASIC_AUTH,
/** Dashboard requires both basic auth credentials and a session token. */ /** Dashboard requires both basic auth credentials and a session token. */
ALL, ALL,
/**
* Dashboard uses OAuth via Nous Portal (gated `0.0.0.0` bind).
*
* TODO: implement the OAuth browser flow + WS ticket minting. Until then the
* UI renders a "coming soon" state and blocks connect. Tracked in issue #639.
*/
OAUTH,
} }
data class AuthLoginUiState( data class AuthLoginUiState(
val host: String = "127.0.0.1", val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val port: String = "9119", val transportWarning: String? = null,
val token: String = "", val token: String = "",
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
@@ -57,8 +73,7 @@ class AuthLoginViewModel(
private val _uiState = private val _uiState =
MutableStateFlow( MutableStateFlow(
AuthLoginUiState( AuthLoginUiState(
host = AuthManager.getHost(), baseUrl = AuthManager.getBaseUrl(),
port = AuthManager.getPort().toString(),
), ),
) )
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
@@ -91,8 +106,13 @@ class AuthLoginViewModel(
private val probeClient: OkHttpClient = private val probeClient: OkHttpClient =
com.m57.hermescontrol.data.remote.OkHttpProvider.probe com.m57.hermescontrol.data.remote.OkHttpProvider.probe
fun onHostChange(value: String) { fun onBaseUrlChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null, authMode = null) } val trimmed = value.trim()
val warning =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null, authMode = null) }
} }
/** Reset ephemeral connection state (called when screen leaves composition). */ /** Reset ephemeral connection state (called when screen leaves composition). */
@@ -100,16 +120,6 @@ class AuthLoginViewModel(
_uiState.update { it.copy(connectionSuccess = false, errorMessage = null, isLoading = false) } _uiState.update { it.copy(connectionSuccess = false, errorMessage = null, isLoading = false) }
} }
fun onPortChange(value: String) {
_uiState.update {
it.copy(
port = value.filter { c -> c.isDigit() },
errorMessage = null,
authMode = null,
)
}
}
fun onTokenChange(value: String) { fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) } _uiState.update { it.copy(token = value.trim(), errorMessage = null) }
} }
@@ -124,16 +134,18 @@ class AuthLoginViewModel(
/** /**
* Step 1: Probe the dashboard to detect what auth it needs. * Step 1: Probe the dashboard to detect what auth it needs.
*
* Uses the public `GET /api/status` endpoint as the authoritative source:
* it reports `auth_required` (gate engaged on non-loopback binds) and
* `auth_providers` (e.g. ["basic"], ["oauth"], []). We map that to a
* [DashboardAuthMode] instead of sniffing redirects, which is a heuristic.
*/ */
fun probe() { fun probe() {
val state = _uiState.value val state = _uiState.value
if (state.host.isBlank()) { val endpoint =
_uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_host_required)) } runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
return if (endpoint == null) {
} _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_url_invalid)) }
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.auth_login_error_port_invalid)) }
return return
} }
@@ -142,7 +154,7 @@ class AuthLoginViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
probeDashboardInternal(state.host, port) probeDashboardInternal(endpoint)
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -169,87 +181,99 @@ class AuthLoginViewModel(
) )
/** /**
* Probes the dashboard to determine [DashboardAuthMode]. * Derives the [DashboardAuthMode] from the authoritative `/api/status` fields.
* Returns null if the dashboard is unreachable. *
* When [ProbeResult.extractedToken] is non-null, the session token * - Gate down (loopback / `--insecure`): caller decides TOKEN_ONLY vs ALL
* was found embedded in the dashboard SPA HTML and can be auto-populated. * based on whether the SPA embedded a token, so this returns a sentinel
* [DashboardAuthMode.ALL] placeholder that [probeDashboardInternal] refines.
* - Gate up (non-loopback): pick from the provider list (oauth > basic).
*
* Internal + pure so it is unit-testable without a live server.
*/ */
private fun probeDashboardInternal( internal fun deriveAuthMode(
host: String, authRequired: Boolean,
port: Int, providers: List<String>,
): ProbeResult? { ): DashboardAuthMode =
val baseUrl = "http://$host:$port" if (!authRequired) {
// Refined by the caller once it knows whether the SPA embedded a token.
DashboardAuthMode.ALL
} else {
when {
providers.contains("oauth") -> DashboardAuthMode.OAUTH
providers.contains("basic") -> DashboardAuthMode.BASIC_AUTH
else -> DashboardAuthMode.BASIC_AUTH // gate up, unknown provider → basic fallback
}
}
// Step 1: Check if dashboard is reachable via /api/status (always public) /**
val statusOk = * Probes `GET /api/status` and derives the [DashboardAuthMode] from the
* authoritative `auth_required` + `auth_providers` fields.
*
* Returns null if the dashboard is unreachable. When [ProbeResult.extractedToken]
* is non-null, the session token was found embedded in the dashboard SPA HTML
* (loopback mode only) and can be auto-populated.
*/
private fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? {
// Step 1: reachability + auth mode from the public status endpoint.
val statusJson =
try { try {
val req = val req =
Request Request
.Builder() .Builder()
.url("$baseUrl/api/status") .url(endpoint.resolve("api/status").toString())
.get() .get()
.build() .build()
val resp = probeClient.newCall(req).execute() val resp = probeClient.newCall(req).execute()
resp.isSuccessful if (!resp.isSuccessful) return null
resp.body.string()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Status probe failed: ${e.message}") Log.w(TAG, "Status probe failed: ${e.message}")
return null // Dashboard unreachable return null // Dashboard unreachable
} }
if (!statusOk) return null val authRequired: Boolean
val providers: List<String>
try {
val node = OkHttpProvider.json.parseToJsonElement(statusJson).jsonObject
authRequired = node["auth_required"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false
providers =
node["auth_providers"]
?.jsonArray
?.mapNotNull { it.jsonPrimitive.content }
.orEmpty()
} catch (e: Exception) {
Log.w(TAG, "Status parse failed: ${e.message}")
return null
}
// Step 2: Probe / to see if it redirects to /login (basic auth) or returns SPA // No gate (loopback / --insecure): token mode. Try to grab the embedded token.
val needsBasicAuth = if (!authRequired) {
var extractedToken: String? = null
try { try {
val req = val spaReq =
Request Request
.Builder() .Builder()
.url(baseUrl) .url(endpoint.resolve("").toString())
.get() .get()
.build() .build()
val resp = probeClient.newCall(req).execute() val spaResp = probeClient.newCall(spaReq).execute()
val code = resp.code val body = spaResp.body.string()
val location = resp.header("location", "")
// 302 to /login means basic auth is active
code == 302 && location?.contains("/login", ignoreCase = true) == true
} catch (e: Exception) {
Log.w(TAG, "SPA probe failed: ${e.message}")
false
}
// Step 3: Extract token from SPA HTML (if reachable without redirect)
var extractedToken: String? = null
if (!needsBasicAuth) {
try {
val req =
Request
.Builder()
.url(baseUrl)
.get()
.build()
val resp = probeClient.newCall(req).execute()
val body = resp.body.string()
// Extract __HERMES_SESSION_TOKEN__ from the SPA HTML
val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body) val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body)
extractedToken = tokenMatch?.groupValues?.getOrNull(1) extractedToken = tokenMatch?.groupValues?.getOrNull(1)
if (extractedToken == null) {
Log.w(TAG, "SPA has no __HERMES_SESSION_TOKEN__ — mode might require both auth")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "SPA token extraction failed: ${e.message}") Log.w(TAG, "SPA token extraction failed: ${e.message}")
} }
val mode =
if (extractedToken != null) {
DashboardAuthMode.TOKEN_ONLY
} else {
DashboardAuthMode.ALL
}
return ProbeResult(authMode = mode, extractedToken = extractedToken)
} }
val authMode = // Gate engaged (non-loopback bind). Derive from the provider list.
if (needsBasicAuth) { return ProbeResult(authMode = deriveAuthMode(authRequired, providers))
DashboardAuthMode.BASIC_AUTH
} else if (extractedToken != null) {
DashboardAuthMode.TOKEN_ONLY
} else {
DashboardAuthMode.ALL
}
return ProbeResult(authMode = authMode, extractedToken = extractedToken)
} }
/** /**
@@ -265,7 +289,9 @@ class AuthLoginViewModel(
*/ */
fun connect() { fun connect() {
val state = _uiState.value val state = _uiState.value
val port = state.port.toIntOrNull() ?: return val endpoint =
runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
?: return
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -274,16 +300,27 @@ class AuthLoginViewModel(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
when (state.authMode) { when (state.authMode) {
DashboardAuthMode.TOKEN_ONLY -> { DashboardAuthMode.TOKEN_ONLY -> {
val token = connectTokenOnly(state.host, port, state.token) val token = connectTokenOnly(endpoint, state.token)
if (token != null) ConnectResult(wsCredential = token) else null if (token != null) ConnectResult(wsCredential = token) else null
} }
DashboardAuthMode.BASIC_AUTH -> { DashboardAuthMode.BASIC_AUTH -> {
connectBasicAuth(state.host, port, state.username, state.password) connectBasicAuth(endpoint, state.username, state.password)
} }
DashboardAuthMode.ALL -> { DashboardAuthMode.ALL -> {
connectBasicAuth(state.host, port, state.username, state.password) connectBasicAuth(endpoint, state.username, state.password)
}
DashboardAuthMode.OAUTH -> {
// TODO(issue #639): implement OAuth browser flow + WS ticket minting.
_uiState.update {
it.copy(
isLoading = false,
errorMessage = app.getString(R.string.auth_login_error_oauth_unsupported),
)
}
null
} }
null -> { null -> {
@@ -293,8 +330,7 @@ class AuthLoginViewModel(
} }
if (result != null) { if (result != null) {
AuthManager.setHost(state.host) AuthManager.setBaseUrl(state.baseUrl)
AuthManager.setPort(port)
AuthManager.setToken(result.wsCredential) AuthManager.setToken(result.wsCredential)
if (state.authMode == DashboardAuthMode.TOKEN_ONLY) { if (state.authMode == DashboardAuthMode.TOKEN_ONLY) {
// Loopback mode — no session cookie; ensure any stale one // Loopback mode — no session cookie; ensure any stale one
@@ -319,8 +355,7 @@ class AuthLoginViewModel(
* Validate the token by calling /api/status with it. * Validate the token by calling /api/status with it.
*/ */
private suspend fun connectTokenOnly( private suspend fun connectTokenOnly(
host: String, endpoint: ServerEndpoint,
port: Int,
token: String, token: String,
): String? { ): String? {
if (token.isBlank()) { if (token.isBlank()) {
@@ -330,7 +365,7 @@ class AuthLoginViewModel(
return null return null
} }
val tempApi = ApiClient.createTempService(host, port, token) val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), token)
val result = safeApiCall { tempApi.getSessions() } val result = safeApiCall { tempApi.getSessions() }
return when (result) { return when (result) {
@@ -372,8 +407,7 @@ class AuthLoginViewModel(
* Returns the WS ticket and the session cookie for REST auth. * Returns the WS ticket and the session cookie for REST auth.
*/ */
private fun connectBasicAuth( private fun connectBasicAuth(
host: String, endpoint: ServerEndpoint,
port: Int,
username: String, username: String,
password: String, password: String,
): ConnectResult? { ): ConnectResult? {
@@ -390,26 +424,22 @@ class AuthLoginViewModel(
return null return null
} }
val baseUrl = "http://$host:$port" val jsonBody = AuthPayloads.passwordLogin(username, password)
val jsonBody = """{"provider":"basic","username":"$username","password":"$password","next":""}"""
try { try {
// Step 1: Authenticate via the password login endpoint to get a session cookie // Step 1: Authenticate via the password login endpoint to get a session cookie.
val loginClient = // IMPORTANT: use the SHARED OkHttpProvider.probe client (it carries the
com.m57.hermescontrol.data.remote.OkHttpProvider.probe // persistent CookieManager.cookieJar). Do NOT build a fresh client here —
.newBuilder() // a separate client would not share the jar, so the Set-Cookie from this
.connectTimeout(10, TimeUnit.SECONDS) // login would never reach the ws-ticket request below and auth would fail.
.readTimeout(10, TimeUnit.SECONDS)
.build()
val loginReq = val loginReq =
Request Request
.Builder() .Builder()
.url("$baseUrl/auth/password-login") .url(endpoint.resolve("auth/password-login").toString())
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.post(jsonBody.toRequestBody()) .post(jsonBody.toRequestBody())
.build() .build()
val loginResp = loginClient.newCall(loginReq).execute() val loginResp = OkHttpProvider.probe.newCall(loginReq).execute()
if (!loginResp.isSuccessful) { if (!loginResp.isSuccessful) {
val msg = val msg =
@@ -421,27 +451,18 @@ class AuthLoginViewModel(
_uiState.update { it.copy(isLoading = false, errorMessage = msg) } _uiState.update { it.copy(isLoading = false, errorMessage = msg) }
return null return null
} }
// The session cookie is now captured in the shared CookieManager.cookieJar
// The session cookie is captured automatically by the shared // (issue #470). The ws-ticket request below uses the same jar, so the
// CookieJar (issue #470) attached to OkHttpProvider.probe — no // authenticated session carries over automatically.
// manual Set-Cookie parsing needed. We still mint a WS ticket below
// using whatever cookie the jar carries into that request.
// Step 3: Mint a WebSocket ticket using the session cookie // Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient =
com.m57.hermescontrol.data.remote.OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val ticketReq = val ticketReq =
Request Request
.Builder() .Builder()
.url("$baseUrl/api/auth/ws-ticket") .url(endpoint.resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody()) .post("{}".toRequestBody())
.build() .build()
val ticketResp = ticketClient.newCall(ticketReq).execute() val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
if (!ticketResp.isSuccessful) { if (!ticketResp.isSuccessful) {
_uiState.update { _uiState.update {
@@ -257,7 +257,19 @@ class ChatViewModel(
// ── Connection ─────────────────────────────────────────────────────── // ── Connection ───────────────────────────────────────────────────────
private fun connectWebSocket(setLoading: Boolean = false) { private fun connectWebSocket(setLoading: Boolean = false) {
val token = AuthManager.getToken() ?: return // In loopback (token) mode the session token is the WS credential and
// must be present before connecting. In gated (ticket) mode the ticket
// is minted fresh by HermesWsClient.refreshWsTicketIfNeeded() from the
// persisted session cookie, so getToken() is expected to be empty here
// and must NOT block the connect (issue #640: chat showed "reconnect"
// immediately after basic-auth login because this guard returned early).
val isGated =
runCatching { AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" }
.getOrNull() ?: false
if (!isGated) {
val token = AuthManager.getToken() ?: return
if (token.isBlank()) return
}
// Don't disturb an already-working (or already-recovering) connection. // Don't disturb an already-working (or already-recovering) connection.
// HermesWsClient is a global singleton shared by every tab; the chat tab // HermesWsClient is a global singleton shared by every tab; the chat tab
@@ -172,23 +172,6 @@ fun ConnectScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text(
text = stringResource(R.string.connect_pairing_string),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
var pairingString by remember { mutableStateOf("") }
OutlinedTextField(
value = pairingString,
onValueChange = {
pairingString = it
if (it.isNotBlank()) viewModel.onPairingString(it)
},
placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Error message // Error message
AnimatedVisibility( AnimatedVisibility(
visible = state.errorMessage != null, visible = state.errorMessage != null,
@@ -275,6 +258,31 @@ fun ConnectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
HorizontalDivider() HorizontalDivider()
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = { Text(stringResource(R.string.connect_server_url_label)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
// Cleartext transport warning
AnimatedVisibility(
visible = state.transportWarning != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.transportWarning?.let { warning ->
Text(
text = warning,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
OutlinedTextField( OutlinedTextField(
value = state.token, value = state.token,
onValueChange = viewModel::onTokenChange, onValueChange = viewModel::onTokenChange,
@@ -307,23 +315,6 @@ fun ConnectScreen(
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }
@@ -8,9 +8,10 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,13 +20,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class ConnectUiState( data class ConnectUiState(
val token: String = "", val token: String = "",
val host: String = "127.0.0.1", val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val port: String = "9119", val transportWarning: String? = null,
val isConnecting: Boolean = false, val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false, val connectionSuccess: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
@@ -47,16 +46,14 @@ class ConnectViewModel(
fun loadSavedValues() { fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: "" val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost() val savedBaseUrl = AuthManager.getBaseUrl()
val savedPort = AuthManager.getPort()
val profiles = AuthManager.getConnectionProfiles() val profiles = AuthManager.getConnectionProfiles()
val selectedId = AuthManager.getSelectedProfileId() val selectedId = AuthManager.getSelectedProfileId()
val selectedProfile = profiles.firstOrNull { it.id == selectedId } val selectedProfile = profiles.firstOrNull { it.id == selectedId }
_uiState.update { _uiState.update {
it.copy( it.copy(
token = savedToken, token = savedToken,
host = selectedProfile?.host ?: savedHost, baseUrl = selectedProfile?.resolvedBaseUrl ?: savedBaseUrl,
port = (selectedProfile?.port ?: savedPort).toString(),
profiles = profiles, profiles = profiles,
selectedProfile = selectedProfile, selectedProfile = selectedProfile,
profileName = selectedProfile?.name ?: "", profileName = selectedProfile?.name ?: "",
@@ -79,8 +76,7 @@ class ConnectViewModel(
it.copy( it.copy(
selectedProfile = profile, selectedProfile = profile,
profileName = profile.name, profileName = profile.name,
host = profile.host, baseUrl = profile.resolvedBaseUrl,
port = profile.port.toString(),
token = token, token = token,
) )
} }
@@ -90,12 +86,27 @@ class ConnectViewModel(
_uiState.update { it.copy(token = value.trim(), errorMessage = null) } _uiState.update { it.copy(token = value.trim(), errorMessage = null) }
} }
fun onBaseUrlChange(value: String) {
val trimmed = value.trim()
val warning =
runCatching {
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null) }
}
fun onHostChange(value: String) { fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null) } val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().host(value.trim()).build().toString()
onBaseUrlChange(next)
} }
fun onPortChange(value: String) { fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) } val portText = value.filter { c -> c.isDigit() }
val port = portText.toIntOrNull() ?: return
val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().port(port).build().toString()
onBaseUrlChange(next)
} }
fun connect() { fun connect() {
@@ -104,13 +115,10 @@ class ConnectViewModel(
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) } _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) }
return return
} }
if (state.host.isBlank()) { val endpoint =
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) } runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
return if (endpoint == null) {
} _uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_url_invalid)) }
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) }
return return
} }
@@ -119,17 +127,14 @@ class ConnectViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(state.host, port, state.token) val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
safeApiCall { tempApi.getStatus() } safeApiCall { tempApi.getStatus() }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
// Persist credentials to the selected (Default) profile upon successful verification. // Persist credentials to the selected (Default) profile upon successful verification.
// setToken/setHost/setPort delegate to the currently-selected profile, which is
// always non-null after issue #478 (never the legacy standalone path).
AuthManager.setToken(state.token) AuthManager.setToken(state.token)
AuthManager.setHost(state.host) AuthManager.setBaseUrl(state.baseUrl)
AuthManager.setPort(port)
ApiClient.rebuild() ApiClient.rebuild()
if (state.saveProfile) { if (state.saveProfile) {
@@ -143,12 +148,11 @@ class ConnectViewModel(
} }
val targetProfile = val targetProfile =
if (existingIndex >= 0) { if (existingIndex >= 0) {
currentProfiles[existingIndex].copy(host = state.host, port = port) currentProfiles[existingIndex].copy(baseUrl = state.baseUrl)
} else { } else {
ConnectionProfile( ConnectionProfile(
name = state.profileName, name = state.profileName,
host = state.host, baseUrl = state.baseUrl,
port = port,
) )
} }
val updatedProfiles = val updatedProfiles =
@@ -274,99 +278,6 @@ class ConnectViewModel(
} }
} }
} }
fun onPairingString(value: String) {
val trimmed = value.trim()
if (trimmed.isBlank()) return
try {
if (trimmed.startsWith("hermes://connect?", ignoreCase = true)) {
val uri = android.net.Uri.parse(trimmed)
val host = uri.getQueryParameter("host")
val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) {
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
}
// Try decoding as Base64 JSON
try {
val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT)
val decodedString = String(decodedBytes, Charsets.UTF_8)
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
val host = json["host"]?.jsonPrimitive?.content
val port = json["port"]?.jsonPrimitive?.content
val token = json["token"]?.jsonPrimitive?.content
if (host != null && port != null && token != null) {
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
// Decoded valid JSON but missing required fields
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_missing_fields),
)
}
return
}
// Decoded to valid string but not JSON shape — not a pairing string
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_malformed),
)
}
return
} catch (e: IllegalArgumentException) {
// Not valid Base64 — check if input looks like a raw token
if (trimmed.length >= 32 && trimmed.matches(Regex("[A-Za-z0-9_\\-]+"))) {
_uiState.update { it.copy(token = trimmed, errorMessage = null) }
} else {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_malformed),
)
}
}
return
} catch (e: Exception) {
_uiState.update {
it.copy(
errorMessage =
String.format(
app.getString(R.string.connect_error_parse_failed),
e.message ?: "",
),
)
}
return
}
} catch (e: Exception) {
_uiState.update {
it.copy(
errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""),
)
}
}
}
} }
class ConnectViewModelFactory( class ConnectViewModelFactory(
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -128,7 +129,11 @@ class PairingCodeEntryViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(host, port, token) val tempApi =
ApiClient.createTempService(
ServerEndpoint.fromLegacy(host, port).baseUrl.toString(),
token,
)
safeApiCall { tempApi.getStatus() } safeApiCall { tempApi.getStatus() }
} }
@@ -227,10 +227,7 @@ internal fun InfoRow(
} }
@Composable @Composable
internal fun SectionCard( internal fun SectionCard(content: @Composable () -> Unit) {
title: String,
content: @Composable () -> Unit,
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -240,15 +237,6 @@ internal fun SectionCard(
elevation = CardDefaults.cardElevation(1.dp), elevation = CardDefaults.cardElevation(1.dp),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content() content()
} }
} }
@@ -6,7 +6,9 @@ import com.m57.hermescontrol.ScreenRegistry
import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
@@ -22,8 +24,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
data class SettingsUiState( data class SettingsUiState(
val host: String = "127.0.0.1", val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val port: String = "9119", val transportWarning: String? = null,
val token: String = "", val token: String = "",
val autoReconnect: Boolean = true, val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM, val themePreference: ThemePreference = ThemePreference.SYSTEM,
@@ -44,9 +46,9 @@ data class SettingsUiState(
val showProfileDialog: Boolean = false, val showProfileDialog: Boolean = false,
val editingProfileId: String? = null, val editingProfileId: String? = null,
val dialogProfileName: String = "", val dialogProfileName: String = "",
val dialogProfileHost: String = "", val dialogProfileBaseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
val dialogProfilePort: String = "",
val dialogProfileToken: String = "", val dialogProfileToken: String = "",
val dialogProfileError: String? = null,
// Delete confirmation // Delete confirmation
val showDeleteConfirm: Boolean = false, val showDeleteConfirm: Boolean = false,
val profileToDeleteId: String? = null, val profileToDeleteId: String? = null,
@@ -68,8 +70,7 @@ class SettingsViewModel(
private suspend fun loadSettings() { private suspend fun loadSettings() {
val selectedId = AuthManager.getSelectedProfileId() val selectedId = AuthManager.getSelectedProfileId()
val host = AuthManager.getHost() val baseUrl = AuthManager.getBaseUrl()
val port = AuthManager.getPort().toString()
val token = AuthManager.getToken() ?: "" val token = AuthManager.getToken() ?: ""
val autoReconnect = AuthManager.isAutoReconnect() val autoReconnect = AuthManager.isAutoReconnect()
val themePreference = AuthManager.getThemePreference() val themePreference = AuthManager.getThemePreference()
@@ -83,10 +84,14 @@ class SettingsViewModel(
val appLanguage = AuthManager.getAppLanguage() val appLanguage = AuthManager.getAppLanguage()
val renameProfileName = val renameProfileName =
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: "" profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
val transportWarning =
runCatching {
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
}.getOrNull()
_uiState.update { _uiState.update {
it.copy( it.copy(
host = host, baseUrl = baseUrl,
port = port, transportWarning = transportWarning,
token = token, token = token,
autoReconnect = autoReconnect, autoReconnect = autoReconnect,
themePreference = themePreference, themePreference = themePreference,
@@ -150,9 +155,9 @@ class SettingsViewModel(
showProfileDialog = true, showProfileDialog = true,
editingProfileId = null, editingProfileId = null,
dialogProfileName = "", dialogProfileName = "",
dialogProfileHost = "127.0.0.1", dialogProfileBaseUrl = ServerEndpoint.DEFAULT_BASE_URL,
dialogProfilePort = "9119",
dialogProfileToken = "", dialogProfileToken = "",
dialogProfileError = null,
) )
} }
} }
@@ -165,29 +170,28 @@ class SettingsViewModel(
showProfileDialog = true, showProfileDialog = true,
editingProfileId = profileId, editingProfileId = profileId,
dialogProfileName = profile.name, dialogProfileName = profile.name,
dialogProfileHost = profile.host, dialogProfileBaseUrl = profile.resolvedBaseUrl,
dialogProfilePort = profile.port.toString(),
dialogProfileToken = token, dialogProfileToken = token,
dialogProfileError = null,
) )
} }
} }
fun closeProfileDialog() { fun closeProfileDialog() {
_uiState.update { _uiState.update { it.copy(showProfileDialog = false, editingProfileId = null) }
it.copy(showProfileDialog = false, editingProfileId = null)
}
} }
fun onDialogProfileNameChange(value: String) { fun onDialogProfileNameChange(value: String) {
_uiState.update { it.copy(dialogProfileName = value) } _uiState.update { it.copy(dialogProfileName = value) }
} }
fun onDialogProfileHostChange(value: String) { fun onDialogProfileBaseUrlChange(value: String) {
_uiState.update { it.copy(dialogProfileHost = value.trim()) } val trimmed = value.trim()
} val error =
runCatching {
fun onDialogProfilePortChange(value: String) { ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING)
_uiState.update { it.copy(dialogProfilePort = value.filter { c -> c.isDigit() }) } }.exceptionOrNull()?.message
_uiState.update { it.copy(dialogProfileBaseUrl = trimmed, dialogProfileError = error) }
} }
fun onDialogProfileTokenChange(value: String) { fun onDialogProfileTokenChange(value: String) {
@@ -197,11 +201,14 @@ class SettingsViewModel(
fun saveProfileFromDialog() { fun saveProfileFromDialog() {
val state = _uiState.value val state = _uiState.value
val name = state.dialogProfileName.trim() val name = state.dialogProfileName.trim()
val host = state.dialogProfileHost.trim() val baseUrl = state.dialogProfileBaseUrl.trim()
val port = state.dialogProfilePort.toIntOrNull() ?: return
val token = state.dialogProfileToken
if (name.isBlank() || host.isBlank()) return if (name.isBlank()) return
val normalized =
runCatching {
ServerEndpoint.parse(baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
}.getOrNull() ?: return
val token = state.dialogProfileToken
val profiles = AuthManager.getConnectionProfiles().toMutableList() val profiles = AuthManager.getConnectionProfiles().toMutableList()
val editingId = state.editingProfileId val editingId = state.editingProfileId
@@ -211,7 +218,7 @@ class SettingsViewModel(
val index = profiles.indexOfFirst { it.id == editingId } val index = profiles.indexOfFirst { it.id == editingId }
if (index == -1) return if (index == -1) return
val oldToken = AuthManager.getProfileToken(editingId) val oldToken = AuthManager.getProfileToken(editingId)
profiles[index] = profiles[index].copy(name = name, host = host, port = port) profiles[index] = profiles[index].copy(name = name, baseUrl = normalized)
AuthManager.saveConnectionProfiles(profiles) AuthManager.saveConnectionProfiles(profiles)
if (token != oldToken) { if (token != oldToken) {
AuthManager.setProfileToken(editingId, token) AuthManager.setProfileToken(editingId, token)
@@ -221,8 +228,7 @@ class SettingsViewModel(
val newProfile = val newProfile =
ConnectionProfile( ConnectionProfile(
name = name, name = name,
host = host, baseUrl = normalized,
port = port,
) )
profiles.add(newProfile) profiles.add(newProfile)
AuthManager.saveConnectionProfiles(profiles) AuthManager.saveConnectionProfiles(profiles)
@@ -267,12 +273,13 @@ class SettingsViewModel(
} }
} }
fun onHostChange(value: String) { fun onBaseUrlChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) } val trimmed = value.trim()
} val transportWarning =
runCatching {
fun onPortChange(value: String) { ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) } }.getOrNull()
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = transportWarning, isSaved = false) }
} }
fun onTokenChange(value: String) { fun onTokenChange(value: String) {
@@ -358,10 +365,12 @@ class SettingsViewModel(
fun save() { fun save() {
val state = _uiState.value val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119 val normalized =
runCatching {
ServerEndpoint.parse(state.baseUrl, CleartextPolicy.ALLOW_WITH_WARNING).baseUrl.toString()
}.getOrNull() ?: ServerEndpoint.DEFAULT_BASE_URL
AuthManager.setHost(state.host) AuthManager.setBaseUrl(normalized)
AuthManager.setPort(port)
AuthManager.setToken(state.token) AuthManager.setToken(state.token)
AuthManager.setAutoReconnect(state.autoReconnect) AuthManager.setAutoReconnect(state.autoReconnect)
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it // B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
@@ -24,7 +24,7 @@ import com.m57.hermescontrol.ui.settings.SectionCard
@Composable @Composable
internal fun AboutSection(onLogout: () -> Unit) { internal fun AboutSection(onLogout: () -> Unit) {
SectionCard(title = stringResource(R.string.settings_sec_about)) { SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = stringResource(R.string.settings_about_app_name), text = stringResource(R.string.settings_about_app_name),
@@ -57,7 +57,7 @@ internal fun AppearanceSection(
appLanguage: String, appLanguage: String,
onAppLanguageChange: (String) -> Unit, onAppLanguageChange: (String) -> Unit,
) { ) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) { SectionCard {
Text( Text(
text = stringResource(R.string.settings_item_theme), text = stringResource(R.string.settings_item_theme),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -243,7 +243,7 @@ internal fun ChatSection(
typingEffectDelayMs: Int, typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit, onTypingEffectDelayMsChange: (Int) -> Unit,
) { ) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) { SectionCard {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -326,7 +326,7 @@ internal fun NavBarSection(
onRemoveNavItem: (String) -> Unit, onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit, onAddNavItem: (String) -> Unit,
) { ) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) { SectionCard {
Text( Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode), text = stringResource(R.string.settings_item_bottom_nav_display_mode),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -22,7 +22,7 @@ internal fun BehaviorSection(
autoReconnect: Boolean, autoReconnect: Boolean,
onAutoReconnectChange: (Boolean) -> Unit, onAutoReconnectChange: (Boolean) -> Unit,
) { ) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) { SectionCard {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -46,7 +46,7 @@ internal fun ConnectionSection(
passwordVisible: Boolean, passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit, onPasswordVisibilityToggle: () -> Unit,
) { ) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) { SectionCard {
// ── Saved profiles list ────────────────────────────────── // ── Saved profiles list ──────────────────────────────────
if (state.profiles.isNotEmpty()) { if (state.profiles.isNotEmpty()) {
Text( Text(
@@ -85,7 +85,7 @@ internal fun ConnectionSection(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
) )
Text( Text(
text = "${profile.host}:${profile.port}", text = profile.resolvedBaseUrl,
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -139,12 +139,11 @@ internal fun ConnectionSection(
ProfileEditorDialog( ProfileEditorDialog(
isEditing = state.editingProfileId != null, isEditing = state.editingProfileId != null,
name = state.dialogProfileName, name = state.dialogProfileName,
host = state.dialogProfileHost, baseUrl = state.dialogProfileBaseUrl,
port = state.dialogProfilePort,
token = state.dialogProfileToken, token = state.dialogProfileToken,
error = state.dialogProfileError,
onNameChange = viewModel::onDialogProfileNameChange, onNameChange = viewModel::onDialogProfileNameChange,
onHostChange = viewModel::onDialogProfileHostChange, onBaseUrlChange = viewModel::onDialogProfileBaseUrlChange,
onPortChange = viewModel::onDialogProfilePortChange,
onTokenChange = viewModel::onDialogProfileTokenChange, onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog, onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog, onDismiss = viewModel::closeProfileDialog,
@@ -25,12 +25,11 @@ import com.m57.hermescontrol.R
internal fun ProfileEditorDialog( internal fun ProfileEditorDialog(
isEditing: Boolean, isEditing: Boolean,
name: String, name: String,
host: String, baseUrl: String,
port: String,
token: String, token: String,
error: String?,
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onHostChange: (String) -> Unit, onBaseUrlChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onTokenChange: (String) -> Unit, onTokenChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -55,20 +54,22 @@ internal fun ProfileEditorDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = host, value = baseUrl,
onValueChange = onHostChange, onValueChange = onBaseUrlChange,
label = { Text(stringResource(R.string.settings_field_host)) }, label = { Text(stringResource(R.string.connect_server_url_label)) },
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
singleLine = true, singleLine = true,
isError = error != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( if (error != null) {
value = port, Text(
onValueChange = onPortChange, text = error,
label = { Text(stringResource(R.string.settings_field_port)) }, color = MaterialTheme.colorScheme.error,
singleLine = true, style = MaterialTheme.typography.bodySmall,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), )
modifier = Modifier.fillMaxWidth(), }
)
if (isEditing) { if (isEditing) {
OutlinedTextField( OutlinedTextField(
value = token, value = token,
@@ -87,7 +88,7 @@ internal fun ProfileEditorDialog(
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) { Button(onClick = onSave, enabled = name.isNotBlank() && error == null) {
Text(stringResource(R.string.action_save)) Text(stringResource(R.string.action_save))
} }
}, },
-1
View File
@@ -224,7 +224,6 @@
<string name="connect_error_malformed">잘못된 페어링 문자열 — URL 또는 Base64 JSON이어야 함</string> <string name="connect_error_malformed">잘못된 페어링 문자열 — URL 또는 Base64 JSON이어야 함</string>
<string name="connect_error_parse_failed">페어링 문자열 파싱 실패: %1$s</string> <string name="connect_error_parse_failed">페어링 문자열 파싱 실패: %1$s</string>
<string name="connect_pairing_string">페어링 문자열</string>
<string name="connect_placeholder_pairing">hermes://… 또는 Base64 설정 붙여넣기</string> <string name="connect_placeholder_pairing">hermes://… 또는 Base64 설정 붙여넣기</string>
<string name="connect_save_profile">연결 프로필 저장</string> <string name="connect_save_profile">연결 프로필 저장</string>
<string name="connect_profile_name">프로필 이름</string> <string name="connect_profile_name">프로필 이름</string>
+7 -2
View File
@@ -254,8 +254,7 @@
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string> <string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string> <string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
<string name="connect_pairing_string">Pairing string</string> <string name="connect_placeholder_pairing">Paste hermes://… or Base64 config</string>
<string name="connect_placeholder_pairing">Paste hermes://\u2026 or Base64 config</string>
<string name="connect_save_profile">Save connection profile</string> <string name="connect_save_profile">Save connection profile</string>
<string name="connect_profile_name">Profile Name</string> <string name="connect_profile_name">Profile Name</string>
<string name="connect_placeholder_profile_name">e.g. Local Hermit, Remote Server</string> <string name="connect_placeholder_profile_name">e.g. Local Hermit, Remote Server</string>
@@ -264,6 +263,9 @@
<string name="connect_placeholder_token">API token</string> <string name="connect_placeholder_token">API token</string>
<string name="connect_placeholder_host">127.0.0.1</string> <string name="connect_placeholder_host">127.0.0.1</string>
<string name="connect_placeholder_port">9119</string> <string name="connect_placeholder_port">9119</string>
<string name="connect_server_url_label">Server URL</string>
<string name="connect_placeholder_server_url">https://host:9119/ or http://127.0.0.1:9119/</string>
<string name="connect_error_url_invalid">Enter a valid server URL (https:// or http://)</string>
<string name="action_connect">Connect</string> <string name="action_connect">Connect</string>
<!-- Kanban Screen --> <!-- Kanban Screen -->
@@ -807,6 +809,7 @@
<!-- Auth Login Screen --> <!-- Auth Login Screen -->
<string name="auth_login_title">Auth Login</string> <string name="auth_login_title">Auth Login</string>
<string name="auth_login_desc">Connect to your Hermes dashboard</string> <string name="auth_login_desc">Connect to your Hermes dashboard</string>
<string name="auth_login_base_url_label">Server URL</string>
<string name="auth_login_host_label">Host</string> <string name="auth_login_host_label">Host</string>
<string name="auth_login_port_label">Port</string> <string name="auth_login_port_label">Port</string>
<string name="auth_login_token_label">Session Token</string> <string name="auth_login_token_label">Session Token</string>
@@ -821,6 +824,8 @@
<string name="auth_login_error_username_required">Username is required</string> <string name="auth_login_error_username_required">Username is required</string>
<string name="auth_login_error_password_required">Password is required</string> <string name="auth_login_error_password_required">Password is required</string>
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string> <string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile.</string>
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string> <string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
<string name="auth_login_use_profile">Enter as %1$s</string> <string name="auth_login_use_profile">Enter as %1$s</string>
@@ -45,7 +45,9 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.ui.channels.ChannelsViewModel import com.m57.hermescontrol.ui.channels.ChannelsViewModel
import com.m57.hermescontrol.ui.connect.ConnectViewModel import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel import com.m57.hermescontrol.ui.cron.CronJobsViewModel
@@ -89,16 +91,21 @@ class E2eIntegrationTest {
mockApiService = mockk() mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any(), any()) } returns mockApiService every { ApiClient.createTempService(any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs // Default AuthManager stubs
every { AuthManager.getToken() } returns "mock-token" every { AuthManager.getToken() } returns "mock-token"
every { AuthManager.getHost() } returns "127.0.0.1" every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119 every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every {
AuthManager.endpoint()
} answers { ServerEndpoint.parse("https://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING) }
every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.getSelectedProfileId() } returns null
@@ -1380,56 +1387,4 @@ class E2eIntegrationTest {
assertEquals(listOf(false, true), capturedRefresh) assertEquals(listOf(false, true), capturedRefresh)
} }
@Test
fun testConnectViewModel_onPairingString_urlFormat() =
runTest {
mockkStatic(android.net.Uri::class)
val mockUri = mockk<android.net.Uri>()
every { android.net.Uri.parse("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") } returns
mockUri
every { mockUri.getQueryParameter("host") } returns "192.168.1.5"
every { mockUri.getQueryParameter("port") } returns "9119"
every { mockUri.getQueryParameter("token") } returns "TEST_TOKEN"
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.net.Uri::class)
}
@Test
fun testConnectViewModel_onPairingString_base64Format() =
runTest {
mockkStatic(android.util.Base64::class)
val base64Str = "eyJob3N0IjoiMTkyLjE2OC4xLjUiLCJwb3J0Ijo5MTE5LCJ0b2tlbiI6IlRFU1RfVE9LRU4ifQ=="
every { android.util.Base64.decode(base64Str, any()) } answers {
java.util.Base64
.getDecoder()
.decode(base64Str)
}
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString(base64Str)
advanceUntilIdle()
assertEquals("192.168.1.5", viewModel.uiState.value.host)
assertEquals("9119", viewModel.uiState.value.port)
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
assertTrue(viewModel.uiState.value.connectionSuccess)
unmockkStatic(android.util.Base64::class)
}
} }
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.data.config
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ServerUrlMigrationTest {
@Test
fun `shouldMigrate true when baseUrl blank`() =
runTest {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = null,
)
val migration = ServerUrlMigration()
assertTrue(migration.shouldMigrate(state))
}
@Test
fun `shouldMigrate true when a profile baseUrl blank`() =
runTest {
val state =
ServerStoreState(
baseUrl = "http://127.0.0.1:9119/",
connectionProfiles =
listOf(
ConnectionProfile(name = "Legacy", host = "10.0.0.1", port = 9220),
),
)
val migration = ServerUrlMigration()
assertTrue(migration.shouldMigrate(state))
}
@Test
fun `shouldMigrate false when all baseUrls present`() =
runTest {
val state =
ServerStoreState(
baseUrl = "http://127.0.0.1:9119/",
connectionProfiles =
listOf(
ConnectionProfile(name = "Modern", baseUrl = "https://example.com/hermes/"),
),
)
val migration = ServerUrlMigration()
assertFalse(migration.shouldMigrate(state))
}
@Test
fun `migrate converts legacy host and port to baseUrl`() {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = null,
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("http://192.168.1.5:9119/", result.baseUrl)
}
@Test
fun `migrate preserves already migrated baseUrl`() {
val state =
ServerStoreState(
host = "192.168.1.5",
port = 9119,
baseUrl = "https://example.com/hermes/",
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("https://example.com/hermes/", result.baseUrl)
}
@Test
fun `migrate converts legacy profiles`() {
val state =
ServerStoreState(
connectionProfiles =
listOf(
ConnectionProfile(name = "Legacy", host = "10.0.0.1", port = 9220),
),
)
val result = ServerUrlMigration.migrateState(state)
assertEquals("http://10.0.0.1:9220/", result.connectionProfiles.first().baseUrl)
}
}
@@ -151,7 +151,9 @@ class AuthManagerTest {
fun testBaseUrl() { fun testBaseUrl() {
AuthManager.setHost("hermes.local") AuthManager.setHost("hermes.local")
AuthManager.setPort(1234) AuthManager.setPort(1234)
assertEquals("http://hermes.local:1234/", AuthManager.baseUrl()) // setHost/setPort derive from the canonical base URL (default is https),
// so the result keeps the https scheme and swaps host/port.
assertEquals("https://hermes.local:1234/", AuthManager.getBaseUrl())
} }
@Test @Test
@@ -181,7 +183,8 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local") AuthManager.setHost("hermes.local")
AuthManager.setPort(1234) AuthManager.setPort(1234)
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl()) // Scheme tracks the canonical base URL (default https) → wss.
assertEquals("wss://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
} }
@Test @Test
@@ -191,14 +194,14 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local") AuthManager.setHost("hermes.local")
AuthManager.setPort(1234) AuthManager.setPort(1234)
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl()) assertEquals("wss://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
} }
// ── TEST-08: Token routing through selected profile ───────────────── // ── TEST-08: Token routing through selected profile ─────────────────
@Test @Test
fun testGetToken_usesProfileTokenWhenSelected() { fun testGetToken_usesProfileTokenWhenSelected() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1)) AuthManager.saveConnectionProfiles(listOf(profile1))
every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token" every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token"
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -207,7 +210,7 @@ class AuthManagerTest {
@Test @Test
fun testGetToken_returnsNullWhenProfileHasNoToken() { fun testGetToken_returnsNullWhenProfileHasNoToken() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1)) AuthManager.saveConnectionProfiles(listOf(profile1))
every { mockPrefs.getString("token_prof-1", null) } returns null every { mockPrefs.getString("token_prof-1", null) } returns null
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -216,7 +219,7 @@ class AuthManagerTest {
@Test @Test
fun testSetToken_writesToProfileTokenWhenSelected() { fun testSetToken_writesToProfileTokenWhenSelected() {
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119) val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1)) AuthManager.saveConnectionProfiles(listOf(profile1))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -241,7 +244,7 @@ class AuthManagerTest {
@Test @Test
fun testGetHost_usesProfileHostWhenSelected() { fun testGetHost_usesProfileHostWhenSelected() {
val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220) val profile = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9220/")
AuthManager.saveConnectionProfiles(listOf(profile)) AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -250,7 +253,7 @@ class AuthManagerTest {
@Test @Test
fun testGetPort_usesProfilePortWhenSelected() { fun testGetPort_usesProfilePortWhenSelected() {
val profile = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9220) val profile = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9220/")
AuthManager.saveConnectionProfiles(listOf(profile)) AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -269,26 +272,27 @@ class AuthManagerTest {
@Test @Test
fun testSetHost_updatesProfileWhenSelected() { fun testSetHost_updatesProfileWhenSelected() {
val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119) val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile)) AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
AuthManager.setHost("10.0.0.99") AuthManager.setHost("10.0.0.99")
val updated = AuthManager.getConnectionProfiles().first() val updated = AuthManager.getConnectionProfiles().first()
assertEquals("10.0.0.99", updated.host) // host/port are now canonicalized into the base URL (profile was http)
assertEquals("http://10.0.0.99:9119/", updated.resolvedBaseUrl)
} }
@Test @Test
fun testSetPort_updatesProfileWhenSelected() { fun testSetPort_updatesProfileWhenSelected() {
val profile = ConnectionProfile("prof-1", "Home", "10.0.0.1", 9119) val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile)) AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
AuthManager.setPort(9999) AuthManager.setPort(9999)
val updated = AuthManager.getConnectionProfiles().first() val updated = AuthManager.getConnectionProfiles().first()
assertEquals(9999, updated.port) assertEquals("http://10.0.0.1:9999/", updated.resolvedBaseUrl)
} }
@Test @Test
@@ -308,23 +312,23 @@ class AuthManagerTest {
fun testConnectionProfiles_roundTrip() { fun testConnectionProfiles_roundTrip() {
val profiles = val profiles =
listOf( listOf(
ConnectionProfile("a", "A", "10.0.0.1", 9119), ConnectionProfile(id = "a", name = "A", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile("b", "B", "10.0.0.2", 9220), ConnectionProfile(id = "b", name = "B", baseUrl = "http://10.0.0.2:9220/"),
) )
AuthManager.saveConnectionProfiles(profiles) AuthManager.saveConnectionProfiles(profiles)
val loaded = AuthManager.getConnectionProfiles() val loaded = AuthManager.getConnectionProfiles()
assertEquals(2, loaded.size) assertEquals(2, loaded.size)
assertEquals("A", loaded[0].name) assertEquals("A", loaded[0].name)
assertEquals("10.0.0.2", loaded[1].host) assertEquals("http://10.0.0.2:9220/", loaded[1].resolvedBaseUrl)
} }
// ── TEST-11: Multi-profile token scenarios ───────────────────────── // ── TEST-11: Multi-profile token scenarios ─────────────────────────
@Test @Test
fun testGetToken_switchesTokenWhenProfileChanges() { fun testGetToken_switchesTokenWhenProfileChanges() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Profile A selected → returns token_a // Profile A selected → returns token_a
@@ -345,8 +349,8 @@ class AuthManagerTest {
@Test @Test
fun testGetToken_profileWithNoTokenReturnsNull() { fun testGetToken_profileWithNoTokenReturnsNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Profile A is selected but has no token → null (no global fallback) // Profile A is selected but has no token → null (no global fallback)
@@ -362,9 +366,9 @@ class AuthManagerTest {
@Test @Test
fun testGetToken_selectiveProfileTokens_someNull() { fun testGetToken_selectiveProfileTokens_someNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
val profileC = ConnectionProfile("prof-c", "Profile C", "127.0.0.1", 9119) val profileC = ConnectionProfile(id = "prof-c", name = "Profile C", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC)) AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC))
// Profile A has a token // Profile A has a token
@@ -385,8 +389,8 @@ class AuthManagerTest {
@Test @Test
fun testSetToken_switchingProfiles_maintainsSeparateTokens() { fun testSetToken_switchingProfiles_maintainsSeparateTokens() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119) val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
// Set a token while Profile A is selected // Set a token while Profile A is selected
@@ -406,7 +410,7 @@ class AuthManagerTest {
@Test @Test
fun testSetToken_toNull_clearsProfileToken() { fun testSetToken_toNull_clearsProfileToken() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119) val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA)) AuthManager.saveConnectionProfiles(listOf(profileA))
AuthManager.setSelectedProfileId("prof-a") AuthManager.setSelectedProfileId("prof-a")
@@ -442,7 +446,7 @@ class AuthManagerTest {
@Test @Test
fun testEnsureDefaultProfile_doesNotDuplicateExisting() { fun testEnsureDefaultProfile_doesNotDuplicateExisting() {
val existing = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119) val existing = ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(existing)) AuthManager.saveConnectionProfiles(listOf(existing))
AuthManager.setSelectedProfileId("prof-1") AuthManager.setSelectedProfileId("prof-1")
@@ -39,8 +39,9 @@ class ApiClientTest {
// can build clients without app context. // can build clients without app context.
CookieManager.setJarForTest(buildFakePersistentCookieJar()) CookieManager.setJarForTest(buildFakePersistentCookieJar())
// Point AuthManager at our MockWebServer // Point AuthManager at our MockWebServer (the interceptor reads the
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString() // canonical base URL via endpointForBuild() → getBaseUrl()).
every { AuthManager.getBaseUrl() } returns mockWebServer.url("/").toString()
every { AuthManager.getHost() } returns "127.0.0.1" every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119 every { AuthManager.getPort() } returns 9119
@@ -24,6 +24,10 @@ class CookieManagerTest {
return jar return jar
} }
private fun ep(host: String): ServerEndpoint {
return ServerEndpoint.fromLegacy(host, 9119)
}
@After @After
fun tearDown() { fun tearDown() {
CookieManager.resetForTest() CookieManager.resetForTest()
@@ -32,7 +36,7 @@ class CookieManagerTest {
@Test @Test
fun setThenGetSessionCookie_roundTrips() { fun setThenGetSessionCookie_roundTrips() {
fakeJar() fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local") CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie()) assertEquals("sess-xyz", CookieManager.getSessionCookie())
} }
@@ -40,10 +44,10 @@ class CookieManagerTest {
@Test @Test
fun setSessionCookie_nullClearsValue() { fun setSessionCookie_nullClearsValue() {
fakeJar() fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local") CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie()) assertEquals("sess-xyz", CookieManager.getSessionCookie())
CookieManager.setSessionCookie(null, "dashboard.local") CookieManager.setSessionCookie(null, ep("dashboard.local"))
assertNull(CookieManager.getSessionCookie()) assertNull(CookieManager.getSessionCookie())
} }
@@ -51,7 +55,7 @@ class CookieManagerTest {
fun setSessionCookie_hostScoped_onlyMatchesThatHost() = fun setSessionCookie_hostScoped_onlyMatchesThatHost() =
runTest { runTest {
val jar = fakeJar() val jar = fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local") CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
val onHost = val onHost =
jar.loadForRequest( jar.loadForRequest(
@@ -68,7 +72,7 @@ class CookieManagerTest {
fun pruneServerCache_delegatesToJar() = fun pruneServerCache_delegatesToJar() =
runTest { runTest {
val jar = fakeJar() val jar = fakeJar()
CookieManager.setSessionCookie("keep-me", "dashboard.local") CookieManager.setSessionCookie("keep-me", ep("dashboard.local"))
CookieManager.pruneServerCache() CookieManager.pruneServerCache()
// Session cookie is preserved by prune. // Session cookie is preserved by prune.
assertEquals("keep-me", CookieManager.getSessionCookie()) assertEquals("keep-me", CookieManager.getSessionCookie())
@@ -78,7 +82,7 @@ class CookieManagerTest {
fun clearAll_wipesSession() = fun clearAll_wipesSession() =
runTest { runTest {
val jar = fakeJar() val jar = fakeJar()
CookieManager.setSessionCookie("bye", "dashboard.local") CookieManager.setSessionCookie("bye", ep("dashboard.local"))
CookieManager.clearAll() CookieManager.clearAll()
assertNull(CookieManager.getSessionCookie()) assertNull(CookieManager.getSessionCookie())
} }
@@ -0,0 +1,149 @@
package com.m57.hermescontrol.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class ServerEndpointTest {
@Test
fun `parse accepts https with trailing slash normalization`() {
val ep = ServerEndpoint.parse("https://example.com/hermes", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("https://example.com/hermes/", ep.baseUrl.toString())
assertNull(ep.securityWarning)
assertFalse(ep.isCleartext)
}
@Test
fun `parse accepts http with warning`() {
val ep = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("http://127.0.0.1:9119/", ep.baseUrl.toString())
assertEquals(ServerEndpoint.CLEARTEXT_WARNING, ep.securityWarning)
assertTrue(ep.isCleartext)
}
@Test
fun `parse rejects non http scheme`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("ftp://example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects embedded credentials`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://user:pass@example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects query string`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://example.com/?token=abc", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `parse rejects fragment`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("https://example.com/#frag", CleartextPolicy.ALLOW_WITH_WARNING)
}
}
@Test
fun `DENY policy rejects http`() {
assertThrows<IllegalArgumentException> {
ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.DENY)
}
}
@Test
fun `DENY policy accepts https`() {
val ep = ServerEndpoint.parse("https://example.com/", CleartextPolicy.DENY)
assertNull(ep.securityWarning)
}
@Test
fun `resolve builds prefixed url and rejects path escape`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("https://example.com/hermes/api/status", ep.resolve("api/status").toString())
assertThrows<IllegalArgumentException> {
ep.resolve("../etc/passwd")
}
assertThrows<IllegalArgumentException> {
ep.resolve(" ")
}
}
@Test
fun `webSocketUrl converts https to wss and http to ws`() {
val https = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals(
"wss://example.com/hermes/api/ws?token=abc",
https.webSocketUrl("token", "abc"),
)
val http = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals(
"ws://127.0.0.1:9119/api/ws?ticket=xyz",
http.webSocketUrl("ticket", "xyz"),
)
}
@Test
fun `webSocketUrl rejects unsupported auth param`() {
val ep = ServerEndpoint.parse("https://example.com/", CleartextPolicy.ALLOW_WITH_WARNING)
assertThrows<IllegalArgumentException> {
ep.webSocketUrl("cookie", "abc")
}
}
@Test
fun `relativeRequestPath strips proxy prefix`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
val request = ep.resolve("api/status")
assertEquals("/api/status", ep.relativeRequestPath(request))
}
@Test
fun `relativeRequestPath passes through non matching origin`() {
val ep = ServerEndpoint.parse("https://example.com/hermes/", CleartextPolicy.ALLOW_WITH_WARNING)
val other = ServerEndpoint.parse("https://other.com/x/", CleartextPolicy.ALLOW_WITH_WARNING)
val request = other.resolve("api/status")
assertEquals("/x/api/status", ep.relativeRequestPath(request))
}
@Test
fun `redactWebSocketUrlForLog strips query`() {
assertEquals(
"wss://example.com/hermes/api/ws",
ServerEndpoint.redactWebSocketUrlForLog("wss://example.com/hermes/api/ws?token=secret"),
)
assertEquals(
"ws://127.0.0.1:9119/api/ws",
ServerEndpoint.redactWebSocketUrlForLog("ws://127.0.0.1:9119/api/ws?ticket=secret"),
)
}
@Test
fun `fromLegacy preserves http host and port`() {
val ep = ServerEndpoint.fromLegacy("10.0.0.1", 8080)
assertEquals("http://10.0.0.1:8080/", ep.baseUrl.toString())
assertTrue(ep.isCleartext)
}
@Test
fun `DEFAULT_BASE_URL is https localhost 9119`() {
assertEquals("https://127.0.0.1:9119/", ServerEndpoint.DEFAULT_BASE_URL)
}
private inline fun <reified T : Throwable> assertThrows(block: () -> Unit) {
try {
block()
fail("Expected ${T::class.simpleName} but nothing was thrown")
} catch (e: Throwable) {
if (e !is T) throw e
}
}
}
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.authlogin
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Regression test for the basic-auth token-derivation bug.
*
* Root cause (issue #634 rework): AuthLoginViewModel.connectBasicAuth() built a
* FRESH OkHttpClient for the /auth/password-login call, so the Set-Cookie from
* that response was captured by an ephemeral jar and never reached the
* /api/auth/ws-ticket call on a DIFFERENT client. The ticket request then got
* 401, connectBasicAuth returned null, and the UI fell back to asking the user
* to paste a token manually.
*
* Fix: both calls use the SHARED OkHttpProvider.probe client (same
* CookieManager.cookieJar), so the session cookie set by password-login flows
* into the ws-ticket request automatically.
*
* This test proves the contract at the layer that was actually broken: a
* password-login with Set-Cookie on the shared client must let a subsequent
* ws-ticket request on the SAME client carry the cookie and succeed.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AuthLoginBasicAuthFlowTest {
private lateinit var server: MockWebServer
private val dispatcher = StandardTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
server = MockWebServer()
server.start()
CookieManager.setJarForTest(buildFakePersistentCookieJar())
}
@AfterEach
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
@Test
fun `password-login Set-Cookie flows into ws-ticket on the shared client`() =
runTest {
// password-login: returns JSON (no token in body) + session Set-Cookie
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setHeader("Set-Cookie", "hermes_access_token=abc123; Path=/; HttpOnly")
.setBody("{\"ok\":true,\"next\":\"/\"}"),
)
// ws-ticket: mints only if the session cookie is present on the request
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{\"ticket\":\"ws-ticket-xyz\",\"ttl_seconds\":30}"),
)
val base = server.url("/").toString().removeSuffix("/")
// Step 1: login on the SHARED client (what the fixed code does)
val loginReq =
Request
.Builder()
.url("$base/auth/password-login")
.header("Content-Type", "application/json")
.post("{\"username\":\"admin\",\"password\":\"s3cret\"}".toRequestBody())
.build()
val loginResp = OkHttpProvider.probe.newCall(loginReq).execute()
assertEquals(200, loginResp.code)
// Step 2: ws-ticket on the SAME shared client — cookie must carry over
val ticketReq =
Request
.Builder()
.url("$base/api/auth/ws-ticket")
.post("{}".toRequestBody())
.build()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
assertEquals(200, ticketResp.code, "ws-ticket must succeed with the session cookie from login")
val body = ticketResp.body.string()
assertTrue(body.contains("\"ticket\""), "ws-ticket response should contain a ticket")
}
@Test
fun `ws-ticket without prior login cookie fails (proves cookie is the gate)`() =
runTest {
// No login queued; ws-ticket should be rejected (401) because no session
server.enqueue(MockResponse().setResponseCode(401).setBody("Unauthorized"))
val base = server.url("/").toString().removeSuffix("/")
val ticketReq =
Request
.Builder()
.url("$base/api/auth/ws-ticket")
.post("{}".toRequestBody())
.build()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
assertEquals(401, ticketResp.code)
}
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
@@ -29,8 +30,7 @@ class AuthLoginViewModelTest {
@Before @Before
fun setup() { fun setup() {
mockkObject(AuthManager) mockkObject(AuthManager)
every { AuthManager.getHost() } returns "localhost" every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getConnectionProfiles() } returns emptyList()
mockkObject(OkHttpProvider) mockkObject(OkHttpProvider)
@@ -51,21 +51,22 @@ class AuthLoginViewModelTest {
} }
@Test @Test
fun `onHostChange updates host and clears error and auth mode`() { fun `onBaseUrlChange updates baseUrl and clears error and auth mode`() {
viewModel.onHostChange(" 192.168.1.100 ") viewModel.onBaseUrlChange(" https://192.168.1.100:9119/ ")
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("192.168.1.100", state.host) assertEquals("https://192.168.1.100:9119/", state.baseUrl)
assertNull(state.errorMessage) assertNull(state.errorMessage)
assertNull(state.authMode) assertNull(state.authMode)
} }
@Test @Test
fun `onPortChange updates port keeping only digits and clears error and auth mode`() { fun `onBaseUrlChange with http sets transport warning`() {
viewModel.onPortChange("9a1b1c9") viewModel.onBaseUrlChange("http://127.0.0.1:9119/")
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("9119", state.port) assertEquals("http://127.0.0.1:9119/", state.baseUrl)
assertEquals(ServerEndpoint.CLEARTEXT_WARNING, state.transportWarning)
assertNull(state.errorMessage) assertNull(state.errorMessage)
assertNull(state.authMode) assertNull(state.authMode)
} }
@@ -109,4 +110,46 @@ class AuthLoginViewModelTest {
assertNull(state.authMode) assertNull(state.authMode)
assertEquals("Dashboard unreachable", state.errorMessage) assertEquals("Dashboard unreachable", state.errorMessage)
} }
// ── deriveAuthMode (authoritative /api/status mapping) ──
@Test
fun `deriveAuthMode gate down returns ALL sentinel`() {
assertEquals(
DashboardAuthMode.ALL,
viewModel.deriveAuthMode(authRequired = false, providers = emptyList()),
)
}
@Test
fun `deriveAuthMode oauth provider returns OAUTH`() {
assertEquals(
DashboardAuthMode.OAUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("oauth")),
)
}
@Test
fun `deriveAuthMode basic provider returns BASIC_AUTH`() {
assertEquals(
DashboardAuthMode.BASIC_AUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("basic")),
)
}
@Test
fun `deriveAuthMode oauth takes precedence over basic`() {
assertEquals(
DashboardAuthMode.OAUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("basic", "oauth")),
)
}
@Test
fun `deriveAuthMode gate up with unknown provider falls back to BASIC_AUTH`() {
assertEquals(
DashboardAuthMode.BASIC_AUTH,
viewModel.deriveAuthMode(authRequired = true, providers = listOf("weird")),
)
}
} }
@@ -6,7 +6,9 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.ServerEndpoint
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -49,27 +51,28 @@ class ConnectViewModelTest {
mockApiService = mockk() mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any(), any()) } returns mockApiService every { ApiClient.createTempService(any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs // Default AuthManager stubs
every { AuthManager.getToken() } returns "" every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1" every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getPort() } returns 9119
every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit every {
AuthManager.endpoint()
} answers { ServerEndpoint.parse("https://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING) }
every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.saveConnectionProfiles(any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.setSelectedProfileId(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.ensureDefaultSelected() } returns Unit
// Mock Application string resources // Mock Application string resources
every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required" every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required"
every { mockApp.getString(R.string.connect_error_host_required) } returns "Host is required" every { mockApp.getString(R.string.connect_error_url_invalid) } returns "URL is invalid"
every { mockApp.getString(R.string.connect_error_port_invalid) } returns "Port must be between 1 and 65535"
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)" every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
every { mockApp.getString(R.string.connect_error_403) } returns "Access denied (403 Forbidden)" every { mockApp.getString(R.string.connect_error_403) } returns "Access denied (403 Forbidden)"
every { mockApp.getString(R.string.connect_error_http_code) } returns "Server returned HTTP %1\$d" every { mockApp.getString(R.string.connect_error_http_code) } returns "Server returned HTTP %1\$d"
@@ -94,15 +97,13 @@ class ConnectViewModelTest {
@Test @Test
fun testInitialLoadingFromAuthManager() { fun testInitialLoadingFromAuthManager() {
every { AuthManager.getToken() } returns "saved-token" every { AuthManager.getToken() } returns "saved-token"
every { AuthManager.getHost() } returns "hermes.local" every { AuthManager.getBaseUrl() } returns "http://hermes.local:8888/"
every { AuthManager.getPort() } returns 8888
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("saved-token", state.token) assertEquals("saved-token", state.token)
assertEquals("hermes.local", state.host) assertEquals("http://hermes.local:8888/", state.baseUrl)
assertEquals("8888", state.port)
assertFalse(state.isConnecting) assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess) assertFalse(state.connectionSuccess)
assertNull(state.errorMessage) assertNull(state.errorMessage)
@@ -120,7 +121,7 @@ class ConnectViewModelTest {
fun testOnHostChange() { fun testOnHostChange() {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onHostChange(" 192.168.1.50 ") viewModel.onHostChange(" 192.168.1.50 ")
assertEquals("192.168.1.50", viewModel.uiState.value.host) assertEquals("https://192.168.1.50:9119/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage) assertNull(viewModel.uiState.value.errorMessage)
} }
@@ -128,7 +129,7 @@ class ConnectViewModelTest {
fun testOnPortChange() { fun testOnPortChange() {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onPortChange("90abc90") viewModel.onPortChange("90abc90")
assertEquals("9090", viewModel.uiState.value.port) assertEquals("https://127.0.0.1:9090/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage) assertNull(viewModel.uiState.value.errorMessage)
} }
@@ -141,31 +142,17 @@ class ConnectViewModelTest {
} }
@Test @Test
fun testConnect_blankHost_showsError() { fun testConnect_invalidUrl_showsError() {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token") viewModel.onTokenChange("valid-token")
viewModel.onHostChange("")
viewModel.connect()
assertEquals("Host is required", viewModel.uiState.value.errorMessage)
}
@Test viewModel.onBaseUrlChange("https://127.0.0.1:65536/")
fun testConnect_invalidPortBounds_showsError() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("0")
viewModel.connect() viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage) assertEquals("URL is invalid", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("65536") viewModel.onBaseUrlChange("not-a-url")
viewModel.connect() viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage) assertEquals("URL is invalid", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
} }
@Test @Test
@@ -173,8 +160,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token") viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true every { mockResponse.isSuccessful } returns true
@@ -195,8 +181,7 @@ class ConnectViewModelTest {
advanceUntilIdle() advanceUntilIdle()
verify { AuthManager.setToken("valid-token") } verify { AuthManager.setToken("valid-token") }
verify { AuthManager.setHost("127.0.0.1") } verify { AuthManager.setBaseUrl("https://127.0.0.1:9119/") }
verify { AuthManager.setPort(9119) }
verify { ApiClient.rebuild() } verify { ApiClient.rebuild() }
val state = viewModel.uiState.value val state = viewModel.uiState.value
@@ -210,8 +195,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token") viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false every { mockResponse.isSuccessful } returns false
@@ -236,8 +220,7 @@ class ConnectViewModelTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token") viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false every { mockResponse.isSuccessful } returns false
@@ -261,8 +244,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token") viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false every { mockResponse.isSuccessful } returns false
@@ -283,8 +265,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token") viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false every { mockResponse.isSuccessful } returns false
@@ -305,8 +286,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token") viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Connection refused") coEvery { mockApiService.getStatus() } throws Exception("Connection refused")
@@ -324,8 +304,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token") viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("timeout exception") coEvery { mockApiService.getStatus() } throws Exception("timeout exception")
@@ -343,8 +322,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token") viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Something went wrong") coEvery { mockApiService.getStatus() } throws Exception("Something went wrong")
@@ -357,43 +335,13 @@ class ConnectViewModelTest {
assertEquals("Connection failed: Something went wrong", state.errorMessage) assertEquals("Connection failed: Something went wrong", state.errorMessage)
} }
@Test
fun testOnPairingString_malformedBase64_showsError() {
val viewModel = ConnectViewModel(mockApp)
// Mock Base64.decode to throw IllegalArgumentException for an invalid short string
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
viewModel.onPairingString("short-invalid-string")
val state = viewModel.uiState.value
assertEquals("Malformed pairing string — expected URL or Base64-encoded JSON", state.errorMessage)
}
@Test
fun testOnPairingString_malformedBase64_validRawToken() {
val viewModel = ConnectViewModel(mockApp)
// Mock Base64.decode to throw IllegalArgumentException
every { android.util.Base64.decode(any<String>(), any()) } throws IllegalArgumentException("bad base64")
// A string that is >= 32 chars and alphanumeric matching the raw token regex
val validToken = "dummy_token_value_that_is_long_enough_to_pass_validation_123"
viewModel.onPairingString(validToken)
val state = viewModel.uiState.value
assertEquals(validToken, state.token)
assertNull(state.errorMessage)
}
@Test @Test
fun testSelectProfile_updatesState() { fun testSelectProfile_updatesState() {
val profile = val profile =
ConnectionProfile( ConnectionProfile(
id = "test-id", id = "test-id",
name = "Test Profile", name = "Test Profile",
host = "192.168.1.100", baseUrl = "https://192.168.1.100:8080/",
port = 8080,
) )
every { AuthManager.getProfileToken("test-id") } returns "profile-token" every { AuthManager.getProfileToken("test-id") } returns "profile-token"
@@ -402,8 +350,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("Test Profile", state.profileName) assertEquals("Test Profile", state.profileName)
assertEquals("192.168.1.100", state.host) assertEquals("https://192.168.1.100:8080/", state.baseUrl)
assertEquals("8080", state.port)
assertEquals("profile-token", state.token) assertEquals("profile-token", state.token)
assertEquals(profile, state.selectedProfile) assertEquals(profile, state.selectedProfile)
@@ -415,8 +362,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token") viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1") viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onPortChange("9119")
viewModel.onProfileNameChange("New Profile") viewModel.onProfileNameChange("New Profile")
viewModel.onSaveProfileChange(true) viewModel.onSaveProfileChange(true)
@@ -444,7 +390,7 @@ class ConnectViewModelTest {
assertTrue(state.connectionSuccess) assertTrue(state.connectionSuccess)
} }
// ── TEST-06: Profile edge cases ───────────────────────────────────── // ── Profile edge cases ─────────────────────────────────────────────
@Test @Test
fun testLoadSavedValues_withProfiles_loadsSelected() { fun testLoadSavedValues_withProfiles_loadsSelected() {
@@ -452,12 +398,10 @@ class ConnectViewModelTest {
ConnectionProfile( ConnectionProfile(
id = "prof-1", id = "prof-1",
name = "Work", name = "Work",
host = "10.0.0.1", baseUrl = "https://10.0.0.1:9119/",
port = 9119,
) )
every { AuthManager.getToken() } returns "" every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1" every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getConnectionProfiles() } returns listOf(profile) every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "prof-1" every { AuthManager.getSelectedProfileId() } returns "prof-1"
@@ -465,8 +409,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("Work", state.profileName) assertEquals("Work", state.profileName)
assertEquals("10.0.0.1", state.host) assertEquals("https://10.0.0.1:9119/", state.baseUrl)
assertEquals("9119", state.port)
assertEquals(profile, state.selectedProfile) assertEquals(profile, state.selectedProfile)
assertEquals(1, state.profiles.size) assertEquals(1, state.profiles.size)
} }
@@ -477,18 +420,18 @@ class ConnectViewModelTest {
ConnectionProfile( ConnectionProfile(
id = "prof-1", id = "prof-1",
name = "Work", name = "Work",
host = "10.0.0.1", baseUrl = "https://10.0.0.1:9119/",
port = 9119,
) )
every { AuthManager.getConnectionProfiles() } returns listOf(profile) every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "nonexistent-id" every { AuthManager.getSelectedProfileId() } returns "nonexistent-id"
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertNull(state.selectedProfile) assertNull(state.selectedProfile)
assertEquals("", state.profileName) assertEquals("", state.profileName)
assertEquals("127.0.0.1", state.host) assertEquals("https://127.0.0.1:9119/", state.baseUrl)
} }
@Test @Test
@@ -499,8 +442,7 @@ class ConnectViewModelTest {
ConnectionProfile( ConnectionProfile(
id = "prof-2", id = "prof-2",
name = "NoToken", name = "NoToken",
host = "10.0.0.2", baseUrl = "http://10.0.0.2:9220/",
port = 9220,
) )
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.selectProfile(profile) viewModel.selectProfile(profile)
@@ -514,8 +456,7 @@ class ConnectViewModelTest {
runTest { runTest {
val viewModel = ConnectViewModel(mockApp) val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("standalone-token") viewModel.onTokenChange("standalone-token")
viewModel.onHostChange("10.0.0.1") viewModel.onBaseUrlChange("http://10.0.0.1:9119/")
viewModel.onPortChange("9119")
viewModel.onSaveProfileChange(false) viewModel.onSaveProfileChange(false)
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
@@ -534,8 +475,7 @@ class ConnectViewModelTest {
advanceUntilIdle() advanceUntilIdle()
verify { AuthManager.setToken("standalone-token") } verify { AuthManager.setToken("standalone-token") }
verify { AuthManager.setHost("10.0.0.1") } verify { AuthManager.setBaseUrl("http://10.0.0.1:9119/") }
verify { AuthManager.setPort(9119) }
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) } verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
} }
@@ -560,8 +500,8 @@ class ConnectViewModelTest {
fun testMultipleProfiles_loadedOnInit() { fun testMultipleProfiles_loadedOnInit() {
val profiles = val profiles =
listOf( listOf(
ConnectionProfile("a", "Alpha", "10.0.0.1", 9119), ConnectionProfile("a", "Alpha", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile("b", "Beta", "10.0.0.2", 9220), ConnectionProfile("b", "Beta", baseUrl = "http://10.0.0.2:9220/"),
) )
every { AuthManager.getConnectionProfiles() } returns profiles every { AuthManager.getConnectionProfiles() } returns profiles
every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.getSelectedProfileId() } returns null
@@ -579,40 +519,4 @@ class ConnectViewModelTest {
.name, .name,
) )
} }
@Test
fun testOnPairingString_hermesUrl_parsesAndConnects() =
runTest {
// Stub the Uri mock for this test
every { android.net.Uri.parse(any()) } answers {
val uri = mockk<android.net.Uri>(relaxed = true)
every { uri.getQueryParameter("host") } returns "192.168.1.1"
every { uri.getQueryParameter("port") } returns "8888"
every { uri.getQueryParameter("token") } returns "abc123"
uri
}
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
every { mockResponse.body() } returns
StatusResponse(
version = "1.0",
gateway_running = true,
active_sessions = 0,
auth_required = false,
gateway_platforms = emptyMap(),
)
coEvery { mockApiService.getStatus() } returns mockResponse
val viewModel = ConnectViewModel(mockApp)
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
val state = viewModel.uiState.value
assertEquals("192.168.1.1", state.host)
assertEquals("8888", state.port)
assertEquals("abc123", state.token)
// Should have triggered connect
advanceUntilIdle()
assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess)
}
} }
@@ -34,8 +34,8 @@ class SettingsViewModelTest {
private val testProfiles = private val testProfiles =
listOf( listOf(
ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119), ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile("prof-2", "Home", "10.0.0.2", 9220), ConnectionProfile(id = "prof-2", name = "Home", baseUrl = "http://10.0.0.2:9220/"),
) )
private fun createViewModel(): SettingsViewModel { private fun createViewModel(): SettingsViewModel {
@@ -56,6 +56,8 @@ class SettingsViewModelTest {
every { AuthManager.getHost() } returns "127.0.0.1" every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119 every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getAppLanguage() } returns "system"
every { AuthManager.getToken() } returns "" every { AuthManager.getToken() } returns ""
every { AuthManager.isAutoReconnect() } returns true every { AuthManager.isAutoReconnect() } returns true
every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM
@@ -70,6 +72,7 @@ class SettingsViewModelTest {
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.setHost(any()) } returns Unit every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.setToken(any()) } returns Unit every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setAutoReconnect(any()) } returns Unit every { AuthManager.setAutoReconnect(any()) } returns Unit
every { AuthManager.setThemePreference(any()) } returns Unit every { AuthManager.setThemePreference(any()) } returns Unit
@@ -80,10 +83,11 @@ class SettingsViewModelTest {
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers { every { AuthManager.setSelectedProfileId(any()) } answers {
storedSelectedProfileId = firstArg() storedSelectedProfileId = firstArg()
Unit
} }
every { AuthManager.saveConnectionProfiles(any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.ensureDefaultProfile() } returns Unit
// Ensure ApiClient.rebuild() is stubbed (used by selectProfile) // Ensure ApiClient.rebuild() is stubbed (used by selectProfile)
every { ApiClient.rebuild() } returns Unit every { ApiClient.rebuild() } returns Unit
@@ -225,8 +229,7 @@ class SettingsViewModelTest {
viewModel.openAddProfile() viewModel.openAddProfile()
viewModel.onDialogProfileNameChange("Test Profile") viewModel.onDialogProfileNameChange("Test Profile")
viewModel.onDialogProfileHostChange("192.168.1.100") viewModel.onDialogProfileBaseUrlChange("https://192.168.1.100:9999/")
viewModel.onDialogProfilePortChange("9999")
viewModel.saveProfileFromDialog() viewModel.saveProfileFromDialog()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()