jenkins bd63b568e1 security(hermes): strip inbound X-Hux-* at the router boundary
Defense in depth: the tenant router deletes every browser-supplied
X-Hux-* header before asserting its own HUX identity headers, so no
client can forge subject, trust class, or relay key. Regression covers
X-Hux-Subject, X-Hux-Trust and X-Hux-Relay-Key forgeries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

752 lines
29 KiB
Go

package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func TestSubjectsReceiveStableIsolatedSlots(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "tenants.json")
router, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
first, err := router.slotFor("keycloak-subject-a")
if err != nil {
t.Fatal(err)
}
again, _ := router.slotFor("keycloak-subject-a")
second, err := router.slotFor("keycloak-subject-b")
if err != nil {
t.Fatal(err)
}
if first != again || first == second {
t.Fatalf("unexpected slots: first=%d again=%d second=%d", first, again, second)
}
if _, err := router.slotFor("keycloak-subject-c"); err == nil {
t.Fatal("expected the fixed private tenant pool to report capacity")
}
reloaded, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
persisted, _ := reloaded.slotFor("keycloak-subject-a")
if persisted != first {
t.Fatalf("assignment changed after reload: %d != %d", persisted, first)
}
}
func TestRouterBlocksAdministrationAndRequiresIdentity(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "http://127.0.0.1" })
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method string
path string
user string
want int
}{
{http.MethodGet, "/", "", http.StatusUnauthorized},
{http.MethodPost, "/api/providers", "subject", http.StatusForbidden},
{http.MethodPost, "/api/commands/exec", "subject", http.StatusForbidden},
{http.MethodGet, "/api/dashboard/config", "subject", http.StatusForbidden},
{http.MethodGet, "/api/env", "subject", http.StatusForbidden},
} {
request := httptest.NewRequest(test.method, test.path, nil)
request.Header.Set("X-Forwarded-User", test.user)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
}
}
}
func TestRouterRetiresStaleServiceWorkerWithoutIdentity(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/sw.js?v=exp-v0.52.181", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
if response.Header().Get("Service-Worker-Allowed") != "/" {
t.Fatal("service worker retirement response does not cover the origin")
}
if !strings.Contains(response.Header().Get("Cache-Control"), "no-store") {
t.Fatal("service worker retirement response is cacheable")
}
if !strings.Contains(response.Header().Get("Clear-Site-Data"), "storage") {
t.Fatal("service worker retirement response does not clear stale storage")
}
if !strings.Contains(response.Body.String(), "registration.unregister") {
t.Fatal("service worker retirement script does not unregister itself")
}
}
func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("X-Auth-Request-User") != "" {
t.Fatal("external identity header leaked to tenant backend")
}
if request.Header.Get(trustedTenantHeader) != "slot-0" {
t.Fatalf("trusted tenant identity was not asserted: %q", request.Header.Get(trustedTenantHeader))
}
if request.Header.Get("X-Hux-Subject") != "" || request.Header.Get("X-Hux-Trust") != "" || request.Header.Get("X-Hux-Relay-Key") != "" {
t.Fatal("browser-supplied HUX trust headers reached the tenant backend")
}
cookies := request.Cookies()
if len(cookies) != 2 || cookies[0].Name != tenantSessionCookie || cookies[1].Name != tenantProfileCookie {
t.Fatalf("unexpected backend cookies: %#v", cookies)
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(writer, "<html><head></head><body>Hermes WebUI</body></html>")
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("X-Auth-Request-User", "subject")
request.Header.Set("X-Hux-Subject", "usr_0123456789abcdef")
request.Header.Set("X-Hux-Trust", "worker")
request.Header.Set("X-Hux-Relay-Key", "browser-forgery")
request.Header.Set("Cookie", "_oauth2_proxy=secret; "+tenantSessionCookie+"=session; "+tenantProfileCookie+"=default")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
t.Fatal("Telegram shortcut assets were not injected")
}
if !strings.Contains(response.Body.String(), "hermes-session-continuity.js") {
t.Fatal("session continuity fallback was not injected")
}
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
assetRequest.Header.Set("X-Forwarded-User", "subject")
assetResponse := httptest.NewRecorder()
router.ServeHTTP(assetResponse, assetRequest)
asset := assetResponse.Body.String()
if !strings.Contains(asset, "hermes-files-sidebar") || !strings.Contains(asset, "hermes-telegram-sidebar") {
t.Fatal("Files and Telegram were not integrated into the existing sidebar")
}
if !strings.Contains(asset, "document.querySelector('.rail')") || !strings.Contains(asset, "data-tooltip', 'Telegram") {
t.Fatal("Telegram was not integrated as a native WebUI navigation action")
}
if strings.Contains(asset, "if (!files) return") {
t.Fatal("Telegram navigation still depends on the removed legacy Files link")
}
if strings.Contains(asset, "position:fixed") || strings.Contains(asset, "hermes-chat-tools") {
t.Fatal("legacy floating chat controls remain in the mobile bridge")
}
}
func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return "" })
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
asset := response.Body.String()
for _, expected := range []string{
"aria-live", "aria-busy", "/api/session?session_id=",
"&messages=1&msg_limit=24", "hermes_fallback=1",
"Latest stored activity", "no renderable messages",
"Session updates disconnected", "oauth2/start?rd=", "payload.session",
"session_profile_mismatch", "session.is_streaming", "latest.observed",
"addEventListener('online'", "addEventListener('pageshow'",
"schedule(document.hidden", "fetch(",
} {
if !strings.Contains(asset, expected) {
t.Fatalf("session fallback omitted %q", expected)
}
}
for _, forbidden := range []string{
"location.replace", "location.reload", "history.replaceState", "WebSocket",
// `/api/sessions/<id>` belongs to the Hermes agent dashboard, not to the
// tenant WebUI this router proxies. Polling it 404s on every request and
// renders a false ownership error after each full page load.
"/api/sessions/",
} {
if strings.Contains(asset, forbidden) {
t.Fatalf("session fallback interferes with native continuity via %q", forbidden)
}
}
}
// tenantWebUI mirrors the dispatch contract of the deployed Hermes WebUI
// (`hermes-webui` server.py): `GET /api/session?session_id=` is the only
// session read route, an unrouted path falls through to a generic 404, and an
// unknown session id is reported as "Session not found".
type tenantWebUI struct {
server *httptest.Server
sessions map[string]bool
identities []string
requests int
cookies []string
}
func newTenantWebUI(t *testing.T, marker string, sessions ...string) *tenantWebUI {
t.Helper()
backend := &tenantWebUI{sessions: map[string]bool{}}
for _, id := range sessions {
backend.sessions[id] = true
}
backend.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
backend.requests++
backend.identities = append(backend.identities, request.Header.Get(trustedTenantHeader))
backend.cookies = append(backend.cookies, request.Header.Get("Cookie"))
for _, leaked := range []string{"X-Forwarded-User", "X-Auth-Request-User", "Authorization"} {
if request.Header.Get(leaked) != "" {
t.Errorf("external identity header %q reached a tenant backend", leaked)
}
}
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path != "/api/session" {
writer.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(writer, `{"error":"not found"}`)
return
}
id := request.URL.Query().Get("session_id")
if !backend.sessions[id] {
writer.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(writer, `{"error":"Session not found"}`)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
"session_id": id,
"title": marker,
"messages": []map[string]any{{"role": "assistant", "content": marker}},
"message_count": 1,
"is_streaming": false,
}})
}))
t.Cleanup(backend.server.Close)
return backend
}
// continuityPollPath derives the request the injected fallback actually issues,
// so a regression in the polled contract fails these tests instead of silently
// reintroducing the permanent 404.
func continuityPollPath(t *testing.T, router *tenantRouter, sessionID string) string {
t.Helper()
request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
request.Header.Set("X-Forwarded-User", "asset-reader")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("continuity asset got status %d", response.Code)
}
matcher := regexp.MustCompile(`'(/api/[^']*)' \+ encodeURIComponent\(sessionId\) \+\s*'([^']*)'`)
parts := matcher.FindStringSubmatch(response.Body.String())
if parts == nil {
t.Fatal("continuity fallback does not build a session-scoped poll URL")
}
return parts[1] + url.QueryEscape(sessionID) + parts[2]
}
func continuityPoll(t *testing.T, router *tenantRouter, subject, cookie, path string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("X-Forwarded-User", subject)
request.Header.Set("X-Auth-Request-User", subject)
if cookie != "" {
request.Header.Set("Cookie", cookie)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
// Reproduction of the reported failure. The tenant WebUI image
// (registry.bstein.dev/bstein/hermes-webui@sha256:c276a9e1…) routes exactly one
// session read, `GET /api/session?session_id=`; `/api/sessions/<id>[/messages]`
// belongs to the separate Hermes agent dashboard and falls through to
// server.py's generic 404. Polling it could never succeed for anyone, so the
// banner fired on every full page load rather than on a real ownership problem.
func TestLegacyDashboardSessionPollAlwaysMissesTheTenantWebUI(t *testing.T) {
const sessionID = "sess-1"
backend := newTenantWebUI(t, "brad-private-session", sessionID)
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1,
func(int) string { return backend.server.URL })
if err != nil {
t.Fatal(err)
}
for _, legacy := range []string{
"/api/sessions/" + sessionID,
"/api/sessions/" + sessionID + "/messages?limit=24&hermes_fallback=1",
} {
response := continuityPoll(t, router, "keycloak-subject-brad", "", legacy)
if response.Code != http.StatusNotFound {
t.Fatalf("%s: got %d, want the WebUI's 404 for an unrouted path", legacy, response.Code)
}
}
if response := continuityPoll(t, router, "keycloak-subject-brad", "",
continuityPollPath(t, router, sessionID)); response.Code != http.StatusOK {
t.Fatalf("the shipped fallback still misses the tenant WebUI: got %d", response.Code)
}
}
// The Keycloak logout/login round-trip returns the browser to /session/<id> as
// a full document load, which is the only moment the injected fallback runs.
// The durable session must still resolve for its stable owner.
func TestSessionContinuityPollSurvivesLogoutAndRelogin(t *testing.T) {
const subject = "keycloak-subject-brad"
const sessionID = "9048e2a574d1"
backend := newTenantWebUI(t, "brad-private-session", sessionID)
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 4, func(slot int) string {
if slot == 0 {
return backend.server.URL
}
return "http://127.0.0.1:1"
})
if err != nil {
t.Fatal(err)
}
pollPath := continuityPollPath(t, router, sessionID)
before := continuityPoll(t, router, subject,
"__Host-hermes_chat=first-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
if before.Code != http.StatusOK {
t.Fatalf("first visit could not read its own session: got %d, want 200", before.Code)
}
// Logout invalidates the Keycloak session, so the browser returns with a
// completely different oauth2-proxy cookie under the same Keycloak subject.
after := continuityPoll(t, router, subject,
"__Host-hermes_chat=second-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
if after.Code != http.StatusOK {
t.Fatalf("relogin lost the durable session: got %d, want 200", after.Code)
}
var payload struct {
Session struct {
SessionID string `json:"session_id"`
Title string `json:"title"`
} `json:"session"`
}
if err := json.NewDecoder(after.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload.Session.SessionID != sessionID || payload.Session.Title != "brad-private-session" {
t.Fatalf("relogin resolved the wrong session: %#v", payload.Session)
}
if len(backend.identities) != 2 || backend.identities[0] != "slot-0" || backend.identities[1] != "slot-0" {
t.Fatalf("relogin did not keep a stable tenant identity: %#v", backend.identities)
}
for _, cookie := range backend.cookies {
if strings.Contains(cookie, "__Host-hermes_chat") {
t.Fatalf("the Keycloak session cookie crossed into a tenant: %q", cookie)
}
}
}
// Repairing continuity must not turn the poll into a cross-tenant read.
func TestSessionContinuityNeverExposesAnotherSubjectsSession(t *testing.T) {
const ownerSession = "owner-session-id"
owner := newTenantWebUI(t, "owner-private-marker", ownerSession)
intruder := newTenantWebUI(t, "intruder-private-marker")
backends := []*tenantWebUI{owner, intruder}
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, func(slot int) string {
return backends[slot].server.URL
})
if err != nil {
t.Fatal(err)
}
ownerSlot, err := router.slotFor("keycloak-owner")
if err != nil {
t.Fatal(err)
}
intruderSlot, err := router.slotFor("keycloak-intruder")
if err != nil {
t.Fatal(err)
}
if ownerSlot == intruderSlot {
t.Fatal("two Keycloak subjects shared one isolated slot")
}
pollPath := continuityPollPath(t, router, ownerSession)
if response := continuityPoll(t, router, "keycloak-owner",
tenantSessionCookie+"=owner-webui", pollPath); response.Code != http.StatusOK {
t.Fatalf("the owner lost its own session: got %d", response.Code)
}
ownerRequests := backends[ownerSlot].requests
// The intruder replays the owner's session id, the owner's WebUI cookie and
// a forged tenant assertion for the owner's slot.
request := httptest.NewRequest(http.MethodGet, pollPath, nil)
request.Header.Set("X-Forwarded-User", "keycloak-intruder")
request.Header.Set("Cookie", tenantSessionCookie+"=owner-webui")
request.Header.Set(trustedTenantHeader, "slot-"+strconv.Itoa(ownerSlot))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("cross-subject session read got %d, want 404", response.Code)
}
if strings.Contains(response.Body.String(), "owner-private-marker") {
t.Fatalf("another subject's session leaked: %s", response.Body.String())
}
if backends[ownerSlot].requests != ownerRequests {
t.Fatal("a forged tenant assertion reached another subject's backend")
}
identities := backends[intruderSlot].identities
if len(identities) == 0 || identities[len(identities)-1] != "slot-"+strconv.Itoa(intruderSlot) {
t.Fatalf("the router did not overwrite the forged tenant identity: %#v", identities)
}
}
func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
messages := make([]map[string]int, 30)
for index := range messages {
messages[index] = map[string]int{"index": index}
}
writer.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
"session_id": "resolved", "messages": messages, "message_count": 30,
}})
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL })
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(
http.MethodGet,
continuityPollPath(t, router, "root"),
nil,
)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
payload := decodeSnapshot(t, response.Result())
if len(payload.Messages) != sessionSnapshotItems || payload.MessageCount != 30 {
t.Fatalf("router returned unbounded fallback: %#v", payload)
}
}
func TestTelegramPageExplainsOneTimeOperatorSetup(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/telegram", nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
body := response.Body.String()
for _, expected := range []string{
"https://t.me/BotFather",
"https://secret.bstein.dev",
"vault login -method=oidc",
"vault kv patch -mount=kv atlas/hermes/chat-telegram",
"bot_token",
"relay_key",
"each Keycloak user",
} {
if !strings.Contains(body, expected) {
t.Fatalf("Telegram operator setup omitted %q", expected)
}
}
}
func TestRouterRedirectsNativeLoginToSafeChatDestination(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
path string
want string
}{
{"/login?next=%2Fsession%2F9048e2a574d1", "/session/9048e2a574d1"},
{"/login?next=%2Fchat%3Fresume%3Dabc", "/chat?resume=abc"},
{"/login?next=https%3A%2F%2Fevil.example", "/"},
{"/login?next=%2F%2Fevil.example", "/"},
{"/login?next=%2Flogin", "/"},
{"/login?next=%2Flogin%2Fagain", "/"},
} {
request := httptest.NewRequest(http.MethodGet, test.path, nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("%s: got status %d", test.path, response.Code)
}
if location := response.Header().Get("Location"); location != test.want {
t.Fatalf("%s: got redirect %q, want %q", test.path, location, test.want)
}
}
}
func TestRouterAllowsTenantScopedPersonalization(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
if err != nil {
t.Fatal(err)
}
for _, path := range []string{
"/api/memory",
"/api/memory/write",
"/api/skills",
"/api/skills/save",
"/api/profiles",
"/api/profile/create",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("GET %s: got %d, want %d", path, response.Code, http.StatusNoContent)
}
}
}
func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
if err != nil {
t.Fatal(err)
}
for _, path := range []string{
"/api/workspaces",
"/api/workspaces/suggest",
"/api/file",
"/api/file/raw",
"/api/folder/download",
"/api/logs",
"/api/rollback/list",
"/api/rollback/diff",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("GET %s: got %d, want %d", path, response.Code, http.StatusNoContent)
}
}
request := httptest.NewRequest(http.MethodPost, "/api/workspaces/add", strings.NewReader(`{"path":"/opt/data"}`))
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("workspace registration got %d, want %d", response.Code, http.StatusForbidden)
}
}
func TestPrivateFileBrowserIsTenantAuthenticatedAndHidesKanban(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
unauthenticated := httptest.NewRequest(http.MethodGet, "/private-files", nil)
unauthenticatedResponse := httptest.NewRecorder()
router.ServeHTTP(unauthenticatedResponse, unauthenticated)
if unauthenticatedResponse.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated browser got %d", unauthenticatedResponse.Code)
}
request := httptest.NewRequest(http.MethodGet, "/private-files?session_id=session-1", nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "Private files") || !strings.Contains(response.Body.String(), "data-files-page") {
t.Fatal("private browser page was not rendered")
}
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
assetRequest.Header.Set("X-Forwarded-User", "subject")
assetResponse := httptest.NewRecorder()
router.ServeHTTP(assetResponse, assetRequest)
if !strings.Contains(assetResponse.Body.String(), "endpoint('list'") || !strings.Contains(assetResponse.Body.String(), `'[data-panel="' + panel + '"]'`) {
t.Fatal("file browsing or chat-only navigation policy is missing")
}
}
func TestChatBridgeHidesOnlyNonChatCockpitChrome(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
asset := func(path string) string {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("%s got status %d", path, response.Code)
}
return response.Body.String()
}
js := asset("/hermes-chat-bridge.js")
css := asset("/hermes-chat-bridge.css")
// The whole cockpit (Kanban plus every non-chat rail panel) is declutter-hidden
// both pre-JS (CSS, no flash) and by the runtime .hidden reinforcement.
for _, panel := range []string{"kanban", "logs", "insights", "memory", "skills", "workspaces", "todos", "tasks", "profiles"} {
if !strings.Contains(css, `[data-panel="`+panel+`"]`) {
t.Fatalf("bridge CSS does not hide cockpit panel %q", panel)
}
if !strings.Contains(js, `'`+panel+`'`) {
t.Fatalf("bridge JS does not mark cockpit panel %q hidden", panel)
}
}
if !strings.Contains(css, "display:none") || !strings.Contains(css, "#settingsModel") {
t.Fatal("bridge CSS must hide the cockpit panels and the redundant settings model selector")
}
// The clean chat experience (chat surface, history, composer, model chip)
// must never be part of the hide set.
for _, keep := range []string{`[data-panel="chat"]`, `[data-panel="history"]`, `[data-panel="sessions"]`, `[data-panel="composer"]`, "composerRoutingChip", "composerRoutingLabel"} {
if strings.Contains(css, keep) || strings.Contains(js, keep) {
t.Fatalf("declutter wrongly targets a core chat control: %q", keep)
}
}
}
func TestWebUIModelAndReasoningOverridesAreProxied(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
t.Fatalf("got method %s", request.Method)
}
writer.WriteHeader(http.StatusNoContent)
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/model/set", "/api/reasoning"} {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
request.Header.Set("X-Forwarded-User", "subject")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("POST %s: got %d", path, response.Code)
}
}
}
func TestTelegramLinkUsesHashedStateAndExpires(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.json")
router, err := newTenantRouter(statePath, 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
router.now = func() time.Time { return now }
code, expires, err := router.createLink("raw-keycloak-subject")
if err != nil {
t.Fatal(err)
}
if expires.Sub(now) != 10*time.Minute {
t.Fatalf("unexpected expiry: %s", expires)
}
if _, err := router.consumeLink("123456789", code); err != nil {
t.Fatal(err)
}
if _, linked := router.telegramSlot("123456789"); !linked {
t.Fatal("Telegram identity was not linked")
}
content, err := os.ReadFile(statePath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "raw-keycloak-subject") || strings.Contains(string(content), "123456789") || strings.Contains(string(content), code) {
t.Fatal("raw identity or one-time code was persisted")
}
secondCode, _, err := router.createLink("raw-keycloak-subject")
if err != nil {
t.Fatal(err)
}
router.now = func() time.Time { return now.Add(11 * time.Minute) }
if _, err := router.consumeLink("987654321", secondCode); err == nil {
t.Fatal("expected expired link code to be rejected")
}
}
func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
router.telegram = &telegramBot{}
request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("got %d", response.Code)
}
router.telegram.setStatus("BsteinAtlasHermesBot", true, "")
request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Hermes-Action", "telegram-link")
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("got %d: %s", response.Code, response.Body.String())
}
}
func TestTelegramWebRejectsLinkWhileConfiguredBotIsNotReady(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
t.Fatal(err)
}
router.telegram = &telegramBot{}
request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Hermes-Action", "telegram-link")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d: %s", response.Code, response.Body.String())
}
}