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
752 lines
29 KiB
Go
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())
|
|
}
|
|
}
|