From fc2218b5fe5ad98b66ff7ccb615155c115adb2f7 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 8 Feb 2026 01:10:59 -0500 Subject: [PATCH] Add comprehensive test suite for sandbox package (78 tests) Expanded from 22 basic tests to 78 tests covering error injection, task polling, IP discovery, context cancellation, HTTP error codes, concurrent access, SSH lifecycle, and request verification. Co-Authored-By: Claude Opus 4.6 --- v2/sandbox/sandbox_test.go | 1530 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1456 insertions(+), 74 deletions(-) diff --git a/v2/sandbox/sandbox_test.go b/v2/sandbox/sandbox_test.go index 10afced..4fcd190 100644 --- a/v2/sandbox/sandbox_test.go +++ b/v2/sandbox/sandbox_test.go @@ -4,73 +4,162 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" "net/http" "net/http/httptest" + "net/url" "strings" + "sync" + "sync/atomic" "testing" + "time" "golang.org/x/crypto/ssh" ) -// --- Proxmox API mock server --- +// ============================================================================= +// Mock Proxmox HTTP Server (enhanced with error injection + call tracking) +// ============================================================================= type mockProxmoxHandler struct { + mu sync.Mutex containers map[int]ContainerStatus nextID int tasks map[string]string // taskID → exitstatus + + // Error injection: endpoint → number of calls before it starts succeeding. + // Key is a pattern like "clone", "start", "config", "interfaces", "nextid", + // "status/current", "delete", "execute", "task". + failUntil map[string]int + + // Call counters per endpoint pattern. + callCounts map[string]int + + // If set, task polling will return "running" this many times before "stopped". + taskPollingRounds int + + // If > 0, interfaces endpoint returns no IP for this many calls before returning one. + interfaceEmptyRounds int + + // If set, the task exit status to return (default "OK"). + taskExitStatus string + + // Track all received requests for verification. + requests []requestRecord +} + +type requestRecord struct { + Method string + Path string + Form url.Values } func newMockProxmoxHandler() *mockProxmoxHandler { return &mockProxmoxHandler{ - containers: make(map[int]ContainerStatus), - nextID: 200, - tasks: make(map[string]string), + containers: make(map[int]ContainerStatus), + nextID: 200, + tasks: make(map[string]string), + failUntil: make(map[string]int), + callCounts: make(map[string]int), + taskExitStatus: "OK", } } +func (m *mockProxmoxHandler) shouldFail(endpoint string, statusCode int, w http.ResponseWriter) bool { + m.callCounts[endpoint]++ + failCount, ok := m.failUntil[endpoint] + if ok && m.callCounts[endpoint] <= failCount { + http.Error(w, fmt.Sprintf("injected error for %s (call %d/%d)", endpoint, m.callCounts[endpoint], failCount), statusCode) + return true + } + return false +} + func (m *mockProxmoxHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + path := r.URL.Path - // Verify auth header is present. + // Verify auth header. auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "PVEAPIToken=") { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + // Parse form for POST/PUT. + r.ParseForm() + m.requests = append(m.requests, requestRecord{ + Method: r.Method, + Path: path, + Form: r.Form, + }) + w.Header().Set("Content-Type", "application/json") switch { case path == "/api2/json/cluster/nextid" && r.Method == http.MethodGet: + if m.shouldFail("nextid", http.StatusInternalServerError, w) { + return + } m.handleNextID(w) case strings.HasSuffix(path, "/clone") && r.Method == http.MethodPost: + if m.shouldFail("clone", http.StatusInternalServerError, w) { + return + } m.handleClone(w, r) case strings.HasSuffix(path, "/config") && r.Method == http.MethodPut: + if m.shouldFail("config", http.StatusInternalServerError, w) { + return + } m.handleConfig(w) case strings.HasSuffix(path, "/status/start") && r.Method == http.MethodPost: + if m.shouldFail("start", http.StatusInternalServerError, w) { + return + } m.handleStart(w, r) case strings.HasSuffix(path, "/status/stop") && r.Method == http.MethodPost: + if m.shouldFail("stop", http.StatusInternalServerError, w) { + return + } m.handleStop(w, r) case strings.HasSuffix(path, "/status/current") && r.Method == http.MethodGet: + if m.shouldFail("status/current", http.StatusInternalServerError, w) { + return + } m.handleStatusCurrent(w, r) case strings.HasSuffix(path, "/interfaces") && r.Method == http.MethodGet: - m.handleInterfaces(w, r) + if m.shouldFail("interfaces", http.StatusInternalServerError, w) { + return + } + m.handleInterfaces(w) case strings.Contains(path, "/tasks/") && strings.HasSuffix(path, "/status"): - m.handleTaskStatus(w, r) + if m.shouldFail("task", http.StatusInternalServerError, w) { + return + } + m.handleTaskStatus(w) case r.Method == http.MethodDelete && strings.Contains(path, "/lxc/"): + if m.shouldFail("delete", http.StatusInternalServerError, w) { + return + } m.handleDelete(w, r) case strings.HasSuffix(path, "/execute") && r.Method == http.MethodPost: + if m.shouldFail("execute", http.StatusInternalServerError, w) { + return + } m.handleExecute(w) default: @@ -85,9 +174,8 @@ func (m *mockProxmoxHandler) handleNextID(w http.ResponseWriter) { } func (m *mockProxmoxHandler) handleClone(w http.ResponseWriter, r *http.Request) { - r.ParseForm() taskID := "UPID:pve:clone-task" - m.tasks[taskID] = "OK" + m.tasks[taskID] = m.taskExitStatus json.NewEncoder(w).Encode(map[string]any{"data": taskID}) } @@ -96,18 +184,27 @@ func (m *mockProxmoxHandler) handleConfig(w http.ResponseWriter) { } func (m *mockProxmoxHandler) handleStart(w http.ResponseWriter, r *http.Request) { - // Extract VMID from path. parts := strings.Split(r.URL.Path, "/") for i, p := range parts { if p == "lxc" && i+1 < len(parts) { var vmid int fmt.Sscanf(parts[i+1], "%d", &vmid) - m.containers[vmid] = ContainerStatus{Status: "running"} + m.containers[vmid] = ContainerStatus{ + Status: "running", + CPU: 0.15, + Mem: 256 * 1024 * 1024, + MaxMem: 1024 * 1024 * 1024, + Disk: 1024 * 1024 * 1024, + MaxDisk: 8 * 1024 * 1024 * 1024, + NetIn: 1024 * 1024, + NetOut: 512 * 1024, + Uptime: 120, + } break } } taskID := "UPID:pve:start-task" - m.tasks[taskID] = "OK" + m.tasks[taskID] = m.taskExitStatus json.NewEncoder(w).Encode(map[string]any{"data": taskID}) } @@ -122,7 +219,7 @@ func (m *mockProxmoxHandler) handleStop(w http.ResponseWriter, r *http.Request) } } taskID := "UPID:pve:stop-task" - m.tasks[taskID] = "OK" + m.tasks[taskID] = m.taskExitStatus json.NewEncoder(w).Encode(map[string]any{"data": taskID}) } @@ -143,7 +240,17 @@ func (m *mockProxmoxHandler) handleStatusCurrent(w http.ResponseWriter, r *http. http.Error(w, "not found", http.StatusNotFound) } -func (m *mockProxmoxHandler) handleInterfaces(w http.ResponseWriter, r *http.Request) { +func (m *mockProxmoxHandler) handleInterfaces(w http.ResponseWriter) { + m.callCounts["interfaces_data"]++ + if m.interfaceEmptyRounds > 0 && m.callCounts["interfaces_data"] <= m.interfaceEmptyRounds { + // Return interfaces with no IP yet. + ifaces := []map[string]string{ + {"name": "lo", "inet": "127.0.0.1/8"}, + {"name": "eth0", "inet": "", "hwaddr": "AA:BB:CC:DD:EE:FF"}, + } + json.NewEncoder(w).Encode(map[string]any{"data": ifaces}) + return + } ifaces := []map[string]string{ {"name": "lo", "inet": "127.0.0.1/8"}, {"name": "eth0", "inet": "10.99.1.5/16", "hwaddr": "AA:BB:CC:DD:EE:FF"}, @@ -151,11 +258,21 @@ func (m *mockProxmoxHandler) handleInterfaces(w http.ResponseWriter, r *http.Req json.NewEncoder(w).Encode(map[string]any{"data": ifaces}) } -func (m *mockProxmoxHandler) handleTaskStatus(w http.ResponseWriter, r *http.Request) { +func (m *mockProxmoxHandler) handleTaskStatus(w http.ResponseWriter) { + m.callCounts["task_poll"]++ + if m.taskPollingRounds > 0 && m.callCounts["task_poll"] <= m.taskPollingRounds { + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "status": "running", + "exitstatus": "", + }, + }) + return + } json.NewEncoder(w).Encode(map[string]any{ "data": map[string]any{ "status": "stopped", - "exitstatus": "OK", + "exitstatus": m.taskExitStatus, }, }) } @@ -171,7 +288,7 @@ func (m *mockProxmoxHandler) handleDelete(w http.ResponseWriter, r *http.Request } } taskID := "UPID:pve:delete-task" - m.tasks[taskID] = "OK" + m.tasks[taskID] = m.taskExitStatus json.NewEncoder(w).Encode(map[string]any{"data": taskID}) } @@ -179,7 +296,9 @@ func (m *mockProxmoxHandler) handleExecute(w http.ResponseWriter) { json.NewEncoder(w).Encode(map[string]any{"data": ""}) } -// --- Test helpers --- +// ============================================================================= +// Test helpers +// ============================================================================= func newTestProxmoxClient(t *testing.T, handler *mockProxmoxHandler) (*ProxmoxClient, *httptest.Server) { t.Helper() @@ -194,7 +313,23 @@ func newTestProxmoxClient(t *testing.T, handler *mockProxmoxHandler) (*ProxmoxCl Bridge: "vmbr1", InsecureSkipVerify: true, }) - // Use the test server's TLS client. + client.http = server.Client() + return client, server +} + +func newTestProxmoxClientNoPool(t *testing.T, handler *mockProxmoxHandler) (*ProxmoxClient, *httptest.Server) { + t.Helper() + server := httptest.NewTLSServer(handler) + client := NewProxmoxClient(ProxmoxConfig{ + BaseURL: server.URL, + TokenID: "test@pve!test-token", + Secret: "test-secret", + Node: "pve", + TemplateID: 9000, + Pool: "", + Bridge: "vmbr1", + InsecureSkipVerify: true, + }) client.http = server.Client() return client, server } @@ -212,7 +347,29 @@ func generateTestSigner(t *testing.T) ssh.Signer { return signer } -// --- Proxmox client tests --- +func newTestSandbox(t *testing.T, handler *mockProxmoxHandler) (*Sandbox, *httptest.Server) { + t.Helper() + client, server := newTestProxmoxClient(t, handler) + signer := generateTestSigner(t) + sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer}) + + // Start the container in the mock so it has a "running" status. + ctx := context.Background() + if err := client.StartContainer(ctx, 200); err != nil { + t.Fatalf("start container: %v", err) + } + + return &Sandbox{ + ID: 200, + IP: "10.99.1.5", + proxmox: client, + ssh: sshExec, + }, server +} + +// ============================================================================= +// Proxmox Client Tests +// ============================================================================= func TestProxmoxNextAvailableID(t *testing.T) { handler := newMockProxmoxHandler() @@ -237,6 +394,21 @@ func TestProxmoxNextAvailableID(t *testing.T) { } } +func TestProxmoxNextAvailableID_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["nextid"] = 999 // Always fail. + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + _, err := client.NextAvailableID(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected 500 error, got: %v", err) + } +} + func TestProxmoxCloneTemplate(t *testing.T) { handler := newMockProxmoxHandler() client, server := newTestProxmoxClient(t, handler) @@ -248,6 +420,88 @@ func TestProxmoxCloneTemplate(t *testing.T) { if err != nil { t.Fatalf("CloneTemplate: %v", err) } + + // Verify clone request had the right params. + handler.mu.Lock() + defer handler.mu.Unlock() + found := false + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/clone") { + found = true + if req.Form.Get("hostname") != "test-sandbox" { + t.Errorf("expected hostname=test-sandbox, got %q", req.Form.Get("hostname")) + } + if req.Form.Get("newid") != "200" { + t.Errorf("expected newid=200, got %q", req.Form.Get("newid")) + } + if req.Form.Get("full") != "1" { + t.Errorf("expected full=1, got %q", req.Form.Get("full")) + } + if req.Form.Get("pool") != "sandbox-pool" { + t.Errorf("expected pool=sandbox-pool, got %q", req.Form.Get("pool")) + } + break + } + } + if !found { + t.Error("clone request not found in recorded requests") + } +} + +func TestProxmoxCloneTemplate_DefaultHostname(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.CloneTemplate(context.Background(), 201, ContainerConfig{}) + if err != nil { + t.Fatalf("CloneTemplate: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/clone") { + if req.Form.Get("hostname") != "sandbox-201" { + t.Errorf("expected default hostname=sandbox-201, got %q", req.Form.Get("hostname")) + } + break + } + } +} + +func TestProxmoxCloneTemplate_NoPool(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClientNoPool(t, handler) + defer server.Close() + + err := client.CloneTemplate(context.Background(), 200, ContainerConfig{Hostname: "test"}) + if err != nil { + t.Fatalf("CloneTemplate: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/clone") { + if pool := req.Form.Get("pool"); pool != "" { + t.Errorf("expected no pool param, got %q", pool) + } + break + } + } +} + +func TestProxmoxCloneTemplate_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["clone"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.CloneTemplate(context.Background(), 200, ContainerConfig{Hostname: "test"}) + if err == nil { + t.Fatal("expected error, got nil") + } } func TestProxmoxContainerLifecycle(t *testing.T) { @@ -257,12 +511,11 @@ func TestProxmoxContainerLifecycle(t *testing.T) { ctx := context.Background() - // Start container. + // Start. if err := client.StartContainer(ctx, 200); err != nil { t.Fatalf("StartContainer: %v", err) } - // Get status — should be running. status, err := client.GetContainerStatus(ctx, 200) if err != nil { t.Fatalf("GetContainerStatus: %v", err) @@ -271,12 +524,11 @@ func TestProxmoxContainerLifecycle(t *testing.T) { t.Errorf("expected status 'running', got %q", status.Status) } - // Stop container. + // Stop. if err := client.StopContainer(ctx, 200); err != nil { t.Fatalf("StopContainer: %v", err) } - // Get status — should be stopped. status, err = client.GetContainerStatus(ctx, 200) if err != nil { t.Fatalf("GetContainerStatus: %v", err) @@ -286,6 +538,62 @@ func TestProxmoxContainerLifecycle(t *testing.T) { } } +func TestProxmoxContainerStatus_FullFields(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx := context.Background() + if err := client.StartContainer(ctx, 200); err != nil { + t.Fatalf("StartContainer: %v", err) + } + + status, err := client.GetContainerStatus(ctx, 200) + if err != nil { + t.Fatalf("GetContainerStatus: %v", err) + } + + if status.CPU != 0.15 { + t.Errorf("expected CPU 0.15, got %f", status.CPU) + } + if status.Mem != 256*1024*1024 { + t.Errorf("expected Mem %d, got %d", 256*1024*1024, status.Mem) + } + if status.MaxMem != 1024*1024*1024 { + t.Errorf("expected MaxMem %d, got %d", 1024*1024*1024, status.MaxMem) + } + if status.Disk != 1024*1024*1024 { + t.Errorf("expected Disk %d, got %d", 1024*1024*1024, status.Disk) + } + if status.MaxDisk != 8*1024*1024*1024 { + t.Errorf("expected MaxDisk %d, got %d", 8*1024*1024*1024, status.MaxDisk) + } + if status.NetIn != 1024*1024 { + t.Errorf("expected NetIn %d, got %d", 1024*1024, status.NetIn) + } + if status.NetOut != 512*1024 { + t.Errorf("expected NetOut %d, got %d", 512*1024, status.NetOut) + } + if status.Uptime != 120 { + t.Errorf("expected Uptime 120, got %d", status.Uptime) + } +} + +func TestProxmoxContainerStatus_UnknownContainer(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // Container 999 doesn't exist in the mock — returns default "stopped". + status, err := client.GetContainerStatus(context.Background(), 999) + if err != nil { + t.Fatalf("GetContainerStatus: %v", err) + } + if status.Status != "stopped" { + t.Errorf("expected 'stopped' for unknown container, got %q", status.Status) + } +} + func TestProxmoxGetContainerIP(t *testing.T) { handler := newMockProxmoxHandler() client, server := newTestProxmoxClient(t, handler) @@ -300,6 +608,66 @@ func TestProxmoxGetContainerIP(t *testing.T) { } } +func TestProxmoxGetContainerIP_Polling(t *testing.T) { + handler := newMockProxmoxHandler() + handler.interfaceEmptyRounds = 2 // First 2 calls return no IP. + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ip, err := client.GetContainerIP(context.Background(), 200) + if err != nil { + t.Fatalf("GetContainerIP: %v", err) + } + if ip != "10.99.1.5" { + t.Errorf("expected IP 10.99.1.5, got %q", ip) + } + + // Should have needed at least 3 calls. + handler.mu.Lock() + count := handler.callCounts["interfaces_data"] + handler.mu.Unlock() + if count < 3 { + t.Errorf("expected at least 3 interface calls (polling), got %d", count) + } +} + +func TestProxmoxGetContainerIP_Timeout(t *testing.T) { + handler := newMockProxmoxHandler() + handler.interfaceEmptyRounds = 9999 // Never return an IP. + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := client.GetContainerIP(ctx, 200) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "deadline exceeded") && !strings.Contains(err.Error(), "context") { + t.Errorf("expected context error, got: %v", err) + } +} + +func TestProxmoxGetContainerIP_SkipsLoopback(t *testing.T) { + // The default mock returns lo (127.0.0.1) and eth0 (10.99.1.5). + // Verify it skips lo and returns eth0. + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ip, err := client.GetContainerIP(context.Background(), 200) + if err != nil { + t.Fatalf("GetContainerIP: %v", err) + } + if ip == "127.0.0.1" { + t.Error("should not return loopback address") + } + if ip != "10.99.1.5" { + t.Errorf("expected 10.99.1.5, got %q", ip) + } +} + func TestProxmoxDestroyContainer(t *testing.T) { handler := newMockProxmoxHandler() client, server := newTestProxmoxClient(t, handler) @@ -307,22 +675,35 @@ func TestProxmoxDestroyContainer(t *testing.T) { ctx := context.Background() - // Start it first so it has a status. if err := client.StartContainer(ctx, 200); err != nil { t.Fatalf("StartContainer: %v", err) } - // Destroy it. if err := client.DestroyContainer(ctx, 200); err != nil { t.Fatalf("DestroyContainer: %v", err) } - // Container should be gone from the handler's map. - if _, exists := handler.containers[200]; exists { + handler.mu.Lock() + _, exists := handler.containers[200] + handler.mu.Unlock() + if exists { t.Error("container 200 should have been deleted") } } +func TestProxmoxDestroyContainer_AlreadyStopped(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // Container 200 doesn't exist → status defaults to "stopped". + // Destroy should skip the stop step and go straight to delete. + err := client.DestroyContainer(context.Background(), 200) + if err != nil { + t.Fatalf("DestroyContainer on stopped container: %v", err) + } +} + func TestProxmoxConfigureContainer(t *testing.T) { handler := newMockProxmoxHandler() client, server := newTestProxmoxClient(t, handler) @@ -335,6 +716,65 @@ func TestProxmoxConfigureContainer(t *testing.T) { if err != nil { t.Fatalf("ConfigureContainer: %v", err) } + + // Verify the request params. + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/config") { + if req.Form.Get("cores") != "2" { + t.Errorf("expected cores=2, got %q", req.Form.Get("cores")) + } + if req.Form.Get("memory") != "2048" { + t.Errorf("expected memory=2048, got %q", req.Form.Get("memory")) + } + if req.Form.Get("swap") != "0" { + t.Errorf("expected swap=0, got %q", req.Form.Get("swap")) + } + if !strings.Contains(req.Form.Get("net0"), "bridge=vmbr1") { + t.Errorf("expected net0 to contain bridge=vmbr1, got %q", req.Form.Get("net0")) + } + break + } + } +} + +func TestProxmoxConfigureContainer_ZeroValueDefaults(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // CPUs=0 and MemoryMB=0 should default to 1 and 1024. + err := client.ConfigureContainer(context.Background(), 200, ContainerConfig{}) + if err != nil { + t.Fatalf("ConfigureContainer: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/config") { + if req.Form.Get("cores") != "1" { + t.Errorf("expected default cores=1, got %q", req.Form.Get("cores")) + } + if req.Form.Get("memory") != "1024" { + t.Errorf("expected default memory=1024, got %q", req.Form.Get("memory")) + } + break + } + } +} + +func TestProxmoxConfigureContainer_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["config"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.ConfigureContainer(context.Background(), 200, ContainerConfig{CPUs: 1, MemoryMB: 1024}) + if err == nil { + t.Fatal("expected error, got nil") + } } func TestProxmoxEnableDisableInternet(t *testing.T) { @@ -351,10 +791,34 @@ func TestProxmoxEnableDisableInternet(t *testing.T) { if err := client.DisableInternet(ctx, "10.99.1.5"); err != nil { t.Fatalf("DisableInternet: %v", err) } + + // Verify execute commands were sent. + handler.mu.Lock() + defer handler.mu.Unlock() + execCount := 0 + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/execute") { + execCount++ + } + } + if execCount != 2 { + t.Errorf("expected 2 execute requests (enable + disable), got %d", execCount) + } +} + +func TestProxmoxEnableInternet_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["execute"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.EnableInternet(context.Background(), "10.99.1.5") + if err == nil { + t.Fatal("expected error, got nil") + } } func TestProxmoxAuthRequired(t *testing.T) { - // Mock that rejects requests without a valid token. handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if auth != "PVEAPIToken=valid@pve!tok=secret123" { @@ -367,7 +831,7 @@ func TestProxmoxAuthRequired(t *testing.T) { server := httptest.NewTLSServer(handler) defer server.Close() - // Client with wrong credentials should fail. + // Wrong credentials. client := NewProxmoxClient(ProxmoxConfig{ BaseURL: server.URL, TokenID: "wrong@pve!tok", @@ -385,7 +849,7 @@ func TestProxmoxAuthRequired(t *testing.T) { t.Errorf("expected 401 error, got: %v", err) } - // Client with correct credentials should succeed. + // Correct credentials. client2 := NewProxmoxClient(ProxmoxConfig{ BaseURL: server.URL, TokenID: "valid@pve!tok", @@ -418,7 +882,223 @@ func TestProxmoxContextCancellation(t *testing.T) { } } -// --- SSH executor tests (unit tests without real SSH) --- +func TestProxmoxContextCancellation_Start(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := client.StartContainer(ctx, 200) + if err == nil { + t.Fatal("expected error with cancelled context, got nil") + } +} + +func TestProxmoxContextCancellation_Clone(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := client.CloneTemplate(ctx, 200, ContainerConfig{Hostname: "test"}) + if err == nil { + t.Fatal("expected error with cancelled context, got nil") + } +} + +func TestProxmoxHTTPErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + }{ + {"400 Bad Request", 400, "invalid parameter"}, + {"403 Forbidden", 403, "permission denied"}, + {"404 Not Found", 404, "resource not found"}, + {"500 Internal Error", 500, "internal server error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, tt.body, tt.statusCode) + })) + defer server.Close() + + client := NewProxmoxClient(ProxmoxConfig{ + BaseURL: server.URL, + TokenID: "test@pve!tok", + Secret: "secret", + Node: "pve", + InsecureSkipVerify: true, + }) + client.http = server.Client() + + _, err := client.NextAvailableID(context.Background()) + if err == nil { + t.Fatalf("expected error for HTTP %d, got nil", tt.statusCode) + } + if !strings.Contains(err.Error(), fmt.Sprintf("%d", tt.statusCode)) { + t.Errorf("expected error to contain '%d', got: %v", tt.statusCode, err) + } + if !strings.Contains(err.Error(), tt.body) { + t.Errorf("expected error to contain %q, got: %v", tt.body, err) + } + }) + } +} + +func TestProxmoxInvalidJSON(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte("not json at all")) + })) + defer server.Close() + + client := NewProxmoxClient(ProxmoxConfig{ + BaseURL: server.URL, + TokenID: "test@pve!tok", + Secret: "secret", + Node: "pve", + InsecureSkipVerify: true, + }) + client.http = server.Client() + + _, err := client.NextAvailableID(context.Background()) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "decode") { + t.Errorf("expected decode error, got: %v", err) + } +} + +func TestProxmoxTaskPolling(t *testing.T) { + handler := newMockProxmoxHandler() + handler.taskPollingRounds = 3 // Task returns "running" for 3 polls. + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // StartContainer triggers waitForTask which should poll until done. + err := client.StartContainer(context.Background(), 200) + if err != nil { + t.Fatalf("StartContainer (with polling): %v", err) + } + + handler.mu.Lock() + pollCount := handler.callCounts["task_poll"] + handler.mu.Unlock() + if pollCount < 4 { + t.Errorf("expected at least 4 task polls (3 running + 1 stopped), got %d", pollCount) + } +} + +func TestProxmoxTaskFailed(t *testing.T) { + handler := newMockProxmoxHandler() + handler.taskExitStatus = "TASK ERROR: storage full" + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.StartContainer(context.Background(), 200) + if err == nil { + t.Fatal("expected error for failed task, got nil") + } + if !strings.Contains(err.Error(), "storage full") { + t.Errorf("expected 'storage full' in error, got: %v", err) + } +} + +func TestProxmoxTaskPolling_ContextTimeout(t *testing.T) { + handler := newMockProxmoxHandler() + handler.taskPollingRounds = 9999 // Never completes. + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + err := client.StartContainer(ctx, 200) + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} + +func TestProxmoxWaitForTask_EmptyTaskID(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // waitForTask with empty taskID should be a no-op. + err := client.waitForTask(context.Background(), "") + if err != nil { + t.Fatalf("waitForTask with empty ID: %v", err) + } +} + +func TestProxmoxStartContainer_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["start"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + err := client.StartContainer(context.Background(), 200) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestProxmoxStopContainer_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["stop"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // Start first (so the mock has the container running). + if err := client.StartContainer(context.Background(), 200); err != nil { + t.Fatalf("start: %v", err) + } + + handler.failUntil["stop"] = 999 + handler.callCounts["stop"] = 0 + err := client.StopContainer(context.Background(), 200) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestProxmoxDestroyContainer_DeleteError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["delete"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // Container defaults to "stopped" so destroy goes straight to delete. + err := client.DestroyContainer(context.Background(), 200) + if err == nil { + t.Fatal("expected error from delete, got nil") + } +} + +func TestProxmoxGetContainerStatus_ServerError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["status/current"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + _, err := client.GetContainerStatus(context.Background(), 200) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ============================================================================= +// SSH Executor Tests +// ============================================================================= func TestSSHExecutorDefaults(t *testing.T) { signer := generateTestSigner(t) @@ -427,14 +1107,34 @@ func TestSSHExecutorDefaults(t *testing.T) { if exec.config.User != "sandbox" { t.Errorf("expected default user 'sandbox', got %q", exec.config.User) } - if exec.config.ConnectTimeout != 10e9 { + if exec.config.ConnectTimeout != 10*time.Second { t.Errorf("expected default connect timeout 10s, got %v", exec.config.ConnectTimeout) } - if exec.config.CommandTimeout != 60e9 { + if exec.config.CommandTimeout != 60*time.Second { t.Errorf("expected default command timeout 60s, got %v", exec.config.CommandTimeout) } } +func TestSSHExecutorCustomConfig(t *testing.T) { + signer := generateTestSigner(t) + exec := NewSSHExecutor("10.99.1.5", SSHConfig{ + Signer: signer, + User: "admin", + ConnectTimeout: 30 * time.Second, + CommandTimeout: 120 * time.Second, + }) + + if exec.config.User != "admin" { + t.Errorf("expected user 'admin', got %q", exec.config.User) + } + if exec.config.ConnectTimeout != 30*time.Second { + t.Errorf("expected connect timeout 30s, got %v", exec.config.ConnectTimeout) + } + if exec.config.CommandTimeout != 120*time.Second { + t.Errorf("expected command timeout 120s, got %v", exec.config.CommandTimeout) + } +} + func TestSSHExecutorNotConnected(t *testing.T) { signer := generateTestSigner(t) exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer}) @@ -456,6 +1156,9 @@ func TestSSHExecutorUploadNotConnected(t *testing.T) { if err == nil { t.Fatal("expected error when not connected, got nil") } + if !strings.Contains(err.Error(), "sftp not connected") { + t.Errorf("expected 'sftp not connected' error, got: %v", err) + } } func TestSSHExecutorDownloadNotConnected(t *testing.T) { @@ -466,6 +1169,9 @@ func TestSSHExecutorDownloadNotConnected(t *testing.T) { if err == nil { t.Fatal("expected error when not connected, got nil") } + if !strings.Contains(err.Error(), "sftp not connected") { + t.Errorf("expected 'sftp not connected' error, got: %v", err) + } } func TestSSHExecutorIsConnected(t *testing.T) { @@ -485,15 +1191,83 @@ func TestSSHExecutorCloseIdempotent(t *testing.T) { if err := exec.Close(); err != nil { t.Errorf("Close on unconnected executor: %v", err) } + + // Closing twice should also be fine. + if err := exec.Close(); err != nil { + t.Errorf("second Close: %v", err) + } } -// --- LoadSSHKey / ParseSSHKey tests --- +func TestSSHExecutorConnectTimeout(t *testing.T) { + signer := generateTestSigner(t) + exec := NewSSHExecutor("10.99.1.5", SSHConfig{ + Signer: signer, + ConnectTimeout: 100 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := exec.Connect(ctx) + if err == nil { + t.Fatal("expected connect to fail (no SSH server), got nil") + } +} + +func TestSSHExecutorConnectCancelled(t *testing.T) { + signer := generateTestSigner(t) + exec := NewSSHExecutor("10.99.1.5", SSHConfig{ + Signer: signer, + ConnectTimeout: 50 * time.Millisecond, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + err := exec.Connect(ctx) + if err == nil { + t.Fatal("expected error with cancelled context, got nil") + } + if !strings.Contains(err.Error(), "context") { + t.Errorf("expected context error, got: %v", err) + } +} + +func TestSSHExecutorConcurrentNotConnected(t *testing.T) { + signer := generateTestSigner(t) + exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer}) + + var wg sync.WaitGroup + var errCount int32 + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := exec.Exec(context.Background(), "echo test") + if err != nil { + atomic.AddInt32(&errCount, 1) + } + }() + } + wg.Wait() + + if errCount != 10 { + t.Errorf("expected 10 errors (all not connected), got %d", errCount) + } +} + +// ============================================================================= +// LoadSSHKey / ParseSSHKey Tests +// ============================================================================= func TestLoadSSHKeyNotFound(t *testing.T) { _, err := LoadSSHKey("/nonexistent/path/to/key") if err == nil { t.Fatal("expected error for nonexistent key, got nil") } + if !strings.Contains(err.Error(), "read SSH key") { + t.Errorf("expected 'read SSH key' error, got: %v", err) + } } func TestParseSSHKeyInvalid(t *testing.T) { @@ -501,9 +1275,42 @@ func TestParseSSHKeyInvalid(t *testing.T) { if err == nil { t.Fatal("expected error for invalid key, got nil") } + if !strings.Contains(err.Error(), "parse SSH key") { + t.Errorf("expected 'parse SSH key' error, got: %v", err) + } } -// --- Sandbox / Manager tests (using mock Proxmox, no real SSH) --- +func TestParseSSHKeyEmpty(t *testing.T) { + _, err := ParseSSHKey([]byte("")) + if err == nil { + t.Fatal("expected error for empty key, got nil") + } +} + +func TestParseSSHKeyValidRSA(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + // MarshalPrivateKey returns PEM block. + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + signer, err := ParseSSHKey(pemBytes) + if err != nil { + t.Fatalf("ParseSSHKey: %v", err) + } + if signer == nil { + t.Fatal("expected non-nil signer") + } +} + +// ============================================================================= +// Manager Tests +// ============================================================================= func TestManagerRequiresSigner(t *testing.T) { _, err := NewManager(Config{}) @@ -515,71 +1322,305 @@ func TestManagerRequiresSigner(t *testing.T) { } } -func TestSandboxDestroyClosesConnections(t *testing.T) { +func TestManagerCreation(t *testing.T) { + signer := generateTestSigner(t) + mgr, err := NewManager(Config{ + Proxmox: ProxmoxConfig{ + BaseURL: "https://localhost:8006", + TokenID: "test@pve!tok", + Secret: "secret", + Node: "pve", + TemplateID: 9000, + }, + SSH: SSHConfig{ + Signer: signer, + User: "admin", + ConnectTimeout: 5 * time.Second, + CommandTimeout: 30 * time.Second, + }, + Defaults: ContainerConfig{ + CPUs: 2, + MemoryMB: 2048, + DiskGB: 16, + }, + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + if mgr.defaults.CPUs != 2 { + t.Errorf("expected defaults.CPUs=2, got %d", mgr.defaults.CPUs) + } + if mgr.defaults.MemoryMB != 2048 { + t.Errorf("expected defaults.MemoryMB=2048, got %d", mgr.defaults.MemoryMB) + } + if mgr.defaults.DiskGB != 16 { + t.Errorf("expected defaults.DiskGB=16, got %d", mgr.defaults.DiskGB) + } + if mgr.sshCfg.User != "admin" { + t.Errorf("expected SSH user=admin, got %q", mgr.sshCfg.User) + } +} + +func TestManagerDestroyByID(t *testing.T) { handler := newMockProxmoxHandler() - _, server := newTestProxmoxClient(t, handler) + client, server := newTestProxmoxClient(t, handler) defer server.Close() signer := generateTestSigner(t) + mgr := &Manager{ + proxmox: client, + sshKey: signer, + sshCfg: SSHConfig{Signer: signer}, + } - proxmoxClient := NewProxmoxClient(ProxmoxConfig{ - BaseURL: server.URL, - TokenID: "test@pve!test-token", - Secret: "test-secret", - Node: "pve", - TemplateID: 9000, - InsecureSkipVerify: true, - }) - proxmoxClient.http = server.Client() - - // Start a container so destroy can check its status. ctx := context.Background() - if err := proxmoxClient.StartContainer(ctx, 200); err != nil { - t.Fatalf("start: %v", err) + // Container 200 defaults to "stopped", DestroyByID should succeed. + if err := mgr.DestroyByID(ctx, 200); err != nil { + t.Fatalf("DestroyByID: %v", err) } +} - sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer}) +// ============================================================================= +// Sandbox Tests +// ============================================================================= - sb := &Sandbox{ - ID: 200, - IP: "10.99.1.5", - Internet: false, - proxmox: proxmoxClient, - ssh: sshExec, - } +func TestSandboxDestroyClosesConnections(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() - // Destroy should succeed even with unconnected SSH (no SFTP/SSH to close). - if err := sb.Destroy(ctx); err != nil { + if err := sb.Destroy(context.Background()); err != nil { t.Fatalf("Destroy: %v", err) } } -func TestSandboxWriteFileAndReadFileRequireConnection(t *testing.T) { - signer := generateTestSigner(t) - sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer}) +func TestSandboxDestroyWithInternet(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() - sb := &Sandbox{ssh: sshExec} + sb.Internet = true + + if err := sb.Destroy(context.Background()); err != nil { + t.Fatalf("Destroy with internet: %v", err) + } + + // Verify execute was called to disable internet. + handler.mu.Lock() + defer handler.mu.Unlock() + found := false + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/execute") { + found = true + break + } + } + if !found { + t.Error("expected execute request to disable internet during destroy") + } +} + +func TestSandboxDestroyWithoutInternet(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + sb.Internet = false + + if err := sb.Destroy(context.Background()); err != nil { + t.Fatalf("Destroy without internet: %v", err) + } + + // Verify execute was NOT called (no internet to disable). + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/execute") { + t.Error("should not call execute when internet is not enabled") + break + } + } +} + +func TestSandboxDestroyIdempotent(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + if err := sb.Destroy(context.Background()); err != nil { + t.Fatalf("first Destroy: %v", err) + } + + // Second destroy should still work (container already gone from mock). + // It may or may not error depending on whether the mock returns something + // for the status check. The important thing is it doesn't panic. + _ = sb.Destroy(context.Background()) +} + +func TestSandboxSetInternet(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + ctx := context.Background() + + // Enable. + if err := sb.SetInternet(ctx, true); err != nil { + t.Fatalf("SetInternet(true): %v", err) + } + if !sb.Internet { + t.Error("expected Internet=true after enable") + } + + // Disable. + if err := sb.SetInternet(ctx, false); err != nil { + t.Fatalf("SetInternet(false): %v", err) + } + if sb.Internet { + t.Error("expected Internet=false after disable") + } + + // Toggle: enable again. + if err := sb.SetInternet(ctx, true); err != nil { + t.Fatalf("SetInternet(true) again: %v", err) + } + if !sb.Internet { + t.Error("expected Internet=true after re-enable") + } +} + +func TestSandboxSetInternet_Error(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["execute"] = 999 + sb, server := newTestSandbox(t, handler) + defer server.Close() + + handler.mu.Lock() + handler.callCounts["execute"] = 0 + handler.mu.Unlock() + + err := sb.SetInternet(context.Background(), true) + if err == nil { + t.Fatal("expected error, got nil") + } + // Internet flag should NOT have been set. + if sb.Internet { + t.Error("Internet flag should not be set when enable fails") + } +} + +func TestSandboxStatus(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + status, err := sb.Status(context.Background()) + if err != nil { + t.Fatalf("Status: %v", err) + } + + if status.Status != "running" { + t.Errorf("expected status 'running', got %q", status.Status) + } + if status.CPU != 0.15 { + t.Errorf("expected CPU 0.15, got %f", status.CPU) + } +} + +func TestSandboxIsConnected(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + // SSH was not actually connected (no real server), so should be false. + if sb.IsConnected() { + t.Error("expected IsConnected=false (no real SSH)") + } +} + +func TestSandboxWriteFileRequiresConnection(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() err := sb.WriteFile(context.Background(), "/tmp/test.txt", "hello") if err == nil { t.Fatal("expected error when SSH not connected") } +} - _, err = sb.ReadFile(context.Background(), "/tmp/test.txt") +func TestSandboxReadFileRequiresConnection(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + _, err := sb.ReadFile(context.Background(), "/tmp/test.txt") if err == nil { t.Fatal("expected error when SSH not connected") } } +func TestSandboxExecRequiresConnection(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + _, err := sb.Exec(context.Background(), "echo hello") + if err == nil { + t.Fatal("expected error when SSH not connected") + } +} + +func TestSandboxUploadRequiresConnection(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + err := sb.Upload(context.Background(), strings.NewReader("data"), "/tmp/file", 0644) + if err == nil { + t.Fatal("expected error when SSH not connected") + } +} + +func TestSandboxDownloadRequiresConnection(t *testing.T) { + handler := newMockProxmoxHandler() + sb, server := newTestSandbox(t, handler) + defer server.Close() + + _, err := sb.Download(context.Background(), "/tmp/file") + if err == nil { + t.Fatal("expected error when SSH not connected") + } +} + +// ============================================================================= +// Option Tests +// ============================================================================= + func TestContainerConfigDefaults(t *testing.T) { - // Verify that zero-value createOpts get proper defaults in the Create flow. o := &createOpts{} if o.cpus != 0 { t.Errorf("expected zero cpus, got %d", o.cpus) } + if o.memoryMB != 0 { + t.Errorf("expected zero memoryMB, got %d", o.memoryMB) + } + if o.diskGB != 0 { + t.Errorf("expected zero diskGB, got %d", o.diskGB) + } + if o.hostname != "" { + t.Errorf("expected empty hostname, got %q", o.hostname) + } + if o.internet { + t.Error("expected internet=false by default") + } +} + +func TestContainerConfigOptions(t *testing.T) { + o := &createOpts{} - // Apply options. WithCPUs(2)(o) WithMemoryMB(2048)(o) WithDiskGB(16)(o) @@ -603,12 +1644,353 @@ func TestContainerConfigDefaults(t *testing.T) { } } -func TestExecResultFields(t *testing.T) { - r := ExecResult{Output: "hello\n", ExitCode: 0} - if r.Output != "hello\n" { - t.Errorf("unexpected output: %q", r.Output) +func TestContainerConfigOptionsOverwrite(t *testing.T) { + o := &createOpts{} + + // Set then overwrite. + WithCPUs(2)(o) + WithCPUs(4)(o) + if o.cpus != 4 { + t.Errorf("expected cpus=4 after overwrite, got %d", o.cpus) } - if r.ExitCode != 0 { - t.Errorf("unexpected exit code: %d", r.ExitCode) + + WithInternet(true)(o) + WithInternet(false)(o) + if o.internet { + t.Error("expected internet=false after overwrite") + } +} + +func TestExecResultFields(t *testing.T) { + t.Run("success", func(t *testing.T) { + r := ExecResult{Output: "hello\n", ExitCode: 0} + if r.Output != "hello\n" { + t.Errorf("unexpected output: %q", r.Output) + } + if r.ExitCode != 0 { + t.Errorf("unexpected exit code: %d", r.ExitCode) + } + }) + + t.Run("failure", func(t *testing.T) { + r := ExecResult{Output: "command not found\n", ExitCode: 127} + if r.ExitCode != 127 { + t.Errorf("expected exit code 127, got %d", r.ExitCode) + } + }) + + t.Run("empty output", func(t *testing.T) { + r := ExecResult{Output: "", ExitCode: 0} + if r.Output != "" { + t.Errorf("expected empty output, got %q", r.Output) + } + }) +} + +// ============================================================================= +// ContainerStatus Tests +// ============================================================================= + +func TestContainerStatusFields(t *testing.T) { + s := ContainerStatus{ + Status: "running", + CPU: 0.85, + Mem: 512 * 1024 * 1024, + MaxMem: 1024 * 1024 * 1024, + Disk: 2 * 1024 * 1024 * 1024, + MaxDisk: 8 * 1024 * 1024 * 1024, + NetIn: 100 * 1024 * 1024, + NetOut: 50 * 1024 * 1024, + Uptime: 3600, + } + + if s.Status != "running" { + t.Errorf("expected 'running', got %q", s.Status) + } + if s.CPU != 0.85 { + t.Errorf("expected CPU 0.85, got %f", s.CPU) + } + if s.Uptime != 3600 { + t.Errorf("expected Uptime 3600, got %d", s.Uptime) + } +} + +func TestContainerStatusJSON(t *testing.T) { + jsonData := `{ + "status": "running", + "cpu": 0.42, + "mem": 268435456, + "maxmem": 1073741824, + "disk": 1073741824, + "maxdisk": 8589934592, + "netin": 1048576, + "netout": 524288, + "uptime": 7200 + }` + + var s ContainerStatus + if err := json.Unmarshal([]byte(jsonData), &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if s.Status != "running" { + t.Errorf("expected 'running', got %q", s.Status) + } + if s.CPU != 0.42 { + t.Errorf("expected CPU 0.42, got %f", s.CPU) + } + if s.Mem != 268435456 { + t.Errorf("expected Mem 268435456, got %d", s.Mem) + } + if s.MaxMem != 1073741824 { + t.Errorf("expected MaxMem 1073741824, got %d", s.MaxMem) + } + if s.Uptime != 7200 { + t.Errorf("expected Uptime 7200, got %d", s.Uptime) + } +} + +// ============================================================================= +// ProxmoxConfig / ProxmoxClient Tests +// ============================================================================= + +func TestNewProxmoxClient(t *testing.T) { + client := NewProxmoxClient(ProxmoxConfig{ + BaseURL: "https://proxmox.local:8006", + TokenID: "user@pve!token", + Secret: "secret", + Node: "pve", + TemplateID: 9000, + Pool: "pool", + Bridge: "vmbr1", + InsecureSkipVerify: true, + }) + + if client == nil { + t.Fatal("expected non-nil client") + } + if client.config.BaseURL != "https://proxmox.local:8006" { + t.Errorf("unexpected BaseURL: %q", client.config.BaseURL) + } + if client.config.Node != "pve" { + t.Errorf("unexpected Node: %q", client.config.Node) + } + if client.config.TemplateID != 9000 { + t.Errorf("unexpected TemplateID: %d", client.config.TemplateID) + } +} + +func TestNewProxmoxClient_SecureByDefault(t *testing.T) { + client := NewProxmoxClient(ProxmoxConfig{ + BaseURL: "https://proxmox.local:8006", + }) + + if client.config.InsecureSkipVerify { + t.Error("InsecureSkipVerify should be false by default") + } +} + +// ============================================================================= +// Concurrent Access Tests +// ============================================================================= + +func TestProxmoxConcurrentRequests(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + var wg sync.WaitGroup + var errCount int32 + + // Fire 20 concurrent NextAvailableID requests. + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := client.NextAvailableID(context.Background()) + if err != nil { + atomic.AddInt32(&errCount, 1) + } + }() + } + wg.Wait() + + if errCount > 0 { + t.Errorf("expected 0 errors, got %d", errCount) + } +} + +func TestProxmoxConcurrentContainerOps(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + ctx := context.Background() + var wg sync.WaitGroup + var errCount int32 + + // Concurrent start + status on different VMIDs. + for i := 200; i < 210; i++ { + vmid := i + wg.Add(1) + go func() { + defer wg.Done() + if err := client.StartContainer(ctx, vmid); err != nil { + atomic.AddInt32(&errCount, 1) + return + } + if _, err := client.GetContainerStatus(ctx, vmid); err != nil { + atomic.AddInt32(&errCount, 1) + } + }() + } + wg.Wait() + + if errCount > 0 { + t.Errorf("expected 0 errors from concurrent ops, got %d", errCount) + } +} + +// ============================================================================= +// Attach Tests (Manager.Attach) +// ============================================================================= + +func TestManagerAttach_NotRunning(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + signer := generateTestSigner(t) + mgr := &Manager{ + proxmox: client, + sshKey: signer, + sshCfg: SSHConfig{Signer: signer}, + } + + // Container 200 defaults to "stopped". + _, err := mgr.Attach(context.Background(), 200) + if err == nil { + t.Fatal("expected error attaching to stopped container") + } + if !strings.Contains(err.Error(), "not running") { + t.Errorf("expected 'not running' error, got: %v", err) + } +} + +func TestManagerAttach_StatusError(t *testing.T) { + handler := newMockProxmoxHandler() + handler.failUntil["status/current"] = 999 + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + signer := generateTestSigner(t) + mgr := &Manager{ + proxmox: client, + sshKey: signer, + sshCfg: SSHConfig{Signer: signer}, + } + + _, err := mgr.Attach(context.Background(), 200) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ============================================================================= +// Request Tracking / Verification Tests +// ============================================================================= + +func TestProxmoxCloneTemplate_RequestPath(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + if err := client.CloneTemplate(context.Background(), 201, ContainerConfig{Hostname: "mybox"}); err != nil { + t.Fatalf("CloneTemplate: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + found := false + for _, req := range handler.requests { + if strings.Contains(req.Path, "/lxc/9000/clone") { + found = true + break + } + } + if !found { + t.Error("expected clone request to use template ID 9000 in path") + } +} + +func TestProxmoxStartContainer_RequestPath(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + if err := client.StartContainer(context.Background(), 201); err != nil { + t.Fatalf("StartContainer: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + found := false + for _, req := range handler.requests { + if strings.Contains(req.Path, "/lxc/201/status/start") { + found = true + break + } + } + if !found { + t.Error("expected start request to use VMID 201 in path") + } +} + +func TestProxmoxDestroyContainer_RequestPath(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + // Destroy on VMID 202 (defaults to stopped, skip stop step). + if err := client.DestroyContainer(context.Background(), 202); err != nil { + t.Fatalf("DestroyContainer: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + found := false + for _, req := range handler.requests { + if req.Method == http.MethodDelete && strings.Contains(req.Path, "/lxc/202") { + found = true + break + } + } + if !found { + t.Error("expected DELETE request for VMID 202") + } +} + +func TestProxmoxConfigureContainer_BridgeInRequest(t *testing.T) { + handler := newMockProxmoxHandler() + client, server := newTestProxmoxClient(t, handler) + defer server.Close() + + if err := client.ConfigureContainer(context.Background(), 200, ContainerConfig{CPUs: 1}); err != nil { + t.Fatalf("ConfigureContainer: %v", err) + } + + handler.mu.Lock() + defer handler.mu.Unlock() + for _, req := range handler.requests { + if strings.HasSuffix(req.Path, "/config") { + net0 := req.Form.Get("net0") + if !strings.Contains(net0, "vmbr1") { + t.Errorf("expected net0 to contain bridge name 'vmbr1', got %q", net0) + } + if !strings.Contains(net0, "ip=dhcp") { + t.Errorf("expected net0 to contain ip=dhcp, got %q", net0) + } + break + } } }