package ui import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/slanos/turnscale/internal/audit" "github.com/slanos/turnscale/internal/config" "github.com/slanos/turnscale/internal/identity" "github.com/slanos/turnscale/internal/policy" ) type mockIdentifier struct { caller *identity.Caller err error } func (m *mockIdentifier) Identify(r *http.Request) (*identity.Caller, error) { return m.caller, m.err } func testConfig() *config.Config { return &config.Config{ Hostname: "mcp", Tailnet: "example.ts.net", Servers: map[string]config.Server{ "gitea": {URL: "http://localhost:8091/mcp", Transport: "streamable-http"}, "nomad": {URL: "http://localhost:8090/mcp", Transport: "streamable-http"}, }, Policies: []config.Policy{ { Name: "admin-full-access", Match: config.Match{Identity: []string{"scott@github"}}, Allow: []string{"*"}, }, { Name: "ai-agents", Match: config.Match{Tags: []string{"tag:ai-agent"}}, Allow: []string{"gitea", "nomad"}, DenyTools: []string{"mcp__gitea__delete_*"}, }, { Name: "default-deny", Match: config.Match{Identity: []string{"*"}}, Deny: []string{"*"}, }, }, } } func setupTestUI(t *testing.T, caller *identity.Caller, identErr error) *UI { t.Helper() cfg := testConfig() pol := policy.NewEngine(cfg.Policies) aud, err := audit.NewLogger(t.TempDir()) if err != nil { t.Fatalf("audit logger: %v", err) } t.Cleanup(func() { aud.Close() }) adminIDs := map[string]bool{"scott@github": true} ident := &mockIdentifier{caller: caller, err: identErr} return New(cfg, ident, pol, aud, adminIDs) } func TestDashboardAdmin(t *testing.T) { caller := &identity.Caller{ UserLogin: "scott@github", DisplayName: "Test Admin", Node: "little-mac", TailscaleIP: "100.64.0.1", } u := setupTestUI(t, caller, nil) // Seed some audit entries u.audit.Log(audit.Entry{ Caller: "scott@github", Server: "gitea", Method: "tools/call", Tool: "mcp__gitea__list_repos", Status: "ok", LatencyMs: 42, }) req := httptest.NewRequest("GET", "/ui/", nil) rec := httptest.NewRecorder() u.HandleDashboard(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html", ct) } body := rec.Body.String() for _, want := range []string{ "scott@github", "little-mac", "100.64.0.1", "admin", "gitea", "nomad", "admin-full-access", "ai-agents", "default-deny", "Recent Activity", "mcp__gitea__list_repos", "Turnscale v", } { if !strings.Contains(body, want) { t.Errorf("body missing %q", want) } } } func TestDashboardNonAdmin(t *testing.T) { caller := &identity.Caller{ UserLogin: "stranger@github", Node: "other-mac", } u := setupTestUI(t, caller, nil) req := httptest.NewRequest("GET", "/ui/", nil) rec := httptest.NewRecorder() u.HandleDashboard(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "stranger@github") { t.Error("body missing caller identity") } if strings.Contains(body, "Recent Activity") { t.Error("non-admin should NOT see Recent Activity") } if strings.Contains(body, ">admin<") { t.Error("non-admin should NOT have admin badge") } } func TestDashboardTaggedNode(t *testing.T) { caller := &identity.Caller{ Node: "owl", TailscaleIP: "100.1.2.3", Tags: []string{"tag:ai-agent"}, IsTagged: true, } u := setupTestUI(t, caller, nil) req := httptest.NewRequest("GET", "/ui/", nil) rec := httptest.NewRecorder() u.HandleDashboard(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "owl") { t.Error("body missing node name") } if !strings.Contains(body, "tag:ai-agent") { t.Error("body missing tag") } } func TestDashboardUnauthorized(t *testing.T) { u := setupTestUI(t, nil, errors.New("no identity")) req := httptest.NewRequest("GET", "/ui/", nil) rec := httptest.NewRecorder() u.HandleDashboard(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } } // fakeMCPServer returns an httptest.Server that responds to MCP initialize and tools/list. func fakeMCPServer(tools []mcpTool) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req jsonRPCRequest json.Unmarshal(body, &req) w.Header().Set("Content-Type", "application/json") switch req.Method { case "initialize": w.Header().Set("Mcp-Session-Id", "test-session") json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": req.ID, "result": map[string]any{ "protocolVersion": "2025-03-26", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]string{"name": "test", "version": "1.0"}, }, }) case "notifications/initialized": w.WriteHeader(http.StatusNoContent) case "tools/list": json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"tools": tools}, }) default: w.WriteHeader(http.StatusBadRequest) } })) } func TestProbeBackendWithTools(t *testing.T) { tools := []mcpTool{ {Name: "list_repos", Description: "List all repositories"}, {Name: "create_issue", Description: "Create a new issue"}, {Name: "delete_branch", Description: "Delete a branch"}, } srv := fakeMCPServer(tools) defer srv.Close() result := probeBackend(t.Context(), srv.URL) if !result.healthy { t.Fatalf("expected healthy, got err: %s", result.err) } if len(result.tools) != 3 { t.Fatalf("expected 3 tools, got %d", len(result.tools)) } // Tools should be sorted by name if result.tools[0].Name != "create_issue" { t.Errorf("first tool = %q, want create_issue (sorted)", result.tools[0].Name) } } func TestProbeBackendUnreachable(t *testing.T) { result := probeBackend(t.Context(), "http://127.0.0.1:1") if result.healthy { t.Error("unreachable server should not be healthy") } if result.err == "" { t.Error("expected error message for unreachable server") } } func TestToolDiscoveryInDashboard(t *testing.T) { tools := []mcpTool{ {Name: "mcp__gitea__list_repos", Description: "List repos"}, {Name: "mcp__gitea__delete_branch", Description: "Delete a branch"}, } srv := fakeMCPServer(tools) defer srv.Close() caller := &identity.Caller{ Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true, } cfg := &config.Config{ Hostname: "mcp", Tailnet: "example.ts.net", Servers: map[string]config.Server{ "gitea": {URL: srv.URL, Transport: "streamable-http"}, }, Policies: []config.Policy{ { Name: "ai-agents", Match: config.Match{Tags: []string{"tag:ai-agent"}}, Allow: []string{"gitea"}, DenyTools: []string{"mcp__gitea__delete_*"}, }, }, } pol := policy.NewEngine(cfg.Policies) aud, err := audit.NewLogger(t.TempDir()) if err != nil { t.Fatal(err) } defer aud.Close() u := New(cfg, &mockIdentifier{caller: caller}, pol, aud, nil) req := httptest.NewRequest("GET", "/ui/", nil) rec := httptest.NewRecorder() u.HandleDashboard(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() // Should show tool names if !strings.Contains(body, "mcp__gitea__list_repos") { t.Error("body missing allowed tool name") } // Denied tool should have strikethrough class if !strings.Contains(body, `class="denied"`) { t.Error("body missing denied tool styling") } // Should show tool count if !strings.Contains(body, "2 tools") { t.Error("body missing tool count") } } func TestHandleAddServer(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) form := strings.NewReader("name=jira&url=http://localhost:9090/mcp&transport=streamable-http") req := httptest.NewRequest("POST", "/ui/servers", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() u.HandleAddServer(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } } func TestHandleAddServerForbidden(t *testing.T) { caller := &identity.Caller{UserLogin: "stranger@github", Node: "test"} u := setupTestUI(t, caller, nil) form := strings.NewReader("name=jira&url=http://localhost:9090/mcp") req := httptest.NewRequest("POST", "/ui/servers", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() u.HandleAddServer(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } } func TestHandleDeleteServer(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) form := strings.NewReader("name=nomad") req := httptest.NewRequest("POST", "/ui/servers/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() u.HandleDeleteServer(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } } func TestHandleEditServer(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) form := strings.NewReader("name=gitea&url=http://localhost:9999/mcp&transport=streamable-http") req := httptest.NewRequest("POST", "/ui/servers/edit", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() u.HandleEditServer(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } } func TestHandleSessionNotFound(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) req := httptest.NewRequest("GET", "/ui/session/99999", nil) req.SetPathValue("id", "99999") rec := httptest.NewRecorder() u.HandleSession(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } } func TestHandleSessionForbidden(t *testing.T) { caller := &identity.Caller{UserLogin: "stranger@github", Node: "test"} u := setupTestUI(t, caller, nil) req := httptest.NewRequest("GET", "/ui/session/1", nil) req.SetPathValue("id", "1") rec := httptest.NewRecorder() u.HandleSession(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } } func TestHandleSessionWithRecording(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) // Create a recording id := u.audit.LogWithRecording( audit.Entry{Caller: "test", Server: "gitea", Method: "tools/call", Tool: "list", Status: "ok"}, []byte(`{"method":"tools/call"}`), []byte(`{"result":"ok"}`), ) req := httptest.NewRequest("GET", "/ui/session/"+fmt.Sprint(id), nil) req.SetPathValue("id", fmt.Sprint(id)) rec := httptest.NewRecorder() u.HandleSession(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "tools/call") { t.Error("body missing method") } } func TestPrettyJSON(t *testing.T) { out := prettyJSON(`{"a":1,"b":2}`) if !strings.Contains(out, "\n") { t.Error("expected indented output") } // Invalid JSON returns as-is out = prettyJSON("not json") if out != "not json" { t.Error("invalid JSON should pass through") } } func TestBuildChartEmpty(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) chart := u.buildChart(24) if chart != nil { t.Error("expected nil chart with no data") } } func TestBuildChartWithData(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) u.audit.Log(audit.Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok"}) u.audit.Log(audit.Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "error", Error: "fail"}) cr := u.buildChart(24) if cr == nil { t.Fatal("expected chart data") } if len(cr.Bars) != 24 { t.Errorf("expected 24 bars, got %d", len(cr.Bars)) } if cr.Total != 2 { t.Errorf("expected total 2, got %d", cr.Total) } if cr.Errors != 1 { t.Errorf("expected 1 error, got %d", cr.Errors) } // At least one bar should have data hasData := false for _, bar := range cr.Bars { if bar.Total > 0 { hasData = true break } } if !hasData { t.Error("expected at least one bar with data") } } func TestBuildChartDenied(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) u.audit.Log(audit.Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok"}) u.audit.Log(audit.Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "denied"}) cr := u.buildChart(24) if cr == nil { t.Fatal("expected chart data") } if cr.Denied != 1 { t.Errorf("expected 1 denied, got %d", cr.Denied) } // Find bar with data and check denied height for _, bar := range cr.Bars { if bar.Denied > 0 { if bar.DenyH == 0 { t.Error("expected non-zero DenyH for bar with denied requests") } return } } t.Error("no bar with denied data found") } func TestInitial(t *testing.T) { if v := initial("scott"); v != "S" { t.Errorf("initial(scott) = %q", v) } if v := initial(""); v != "?" { t.Errorf("initial('') = %q", v) } } func TestParseToolsList(t *testing.T) { body := `{"jsonrpc":"2.0","id":2,"result":{"tools":[ {"name":"foo","description":"Do foo"}, {"name":"bar","description":"Do bar"} ]}}` tools := parseToolsList([]byte(body)) if len(tools) != 2 { t.Fatalf("expected 2 tools, got %d", len(tools)) } if tools[0].Name != "foo" || tools[1].Name != "bar" { t.Errorf("unexpected tools: %v", tools) } } func TestBuildChartCallerLimit(t *testing.T) { caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} u := setupTestUI(t, caller, nil) // Insert entries from many different callers to the same server for i := 0; i < 10; i++ { u.audit.Log(audit.Entry{ Caller: fmt.Sprintf("caller%d", i), Server: "gitea", Method: "tools/call", Status: "ok", }) } cr := u.buildChart(24) if cr == nil { t.Fatal("expected chart data") } // Find the bar with data for _, bar := range cr.Bars { if bar.Total > 0 { if len(bar.Callers) > 3 { t.Errorf("callers should be limited to 3, got %d", len(bar.Callers)) } return } } t.Error("no bar with data found") } func TestMatchGlob(t *testing.T) { tests := []struct { pattern, name string want bool }{ {"mcp__gitea__delete_*", "mcp__gitea__delete_branch", true}, {"mcp__gitea__delete_*", "mcp__gitea__list_repos", false}, {"exact_match", "exact_match", true}, {"exact_match", "not_match", false}, } for _, tt := range tests { if got := matchGlob(tt.pattern, tt.name); got != tt.want { t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want) } } }