|
| 1 | +package handler |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "io" |
| 6 | + "log/slog" |
| 7 | + "net/http" |
| 8 | + "net/http/httptest" |
| 9 | + "testing" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/sarg3nt/gearbox/internal/framework/agent" |
| 13 | + "github.com/sarg3nt/gearbox/internal/framework/database" |
| 14 | + "github.com/sarg3nt/gearbox/internal/framework/models" |
| 15 | +) |
| 16 | + |
| 17 | +// newCapabilitiesHandler returns an http.Handler that serves the supplied |
| 18 | +// CapabilitiesResponse on /api/v1/system/capabilities. Used by the tests |
| 19 | +// below to drive the filter through a real httptest.Server so the |
| 20 | +// production CapabilitiesCache fetch path (not just a stubbed cache) is |
| 21 | +// exercised. |
| 22 | +func newCapabilitiesHandler(t *testing.T, resp agent.CapabilitiesResponse) http.Handler { |
| 23 | + t.Helper() |
| 24 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | + if r.URL.Path != "/api/v1/system/capabilities" { |
| 26 | + http.NotFound(w, r) |
| 27 | + return |
| 28 | + } |
| 29 | + w.Header().Set("Content-Type", "application/json") |
| 30 | + if err := json.NewEncoder(w).Encode(resp); err != nil { |
| 31 | + t.Fatalf("encode capabilities: %v", err) |
| 32 | + } |
| 33 | + }) |
| 34 | +} |
| 35 | + |
| 36 | +// newCapabilitiesTestHandler stitches together the minimum Handler state |
| 37 | +// the filter needs: a logger, a static `servers` slice (so getServerConfig |
| 38 | +// resolves without DB), and a fresh CapabilitiesCache. |
| 39 | +func newCapabilitiesTestHandler(t *testing.T, boxID, agentURL string) *Handler { |
| 40 | + t.Helper() |
| 41 | + return &Handler{ |
| 42 | + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 43 | + capabilities: agent.NewCapabilitiesCache(5*time.Minute, 2*time.Second), |
| 44 | + servers: []models.BoxConfig{ |
| 45 | + {ID: boxID, AgentURL: agentURL, APIKey: "test-key"}, |
| 46 | + }, |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +func gearsByName(in []database.Gear) map[string]bool { |
| 51 | + out := make(map[string]bool, len(in)) |
| 52 | + for _, g := range in { |
| 53 | + out[g.Name] = true |
| 54 | + } |
| 55 | + return out |
| 56 | +} |
| 57 | + |
| 58 | +// TestFilterGearsByAgentCapabilities_HidesUnavailable mirrors the |
| 59 | +// production Mjolnir agent's probe table: access-log/host/metrics |
| 60 | +// available, every other gear unavailable. The filter must keep only |
| 61 | +// dashboard gears whose agent counterpart is available, drop gears |
| 62 | +// whose counterpart is reported unavailable, and leave dashboard-only |
| 63 | +// gears (alerts, home, bx) untouched. Guards both the Gears settings |
| 64 | +// page and the sidebar middleware path (issue #112). |
| 65 | +func TestFilterGearsByAgentCapabilities_HidesUnavailable(t *testing.T) { |
| 66 | + srv := httptest.NewServer(newCapabilitiesHandler(t, agent.CapabilitiesResponse{ |
| 67 | + Gears: map[string]agent.CapabilityEntry{ |
| 68 | + "access-log": {Status: "available"}, |
| 69 | + "host": {Status: "available"}, |
| 70 | + "metrics": {Status: "available"}, |
| 71 | + "apache": {Status: "not_installed"}, |
| 72 | + "caddy": {Status: "not_installed"}, |
| 73 | + "certificates": {Status: "not_installed"}, |
| 74 | + "docker": {Status: "not_installed"}, |
| 75 | + "haproxy": {Status: "inaccessible"}, |
| 76 | + "logs": {Status: "inaccessible"}, |
| 77 | + "nginx": {Status: "not_installed"}, |
| 78 | + "security": {Status: "not_installed"}, |
| 79 | + "traefik": {Status: "not_installed"}, |
| 80 | + "traffic": {Status: "inaccessible"}, |
| 81 | + "updates": {Status: "not_installed"}, |
| 82 | + }, |
| 83 | + })) |
| 84 | + t.Cleanup(srv.Close) |
| 85 | + |
| 86 | + h := newCapabilitiesTestHandler(t, "mjolnir", srv.URL) |
| 87 | + dashboardGears := []database.Gear{ |
| 88 | + {Name: database.GearHAProxy}, |
| 89 | + {Name: database.GearLogs}, |
| 90 | + {Name: database.GearMetrics}, |
| 91 | + {Name: database.GearServices}, |
| 92 | + {Name: database.GearCertificates}, |
| 93 | + {Name: database.GearTraffic}, |
| 94 | + {Name: database.GearOSUpdates}, |
| 95 | + {Name: database.GearAlerts}, |
| 96 | + } |
| 97 | + |
| 98 | + got := gearsByName(h.filterGearsByAgentCapabilities("mjolnir", dashboardGears)) |
| 99 | + |
| 100 | + want := map[string]bool{ |
| 101 | + // metrics is available → keep |
| 102 | + database.GearMetrics: true, |
| 103 | + // services maps to "metrics" (which is available) → keep |
| 104 | + database.GearServices: true, |
| 105 | + // alerts has no agent counterpart → keep (always-on) |
| 106 | + database.GearAlerts: true, |
| 107 | + } |
| 108 | + // haproxy / logs / traffic mapped to inaccessible agent gears → drop. |
| 109 | + // certificates / updates mapped to not_installed → drop. |
| 110 | + for name := range gearsByName(dashboardGears) { |
| 111 | + if want[name] { |
| 112 | + if !got[name] { |
| 113 | + t.Errorf("filter dropped %q but it should remain", name) |
| 114 | + } |
| 115 | + continue |
| 116 | + } |
| 117 | + if got[name] { |
| 118 | + t.Errorf("filter kept %q but it should be hidden (agent gear unavailable)", name) |
| 119 | + } |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +// TestFilterGearsByAgentCapabilities_FailsOpenOnAgentDown verifies that |
| 124 | +// when capabilities can't be fetched, the filter returns the full list |
| 125 | +// unchanged. Same contract as the Gears settings page — a flaky agent |
| 126 | +// must not collapse the sidebar / settings page (issue #112). |
| 127 | +func TestFilterGearsByAgentCapabilities_FailsOpenOnAgentDown(t *testing.T) { |
| 128 | + // AgentURL points at a closed port so the fetch errors. APIKey isn't |
| 129 | + // empty so the dashboard considers the box agent-API-backed. |
| 130 | + closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 131 | + http.Error(w, "boom", http.StatusInternalServerError) |
| 132 | + })) |
| 133 | + closed.Close() |
| 134 | + |
| 135 | + h := newCapabilitiesTestHandler(t, "downbox", closed.URL) |
| 136 | + gears := []database.Gear{ |
| 137 | + {Name: database.GearHAProxy}, |
| 138 | + {Name: database.GearLogs}, |
| 139 | + {Name: database.GearMetrics}, |
| 140 | + } |
| 141 | + got := h.filterGearsByAgentCapabilities("downbox", gears) |
| 142 | + if len(got) != len(gears) { |
| 143 | + t.Errorf("fail-open broken: filter returned %d gears, expected all %d", len(got), len(gears)) |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// TestFilterGearsByAgentCapabilities_FailsOpenOnUnknownGear verifies |
| 148 | +// that an agent that doesn't surface a particular gear name at all |
| 149 | +// (older agent that pre-dates the gear) leaves the dashboard gear in |
| 150 | +// the result — distinguishing "not reported" from "reported as |
| 151 | +// unavailable" is the same fix #116 added on the Logs source picker. |
| 152 | +func TestFilterGearsByAgentCapabilities_FailsOpenOnUnknownGear(t *testing.T) { |
| 153 | + srv := httptest.NewServer(newCapabilitiesHandler(t, agent.CapabilitiesResponse{ |
| 154 | + // Older agent: surfaces only haproxy; doesn't know about logs/metrics yet. |
| 155 | + Gears: map[string]agent.CapabilityEntry{ |
| 156 | + "haproxy": {Status: "available"}, |
| 157 | + }, |
| 158 | + })) |
| 159 | + t.Cleanup(srv.Close) |
| 160 | + |
| 161 | + h := newCapabilitiesTestHandler(t, "oldbox", srv.URL) |
| 162 | + gears := []database.Gear{ |
| 163 | + {Name: database.GearHAProxy}, |
| 164 | + {Name: database.GearLogs}, |
| 165 | + {Name: database.GearMetrics}, |
| 166 | + } |
| 167 | + got := gearsByName(h.filterGearsByAgentCapabilities("oldbox", gears)) |
| 168 | + // All three should be present: haproxy because available, logs and |
| 169 | + // metrics because the agent didn't report them (forward-compat). |
| 170 | + for _, name := range []string{database.GearHAProxy, database.GearLogs, database.GearMetrics} { |
| 171 | + if !got[name] { |
| 172 | + t.Errorf("forward-compat fail-open broken: %q dropped", name) |
| 173 | + } |
| 174 | + } |
| 175 | +} |
0 commit comments