Claude Config API (v0.6): - Add CRUD endpoints for commands, skills, and agents - Commands/skills/agents stored in /workspace/.claude/ (per-project, in git) - Credentials shared via PVC at /root/.claude/ (shared across pods) - Use base64 encoding for file writes (prevents shell injection) - Add content size limits (1MB max) Security Hardening: - Add sanitize package for command/prompt validation - Add rate limiting middleware (token bucket algorithm) - Add concurrent command limiting - Add input sanitization to all command handlers - Gitignore secrets.yaml and credentials.yaml - Add *.example templates for secrets Testing Infrastructure: - Add testutil package with mocks and fixtures - Add unit tests for auth package (63% coverage) - Add unit tests for executor (47% coverage) - Add handler integration tests (40% coverage) - Add 100% coverage for sanitize, cmdlimit packages - Add 96% coverage for ratelimit package Infrastructure: - Shared Claude credentials PVC (ReadWriteMany) - Reduced workspace PVC size from 20Gi to 5Gi - Add init container cleanup before git clone - Document Longhorn RWX requirements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
360 lines
7.9 KiB
Go
360 lines
7.9 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
e := New("test-namespace")
|
|
if e.namespace != "test-namespace" {
|
|
t.Errorf("namespace = %q, want %q", e.namespace, "test-namespace")
|
|
}
|
|
}
|
|
|
|
func TestCommand_Types(t *testing.T) {
|
|
tests := []struct {
|
|
cmdType CommandType
|
|
want string
|
|
}{
|
|
{CommandTypeClaude, "claude"},
|
|
{CommandTypeShell, "shell"},
|
|
{CommandTypeGit, "git"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.cmdType), func(t *testing.T) {
|
|
if string(tt.cmdType) != tt.want {
|
|
t.Errorf("CommandType = %q, want %q", tt.cmdType, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecutor_buildArgs(t *testing.T) {
|
|
e := New("apps")
|
|
|
|
tests := []struct {
|
|
name string
|
|
cmd *Command
|
|
wantArgs []string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "claude command",
|
|
cmd: &Command{
|
|
ID: "cmd-1",
|
|
PodName: "claudebox-test",
|
|
Type: CommandTypeClaude,
|
|
Args: []string{"Write a hello world"},
|
|
},
|
|
wantArgs: []string{
|
|
"exec", "-n", "apps", "claudebox-test", "--",
|
|
"claude", "Write a hello world",
|
|
},
|
|
},
|
|
{
|
|
name: "shell command",
|
|
cmd: &Command{
|
|
ID: "cmd-2",
|
|
PodName: "claudebox-test",
|
|
Type: CommandTypeShell,
|
|
Args: []string{"ls -la /workspace"},
|
|
},
|
|
wantArgs: []string{
|
|
"exec", "-n", "apps", "claudebox-test", "--",
|
|
"bash", "-c", "ls -la /workspace",
|
|
},
|
|
},
|
|
{
|
|
name: "git command",
|
|
cmd: &Command{
|
|
ID: "cmd-3",
|
|
PodName: "claudebox-test",
|
|
Type: CommandTypeGit,
|
|
Args: []string{"status"},
|
|
},
|
|
wantArgs: []string{
|
|
"exec", "-n", "apps", "claudebox-test", "--",
|
|
"git", "-C", "/workspace", "status",
|
|
},
|
|
},
|
|
{
|
|
name: "git command with multiple args",
|
|
cmd: &Command{
|
|
ID: "cmd-4",
|
|
PodName: "claudebox-test",
|
|
Type: CommandTypeGit,
|
|
Args: []string{"commit", "-m", "test message"},
|
|
},
|
|
wantArgs: []string{
|
|
"exec", "-n", "apps", "claudebox-test", "--",
|
|
"git", "-C", "/workspace", "commit", "-m", "test message",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// We can't directly test buildArgs since it's internal to Exec,
|
|
// but we can verify the command construction by checking what would be built
|
|
var args []string
|
|
|
|
switch tt.cmd.Type {
|
|
case CommandTypeClaude:
|
|
args = []string{
|
|
"exec", "-n", e.namespace, tt.cmd.PodName, "--",
|
|
"claude", tt.cmd.Args[0],
|
|
}
|
|
case CommandTypeShell:
|
|
args = []string{
|
|
"exec", "-n", e.namespace, tt.cmd.PodName, "--",
|
|
"bash", "-c", tt.cmd.Args[0],
|
|
}
|
|
case CommandTypeGit:
|
|
args = append([]string{
|
|
"exec", "-n", e.namespace, tt.cmd.PodName, "--",
|
|
"git", "-C", "/workspace",
|
|
}, tt.cmd.Args...)
|
|
}
|
|
|
|
if len(args) != len(tt.wantArgs) {
|
|
t.Errorf("args length = %d, want %d", len(args), len(tt.wantArgs))
|
|
return
|
|
}
|
|
|
|
for i, arg := range args {
|
|
if arg != tt.wantArgs[i] {
|
|
t.Errorf("args[%d] = %q, want %q", i, arg, tt.wantArgs[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecutor_Exec_UnknownType(t *testing.T) {
|
|
e := New("test")
|
|
|
|
var output []string
|
|
handler := func(stream, line string) {
|
|
output = append(output, line)
|
|
}
|
|
|
|
result := e.Exec(context.Background(), &Command{
|
|
Type: CommandType("unknown"),
|
|
}, handler)
|
|
|
|
if result.ExitCode != 1 {
|
|
t.Errorf("ExitCode = %d, want 1", result.ExitCode)
|
|
}
|
|
|
|
if result.Error == nil {
|
|
t.Error("Error should not be nil for unknown command type")
|
|
}
|
|
|
|
if !strings.Contains(result.Error.Error(), "unknown command type") {
|
|
t.Errorf("Error = %v, want to contain 'unknown command type'", result.Error)
|
|
}
|
|
}
|
|
|
|
func TestExecutor_Exec_ContextCancellation(t *testing.T) {
|
|
// Skip if kubectl is not available
|
|
if _, err := exec.LookPath("kubectl"); err != nil {
|
|
t.Skip("kubectl not available")
|
|
}
|
|
|
|
e := New("default")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
var wg sync.WaitGroup
|
|
var result Result
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
result = e.Exec(ctx, &Command{
|
|
ID: "test-cancel",
|
|
PodName: "nonexistent-pod",
|
|
Type: CommandTypeShell,
|
|
Args: []string{"sleep 10"},
|
|
}, func(stream, line string) {})
|
|
}()
|
|
|
|
// Cancel immediately
|
|
cancel()
|
|
|
|
wg.Wait()
|
|
|
|
// The command should either fail due to context cancellation or pod not found
|
|
// Either way it shouldn't hang
|
|
if result.ExitCode == 0 && result.Error == nil {
|
|
t.Error("Expected command to fail due to cancellation or pod not found")
|
|
}
|
|
}
|
|
|
|
func TestExecutor_CheckConnection(t *testing.T) {
|
|
// This test requires kubectl to be configured
|
|
if _, err := exec.LookPath("kubectl"); err != nil {
|
|
t.Skip("kubectl not available")
|
|
}
|
|
|
|
e := New("default")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// CheckConnection will succeed if kubectl is configured, fail otherwise
|
|
// We just verify it doesn't panic
|
|
_ = e.CheckConnection(ctx)
|
|
}
|
|
|
|
func TestExecutor_PodExists(t *testing.T) {
|
|
// Skip if kubectl is not available
|
|
if _, err := exec.LookPath("kubectl"); err != nil {
|
|
t.Skip("kubectl not available")
|
|
}
|
|
|
|
e := New("default")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Check for a pod that definitely doesn't exist
|
|
exists, err := e.PodExists(ctx, "definitely-nonexistent-pod-12345")
|
|
|
|
// Should return false without error (or skip if cluster not available)
|
|
if err != nil {
|
|
t.Skipf("cluster not available: %v", err)
|
|
}
|
|
|
|
if exists {
|
|
t.Error("Expected pod to not exist")
|
|
}
|
|
}
|
|
|
|
// TestStreamOutput tests the streamOutput function behavior
|
|
func TestStreamOutput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "single line",
|
|
input: "hello world",
|
|
want: []string{"hello world"},
|
|
},
|
|
{
|
|
name: "multiple lines",
|
|
input: "line1\nline2\nline3",
|
|
want: []string{"line1", "line2", "line3"},
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: "",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "trailing newline",
|
|
input: "hello\n",
|
|
want: []string{"hello"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got []string
|
|
handler := func(stream, line string) {
|
|
if stream != "stdout" {
|
|
t.Errorf("stream = %q, want %q", stream, "stdout")
|
|
}
|
|
got = append(got, line)
|
|
}
|
|
|
|
r := strings.NewReader(tt.input)
|
|
streamOutput(r, "stdout", handler)
|
|
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("got %d lines, want %d", len(got), len(tt.want))
|
|
return
|
|
}
|
|
|
|
for i, line := range got {
|
|
if line != tt.want[i] {
|
|
t.Errorf("line[%d] = %q, want %q", i, line, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResult verifies Result struct behavior
|
|
func TestResult(t *testing.T) {
|
|
t.Run("successful result", func(t *testing.T) {
|
|
r := Result{
|
|
ExitCode: 0,
|
|
DurationMs: 1500,
|
|
Error: nil,
|
|
}
|
|
|
|
if r.ExitCode != 0 {
|
|
t.Errorf("ExitCode = %d, want 0", r.ExitCode)
|
|
}
|
|
|
|
if r.DurationMs != 1500 {
|
|
t.Errorf("DurationMs = %d, want 1500", r.DurationMs)
|
|
}
|
|
|
|
if r.Error != nil {
|
|
t.Errorf("Error = %v, want nil", r.Error)
|
|
}
|
|
})
|
|
|
|
t.Run("failed result", func(t *testing.T) {
|
|
r := Result{
|
|
ExitCode: 1,
|
|
DurationMs: 500,
|
|
Error: nil,
|
|
}
|
|
|
|
if r.ExitCode != 1 {
|
|
t.Errorf("ExitCode = %d, want 1", r.ExitCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCommand verifies Command struct
|
|
func TestCommand(t *testing.T) {
|
|
now := time.Now()
|
|
cmd := Command{
|
|
ID: "cmd-123",
|
|
PodName: "test-pod",
|
|
Type: CommandTypeClaude,
|
|
Args: []string{"prompt here"},
|
|
StartedAt: now,
|
|
}
|
|
|
|
if cmd.ID != "cmd-123" {
|
|
t.Errorf("ID = %q, want %q", cmd.ID, "cmd-123")
|
|
}
|
|
|
|
if cmd.PodName != "test-pod" {
|
|
t.Errorf("PodName = %q, want %q", cmd.PodName, "test-pod")
|
|
}
|
|
|
|
if cmd.Type != CommandTypeClaude {
|
|
t.Errorf("Type = %q, want %q", cmd.Type, CommandTypeClaude)
|
|
}
|
|
|
|
if len(cmd.Args) != 1 || cmd.Args[0] != "prompt here" {
|
|
t.Errorf("Args = %v, want [\"prompt here\"]", cmd.Args)
|
|
}
|
|
|
|
if !cmd.StartedAt.Equal(now) {
|
|
t.Errorf("StartedAt = %v, want %v", cmd.StartedAt, now)
|
|
}
|
|
}
|