fix(network): scope management REST calls with ?profile= (fixes #528) (#540)

* fix(network): scope management REST calls with ?profile= (fixes #528)

Mobile fired all management REST calls with NO profile scope, so every
profile read/wrote the backend's implicit default profile — multi-profile
setups silently corrupted config/skills/toolsets/mcp/model/env.

Add ProfileScopeInterceptor (OkHttp) that appends ?profile=<activeProfile>
to the same endpoint families the desktop/web dashboard scopes
(web/src/lib/api.ts PROFILE_SCOPED_PREFIXES), respecting:
- no active profile -> pass through (legacy default behavior)
- explicit ?profile= on the URL wins over the global scope
- only scoped families rewritten; ops/pairing/cron/profiles stay global

Wired into ApiClient.buildService(). 5 MockWebServer tests cover scoped
append, explicit-param precedence, no-profile passthrough, non-scoped skip,
and skills scoping.

Mirrors desktop/web contract derived from audit of /opt/hermes-agent.

* fix(ci): stub getSelectedProfileId in ApiClientTest + harden prefix match (fixes PR #540)

- ApiClientTest: stub AuthManager.getSelectedProfileId() to null. The new
  ProfileScopeInterceptor (registered in ApiClient.buildService) calls it, and
  the unstubbed real method threw IllegalStateException at AuthManager.kt:56,
  breaking all 4 auth-interceptor tests.
- ProfileScopeInterceptor: replace startsWith(prefix) with whole-segment match
  (path == prefix || path.startsWith(prefix/)) so lookalike paths like
  /api/statusXYZ are NOT wrongly scoped (Sourcery review, PR #540).
- ProfileScopeInterceptorTest: add lookalikePath_notScoped + scopedSubPath_isScoped
  to lock the segment-boundary fix.
This commit is contained in:
M57 authored and GitHub committed 2026-07-10 14:02:45 +03:00
1 parent d056c77c93
commit c5d1ef1c42
4 files changed
+212

No files matched your search

@@ -43,6 +43,11 @@ class ApiClientTest {
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString()
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
// PR #540 registers ProfileScopeInterceptor in ApiClient.buildService(),
// which calls getSelectedProfileId(). Stub it to null so the interceptor
// short-circuits (no profile scope) and these auth tests stay focused.
every { AuthManager.getSelectedProfileId() } returns null
}
@AfterEach
@@ -0,0 +1,129 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class ProfileScopeInterceptorTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
mockkObject(AuthManager)
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
unmockkAll()
}
/** Builds a real client with the interceptor + a stubbed AuthManager profile. */
private fun clientFor(profile: String?): OkHttpClient {
every { AuthManager.getSelectedProfileId() } returns profile
return OkHttpClient
.Builder()
.addInterceptor(ProfileScopeInterceptor)
.build()
}
private fun lastRequestedPath(client: OkHttpClient): HttpUrl {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url("api/config")).build()
client.newCall(req).execute().close()
return server.takeRequest().requestUrl!!
}
@Test
fun scopedEndpoint_appendsProfile() {
val client = clientFor("work")
val url = lastRequestedPath(client)
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/config", url.encodedPath)
}
@Test
fun noActiveProfile_passesThrough() {
val client = clientFor(null)
val url = lastRequestedPath(client)
assertNull(url.queryParameter("profile"))
}
@Test
fun explicitProfileParam_wins() {
every { AuthManager.getSelectedProfileId() } returns "work"
val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build()
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req =
Request
.Builder()
.url(server.url("api/config?profile=other"))
.build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertEquals("other", url.queryParameter("profile"))
}
@Test
fun nonScopedEndpoint_untouched() {
every { AuthManager.getSelectedProfileId() } returns "work"
val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build()
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url("api/cron/jobs")).build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertNull(url.queryParameter("profile"))
assertEquals("/api/cron/jobs", url.encodedPath)
}
@Test
fun skillsEndpoint_isScoped() {
val client = clientFor("alpha")
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url("api/skills")).build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertEquals("alpha", url.queryParameter("profile"))
}
@Test
fun lookalikePath_notScoped() {
// Sourcery review (PR #540): `startsWith` must not match non-segment
// suffixes like /api/statusXYZ or /api/gatewayExtra.
every { AuthManager.getSelectedProfileId() } returns "work"
val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build()
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url("api/statusXYZ")).build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertNull(url.queryParameter("profile"))
assertEquals("/api/statusXYZ", url.encodedPath)
}
@Test
fun scopedSubPath_isScoped() {
// A scoped prefix with a trailing segment (/api/status/health) MUST
// still receive the profile param.
every { AuthManager.getSelectedProfileId() } returns "work"
val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build()
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val req = Request.Builder().url(server.url("api/status/health")).build()
client.newCall(req).execute().close()
val url = server.takeRequest().requestUrl!!
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/status/health", url.encodedPath)
}
}