package main import ( "io" "net/http" "net/http/httptest" "os" "path/filepath" "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)) } 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, "Hermes WebUI") })) 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("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") } 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 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", "kv/atlas/hermes/chat-telegram", "bot_token", "relay_key"} { 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="kanban"]'`) { t.Fatal("file browsing or chat-only navigation policy is missing") } } 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) } 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()) } }