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 + } } }