[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:
M57andhy4ri authored and GitHub committed 2026-07-20 17:26:10 +03:00
1 parent d839c5e9ad
commit 26b104b2bb
40 files changed
+1299 -688

No files matched your search

@@ -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.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -29,8 +30,7 @@ class AuthLoginViewModelTest {
@Before
fun setup() {
mockkObject(AuthManager)
every { AuthManager.getHost() } returns "localhost"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getConnectionProfiles() } returns emptyList()
mockkObject(OkHttpProvider)
@@ -51,21 +51,22 @@ class AuthLoginViewModelTest {
}
@Test
fun `onHostChange updates host and clears error and auth mode`() {
viewModel.onHostChange(" 192.168.1.100 ")
fun `onBaseUrlChange updates baseUrl and clears error and auth mode`() {
viewModel.onBaseUrlChange(" https://192.168.1.100:9119/ ")
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.authMode)
}
@Test
fun `onPortChange updates port keeping only digits and clears error and auth mode`() {
viewModel.onPortChange("9a1b1c9")
fun `onBaseUrlChange with http sets transport warning`() {
viewModel.onBaseUrlChange("http://127.0.0.1:9119/")
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.authMode)
}
@@ -109,4 +110,46 @@ class AuthLoginViewModelTest {
assertNull(state.authMode)
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.model.StatusResponse
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.ServerEndpoint
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -49,27 +51,28 @@ class ConnectViewModelTest {
mockApiService = mockk()
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
// Default AuthManager stubs
every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(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.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.ensureDefaultSelected() } returns Unit
// Mock Application string resources
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_port_invalid) } returns "Port must be between 1 and 65535"
every { mockApp.getString(R.string.connect_error_url_invalid) } returns "URL is invalid"
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_http_code) } returns "Server returned HTTP %1\$d"
@@ -94,15 +97,13 @@ class ConnectViewModelTest {
@Test
fun testInitialLoadingFromAuthManager() {
every { AuthManager.getToken() } returns "saved-token"
every { AuthManager.getHost() } returns "hermes.local"
every { AuthManager.getPort() } returns 8888
every { AuthManager.getBaseUrl() } returns "http://hermes.local:8888/"
val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value
assertEquals("saved-token", state.token)
assertEquals("hermes.local", state.host)
assertEquals("8888", state.port)
assertEquals("http://hermes.local:8888/", state.baseUrl)
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertNull(state.errorMessage)
@@ -120,7 +121,7 @@ class ConnectViewModelTest {
fun testOnHostChange() {
val viewModel = ConnectViewModel(mockApp)
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)
}
@@ -128,7 +129,7 @@ class ConnectViewModelTest {
fun testOnPortChange() {
val viewModel = ConnectViewModel(mockApp)
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)
}
@@ -141,31 +142,17 @@ class ConnectViewModelTest {
}
@Test
fun testConnect_blankHost_showsError() {
fun testConnect_invalidUrl_showsError() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("")
viewModel.connect()
assertEquals("Host is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_invalidPortBounds_showsError() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("0")
viewModel.onBaseUrlChange("https://127.0.0.1:65536/")
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()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
assertEquals("URL is invalid", viewModel.uiState.value.errorMessage)
}
@Test
@@ -173,8 +160,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
@@ -195,8 +181,7 @@ class ConnectViewModelTest {
advanceUntilIdle()
verify { AuthManager.setToken("valid-token") }
verify { AuthManager.setHost("127.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { AuthManager.setBaseUrl("https://127.0.0.1:9119/") }
verify { ApiClient.rebuild() }
val state = viewModel.uiState.value
@@ -210,8 +195,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -236,8 +220,7 @@ class ConnectViewModelTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -261,8 +244,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -283,8 +265,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
@@ -305,8 +286,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("Connection refused")
@@ -324,8 +304,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("timeout exception")
@@ -343,8 +322,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
coEvery { mockApiService.getStatus() } throws Exception("Something went wrong")
@@ -357,43 +335,13 @@ class ConnectViewModelTest {
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
fun testSelectProfile_updatesState() {
val profile =
ConnectionProfile(
id = "test-id",
name = "Test Profile",
host = "192.168.1.100",
port = 8080,
baseUrl = "https://192.168.1.100:8080/",
)
every { AuthManager.getProfileToken("test-id") } returns "profile-token"
@@ -402,8 +350,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value
assertEquals("Test Profile", state.profileName)
assertEquals("192.168.1.100", state.host)
assertEquals("8080", state.port)
assertEquals("https://192.168.1.100:8080/", state.baseUrl)
assertEquals("profile-token", state.token)
assertEquals(profile, state.selectedProfile)
@@ -415,8 +362,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("https://127.0.0.1:9119/")
viewModel.onProfileNameChange("New Profile")
viewModel.onSaveProfileChange(true)
@@ -444,7 +390,7 @@ class ConnectViewModelTest {
assertTrue(state.connectionSuccess)
}
// ── TEST-06: Profile edge cases ─────────────────────────────────────
// ── Profile edge cases ─────────────────────────────────────────────
@Test
fun testLoadSavedValues_withProfiles_loadsSelected() {
@@ -452,12 +398,10 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-1",
name = "Work",
host = "10.0.0.1",
port = 9119,
baseUrl = "https://10.0.0.1:9119/",
)
every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "prof-1"
@@ -465,8 +409,7 @@ class ConnectViewModelTest {
val state = viewModel.uiState.value
assertEquals("Work", state.profileName)
assertEquals("10.0.0.1", state.host)
assertEquals("9119", state.port)
assertEquals("https://10.0.0.1:9119/", state.baseUrl)
assertEquals(profile, state.selectedProfile)
assertEquals(1, state.profiles.size)
}
@@ -477,18 +420,18 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-1",
name = "Work",
host = "10.0.0.1",
port = 9119,
baseUrl = "https://10.0.0.1:9119/",
)
every { AuthManager.getConnectionProfiles() } returns listOf(profile)
every { AuthManager.getSelectedProfileId() } returns "nonexistent-id"
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
val viewModel = ConnectViewModel(mockApp)
val state = viewModel.uiState.value
assertNull(state.selectedProfile)
assertEquals("", state.profileName)
assertEquals("127.0.0.1", state.host)
assertEquals("https://127.0.0.1:9119/", state.baseUrl)
}
@Test
@@ -499,8 +442,7 @@ class ConnectViewModelTest {
ConnectionProfile(
id = "prof-2",
name = "NoToken",
host = "10.0.0.2",
port = 9220,
baseUrl = "http://10.0.0.2:9220/",
)
val viewModel = ConnectViewModel(mockApp)
viewModel.selectProfile(profile)
@@ -514,8 +456,7 @@ class ConnectViewModelTest {
runTest {
val viewModel = ConnectViewModel(mockApp)
viewModel.onTokenChange("standalone-token")
viewModel.onHostChange("10.0.0.1")
viewModel.onPortChange("9119")
viewModel.onBaseUrlChange("http://10.0.0.1:9119/")
viewModel.onSaveProfileChange(false)
val mockResponse = mockk<Response<StatusResponse>>()
@@ -534,8 +475,7 @@ class ConnectViewModelTest {
advanceUntilIdle()
verify { AuthManager.setToken("standalone-token") }
verify { AuthManager.setHost("10.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { AuthManager.setBaseUrl("http://10.0.0.1:9119/") }
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
}
@@ -560,8 +500,8 @@ class ConnectViewModelTest {
fun testMultipleProfiles_loadedOnInit() {
val profiles =
listOf(
ConnectionProfile("a", "Alpha", "10.0.0.1", 9119),
ConnectionProfile("b", "Beta", "10.0.0.2", 9220),
ConnectionProfile("a", "Alpha", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile("b", "Beta", baseUrl = "http://10.0.0.2:9220/"),
)
every { AuthManager.getConnectionProfiles() } returns profiles
every { AuthManager.getSelectedProfileId() } returns null
@@ -579,40 +519,4 @@ class ConnectViewModelTest {
.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 =
listOf(
ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119),
ConnectionProfile("prof-2", "Home", "10.0.0.2", 9220),
ConnectionProfile(id = "prof-1", name = "Work", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile(id = "prof-2", name = "Home", baseUrl = "http://10.0.0.2:9220/"),
)
private fun createViewModel(): SettingsViewModel {
@@ -56,6 +56,8 @@ class SettingsViewModelTest {
every { AuthManager.getHost() } returns "127.0.0.1"
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.isAutoReconnect() } returns true
every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM
@@ -70,6 +72,7 @@ class SettingsViewModelTest {
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setAutoReconnect(any()) } returns Unit
every { AuthManager.setThemePreference(any()) } returns Unit
@@ -80,10 +83,11 @@ class SettingsViewModelTest {
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers {
storedSelectedProfileId = firstArg()
Unit
}
every { AuthManager.saveConnectionProfiles(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)
every { ApiClient.rebuild() } returns Unit
@@ -225,8 +229,7 @@ class SettingsViewModelTest {
viewModel.openAddProfile()
viewModel.onDialogProfileNameChange("Test Profile")
viewModel.onDialogProfileHostChange("192.168.1.100")
viewModel.onDialogProfilePortChange("9999")
viewModel.onDialogProfileBaseUrlChange("https://192.168.1.100:9999/")
viewModel.saveProfileFromDialog()
testDispatcher.scheduler.advanceUntilIdle()