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>
415 lines
9.9 KiB
Go
415 lines
9.9 KiB
Go
package cmdlimit
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
t.Run("default config", func(t *testing.T) {
|
|
l := New(Config{})
|
|
|
|
if l.cfg.MaxConcurrentPerProject != 5 {
|
|
t.Errorf("MaxConcurrentPerProject = %d, want 5", l.cfg.MaxConcurrentPerProject)
|
|
}
|
|
if l.cfg.MaxConcurrentTotal != 20 {
|
|
t.Errorf("MaxConcurrentTotal = %d, want 20", l.cfg.MaxConcurrentTotal)
|
|
}
|
|
if l.cfg.CommandTimeout != 30*time.Minute {
|
|
t.Errorf("CommandTimeout = %v, want 30m", l.cfg.CommandTimeout)
|
|
}
|
|
})
|
|
|
|
t.Run("custom config", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 10,
|
|
MaxConcurrentTotal: 50,
|
|
CommandTimeout: time.Hour,
|
|
})
|
|
|
|
if l.cfg.MaxConcurrentPerProject != 10 {
|
|
t.Errorf("MaxConcurrentPerProject = %d, want 10", l.cfg.MaxConcurrentPerProject)
|
|
}
|
|
if l.cfg.MaxConcurrentTotal != 50 {
|
|
t.Errorf("MaxConcurrentTotal = %d, want 50", l.cfg.MaxConcurrentTotal)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDefaultConfig(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
|
|
if cfg.MaxConcurrentPerProject != 5 {
|
|
t.Errorf("MaxConcurrentPerProject = %d, want 5", cfg.MaxConcurrentPerProject)
|
|
}
|
|
if cfg.MaxConcurrentTotal != 20 {
|
|
t.Errorf("MaxConcurrentTotal = %d, want 20", cfg.MaxConcurrentTotal)
|
|
}
|
|
if cfg.CommandTimeout != 30*time.Minute {
|
|
t.Errorf("CommandTimeout = %v, want 30m", cfg.CommandTimeout)
|
|
}
|
|
}
|
|
|
|
func TestAcquire(t *testing.T) {
|
|
t.Run("allows commands within limit", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 3,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Should allow 3 commands for project-a
|
|
releases := make([]func(), 0)
|
|
for i := 0; i < 3; i++ {
|
|
release, err := l.Acquire(ctx, "project-a", "cmd-"+string(rune('a'+i)))
|
|
if err != nil {
|
|
t.Fatalf("Acquire %d failed: %v", i, err)
|
|
}
|
|
releases = append(releases, release)
|
|
}
|
|
|
|
// Clean up
|
|
for _, r := range releases {
|
|
r()
|
|
}
|
|
})
|
|
|
|
t.Run("rejects when per-project limit reached", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 2,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Acquire 2 slots
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
defer release1()
|
|
release2, _ := l.Acquire(ctx, "project-a", "cmd-2")
|
|
defer release2()
|
|
|
|
// Third should fail
|
|
_, err := l.Acquire(ctx, "project-a", "cmd-3")
|
|
if err != ErrLimitExceeded {
|
|
t.Errorf("Acquire() error = %v, want ErrLimitExceeded", err)
|
|
}
|
|
})
|
|
|
|
t.Run("allows other projects when one is at limit", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 1,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Fill project-a
|
|
releaseA, _ := l.Acquire(ctx, "project-a", "cmd-a")
|
|
defer releaseA()
|
|
|
|
// project-b should still work
|
|
releaseB, err := l.Acquire(ctx, "project-b", "cmd-b")
|
|
if err != nil {
|
|
t.Errorf("Acquire(project-b) error = %v, want nil", err)
|
|
}
|
|
defer releaseB()
|
|
})
|
|
|
|
t.Run("rejects when total limit reached", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 3,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Fill total limit across different projects
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
defer release1()
|
|
release2, _ := l.Acquire(ctx, "project-b", "cmd-2")
|
|
defer release2()
|
|
release3, _ := l.Acquire(ctx, "project-c", "cmd-3")
|
|
defer release3()
|
|
|
|
// Fourth should fail even for new project
|
|
_, err := l.Acquire(ctx, "project-d", "cmd-4")
|
|
if err != ErrLimitExceeded {
|
|
t.Errorf("Acquire() error = %v, want ErrLimitExceeded", err)
|
|
}
|
|
})
|
|
|
|
t.Run("release allows new commands", func(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 1,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Acquire and release
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
release1()
|
|
|
|
// Should be able to acquire again
|
|
release2, err := l.Acquire(ctx, "project-a", "cmd-2")
|
|
if err != nil {
|
|
t.Errorf("Acquire() after release error = %v, want nil", err)
|
|
}
|
|
release2()
|
|
})
|
|
}
|
|
|
|
func TestStats(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 20,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
release2, _ := l.Acquire(ctx, "project-a", "cmd-2")
|
|
release3, _ := l.Acquire(ctx, "project-b", "cmd-3")
|
|
|
|
stats := l.Stats()
|
|
|
|
if stats.TotalActive != 3 {
|
|
t.Errorf("TotalActive = %d, want 3", stats.TotalActive)
|
|
}
|
|
if stats.MaxTotal != 20 {
|
|
t.Errorf("MaxTotal = %d, want 20", stats.MaxTotal)
|
|
}
|
|
if stats.ProjectCounts["project-a"] != 2 {
|
|
t.Errorf("ProjectCounts[project-a] = %d, want 2", stats.ProjectCounts["project-a"])
|
|
}
|
|
if stats.ProjectCounts["project-b"] != 1 {
|
|
t.Errorf("ProjectCounts[project-b] = %d, want 1", stats.ProjectCounts["project-b"])
|
|
}
|
|
if len(stats.ActiveCommandIDs) != 3 {
|
|
t.Errorf("ActiveCommandIDs length = %d, want 3", len(stats.ActiveCommandIDs))
|
|
}
|
|
|
|
release1()
|
|
release2()
|
|
release3()
|
|
}
|
|
|
|
func TestIsProjectAtLimit(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 2,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
if l.IsProjectAtLimit("project-a") {
|
|
t.Error("IsProjectAtLimit should be false initially")
|
|
}
|
|
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
release2, _ := l.Acquire(ctx, "project-a", "cmd-2")
|
|
|
|
if !l.IsProjectAtLimit("project-a") {
|
|
t.Error("IsProjectAtLimit should be true after reaching limit")
|
|
}
|
|
|
|
release1()
|
|
release2()
|
|
}
|
|
|
|
func TestIsTotalAtLimit(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 2,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
if l.IsTotalAtLimit() {
|
|
t.Error("IsTotalAtLimit should be false initially")
|
|
}
|
|
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
release2, _ := l.Acquire(ctx, "project-b", "cmd-2")
|
|
|
|
if !l.IsTotalAtLimit() {
|
|
t.Error("IsTotalAtLimit should be true after reaching limit")
|
|
}
|
|
|
|
release1()
|
|
release2()
|
|
}
|
|
|
|
func TestActiveCount(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
if l.ActiveCount("project-a") != 0 {
|
|
t.Error("ActiveCount should be 0 initially")
|
|
}
|
|
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
|
|
if l.ActiveCount("project-a") != 1 {
|
|
t.Errorf("ActiveCount = %d, want 1", l.ActiveCount("project-a"))
|
|
}
|
|
|
|
release1()
|
|
|
|
if l.ActiveCount("project-a") != 0 {
|
|
t.Errorf("ActiveCount after release = %d, want 0", l.ActiveCount("project-a"))
|
|
}
|
|
}
|
|
|
|
func TestTotalActiveCount(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
if l.TotalActiveCount() != 0 {
|
|
t.Error("TotalActiveCount should be 0 initially")
|
|
}
|
|
|
|
release1, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
release2, _ := l.Acquire(ctx, "project-b", "cmd-2")
|
|
|
|
if l.TotalActiveCount() != 2 {
|
|
t.Errorf("TotalActiveCount = %d, want 2", l.TotalActiveCount())
|
|
}
|
|
|
|
release1()
|
|
release2()
|
|
|
|
if l.TotalActiveCount() != 0 {
|
|
t.Errorf("TotalActiveCount after release = %d, want 0", l.TotalActiveCount())
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 10,
|
|
MaxConcurrentTotal: 100,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
var wg sync.WaitGroup
|
|
var successCount, failCount int64
|
|
var mu sync.Mutex
|
|
|
|
// Spawn many goroutines trying to acquire
|
|
for i := 0; i < 150; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
|
|
release, err := l.Acquire(ctx, "project-a", "cmd-"+string(rune(idx)))
|
|
mu.Lock()
|
|
if err == nil {
|
|
successCount++
|
|
mu.Unlock()
|
|
time.Sleep(10 * time.Millisecond)
|
|
release()
|
|
} else {
|
|
failCount++
|
|
mu.Unlock()
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Should have had some successes and some failures
|
|
if successCount == 0 {
|
|
t.Error("Expected some successful acquires")
|
|
}
|
|
if failCount == 0 {
|
|
t.Error("Expected some failed acquires (limit exceeded)")
|
|
}
|
|
}
|
|
|
|
func TestAutoReleaseOnTimeout(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 1,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: 100 * time.Millisecond,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Acquire a slot
|
|
_, err := l.Acquire(ctx, "project-a", "cmd-1")
|
|
if err != nil {
|
|
t.Fatalf("Acquire failed: %v", err)
|
|
}
|
|
|
|
// Should be at limit
|
|
if !l.IsProjectAtLimit("project-a") {
|
|
t.Error("Should be at limit after acquire")
|
|
}
|
|
|
|
// Wait for timeout
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
// Should be auto-released
|
|
if l.IsProjectAtLimit("project-a") {
|
|
t.Error("Should not be at limit after auto-release")
|
|
}
|
|
}
|
|
|
|
func TestDoubleRelease(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 5,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
release, _ := l.Acquire(ctx, "project-a", "cmd-1")
|
|
|
|
// Release twice - should not panic or cause issues
|
|
release()
|
|
release()
|
|
|
|
// Count should be 0, not negative
|
|
if l.ActiveCount("project-a") != 0 {
|
|
t.Errorf("ActiveCount after double release = %d, want 0", l.ActiveCount("project-a"))
|
|
}
|
|
}
|
|
|
|
func TestContextCancellation(t *testing.T) {
|
|
l := New(Config{
|
|
MaxConcurrentPerProject: 1,
|
|
MaxConcurrentTotal: 10,
|
|
CommandTimeout: time.Minute,
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Acquire a slot
|
|
_, err := l.Acquire(ctx, "project-a", "cmd-1")
|
|
if err != nil {
|
|
t.Fatalf("Acquire failed: %v", err)
|
|
}
|
|
|
|
// Cancel the context
|
|
cancel()
|
|
|
|
// Give goroutine time to process
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Should be auto-released due to context cancellation
|
|
if l.IsProjectAtLimit("project-a") {
|
|
t.Error("Should be released after context cancellation")
|
|
}
|
|
}
|