Add versioned Audita config support

This commit is contained in:
2026-05-13 12:36:20 +00:00
parent ebbd2c8a63
commit 9a77a0cd0b
11 changed files with 1539 additions and 32 deletions

View File

@@ -54,6 +54,7 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) {
}
for _, expectedFlag := range []string{
"--config",
"--glossary",
"--output",
"--report-json",
@@ -97,6 +98,394 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) {
}
}
func TestResolveConfigPathDefaultIgnoredWhenMissing(t *testing.T) {
lookup := func(string) (string, bool) { return "", false }
path, source, err := resolveConfigPath("", false, lookup)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path != "" || source != "" {
t.Fatalf("expected no config path/source, got path=%q source=%q", path, source)
}
}
func TestRunConfigValidateSuccess(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfgPath := writeFile(t, "config.yml", "version: 1\n")
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "config is valid") {
t.Fatalf("expected success message, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
}
func TestRunConfigValidateInvalidVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfgPath := writeFile(t, "config.yml", "version: 999\n")
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected failure for unsupported config version")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "unsupported config version") {
t.Fatalf("expected version error, got %q", stderr.String())
}
}
func TestRunConfigValidateUnknownField(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfgPath := writeFile(t, "config.yml", "version: 1\nunknown_field: true\n")
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected failure for unknown field")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "field unknown_field not found") {
t.Fatalf("expected unknown-field error, got %q", stderr.String())
}
}
func TestRunConfigPrintEffectiveOutputsRedactedJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
secret := "super-secret-api-key"
t.Setenv("AUDITA_LLM_API_KEY", secret)
cfgPath := writeFile(t, "config.yml", "version: 1\n")
exitCode := Run([]string{"config", "print-effective", "--config", cfgPath}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
if strings.Contains(stdout.String(), secret) {
t.Fatalf("print-effective leaked secret")
}
var out map[string]any
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("expected valid JSON output, got error: %v output=%q", err, stdout.String())
}
primary, ok := out["PrimaryLLM"].(map[string]any)
if !ok {
t.Fatalf("expected PrimaryLLM object, got %#v", out["PrimaryLLM"])
}
if got := primary["APIKey"]; got != "[REDACTED]" {
t.Fatalf("expected redacted API key, got %#v", got)
}
}
func TestRunConfigCommandDoesNotRequireTranscriptOrGlossary(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfgPath := writeFile(t, "config.yml", "version: 1\n")
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success without transcript/glossary args, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessConfigFlagMissingFileFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--config",
filepath.Join(t.TempDir(), "missing.yaml"),
}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected failure for missing --config file")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "config file not found") {
t.Fatalf("expected missing config file error, got %q", stderr.String())
}
}
func TestRunProcessAUDITAConfigMissingFileFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
t.Setenv("AUDITA_CONFIG", filepath.Join(t.TempDir(), "missing-env.yaml"))
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary",
fixturePath("tiny_glossary.yaml"),
}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected failure for missing AUDITA_CONFIG file")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "config file not found") {
t.Fatalf("expected missing config file error, got %q", stderr.String())
}
}
func TestRunProcessConfigFileAffectsRuntimeAndInvocationMetadata(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
workDir := t.TempDir()
reportPath := filepath.Join(t.TempDir(), "report.json")
outputPath := filepath.Join(t.TempDir(), "out.json")
cfgPath := writeFile(t, "config.yml", `
version: 1
pipeline:
modules: [grammar]
context:
description: "config file transcript context"
diagnostics:
work_dir: `+workDir+`
retention: always
`)
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--config",
cfgPath,
"--output",
outputPath,
"--report-json",
reportPath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
}
runPath := onlyRunDir(t, workDir)
configBytes := readFile(t, filepath.Join(runPath, "effective-config.json"))
var effectiveConfig struct {
Modules []string `json:"Modules"`
TranscriptDescription string `json:"TranscriptDescription"`
}
if err := json.Unmarshal(configBytes, &effectiveConfig); err != nil {
t.Fatalf("failed to parse effective config metadata: %v", err)
}
if strings.Join(effectiveConfig.Modules, ",") != "grammar" {
t.Fatalf("expected grammar module from config file, got %#v", effectiveConfig.Modules)
}
if effectiveConfig.TranscriptDescription != "config file transcript context" {
t.Fatalf("unexpected transcript description from config file: %q", effectiveConfig.TranscriptDescription)
}
invocationBytes := readFile(t, filepath.Join(runPath, "invocation.json"))
var invocation struct {
ConfigPath string `json:"config_path"`
ConfigSource string `json:"config_source"`
}
if err := json.Unmarshal(invocationBytes, &invocation); err != nil {
t.Fatalf("failed to parse invocation metadata: %v", err)
}
if invocation.ConfigPath != cfgPath {
t.Fatalf("unexpected invocation config_path: %q", invocation.ConfigPath)
}
if invocation.ConfigSource != "flag" {
t.Fatalf("unexpected invocation config_source: %q", invocation.ConfigSource)
}
}
func TestRunProcessEnvOverridesConfigFile(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{
key: "m",
policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
if req.Config == nil {
t.Fatal("expected config in validation request")
}
if req.Config.PrimaryLLM.Model != "env-model" {
t.Fatalf("expected env model override, got %q", req.Config.PrimaryLLM.Model)
}
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}},
},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
},
}}
t.Cleanup(func() { processModuleFactory = nil })
t.Setenv("AUDITA_MODEL", "env-model")
cfgPath := writeFile(t, "config.yml", `
version: 1
pipeline:
modules: [m]
llm:
proposal:
model: file-model
`)
var stdout, stderr bytes.Buffer
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary", fixturePath("tiny_glossary.yaml"),
"--config", cfgPath,
"--modules", "m",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessCLIOverridesEnvAndConfigFile(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{
key: "m",
policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
if req.Config == nil {
t.Fatal("expected config in validation request")
}
if req.Config.PrimaryLLM.Model != "cli-model" {
t.Fatalf("expected CLI model override, got %q", req.Config.PrimaryLLM.Model)
}
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}},
},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
},
}}
t.Cleanup(func() { processModuleFactory = nil })
t.Setenv("AUDITA_MODEL", "env-model")
cfgPath := writeFile(t, "config.yml", `
version: 1
pipeline:
modules: [m]
llm:
proposal:
model: file-model
`)
var stdout, stderr bytes.Buffer
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary", fixturePath("tiny_glossary.yaml"),
"--config", cfgPath,
"--modules", "m",
"--model", "cli-model",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessConfigAPIKeyEnvResolvesAndRedactsEffectiveConfig(t *testing.T) {
secret := "secret-from-config-env"
t.Setenv("PROPOSAL_KEY_FROM_CONFIG", secret)
workDir := t.TempDir()
outputPath := filepath.Join(t.TempDir(), "out.json")
cfgPath := writeFile(t, "config.yml", `
version: 1
pipeline:
modules: [grammar]
llm:
proposal:
api_key_env: PROPOSAL_KEY_FROM_CONFIG
diagnostics:
work_dir: `+workDir+`
retention: always
`)
var stdout, stderr bytes.Buffer
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary", fixturePath("tiny_glossary.yaml"),
"--config", cfgPath,
"--output", outputPath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
runPath := onlyRunDir(t, workDir)
configBytes := readFile(t, filepath.Join(runPath, "effective-config.json"))
if strings.Contains(string(configBytes), secret) {
t.Fatalf("effective config artifact leaked API key from api_key_env")
}
}
func TestRunProcessTranscriptDescriptionCLIOverridesConfigFileContextDescription(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{
key: "m",
policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
if req.Config == nil {
t.Fatal("expected config in validation request")
}
if req.Config.TranscriptDescription != "cli transcript description" {
t.Fatalf("expected CLI transcript description to override config file, got %q", req.Config.TranscriptDescription)
}
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}},
},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
},
}}
t.Cleanup(func() { processModuleFactory = nil })
cfgPath := writeFile(t, "config.yml", `
version: 1
pipeline:
modules: [m]
context:
description: "file transcript description"
`)
var stdout, stderr bytes.Buffer
exitCode := Run([]string{
"process",
fixturePath("tiny_transcript.json"),
"--glossary", fixturePath("tiny_glossary.yaml"),
"--config", cfgPath,
"--modules", "m",
"--transcript-description", "cli transcript description",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessMissingTranscriptPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer