* [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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user