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

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

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

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

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

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

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

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

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

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

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

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

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

Closes nothing; OAuth tracked in #639.

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

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

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

Fixes #640.

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

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

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

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-07-20 17:26:10 +03:00
1 parent d839c5e9ad
commit 26b104b2bb
40 files changed
+1299 -688

No files matched your search

@@ -0,0 +1,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
}
}
}