305 lines
11 KiB
Go
305 lines
11 KiB
Go
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("Cookie") != "" {
|
|
t.Fatal("identity or session cookie leaked to tenant backend")
|
|
}
|
|
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("Cookie", "oauth-cookie")
|
|
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")
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|