Adds tests.

This commit is contained in:
2025-11-08 20:10:13 +00:00
parent 7cd434aabf
commit b330b2aea7
9 changed files with 5462 additions and 159 deletions

View File

@@ -18,6 +18,7 @@ import (
"github.com/wild-cloud/wild-central/daemon/internal/instance"
"github.com/wild-cloud/wild-central/daemon/internal/operations"
"github.com/wild-cloud/wild-central/daemon/internal/secrets"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
"github.com/wild-cloud/wild-central/daemon/internal/tools"
)
@@ -302,7 +303,7 @@ func (api *API) GetConfig(w http.ResponseWriter, r *http.Request) {
}
// updateYAMLFile updates a YAML file with the provided key-value pairs
func (api *API) updateYAMLFile(w http.ResponseWriter, r *http.Request, instanceName, fileType string, updateFunc func(string, string, string) error) {
func (api *API) updateYAMLFile(w http.ResponseWriter, r *http.Request, instanceName, fileType string) {
if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return
@@ -327,13 +328,44 @@ func (api *API) updateYAMLFile(w http.ResponseWriter, r *http.Request, instanceN
filePath = api.instance.GetInstanceSecretsPath(instanceName)
}
// Update each key-value pair
for key, value := range updates {
valueStr := fmt.Sprintf("%v", value)
if err := updateFunc(filePath, key, valueStr); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to update %s key %s: %v", fileType, key, err))
// Read existing config/secrets file
existingContent, err := storage.ReadFile(filePath)
if err != nil && !os.IsNotExist(err) {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read existing %s: %v", fileType, err))
return
}
// Parse existing content or initialize empty map
var existingConfig map[string]interface{}
if len(existingContent) > 0 {
if err := yaml.Unmarshal(existingContent, &existingConfig); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to parse existing %s: %v", fileType, err))
return
}
} else {
existingConfig = make(map[string]interface{})
}
// Merge updates into existing config (shallow merge for top-level keys)
// This preserves unmodified keys while updating specified ones
for key, value := range updates {
existingConfig[key] = value
}
// Marshal the merged config back to YAML with proper formatting
yamlContent, err := yaml.Marshal(existingConfig)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to marshal YAML: %v", err))
return
}
// Write the complete merged YAML content to the file with proper locking
lockPath := filePath + ".lock"
if err := storage.WithLock(lockPath, func() error {
return storage.WriteFile(filePath, yamlContent, 0644)
}); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to update %s: %v", fileType, err))
return
}
// Capitalize first letter of fileType for message
@@ -351,7 +383,7 @@ func (api *API) updateYAMLFile(w http.ResponseWriter, r *http.Request, instanceN
func (api *API) UpdateConfig(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
api.updateYAMLFile(w, r, name, "config", api.config.SetConfigValue)
api.updateYAMLFile(w, r, name, "config")
}
// GetSecrets retrieves instance secrets (redacted by default)
@@ -399,7 +431,7 @@ func (api *API) GetSecrets(w http.ResponseWriter, r *http.Request) {
func (api *API) UpdateSecrets(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
api.updateYAMLFile(w, r, name, "secrets", api.secrets.SetSecret)
api.updateYAMLFile(w, r, name, "secrets")
}
// GetContext retrieves current context

View File

@@ -0,0 +1,656 @@
package v1
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gorilla/mux"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
)
func setupTestAPI(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
appsDir := filepath.Join(tmpDir, "apps")
api, err := NewAPI(tmpDir, appsDir)
if err != nil {
t.Fatalf("Failed to create test API: %v", err)
}
return api, tmpDir
}
func createTestInstance(t *testing.T, api *API, name string) {
if err := api.instance.CreateInstance(name); err != nil {
t.Fatalf("Failed to create test instance: %v", err)
}
}
func TestUpdateYAMLFile_DeltaUpdate(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Create initial config
initialConfig := map[string]interface{}{
"domain": "old.com",
"email": "admin@old.com",
"cluster": map[string]interface{}{
"name": "test-cluster",
},
}
initialYAML, _ := yaml.Marshal(initialConfig)
if err := storage.WriteFile(configPath, initialYAML, 0644); err != nil {
t.Fatalf("Failed to write initial config: %v", err)
}
// Update only domain
updateData := map[string]interface{}{
"domain": "new.com",
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify merged config
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
// Domain should be updated
if result["domain"] != "new.com" {
t.Errorf("Expected domain='new.com', got %v", result["domain"])
}
// Email should be preserved
if result["email"] != "admin@old.com" {
t.Errorf("Expected email='admin@old.com', got %v", result["email"])
}
// Cluster should be preserved
if cluster, ok := result["cluster"].(map[string]interface{}); !ok {
t.Errorf("Cluster not preserved as map")
} else if cluster["name"] != "test-cluster" {
t.Errorf("Cluster name not preserved")
}
}
func TestUpdateYAMLFile_FullReplacement(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Create initial config
initialConfig := map[string]interface{}{
"domain": "old.com",
"email": "admin@old.com",
"oldKey": "oldValue",
}
initialYAML, _ := yaml.Marshal(initialConfig)
if err := storage.WriteFile(configPath, initialYAML, 0644); err != nil {
t.Fatalf("Failed to write initial config: %v", err)
}
// Full replacement
newConfig := map[string]interface{}{
"domain": "new.com",
"email": "new@new.com",
"newKey": "newValue",
}
newYAML, _ := yaml.Marshal(newConfig)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(newYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify result
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
// All new values should be present
if result["domain"] != "new.com" {
t.Errorf("Expected domain='new.com', got %v", result["domain"])
}
if result["email"] != "new@new.com" {
t.Errorf("Expected email='new@new.com', got %v", result["email"])
}
if result["newKey"] != "newValue" {
t.Errorf("Expected newKey='newValue', got %v", result["newKey"])
}
// Old key should still be present (shallow merge)
if result["oldKey"] != "oldValue" {
t.Errorf("Expected oldKey='oldValue', got %v", result["oldKey"])
}
}
func TestUpdateYAMLFile_NestedStructure(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Update with nested structure
updateData := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "test.com",
"dns": map[string]interface{}{
"ip": "1.2.3.4",
"port": 53,
},
},
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify nested structure preserved
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
// Verify nested structure is proper YAML, not Go map notation
resultStr := string(resultData)
if bytes.Contains(resultData, []byte("map[")) {
t.Errorf("Result contains Go map notation: %s", resultStr)
}
// Verify structure is accessible
cloud, ok := result["cloud"].(map[string]interface{})
if !ok {
t.Fatalf("cloud is not a map: %T", result["cloud"])
}
if cloud["domain"] != "test.com" {
t.Errorf("Expected cloud.domain='test.com', got %v", cloud["domain"])
}
dns, ok := cloud["dns"].(map[string]interface{})
if !ok {
t.Fatalf("cloud.dns is not a map: %T", cloud["dns"])
}
if dns["ip"] != "1.2.3.4" {
t.Errorf("Expected dns.ip='1.2.3.4', got %v", dns["ip"])
}
if dns["port"] != 53 {
t.Errorf("Expected dns.port=53, got %v", dns["port"])
}
}
func TestUpdateYAMLFile_EmptyFileCreation(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Truncate the config file to make it empty (but still exists)
if err := storage.WriteFile(configPath, []byte(""), 0644); err != nil {
t.Fatalf("Failed to empty config file: %v", err)
}
// Update should populate empty file
updateData := map[string]interface{}{
"domain": "new.com",
"email": "admin@new.com",
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify content
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
if result["domain"] != "new.com" {
t.Errorf("Expected domain='new.com', got %v", result["domain"])
}
if result["email"] != "admin@new.com" {
t.Errorf("Expected email='admin@new.com', got %v", result["email"])
}
}
func TestUpdateYAMLFile_EmptyUpdate(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Create initial config
initialConfig := map[string]interface{}{
"domain": "test.com",
}
initialYAML, _ := yaml.Marshal(initialConfig)
if err := storage.WriteFile(configPath, initialYAML, 0644); err != nil {
t.Fatalf("Failed to write initial config: %v", err)
}
// Empty update
updateData := map[string]interface{}{}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify file unchanged
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
if result["domain"] != "test.com" {
t.Errorf("Expected domain='test.com', got %v", result["domain"])
}
}
func TestUpdateYAMLFile_YAMLFormatting(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Update with complex nested structure
updateData := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "test.com",
"dns": map[string]interface{}{
"ip": "1.2.3.4",
},
},
"cluster": map[string]interface{}{
"nodes": []interface{}{
map[string]interface{}{
"name": "node1",
"ip": "10.0.0.1",
},
map[string]interface{}{
"name": "node2",
"ip": "10.0.0.2",
},
},
},
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify YAML formatting
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
resultStr := string(resultData)
// Should not contain Go map notation
if bytes.Contains(resultData, []byte("map[")) {
t.Errorf("Result contains Go map notation: %s", resultStr)
}
// Should be valid YAML
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Result is not valid YAML: %v", err)
}
// Should have proper indentation (check for nested structure indicators)
if !bytes.Contains(resultData, []byte(" ")) {
t.Error("Result appears to lack proper indentation")
}
}
func TestUpdateYAMLFile_InvalidYAML(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
// Send invalid YAML
invalidYAML := []byte("invalid: yaml: content: [")
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(invalidYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
func TestUpdateYAMLFile_InvalidInstance(t *testing.T) {
api, _ := setupTestAPI(t)
updateData := map[string]interface{}{
"domain": "test.com",
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/nonexistent/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": "nonexistent"}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
}
func TestUpdateYAMLFile_FilePermissions(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
updateData := map[string]interface{}{
"domain": "test.com",
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Check file permissions
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("Failed to stat config file: %v", err)
}
expectedPerm := os.FileMode(0644)
if info.Mode().Perm() != expectedPerm {
t.Errorf("Expected permissions %v, got %v", expectedPerm, info.Mode().Perm())
}
}
func TestUpdateYAMLFile_UpdateSecrets(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
secretsPath := api.instance.GetInstanceSecretsPath(instanceName)
// Update secrets
updateData := map[string]interface{}{
"dbPassword": "secret123",
"apiKey": "key456",
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/secrets", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateSecrets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify secrets file created and contains data
resultData, err := storage.ReadFile(secretsPath)
if err != nil {
t.Fatalf("Failed to read secrets: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse secrets: %v", err)
}
if result["dbPassword"] != "secret123" {
t.Errorf("Expected dbPassword='secret123', got %v", result["dbPassword"])
}
if result["apiKey"] != "key456" {
t.Errorf("Expected apiKey='key456', got %v", result["apiKey"])
}
}
func TestUpdateYAMLFile_ConcurrentUpdates(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
// This test verifies that file locking prevents race conditions
// We'll simulate concurrent updates and verify data integrity
numUpdates := 10
done := make(chan bool, numUpdates)
for i := 0; i < numUpdates; i++ {
go func(index int) {
updateData := map[string]interface{}{
"counter": index,
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
done <- w.Code == http.StatusOK
}(i)
}
// Wait for all updates to complete
successCount := 0
for i := 0; i < numUpdates; i++ {
if <-done {
successCount++
}
}
if successCount != numUpdates {
t.Errorf("Expected %d successful updates, got %d", numUpdates, successCount)
}
// Verify file is still valid YAML
configPath := api.instance.GetInstanceConfigPath(instanceName)
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read final config: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Final config is not valid YAML: %v", err)
}
}
func TestUpdateYAMLFile_PreservesComplexTypes(t *testing.T) {
api, _ := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Create config with various types
updateData := map[string]interface{}{
"stringValue": "text",
"intValue": 42,
"floatValue": 3.14,
"boolValue": true,
"arrayValue": []interface{}{"a", "b", "c"},
"mapValue": map[string]interface{}{
"nested": "value",
},
"nullValue": nil,
}
updateYAML, _ := yaml.Marshal(updateData)
req := httptest.NewRequest("PUT", "/api/v1/instances/"+instanceName+"/config", bytes.NewBuffer(updateYAML))
w := httptest.NewRecorder()
vars := map[string]string{"name": instanceName}
req = mux.SetURLVars(req, vars)
api.UpdateConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify types preserved
resultData, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read result: %v", err)
}
var result map[string]interface{}
if err := yaml.Unmarshal(resultData, &result); err != nil {
t.Fatalf("Failed to parse result: %v", err)
}
if result["stringValue"] != "text" {
t.Errorf("String value not preserved: %v", result["stringValue"])
}
if result["intValue"] != 42 {
t.Errorf("Int value not preserved: %v", result["intValue"])
}
if result["floatValue"] != 3.14 {
t.Errorf("Float value not preserved: %v", result["floatValue"])
}
if result["boolValue"] != true {
t.Errorf("Bool value not preserved: %v", result["boolValue"])
}
arrayValue, ok := result["arrayValue"].([]interface{})
if !ok {
t.Errorf("Array not preserved as slice: %T", result["arrayValue"])
} else if len(arrayValue) != 3 {
t.Errorf("Array length not preserved: %d", len(arrayValue))
}
mapValue, ok := result["mapValue"].(map[string]interface{})
if !ok {
t.Errorf("Map not preserved: %T", result["mapValue"])
} else if mapValue["nested"] != "value" {
t.Errorf("Nested map value not preserved: %v", mapValue["nested"])
}
if result["nullValue"] != nil {
t.Errorf("Null value not preserved: %v", result["nullValue"])
}
}