[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

@@ -45,7 +45,9 @@ import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.UpdateCheckResponse
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 com.m57.hermescontrol.ui.channels.ChannelsViewModel
import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
@@ -89,16 +91,21 @@ class E2eIntegrationTest {
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 "mock-token"
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.getSelectedProfileId() } returns null
@@ -1380,56 +1387,4 @@ class E2eIntegrationTest {
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() {
AuthManager.setHost("hermes.local")
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
@@ -181,7 +183,8 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
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
@@ -191,14 +194,14 @@ class AuthManagerTest {
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
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
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))
every { mockPrefs.getString("token_prof-1", null) } returns "profile-specific-token"
AuthManager.setSelectedProfileId("prof-1")
@@ -207,7 +210,7 @@ class AuthManagerTest {
@Test
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))
every { mockPrefs.getString("token_prof-1", null) } returns null
AuthManager.setSelectedProfileId("prof-1")
@@ -216,7 +219,7 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-1")
@@ -241,7 +244,7 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-1")
@@ -250,7 +253,7 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-1")
@@ -269,26 +272,27 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-1")
AuthManager.setHost("10.0.0.99")
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
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.setSelectedProfileId("prof-1")
AuthManager.setPort(9999)
val updated = AuthManager.getConnectionProfiles().first()
assertEquals(9999, updated.port)
assertEquals("http://10.0.0.1:9999/", updated.resolvedBaseUrl)
}
@Test
@@ -308,23 +312,23 @@ class AuthManagerTest {
fun testConnectionProfiles_roundTrip() {
val profiles =
listOf(
ConnectionProfile("a", "A", "10.0.0.1", 9119),
ConnectionProfile("b", "B", "10.0.0.2", 9220),
ConnectionProfile(id = "a", name = "A", baseUrl = "http://10.0.0.1:9119/"),
ConnectionProfile(id = "b", name = "B", baseUrl = "http://10.0.0.2:9220/"),
)
AuthManager.saveConnectionProfiles(profiles)
val loaded = AuthManager.getConnectionProfiles()
assertEquals(2, loaded.size)
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
fun testGetToken_switchesTokenWhenProfileChanges() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://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))
// Profile A selected → returns token_a
@@ -345,8 +349,8 @@ class AuthManagerTest {
@Test
fun testGetToken_profileWithNoTokenReturnsNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://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))
// Profile A is selected but has no token → null (no global fallback)
@@ -362,9 +366,9 @@ class AuthManagerTest {
@Test
fun testGetToken_selectiveProfileTokens_someNull() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileC = ConnectionProfile("prof-c", "Profile C", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/")
val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://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))
// Profile A has a token
@@ -385,8 +389,8 @@ class AuthManagerTest {
@Test
fun testSetToken_switchingProfiles_maintainsSeparateTokens() {
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://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))
// Set a token while Profile A is selected
@@ -406,7 +410,7 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-a")
@@ -442,7 +446,7 @@ class AuthManagerTest {
@Test
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.setSelectedProfileId("prof-1")
@@ -39,8 +39,9 @@ class ApiClientTest {
// can build clients without app context.
CookieManager.setJarForTest(buildFakePersistentCookieJar())
// Point AuthManager at our MockWebServer
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString()
// Point AuthManager at our MockWebServer (the interceptor reads the
// canonical base URL via endpointForBuild() → getBaseUrl()).
every { AuthManager.getBaseUrl() } returns mockWebServer.url("/").toString()
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
@@ -24,6 +24,10 @@ class CookieManagerTest {
return jar
}
private fun ep(host: String): ServerEndpoint {
return ServerEndpoint.fromLegacy(host, 9119)
}
@After
fun tearDown() {
CookieManager.resetForTest()
@@ -32,7 +36,7 @@ class CookieManagerTest {
@Test
fun setThenGetSessionCookie_roundTrips() {
fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie())
}
@@ -40,10 +44,10 @@ class CookieManagerTest {
@Test
fun setSessionCookie_nullClearsValue() {
fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
assertEquals("sess-xyz", CookieManager.getSessionCookie())
CookieManager.setSessionCookie(null, "dashboard.local")
CookieManager.setSessionCookie(null, ep("dashboard.local"))
assertNull(CookieManager.getSessionCookie())
}
@@ -51,7 +55,7 @@ class CookieManagerTest {
fun setSessionCookie_hostScoped_onlyMatchesThatHost() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("sess-xyz", "dashboard.local")
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
val onHost =
jar.loadForRequest(
@@ -68,7 +72,7 @@ class CookieManagerTest {
fun pruneServerCache_delegatesToJar() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("keep-me", "dashboard.local")
CookieManager.setSessionCookie("keep-me", ep("dashboard.local"))
CookieManager.pruneServerCache()
// Session cookie is preserved by prune.
assertEquals("keep-me", CookieManager.getSessionCookie())
@@ -78,7 +82,7 @@ class CookieManagerTest {
fun clearAll_wipesSession() =
runTest {
val jar = fakeJar()
CookieManager.setSessionCookie("bye", "dashboard.local")
CookieManager.setSessionCookie("bye", ep("dashboard.local"))
CookieManager.clearAll()
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.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()