package main import ( "encoding/base64" "encoding/json" "os" "path/filepath" "runtime" "strings" "testing" ) // setupTestEnv crea un dir temporal, redirige approvalQueuePath alli, y devuelve cleanup. func setupTestEnv(t *testing.T) (string, func()) { t.Helper() tmp, err := os.MkdirTemp("", "device_agent_test_*") if err != nil { t.Fatalf("mkdtemp: %v", err) } prevWD, _ := os.Getwd() if err := os.Chdir(tmp); err != nil { _ = os.RemoveAll(tmp) t.Fatalf("chdir: %v", err) } prevApprovalPath := approvalQueuePath approvalQueuePath = filepath.Join(tmp, "local_files", "approval_queue.jsonl") cleanup := func() { approvalQueuePath = prevApprovalPath _ = os.Chdir(prevWD) _ = os.RemoveAll(tmp) } return tmp, cleanup } // TestShellEval_BlocklistRmRf comprueba que rm -rf / es rechazado sin ejecucion. func TestShellEval_BlocklistRmRf(t *testing.T) { _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{Name: "shell.eval"} _, _, _, err := runShellEval(cap, map[string]any{"cmd": "rm -rf /"}) if err == nil { t.Fatal("expected error from blocklist, got nil") } if !strings.Contains(err.Error(), "blocked") { t.Fatalf("expected 'blocked' in err, got: %v", err) } } // TestShellEval_BlocklistCaseInsensitive comprueba que case-insensitive funciona. func TestShellEval_BlocklistCaseInsensitive(t *testing.T) { _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{Name: "shell.eval"} _, _, _, err := runShellEval(cap, map[string]any{"cmd": "RM -RF /"}) if err == nil { t.Fatal("expected error from blocklist (case insensitive), got nil") } if !strings.Contains(err.Error(), "blocked") { t.Fatalf("expected 'blocked' in err, got: %v", err) } } // TestShellEval_AutoApproveGitStatus comprueba que git status entra en defaultAutoApprove. func TestShellEval_AutoApproveGitStatus(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bash test on windows skipped") } _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{Name: "shell.eval"} res, exit, extra, err := runShellEval(cap, map[string]any{"cmd": "git status --porcelain"}) if err != nil { t.Fatalf("expected no error, got: %v", err) } if res["approval_status"] != "auto-approved" { t.Fatalf("expected approval_status=auto-approved, got: %v", res["approval_status"]) } // git might exit non-zero if not in a repo, but it should have *executed* if extra == nil { t.Fatal("expected extra audit record, got nil") } if extra.Cmd != "git status --porcelain" { t.Fatalf("extra.Cmd mismatch: %q", extra.Cmd) } _ = exit } // TestShellEval_QueuedWhenRequiresApproval comprueba que un cmd no auto + requires_approval = queued + error. func TestShellEval_QueuedWhenRequiresApproval(t *testing.T) { tmp, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{ Name: "shell.eval", RequiresApproval: true, AutoApprove: []string{`^echo\s`}, // override defaults — solo echo entra } _, _, _, err := runShellEval(cap, map[string]any{"cmd": "ls -la"}) if err == nil { t.Fatal("expected approval_required error, got nil") } if !strings.Contains(err.Error(), "approval_required") { t.Fatalf("expected 'approval_required' in err, got: %v", err) } // queue file existe + contiene la entry qPath := filepath.Join(tmp, "local_files", "approval_queue.jsonl") data, ferr := os.ReadFile(qPath) if ferr != nil { t.Fatalf("expected approval queue file, got: %v", ferr) } if !strings.Contains(string(data), `"cmd":"ls -la"`) { t.Fatalf("approval queue missing cmd, got: %s", data) } if !strings.Contains(string(data), `"status":"pending"`) { t.Fatalf("approval queue missing status=pending, got: %s", data) } } // TestShellEval_NoApprovalNeededExecutes: cmd no auto + no requires_approval -> ejecuta directo. func TestShellEval_NoApprovalNeededExecutes(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bash test on windows skipped") } _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{ Name: "shell.eval", RequiresApproval: false, AutoApprove: []string{`^echo\s`}, // strict autoapprove (irrelevant since no approval needed) } res, _, extra, err := runShellEval(cap, map[string]any{"cmd": "printf hello"}) if err != nil { t.Fatalf("expected no error, got: %v", err) } if res["approval_status"] != "none-required" { t.Fatalf("expected approval_status=none-required, got: %v", res["approval_status"]) } if extra.Stdout != "hello" { t.Fatalf("expected stdout 'hello', got: %q", extra.Stdout) } } // TestShellEval_Timeout: sleep 5 con TimeoutSeconds=1 -> exit_code != 0. func TestShellEval_Timeout(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bash test on windows skipped") } _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{ Name: "shell.eval", TimeoutSeconds: 1, AutoApprove: []string{`^sleep\s`}, // autoapprove sleep for the test } res, _, _, err := runShellEval(cap, map[string]any{"cmd": "sleep 5"}) if err != nil { t.Fatalf("unexpected err: %v", err) } exit, _ := res["exit_code"].(int) if exit == 0 { t.Fatalf("expected non-zero exit on timeout, got 0; result: %+v", res) } } // TestShellEval_OutputTruncation: output > MaxOutputBytes -> truncated=true. func TestShellEval_OutputTruncation(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bash test on windows skipped") } _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{ Name: "shell.eval", MaxOutputBytes: 50, AutoApprove: []string{`^printf`}, } // produce > 50 bytes res, _, _, err := runShellEval(cap, map[string]any{ "cmd": `printf '%0.s-' {1..500}`, }) if err != nil { t.Fatalf("unexpected err: %v", err) } if res["truncated"] != true { t.Fatalf("expected truncated=true, got: %v (full result %+v)", res["truncated"], res) } } // TestShellEval_PowershellOnLinuxWithoutPwsh: si pwsh no esta en PATH y se pide powershell, error. func TestShellEval_PowershellOnLinuxWithoutPwsh(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test only meaningful on non-windows") } // Save PATH and clear it to ensure pwsh not found origPATH := os.Getenv("PATH") defer os.Setenv("PATH", origPATH) os.Setenv("PATH", "/nonexistent") _, cleanup := setupTestEnv(t) defer cleanup() cap := &Capability{Name: "shell.eval"} _, _, _, err := runShellEval(cap, map[string]any{ "cmd": "echo hi", "shell": "powershell", }) if err == nil { t.Fatal("expected error when powershell requested on linux without pwsh") } if !strings.Contains(err.Error(), "powershell") && !strings.Contains(err.Error(), "pwsh") { t.Fatalf("err msg doesn't mention powershell/pwsh: %v", err) } } // TestShellEval_AuditVerboseWritesCleartext: tras una exec, audit_shell_eval tiene row con cmd en claro. func TestShellEval_AuditVerboseWritesCleartext(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bash test on windows skipped") } tmp, cleanup := setupTestEnv(t) defer cleanup() auditPath := filepath.Join(tmp, "audit.db") audit, err := OpenAudit(auditPath) if err != nil { t.Fatalf("open audit: %v", err) } defer audit.Close() cap := &Capability{ Name: "shell.eval", AutoApprove: []string{`^printf`}, } _, exitCode, extra, err := runShellEval(cap, map[string]any{"cmd": "printf forensic-cleartext"}) if err != nil { t.Fatalf("runShellEval: %v", err) } if extra == nil { t.Fatal("expected extra record") } _, err = audit.AppendVerbose("req-1", "shell.eval", []string{"printf forensic-cleartext"}, exitCode, *extra) if err != nil { t.Fatalf("AppendVerbose: %v", err) } // Read back rows, err := audit.db.Query(`SELECT cmd, shell, stdout_b64 FROM audit_shell_eval`) if err != nil { t.Fatalf("query: %v", err) } defer rows.Close() found := false for rows.Next() { var cmd, shell, stdoutB64 string if err := rows.Scan(&cmd, &shell, &stdoutB64); err != nil { t.Fatalf("scan: %v", err) } if cmd != "printf forensic-cleartext" { t.Fatalf("expected cleartext cmd, got: %q", cmd) } if !strings.HasPrefix(stdoutB64, "plain:") { t.Fatalf("expected plain-base64 stdout (short), got: %q", stdoutB64) } // Decode + verify raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(stdoutB64, "plain:")) if string(raw) != "forensic-cleartext" { t.Fatalf("decoded stdout mismatch: %q", raw) } _ = shell found = true } if !found { t.Fatal("expected at least one row in audit_shell_eval") } } // TestShellEval_ParseArgsJSONObject roundtrip. func TestShellEval_ParseArgsJSONObject(t *testing.T) { jsonStr, _ := json.Marshal(map[string]any{"cmd": "ls", "cwd": "/tmp"}) m, err := parseShellEvalArgs([]string{string(jsonStr)}) if err != nil { t.Fatalf("parse: %v", err) } if m["cmd"] != "ls" || m["cwd"] != "/tmp" { t.Fatalf("unexpected parsed: %+v", m) } } // TestShellEval_ParseArgsPositional fallback. func TestShellEval_ParseArgsPositional(t *testing.T) { m, err := parseShellEvalArgs([]string{"ls -la", "/tmp", "bash"}) if err != nil { t.Fatalf("parse: %v", err) } if m["cmd"] != "ls -la" || m["cwd"] != "/tmp" || m["shell"] != "bash" { t.Fatalf("unexpected parsed: %+v", m) } }