package api import ( "context" "errors" "io" "log/slog" "net/http" "testing" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) // mockClusterInfo implements ClusterInfoProvider for testing. type mockClusterInfo struct { status *model.ClusterStatus stats []model.CacheStat statusErr error statsErr error } func (m *mockClusterInfo) GetClusterStatus(_ context.Context) (*model.ClusterStatus, error) { if m.statusErr != nil { return nil, m.statusErr } return m.status, nil } func (m *mockClusterInfo) GetCacheStats(_ context.Context) ([]model.CacheStat, error) { if m.statsErr != nil { return nil, m.statsErr } return m.stats, nil } func newClusterTestRouter(ci ClusterInfoProvider) (*mockKeyValidator, http.Handler) { kv := newMockValidator() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, K8s: newMockPodManager(), Cluster: ci, Users: newMockUserStore(), Usage: newMockUsageStore(), Logger: logger, GenerateKey: mockGenerateKey, } return kv, NewRouter(srv) } // --- GET /api/v1/cluster/status --- func TestClusterStatus_AdminSuccess(t *testing.T) { ci := &mockClusterInfo{ status: &model.ClusterStatus{ Nodes: []model.NodeStatus{ { Name: "node-1", Status: "Ready", CPUCapacity: "8", CPUAllocatable: "7800m", MemCapacity: "32Gi", MemAllocatable: "30Gi", CPUUsage: "2500m", MemUsage: "12Gi", }, { Name: "node-2", Status: "Ready", CPUCapacity: "8", CPUAllocatable: "7800m", MemCapacity: "32Gi", MemAllocatable: "30Gi", }, }, Total: model.ResourceSummary{ CPUCapacity: "16", CPUAllocatable: "15600m", MemCapacity: "64Gi", MemAllocatable: "60Gi", }, }, } kv, router := newClusterTestRouter(ci) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var status model.ClusterStatus decodeJSON(t, rr, &status) if len(status.Nodes) != 2 { t.Fatalf("expected 2 nodes, got %d", len(status.Nodes)) } if status.Nodes[0].Name != "node-1" { t.Fatalf("expected node-1, got %s", status.Nodes[0].Name) } if status.Nodes[0].CPUUsage != "2500m" { t.Fatalf("expected cpu usage 2500m, got %s", status.Nodes[0].CPUUsage) } if status.Total.CPUCapacity != "16" { t.Fatalf("expected total CPU capacity 16, got %s", status.Total.CPUCapacity) } } func TestClusterStatus_ForbiddenForUser(t *testing.T) { ci := &mockClusterInfo{status: &model.ClusterStatus{}} kv, router := newClusterTestRouter(ci) user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestClusterStatus_NoAuth(t *testing.T) { ci := &mockClusterInfo{status: &model.ClusterStatus{}} _, router := newClusterTestRouter(ci) rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "") if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rr.Code) } } func TestClusterStatus_Error(t *testing.T) { ci := &mockClusterInfo{statusErr: errors.New("k8s unreachable")} kv, router := newClusterTestRouter(ci) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) } } func TestClusterStatus_NilCluster(t *testing.T) { kv, router := newClusterTestRouter(nil) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cluster/status", "", "dpk_admin") if rr.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String()) } } // --- GET /api/v1/cache/stats --- func TestCacheStats_AdminSuccess(t *testing.T) { ci := &mockClusterInfo{ stats: []model.CacheStat{ {Name: "verdaccio", PVCName: "verdaccio-storage", Capacity: "10Gi", Status: "Bound"}, {Name: "athens", PVCName: "athens-storage", Capacity: "10Gi", Status: "Bound"}, {Name: "cargo-proxy", PVCName: "cargo-proxy-cache", Capacity: "10Gi", Status: "Bound"}, }, } kv, router := newClusterTestRouter(ci) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var stats []model.CacheStat decodeJSON(t, rr, &stats) if len(stats) != 3 { t.Fatalf("expected 3 cache stats, got %d", len(stats)) } names := map[string]bool{} for _, s := range stats { names[s.Name] = true if s.Status != "Bound" { t.Fatalf("expected Bound status for %s, got %s", s.Name, s.Status) } if s.Capacity != "10Gi" { t.Fatalf("expected 10Gi capacity for %s, got %s", s.Name, s.Capacity) } } for _, expected := range []string{"verdaccio", "athens", "cargo-proxy"} { if !names[expected] { t.Fatalf("missing cache stat for %s", expected) } } } func TestCacheStats_ForbiddenForUser(t *testing.T) { ci := &mockClusterInfo{stats: []model.CacheStat{}} kv, router := newClusterTestRouter(ci) user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestCacheStats_NoAuth(t *testing.T) { ci := &mockClusterInfo{stats: []model.CacheStat{}} _, router := newClusterTestRouter(ci) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "") if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rr.Code) } } func TestCacheStats_Error(t *testing.T) { ci := &mockClusterInfo{statsErr: errors.New("pvc query failed")} kv, router := newClusterTestRouter(ci) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) } } func TestCacheStats_NilCluster(t *testing.T) { kv, router := newClusterTestRouter(nil) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin") if rr.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String()) } } func TestCacheStats_PartialNotFound(t *testing.T) { ci := &mockClusterInfo{ stats: []model.CacheStat{ {Name: "verdaccio", PVCName: "verdaccio-storage", Capacity: "10Gi", Status: "Bound"}, {Name: "athens", PVCName: "athens-storage", Status: "NotFound"}, {Name: "cargo-proxy", PVCName: "cargo-proxy-cache", Capacity: "10Gi", Status: "Bound"}, }, } kv, router := newClusterTestRouter(ci) admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/cache/stats", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var stats []model.CacheStat decodeJSON(t, rr, &stats) if len(stats) != 3 { t.Fatalf("expected 3 entries, got %d", len(stats)) } for _, s := range stats { if s.Name == "athens" && s.Status != "NotFound" { t.Fatalf("expected athens NotFound, got %s", s.Status) } } }