Harden Go CLI subprocess behavior
This commit is contained in:
230
cmd/audita/main_integration_test.go
Normal file
230
cmd/audita/main_integration_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/audita/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHelperProcess(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sep := -1
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "--" {
|
||||||
|
sep = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sep == -1 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := cli.Run(os.Args[sep+1:], os.Stdout, os.Stderr)
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHelpSubprocess(t *testing.T) {
|
||||||
|
result := runCLISubprocess(t, "process", "--help")
|
||||||
|
if result.exitCode != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.stdout, "Usage:") || !strings.Contains(result.stdout, "--glossary") {
|
||||||
|
t.Fatalf("unexpected help stdout: %q", result.stdout)
|
||||||
|
}
|
||||||
|
if result.stderr != "" {
|
||||||
|
t.Fatalf("expected empty stderr, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSuccessWithOutputSubprocess(t *testing.T) {
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "corrected.json")
|
||||||
|
result := runCLISubprocess(
|
||||||
|
t,
|
||||||
|
"process",
|
||||||
|
fixturePath("tiny_transcript.json"),
|
||||||
|
"--glossary",
|
||||||
|
fixturePath("tiny_glossary.yaml"),
|
||||||
|
"--output",
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.exitCode != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr)
|
||||||
|
}
|
||||||
|
if result.stdout != "" {
|
||||||
|
t.Fatalf("expected empty stdout when --output is set, got %q", result.stdout)
|
||||||
|
}
|
||||||
|
if result.stderr != "" {
|
||||||
|
t.Fatalf("expected empty stderr on success, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBytes := readFile(t, fixturePath("tiny_transcript.json"))
|
||||||
|
outputBytes := readFile(t, outputPath)
|
||||||
|
assertJSONSemanticallyEqual(t, inputBytes, outputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSuccessWithoutOutputSubprocess(t *testing.T) {
|
||||||
|
result := runCLISubprocess(
|
||||||
|
t,
|
||||||
|
"process",
|
||||||
|
fixturePath("tiny_transcript.json"),
|
||||||
|
"--glossary",
|
||||||
|
fixturePath("tiny_glossary.yaml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.exitCode != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr)
|
||||||
|
}
|
||||||
|
if result.stderr != "" {
|
||||||
|
t.Fatalf("expected empty stderr on success, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBytes := readFile(t, fixturePath("tiny_transcript.json"))
|
||||||
|
assertJSONSemanticallyEqual(t, inputBytes, []byte(result.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessFailureMissingTranscriptSubprocess(t *testing.T) {
|
||||||
|
result := runCLISubprocess(t, "process", "--glossary", fixturePath("tiny_glossary.yaml"))
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
t.Fatalf("expected nonzero exit code")
|
||||||
|
}
|
||||||
|
if result.stdout != "" {
|
||||||
|
t.Fatalf("expected empty stdout on failure, got %q", result.stdout)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.stderr, "expected exactly 1 transcript JSON path argument") {
|
||||||
|
t.Fatalf("expected actionable missing transcript error, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessFailureMalformedJSONSubprocess(t *testing.T) {
|
||||||
|
result := runCLISubprocess(
|
||||||
|
t,
|
||||||
|
"process",
|
||||||
|
fixturePath("malformed_transcript.json"),
|
||||||
|
"--glossary",
|
||||||
|
fixturePath("tiny_glossary.yaml"),
|
||||||
|
)
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
t.Fatalf("expected nonzero exit code")
|
||||||
|
}
|
||||||
|
if result.stdout != "" {
|
||||||
|
t.Fatalf("expected empty stdout on failure, got %q", result.stdout)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.stderr, "is not valid JSON") {
|
||||||
|
t.Fatalf("expected malformed JSON error, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessFailureUnwritableOutputSubprocess(t *testing.T) {
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
result := runCLISubprocess(
|
||||||
|
t,
|
||||||
|
"process",
|
||||||
|
fixturePath("tiny_transcript.json"),
|
||||||
|
"--glossary",
|
||||||
|
fixturePath("tiny_glossary.yaml"),
|
||||||
|
"--output",
|
||||||
|
outputDir,
|
||||||
|
)
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
t.Fatalf("expected nonzero exit code")
|
||||||
|
}
|
||||||
|
if result.stdout != "" {
|
||||||
|
t.Fatalf("expected empty stdout on failure, got %q", result.stdout)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.stderr, "failed to write output file") {
|
||||||
|
t.Fatalf("expected write failure message, got %q", result.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type subprocessResult struct {
|
||||||
|
stdout string
|
||||||
|
stderr string
|
||||||
|
exitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLISubprocess(t *testing.T, args ...string) subprocessResult {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmdArgs := append([]string{"-test.run=TestHelperProcess", "--"}, args...)
|
||||||
|
cmd := exec.Command(os.Args[0], cmdArgs...)
|
||||||
|
cmd.Env = append(filterAuditaEnv(os.Environ()), "GO_WANT_HELPER_PROCESS=1")
|
||||||
|
var stdoutBuf bytes.Buffer
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &stdoutBuf
|
||||||
|
cmd.Stderr = &stderrBuf
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
result := subprocessResult{
|
||||||
|
stdout: stdoutBuf.String(),
|
||||||
|
stderr: stderrBuf.String(),
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
result.exitCode = exitErr.ExitCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("subprocess execution failed: %v", err)
|
||||||
|
return subprocessResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterAuditaEnv(env []string) []string {
|
||||||
|
filtered := make([]string, 0, len(env))
|
||||||
|
for _, entry := range env {
|
||||||
|
key := entry
|
||||||
|
if idx := strings.IndexByte(entry, '='); idx >= 0 {
|
||||||
|
key = entry[:idx]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "AUDITA_") || key == "OPENROUTER_API_KEY" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixturePath(name string) string {
|
||||||
|
return filepath.Join("..", "..", "internal", "cli", "testdata", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if !json.Valid(actual) {
|
||||||
|
t.Fatalf("actual output is not valid JSON: %q", string(actual))
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedValue any
|
||||||
|
var actualValue any
|
||||||
|
if err := json.Unmarshal(expected, &expectedValue); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal expected JSON: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(actual, &actualValue); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal actual JSON: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(expectedValue, actualValue) {
|
||||||
|
t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := coreio.ValidateWellFormedJSON(transcriptBytes); err != nil {
|
if err := coreio.ValidateWellFormedJSON(inv.TranscriptPath, transcriptBytes); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary"); err != nil {
|
if _, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary"); err != nil {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func TestRunProcessMalformedTranscriptJSON(t *testing.T) {
|
|||||||
if stdout.Len() != 0 {
|
if stdout.Len() != 0 {
|
||||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "transcript file is not valid JSON") {
|
if !strings.Contains(stderr.String(), "is not valid JSON") {
|
||||||
t.Fatalf("expected malformed transcript error, got %q", stderr.String())
|
t.Fatalf("expected malformed transcript error, got %q", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ func ReadRequiredFile(path string, label string) ([]byte, error) {
|
|||||||
return contents, nil
|
return contents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateWellFormedJSON(raw []byte) error {
|
func ValidateWellFormedJSON(path string, raw []byte) error {
|
||||||
if !json.Valid(raw) {
|
if !json.Valid(raw) {
|
||||||
return fmt.Errorf("transcript file is not valid JSON")
|
return fmt.Errorf("transcript file %q is not valid JSON", path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user