Provides a complete lifecycle manager for ephemeral sandbox environments: - ProxmoxClient: thin REST wrapper for container CRUD, IP discovery, internet toggle - SSHExecutor: persistent SSH/SFTP for command execution and file transfer - Manager/Sandbox: high-level orchestrator tying Proxmox + SSH together - 22 unit tests with mock Proxmox HTTP server - Proxmox setup & hardening guide (docs/sandbox-setup.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
615 lines
16 KiB
Go
615 lines
16 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// --- Proxmox API mock server ---
|
|
|
|
type mockProxmoxHandler struct {
|
|
containers map[int]ContainerStatus
|
|
nextID int
|
|
tasks map[string]string // taskID → exitstatus
|
|
}
|
|
|
|
func newMockProxmoxHandler() *mockProxmoxHandler {
|
|
return &mockProxmoxHandler{
|
|
containers: make(map[int]ContainerStatus),
|
|
nextID: 200,
|
|
tasks: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
// Verify auth header is present.
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "PVEAPIToken=") {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch {
|
|
case path == "/api2/json/cluster/nextid" && r.Method == http.MethodGet:
|
|
m.handleNextID(w)
|
|
|
|
case strings.HasSuffix(path, "/clone") && r.Method == http.MethodPost:
|
|
m.handleClone(w, r)
|
|
|
|
case strings.HasSuffix(path, "/config") && r.Method == http.MethodPut:
|
|
m.handleConfig(w)
|
|
|
|
case strings.HasSuffix(path, "/status/start") && r.Method == http.MethodPost:
|
|
m.handleStart(w, r)
|
|
|
|
case strings.HasSuffix(path, "/status/stop") && r.Method == http.MethodPost:
|
|
m.handleStop(w, r)
|
|
|
|
case strings.HasSuffix(path, "/status/current") && r.Method == http.MethodGet:
|
|
m.handleStatusCurrent(w, r)
|
|
|
|
case strings.HasSuffix(path, "/interfaces") && r.Method == http.MethodGet:
|
|
m.handleInterfaces(w, r)
|
|
|
|
case strings.Contains(path, "/tasks/") && strings.HasSuffix(path, "/status"):
|
|
m.handleTaskStatus(w, r)
|
|
|
|
case r.Method == http.MethodDelete && strings.Contains(path, "/lxc/"):
|
|
m.handleDelete(w, r)
|
|
|
|
case strings.HasSuffix(path, "/execute") && r.Method == http.MethodPost:
|
|
m.handleExecute(w)
|
|
|
|
default:
|
|
http.Error(w, fmt.Sprintf("unhandled: %s %s", r.Method, path), http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleNextID(w http.ResponseWriter) {
|
|
id := m.nextID
|
|
m.nextID++
|
|
json.NewEncoder(w).Encode(map[string]any{"data": id})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleClone(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
taskID := "UPID:pve:clone-task"
|
|
m.tasks[taskID] = "OK"
|
|
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleConfig(w http.ResponseWriter) {
|
|
json.NewEncoder(w).Encode(map[string]any{"data": nil})
|
|
}
|
|
|
|
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"}
|
|
break
|
|
}
|
|
}
|
|
taskID := "UPID:pve:start-task"
|
|
m.tasks[taskID] = "OK"
|
|
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleStop(w http.ResponseWriter, r *http.Request) {
|
|
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: "stopped"}
|
|
break
|
|
}
|
|
}
|
|
taskID := "UPID:pve:stop-task"
|
|
m.tasks[taskID] = "OK"
|
|
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleStatusCurrent(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
status, ok := m.containers[vmid]
|
|
if !ok {
|
|
status = ContainerStatus{Status: "stopped"}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": status})
|
|
return
|
|
}
|
|
}
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleInterfaces(w http.ResponseWriter, r *http.Request) {
|
|
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"},
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": ifaces})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"data": map[string]any{
|
|
"status": "stopped",
|
|
"exitstatus": "OK",
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
delete(m.containers, vmid)
|
|
break
|
|
}
|
|
}
|
|
taskID := "UPID:pve:delete-task"
|
|
m.tasks[taskID] = "OK"
|
|
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
|
|
}
|
|
|
|
func (m *mockProxmoxHandler) handleExecute(w http.ResponseWriter) {
|
|
json.NewEncoder(w).Encode(map[string]any{"data": ""})
|
|
}
|
|
|
|
// --- Test helpers ---
|
|
|
|
func newTestProxmoxClient(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: "sandbox-pool",
|
|
Bridge: "vmbr1",
|
|
InsecureSkipVerify: true,
|
|
})
|
|
// Use the test server's TLS client.
|
|
client.http = server.Client()
|
|
return client, server
|
|
}
|
|
|
|
func generateTestSigner(t *testing.T) ssh.Signer {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("generate RSA key: %v", err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(key)
|
|
if err != nil {
|
|
t.Fatalf("create signer: %v", err)
|
|
}
|
|
return signer
|
|
}
|
|
|
|
// --- Proxmox client tests ---
|
|
|
|
func TestProxmoxNextAvailableID(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
id, err := client.NextAvailableID(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("NextAvailableID: %v", err)
|
|
}
|
|
if id != 200 {
|
|
t.Errorf("expected VMID 200, got %d", id)
|
|
}
|
|
|
|
// Second call should return 201.
|
|
id2, err := client.NextAvailableID(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("NextAvailableID (2nd): %v", err)
|
|
}
|
|
if id2 != 201 {
|
|
t.Errorf("expected VMID 201, got %d", id2)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxCloneTemplate(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
err := client.CloneTemplate(context.Background(), 200, ContainerConfig{
|
|
Hostname: "test-sandbox",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CloneTemplate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxContainerLifecycle(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Start container.
|
|
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)
|
|
}
|
|
if status.Status != "running" {
|
|
t.Errorf("expected status 'running', got %q", status.Status)
|
|
}
|
|
|
|
// Stop container.
|
|
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)
|
|
}
|
|
if status.Status != "stopped" {
|
|
t.Errorf("expected status 'stopped', got %q", status.Status)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxGetContainerIP(t *testing.T) {
|
|
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 != "10.99.1.5" {
|
|
t.Errorf("expected IP 10.99.1.5, got %q", ip)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxDestroyContainer(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
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 {
|
|
t.Error("container 200 should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestProxmoxConfigureContainer(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
err := client.ConfigureContainer(context.Background(), 200, ContainerConfig{
|
|
CPUs: 2,
|
|
MemoryMB: 2048,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigureContainer: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxEnableDisableInternet(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
if err := client.EnableInternet(ctx, "10.99.1.5"); err != nil {
|
|
t.Fatalf("EnableInternet: %v", err)
|
|
}
|
|
|
|
if err := client.DisableInternet(ctx, "10.99.1.5"); err != nil {
|
|
t.Fatalf("DisableInternet: %v", err)
|
|
}
|
|
}
|
|
|
|
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" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"data": 200})
|
|
})
|
|
server := httptest.NewTLSServer(handler)
|
|
defer server.Close()
|
|
|
|
// Client with wrong credentials should fail.
|
|
client := NewProxmoxClient(ProxmoxConfig{
|
|
BaseURL: server.URL,
|
|
TokenID: "wrong@pve!tok",
|
|
Secret: "wrong",
|
|
Node: "pve",
|
|
InsecureSkipVerify: true,
|
|
})
|
|
client.http = server.Client()
|
|
|
|
_, err := client.NextAvailableID(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error with wrong auth, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Errorf("expected 401 error, got: %v", err)
|
|
}
|
|
|
|
// Client with correct credentials should succeed.
|
|
client2 := NewProxmoxClient(ProxmoxConfig{
|
|
BaseURL: server.URL,
|
|
TokenID: "valid@pve!tok",
|
|
Secret: "secret123",
|
|
Node: "pve",
|
|
InsecureSkipVerify: true,
|
|
})
|
|
client2.http = server.Client()
|
|
|
|
id, err := client2.NextAvailableID(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("expected success with correct auth, got: %v", err)
|
|
}
|
|
if id != 200 {
|
|
t.Errorf("expected VMID 200, got %d", id)
|
|
}
|
|
}
|
|
|
|
func TestProxmoxContextCancellation(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
client, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately.
|
|
|
|
_, err := client.NextAvailableID(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error with cancelled context, got nil")
|
|
}
|
|
}
|
|
|
|
// --- SSH executor tests (unit tests without real SSH) ---
|
|
|
|
func TestSSHExecutorDefaults(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
if exec.config.User != "sandbox" {
|
|
t.Errorf("expected default user 'sandbox', got %q", exec.config.User)
|
|
}
|
|
if exec.config.ConnectTimeout != 10e9 {
|
|
t.Errorf("expected default connect timeout 10s, got %v", exec.config.ConnectTimeout)
|
|
}
|
|
if exec.config.CommandTimeout != 60e9 {
|
|
t.Errorf("expected default command timeout 60s, got %v", exec.config.CommandTimeout)
|
|
}
|
|
}
|
|
|
|
func TestSSHExecutorNotConnected(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
_, err := exec.Exec(context.Background(), "echo hello")
|
|
if err == nil {
|
|
t.Fatal("expected error when not connected, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not connected") {
|
|
t.Errorf("expected 'not connected' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSSHExecutorUploadNotConnected(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
err := exec.Upload(context.Background(), strings.NewReader("test"), "/tmp/test", 0644)
|
|
if err == nil {
|
|
t.Fatal("expected error when not connected, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSSHExecutorDownloadNotConnected(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
_, err := exec.Download(context.Background(), "/tmp/test")
|
|
if err == nil {
|
|
t.Fatal("expected error when not connected, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSSHExecutorIsConnected(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
if exec.IsConnected() {
|
|
t.Error("should not be connected initially")
|
|
}
|
|
}
|
|
|
|
func TestSSHExecutorCloseIdempotent(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
// Close without connecting should not error.
|
|
if err := exec.Close(); err != nil {
|
|
t.Errorf("Close on unconnected executor: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- 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")
|
|
}
|
|
}
|
|
|
|
func TestParseSSHKeyInvalid(t *testing.T) {
|
|
_, err := ParseSSHKey([]byte("not a valid PEM key"))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid key, got nil")
|
|
}
|
|
}
|
|
|
|
// --- Sandbox / Manager tests (using mock Proxmox, no real SSH) ---
|
|
|
|
func TestManagerRequiresSigner(t *testing.T) {
|
|
_, err := NewManager(Config{})
|
|
if err == nil {
|
|
t.Fatal("expected error when no SSH signer provided")
|
|
}
|
|
if !strings.Contains(err.Error(), "SSH signer") {
|
|
t.Errorf("expected SSH signer error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSandboxDestroyClosesConnections(t *testing.T) {
|
|
handler := newMockProxmoxHandler()
|
|
_, server := newTestProxmoxClient(t, handler)
|
|
defer server.Close()
|
|
|
|
signer := generateTestSigner(t)
|
|
|
|
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)
|
|
}
|
|
|
|
sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
sb := &Sandbox{
|
|
ID: 200,
|
|
IP: "10.99.1.5",
|
|
Internet: false,
|
|
proxmox: proxmoxClient,
|
|
ssh: sshExec,
|
|
}
|
|
|
|
// Destroy should succeed even with unconnected SSH (no SFTP/SSH to close).
|
|
if err := sb.Destroy(ctx); err != nil {
|
|
t.Fatalf("Destroy: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSandboxWriteFileAndReadFileRequireConnection(t *testing.T) {
|
|
signer := generateTestSigner(t)
|
|
sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
|
|
|
|
sb := &Sandbox{ssh: sshExec}
|
|
|
|
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")
|
|
if err == nil {
|
|
t.Fatal("expected error when SSH not connected")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Apply options.
|
|
WithCPUs(2)(o)
|
|
WithMemoryMB(2048)(o)
|
|
WithDiskGB(16)(o)
|
|
WithHostname("test")(o)
|
|
WithInternet(true)(o)
|
|
|
|
if o.cpus != 2 {
|
|
t.Errorf("expected cpus=2, got %d", o.cpus)
|
|
}
|
|
if o.memoryMB != 2048 {
|
|
t.Errorf("expected memoryMB=2048, got %d", o.memoryMB)
|
|
}
|
|
if o.diskGB != 16 {
|
|
t.Errorf("expected diskGB=16, got %d", o.diskGB)
|
|
}
|
|
if o.hostname != "test" {
|
|
t.Errorf("expected hostname='test', got %q", o.hostname)
|
|
}
|
|
if !o.internet {
|
|
t.Error("expected internet=true")
|
|
}
|
|
}
|
|
|
|
func TestExecResultFields(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)
|
|
}
|
|
}
|