From 909527f06047d1d85831fe80bf8c74ed0dec04ab Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 04:45:55 +0000 Subject: [PATCH] feat: Add tests for config, secrets, services, and cloudflare endpoints Fix config endpoint to return wrapped { configured, config } response that the web UI expects. Fix services handler to return stored version with defaults applied. New tests: - GetGlobalConfig: wrapped response, empty/not-configured case - GetGlobalSecrets: leaf redaction preserves structure, raw mode - UpdateGlobalConfig: write and verify - CloudflareVerify: no-token case - Services: register, list, get, update, deregister, TCP passthrough defaults, validation errors 15 new test cases, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/handlers.go | 11 +- internal/api/v1/handlers_config_test.go | 200 ++++++++++++++++ internal/api/v1/handlers_services.go | 4 +- internal/api/v1/handlers_services_test.go | 278 ++++++++++++++++++++++ 4 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 internal/api/v1/handlers_config_test.go create mode 100644 internal/api/v1/handlers_services_test.go diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 77ec9b7..7433271 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -230,12 +230,14 @@ func (api *API) RegisterRoutes(r *mux.Router) { // --- Global Config/Secrets handlers --- -// GetGlobalConfig returns the global config +// GetGlobalConfig returns the global config wrapped in { configured, config }. func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { configPath := filepath.Join(api.dataDir, "config.yaml") data, err := os.ReadFile(configPath) if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to read config") + respondJSON(w, http.StatusOK, map[string]any{ + "configured": false, + }) return } @@ -245,7 +247,10 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { return } - respondJSON(w, http.StatusOK, configMap) + respondJSON(w, http.StatusOK, map[string]any{ + "configured": true, + "config": configMap, + }) } // UpdateGlobalConfig updates the global config diff --git a/internal/api/v1/handlers_config_test.go b/internal/api/v1/handlers_config_test.go new file mode 100644 index 0000000..d4f4fa1 --- /dev/null +++ b/internal/api/v1/handlers_config_test.go @@ -0,0 +1,200 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/wild-cloud/wild-central/internal/storage" + "gopkg.in/yaml.v3" +) + +func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { + api, tmpDir := setupTestAPI(t) + + // Write a config with a central domain + cfg := map[string]any{ + "cloud": map[string]any{ + "central": map[string]any{ + "domain": "central.example.com", + }, + }, + "operator": map[string]any{ + "email": "test@example.com", + }, + } + data, _ := yaml.Marshal(cfg) + storage.WriteFile(filepath.Join(tmpDir, "config.yaml"), data, 0644) + + req := httptest.NewRequest("GET", "/api/v1/config", nil) + w := httptest.NewRecorder() + + api.GetGlobalConfig(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + // Must have "configured" and "config" keys + if resp["configured"] != true { + t.Errorf("expected configured=true, got %v", resp["configured"]) + } + config, ok := resp["config"].(map[string]any) + if !ok { + t.Fatalf("expected config to be a map, got %T", resp["config"]) + } + + // Verify nested structure is preserved + cloud, _ := config["cloud"].(map[string]any) + central, _ := cloud["central"].(map[string]any) + if central["domain"] != "central.example.com" { + t.Errorf("expected domain central.example.com, got %v", central["domain"]) + } +} + +func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) { + api, tmpDir := setupTestAPI(t) + + // Remove the default config that EnsureGlobalConfig created + os.Remove(filepath.Join(tmpDir, "config.yaml")) + + req := httptest.NewRequest("GET", "/api/v1/config", nil) + w := httptest.NewRecorder() + + api.GetGlobalConfig(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["configured"] != false { + t.Errorf("expected configured=false, got %v", resp["configured"]) + } +} + +func TestGetGlobalSecrets_RedactsLeafValues(t *testing.T) { + api, tmpDir := setupTestAPI(t) + + secrets := map[string]any{ + "cloudflare": map[string]any{ + "apiToken": "super-secret-token", + "zoneId": "zone-123", + }, + "topLevelSecret": "another-secret", + } + data, _ := yaml.Marshal(secrets) + os.WriteFile(filepath.Join(tmpDir, "secrets.yaml"), data, 0600) + + req := httptest.NewRequest("GET", "/api/v1/secrets", nil) + w := httptest.NewRecorder() + + api.GetGlobalSecrets(w, req) + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + // Top-level leaf should be redacted + if resp["topLevelSecret"] != "********" { + t.Errorf("expected topLevelSecret redacted, got %v", resp["topLevelSecret"]) + } + + // Nested map structure should be preserved + cf, ok := resp["cloudflare"].(map[string]any) + if !ok { + t.Fatalf("expected cloudflare to be a map, got %T", resp["cloudflare"]) + } + if cf["apiToken"] != "********" { + t.Errorf("expected apiToken redacted, got %v", cf["apiToken"]) + } + if cf["zoneId"] != "********" { + t.Errorf("expected zoneId redacted, got %v", cf["zoneId"]) + } +} + +func TestGetGlobalSecrets_RawShowsValues(t *testing.T) { + api, tmpDir := setupTestAPI(t) + + secrets := map[string]any{ + "cloudflare": map[string]any{ + "apiToken": "my-token", + }, + } + data, _ := yaml.Marshal(secrets) + os.WriteFile(filepath.Join(tmpDir, "secrets.yaml"), data, 0600) + + req := httptest.NewRequest("GET", "/api/v1/secrets?raw=true", nil) + w := httptest.NewRecorder() + + api.GetGlobalSecrets(w, req) + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + cf := resp["cloudflare"].(map[string]any) + if cf["apiToken"] != "my-token" { + t.Errorf("expected raw apiToken, got %v", cf["apiToken"]) + } +} + +func TestUpdateGlobalConfig(t *testing.T) { + api, tmpDir := setupTestAPI(t) + + update := map[string]any{ + "cloud": map[string]any{ + "central": map[string]any{ + "domain": "new.example.com", + }, + }, + } + body, _ := json.Marshal(update) + + req := httptest.NewRequest("PUT", "/api/v1/config", bytes.NewReader(body)) + w := httptest.NewRecorder() + + api.UpdateGlobalConfig(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify file was written + data, _ := os.ReadFile(filepath.Join(tmpDir, "config.yaml")) + var cfg map[string]any + yaml.Unmarshal(data, &cfg) + + cloud := cfg["cloud"].(map[string]any) + central := cloud["central"].(map[string]any) + if central["domain"] != "new.example.com" { + t.Errorf("expected domain new.example.com, got %v", central["domain"]) + } +} + +func TestCloudflareVerify_NoToken(t *testing.T) { + api, _ := setupTestAPI(t) + + req := httptest.NewRequest("GET", "/api/v1/cloudflare/verify", nil) + w := httptest.NewRecorder() + + api.CloudflareVerify(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["tokenConfigured"] != false { + t.Errorf("expected tokenConfigured=false with no secrets, got %v", resp["tokenConfigured"]) + } +} diff --git a/internal/api/v1/handlers_services.go b/internal/api/v1/handlers_services.go index 0b5aba3..0ae28fc 100644 --- a/internal/api/v1/handlers_services.go +++ b/internal/api/v1/handlers_services.go @@ -53,9 +53,11 @@ func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) { return } + // Return the stored version (has defaults applied) + stored, _ := api.services.Get(svc.Name) respondJSON(w, http.StatusCreated, map[string]any{ "message": fmt.Sprintf("Service %q registered", svc.Name), - "service": svc, + "service": stored, }) } diff --git a/internal/api/v1/handlers_services_test.go b/internal/api/v1/handlers_services_test.go new file mode 100644 index 0000000..182c0f7 --- /dev/null +++ b/internal/api/v1/handlers_services_test.go @@ -0,0 +1,278 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func TestServicesRegister(t *testing.T) { + api, _ := setupTestAPI(t) + + body, _ := json.Marshal(map[string]any{ + "name": "my-api", + "source": "wild-works", + "domain": "my-api.payne.io", + "backend": map[string]any{ + "address": "192.168.8.60:9001", + "type": "http", + "health": "/health", + }, + "reach": "internal", + }) + + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + + api.ServicesRegister(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + svc, ok := resp["service"].(map[string]any) + if !ok { + t.Fatal("expected service in response") + } + if svc["name"] != "my-api" { + t.Errorf("expected name my-api, got %v", svc["name"]) + } + if svc["tls"] != "terminate" { + t.Errorf("expected tls=terminate default for http backend, got %v", svc["tls"]) + } +} + +func TestServicesListAll_Empty(t *testing.T) { + api, _ := setupTestAPI(t) + + req := httptest.NewRequest("GET", "/api/v1/services", nil) + w := httptest.NewRecorder() + + api.ServicesListAll(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + svcs, ok := resp["services"].([]any) + if !ok { + t.Fatal("expected services array") + } + if len(svcs) != 0 { + t.Errorf("expected empty services, got %d", len(svcs)) + } +} + +func TestServicesListAll_WithServices(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register two services + for _, name := range []string{"svc-a", "svc-b"} { + body, _ := json.Marshal(map[string]any{ + "name": name, + "source": "test", + "domain": name + ".example.com", + "backend": map[string]any{"address": "127.0.0.1:8080", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("register %s failed: %d", name, w.Code) + } + } + + req := httptest.NewRequest("GET", "/api/v1/services", nil) + w := httptest.NewRecorder() + + api.ServicesListAll(w, req) + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + svcs := resp["services"].([]any) + if len(svcs) != 2 { + t.Errorf("expected 2 services, got %d", len(svcs)) + } +} + +func TestServicesGet(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register + body, _ := json.Marshal(map[string]any{ + "name": "get-test", + "source": "test", + "domain": "get-test.example.com", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + // Get + req = httptest.NewRequest("GET", "/api/v1/services/get-test", nil) + req = mux.SetURLVars(req, map[string]string{"name": "get-test"}) + w = httptest.NewRecorder() + + api.ServicesGet(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var svc map[string]any + json.Unmarshal(w.Body.Bytes(), &svc) + + if svc["domain"] != "get-test.example.com" { + t.Errorf("expected domain get-test.example.com, got %v", svc["domain"]) + } +} + +func TestServicesGet_NotFound(t *testing.T) { + api, _ := setupTestAPI(t) + + req := httptest.NewRequest("GET", "/api/v1/services/nonexistent", nil) + req = mux.SetURLVars(req, map[string]string{"name": "nonexistent"}) + w := httptest.NewRecorder() + + api.ServicesGet(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestServicesUpdate(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register + body, _ := json.Marshal(map[string]any{ + "name": "update-test", + "source": "test", + "domain": "update-test.example.com", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + // Update reach to public + update, _ := json.Marshal(map[string]any{"reach": "public"}) + req = httptest.NewRequest("PATCH", "/api/v1/services/update-test", bytes.NewReader(update)) + req = mux.SetURLVars(req, map[string]string{"name": "update-test"}) + w = httptest.NewRecorder() + + api.ServicesUpdate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + svc := resp["service"].(map[string]any) + if svc["reach"] != "public" { + t.Errorf("expected reach=public after update, got %v", svc["reach"]) + } +} + +func TestServicesDeregister(t *testing.T) { + api, _ := setupTestAPI(t) + + // Register + body, _ := json.Marshal(map[string]any{ + "name": "delete-me", + "source": "test", + "domain": "delete-me.example.com", + "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, + "reach": "internal", + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + // Delete + req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me", nil) + req = mux.SetURLVars(req, map[string]string{"name": "delete-me"}) + w = httptest.NewRecorder() + + api.ServicesDeregister(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Verify it's gone + req = httptest.NewRequest("GET", "/api/v1/services/delete-me", nil) + req = mux.SetURLVars(req, map[string]string{"name": "delete-me"}) + w = httptest.NewRecorder() + api.ServicesGet(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 after delete, got %d", w.Code) + } +} + +func TestServicesRegister_TCPPassthrough(t *testing.T) { + api, _ := setupTestAPI(t) + + body, _ := json.Marshal(map[string]any{ + "name": "cloud-instance", + "source": "wild-cloud", + "domain": "cloud.payne.io", + "backend": map[string]any{ + "address": "192.168.8.100:443", + "type": "tcp-passthrough", + }, + "reach": "internal", + }) + + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + + api.ServicesRegister(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + svc := resp["service"].(map[string]any) + if svc["tls"] != "passthrough" { + t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"]) + } +} + +func TestServicesRegister_Validation(t *testing.T) { + api, _ := setupTestAPI(t) + + // Missing name + body, _ := json.Marshal(map[string]any{ + "domain": "x.com", + "backend": map[string]any{"address": "127.0.0.1:80", "type": "http"}, + }) + req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + w := httptest.NewRecorder() + api.ServicesRegister(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing name, got %d", w.Code) + } +}