* [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:
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()
|
||||
|
||||
Reference in new issue
Block a user