[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)
}
}