Files
narratio/internal/adapters/audita/subprocess_test.go

264 lines
10 KiB
Go

package audita
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
)
func auditaTestRequest(t *testing.T) PolishRequest {
t.Helper()
dir := t.TempDir()
return PolishRequest{
MergedTranscriptPath: filepath.Join(dir, "merged.json"),
GlossaryPath: filepath.Join(dir, "glossary.yml"),
OutputProcessedPath: filepath.Join(dir, "polished.json"),
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
StdoutLogPath: filepath.Join(dir, "stdout.log"),
StderrLogPath: filepath.Join(dir, "stderr.log"),
}
}
func auditaTestRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner {
t.Helper()
if cfg.Binary == "" {
cfg.Binary = "audita"
}
if cfg.Timeout == 0 {
cfg.Timeout = time.Second
}
runner, err := NewSubprocessRunner(cfg)
if err != nil {
t.Fatal(err)
}
return runner
}
func auditaTestSecret(t *testing.T, name, value string) configkit.Secret {
t.Helper()
t.Setenv(name, value)
type loaded struct {
Credential configkit.Secret `yaml:"credential"`
}
result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+name+"\n")), configkit.Options[loaded]{})
if err != nil {
t.Fatal(err)
}
return result.Config.Credential
}
func writeAuditaTestFile(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value), 0o600); err != nil {
t.Fatal(err)
}
}
func TestSubprocessRunnerUsesV2InvocationAndCapturedCredentials(t *testing.T) {
const first = "NARRATIO_AUDITA_CUSTOM_ONE"
const second = "NARRATIO_AUDITA_CUSTOM_TWO"
firstSecret := auditaTestSecret(t, first, "loaded-one")
secondSecret := auditaTestSecret(t, second, "loaded-two")
t.Setenv(first, "parent-changed")
t.Setenv(second, "parent-changed-too")
t.Setenv("NARRATIO_AUDITA_UNSELECTED", "not-forwarded")
runner := auditaTestRunner(t, SubprocessRunnerConfig{
Credentials: []configkit.Secret{firstSecret, secondSecret},
Modules: []string{"grammar", "glossary", "grammar"},
Overlay: "testing", ProposalProfile: "proposal", ValidationProfile: "validation",
TranscriptDescription: "campaign", ConfigPath: "/etc/audita/config.yml", OutputSchema: "audita-v1",
})
req := auditaTestRequest(t)
var captured subprocess.RunRequest
runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) {
captured = processReq
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), name), "recoverable diagnostic\n")
}
return subprocess.RunResult{}, nil
}
result, err := runner.Run(context.Background(), req)
if err != nil {
t.Fatal(err)
}
want := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath,
"--modules", "grammar,glossary,grammar", "--config", "/etc/audita/config.yml", "--overlay", "testing",
"--proposal-profile", "proposal", "--validation-profile", "validation", "--transcript-description", "campaign", "--output-schema", "audita-v1"}
if !reflect.DeepEqual(captured.Args, want) {
t.Fatalf("args = %#v, want %#v", captured.Args, want)
}
if len(captured.EnvOverrides) != 2 || captured.EnvOverrides[first] != "loaded-one" || captured.EnvOverrides[second] != "loaded-two" || captured.EnvOverrides["NARRATIO_AUDITA_UNSELECTED"] != "" {
t.Fatalf("credential forwarding = %#v", captured.EnvOverrides)
}
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{first, second}) {
t.Fatalf("sensitive names = %#v", captured.SensitiveEnvNames)
}
if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath == "" || result.Reports.OverviewPath == "" {
t.Fatalf("reports = %#v", result.Reports)
}
generated, err := os.ReadFile(req.GeneratedConfigPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(generated), "audita.generated.v2") || !strings.Contains(string(generated), first) || strings.Contains(string(generated), "loaded-one") {
t.Fatalf("unsafe or incomplete generated invocation: %s", generated)
}
}
func TestSubprocessRunnerModulePresenceAndReportAbsence(t *testing.T) {
for _, test := range []struct {
name string
configured []string
requested []string
wantFlag bool
wantValue string
}{
{"omitted", nil, nil, false, ""},
{"configured", []string{"grammar", "grammar"}, nil, true, "grammar,grammar"},
{"empty override", []string{"grammar"}, []string{}, true, ""},
} {
t.Run(test.name, func(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{Modules: test.configured})
req := auditaTestRequest(t)
req.Modules = test.requested
runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) {
index := -1
for i, arg := range processReq.Args {
if arg == "--modules" {
index = i
}
if strings.HasPrefix(arg, "--report") || arg == "--debug" || arg == "--work-dir" {
t.Fatalf("retired or unwanted flag %q", arg)
}
}
if (index >= 0) != test.wantFlag || (index >= 0 && processReq.Args[index+1] != test.wantValue) {
t.Fatalf("args = %#v", processReq.Args)
}
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
return subprocess.RunResult{}, nil
}
result, err := runner.Run(context.Background(), req)
if err != nil || result.Reports != (ReportBundle{}) {
t.Fatalf("result = %#v, error = %v", result, err)
}
})
}
}
func TestSubprocessRunnerRejectsMissingOrMalformedTranscript(t *testing.T) {
for _, output := range []string{"", `{"segments":"wrong"}`} {
t.Run(output, func(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
if output != "" {
writeAuditaTestFile(t, req.OutputProcessedPath, output)
}
return subprocess.RunResult{}, nil
}
if _, err := runner.Run(context.Background(), req); err == nil {
t.Fatal("missing or malformed transcript was accepted")
}
})
}
}
func TestSubprocessRunnerPreservesFailureAndAvailableReports(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
processFailure := errors.New("audita exited after writing output")
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml"), "status: failed\n")
return subprocess.RunResult{ExitCode: 7}, processFailure
}
result, err := runner.Run(context.Background(), req)
if !errors.Is(err, processFailure) || !strings.Contains(err.Error(), filepath.Dir(req.OutputProcessedPath)) {
t.Fatalf("error = %v", err)
}
if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath != "" || result.Reports.OverviewPath != "" {
t.Fatalf("reports = %#v", result.Reports)
}
}
func TestSubprocessRunnerDoesNotMaskProcessFailureWithReportError(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
processFailure := errors.New("audita process failed")
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml")
if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil {
t.Fatal(err)
}
return subprocess.RunResult{ExitCode: 7}, processFailure
}
if _, err := runner.Run(context.Background(), req); !errors.Is(err, processFailure) {
t.Fatalf("error = %v, want original process error", err)
}
}
func TestSubprocessRunnerBoundsAdjacentReports(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml")
if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil {
t.Fatal(err)
}
return subprocess.RunResult{}, nil
}
if _, err := runner.Run(context.Background(), req); err == nil || !strings.Contains(err.Error(), "errors.yml") || !strings.Contains(err.Error(), "16777216") {
t.Fatalf("error = %v, want named report limit", err)
}
}
func TestSubprocessRunnerRedactsCustomCredentialStreams(t *testing.T) {
const first = "NARRATIO_AUDITA_STREAM_ONE"
const second = "NARRATIO_AUDITA_STREAM_TWO"
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) {
firstSecret := auditaTestSecret(t, first, "audita-first-secret")
secondSecret := auditaTestSecret(t, second, "audita-second-secret")
req := auditaTestRequest(t)
body := "#!/bin/sh\nprintf '%s\\n' \"$" + first + "\"\nprintf '%s\\n' \"$" + second + "\" >&2\n"
if fail {
body += "exit 7\n"
} else {
body += "printf '{\"segments\":[]}' > \"" + req.OutputProcessedPath + "\"\n"
}
binary := filepath.Join(t.TempDir(), "audita-helper")
writeAuditaTestFile(t, binary, body)
if err := os.Chmod(binary, 0o700); err != nil {
t.Fatal(err)
}
runner := auditaTestRunner(t, SubprocessRunnerConfig{Binary: binary, Credentials: []configkit.Secret{firstSecret, secondSecret}})
_, err := runner.Run(context.Background(), req)
if (err != nil) != fail {
t.Fatalf("error = %v, fail = %v", err, fail)
}
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if strings.Contains(string(data), "audita-first-secret") || strings.Contains(string(data), "audita-second-secret") || !strings.Contains(string(data), "<redacted>") {
t.Fatalf("unredacted stream %q: %s", path, data)
}
}
if fail && (strings.Contains(err.Error(), "audita-first-secret") || strings.Contains(err.Error(), "audita-second-secret")) {
t.Fatalf("unredacted failure: %v", err)
}
})
}
}