Add CLI config validation commands
This commit is contained in:
@@ -2,8 +2,13 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
|
||||
@@ -46,6 +51,9 @@ func TestRunHelpArgsWriteUsageToStdout(t *testing.T) {
|
||||
if stdout.String() != usage {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), usage)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "config validate") || !strings.Contains(stdout.String(), "pipelines list") {
|
||||
t.Fatalf("usage does not mention new commands: %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
@@ -73,3 +81,326 @@ func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want usage", gotStderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigValidateSuccessWithFakeCatalog(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes"))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "is valid for pipeline") {
|
||||
t.Fatalf("stdout = %q, want validation success", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigValidateReportsParseErrors(t *testing.T) {
|
||||
configPath := writeFile(t, "config.yml", "version: 2\n")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 1", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "unsupported config version") {
|
||||
t.Fatalf("stderr = %q, want parse error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigValidatePipelineOnlySuccessAndInvalidLane(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes"))
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "notes"}, &stdout, &stderr, Options{
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid lane", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "missing"}, &stdout, &stderr, Options{
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "selected artifact lane") {
|
||||
t.Fatalf("stderr = %q, want invalid lane error", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunConfigValidateOnlyWithoutPipelineFails(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "events"}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 2 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--only requires --pipeline") {
|
||||
t.Fatalf("stderr = %q, want only/pipeline error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPipelinesListSortedTextOutput(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{
|
||||
"zeta": {"events"},
|
||||
"alpha": {"events"},
|
||||
}))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "alpha\nzeta\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPipelinesListStableJSONOutput(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{
|
||||
"b": {"events"},
|
||||
"a": {"events"},
|
||||
}))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "{\"pipelines\":[\"a\",\"b\"]}\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, Options{
|
||||
LookupEnv: mapLookup(map[string]string{"NOTARIUS_CONFIG": configPath}),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "example\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) {
|
||||
configPath := writeTestConfig(t, `version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
api_key_env: NOTARIUS_TEST_API_KEY
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
`)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{
|
||||
LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingConfigPathProducesActionableError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", filepath.Join(t.TempDir(), "missing.yml")}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "config file") || !strings.Contains(stderr.String(), "not available") {
|
||||
t.Fatalf("stderr = %q, want actionable missing config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsAdHocStructuralFlags(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
||||
flags := []string{"--extractor", "--chunker", "--input", "--merge", "--normalize"}
|
||||
|
||||
for _, flagName := range flags {
|
||||
t.Run(flagName, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, flagName, "value"}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 2 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "flag provided but not defined") {
|
||||
t.Fatalf("stderr = %q, want invalid flag error", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagsExitTwo(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--bogus"}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 2 {
|
||||
t.Fatalf("RunWithOptions() code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "flag provided but not defined") {
|
||||
t.Fatalf("stderr = %q, want invalid flag error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
return writeFile(t, "config.yml", content)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, name string, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func testConfigYAML(pipelineID string, laneIDs ...string) string {
|
||||
return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs})
|
||||
}
|
||||
|
||||
func testConfigYAMLForPipelines(pipelines map[string][]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("version: 1\n")
|
||||
b.WriteString("pipelines:\n")
|
||||
for pipelineID, laneIDs := range pipelines {
|
||||
b.WriteString(" " + pipelineID + ":\n")
|
||||
b.WriteString(" input: fake/input\n")
|
||||
b.WriteString(" artifacts:\n")
|
||||
for _, laneID := range laneIDs {
|
||||
b.WriteString(" " + laneID + ":\n")
|
||||
b.WriteString(" extract: fake/extract\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fakeCatalog(t *testing.T) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
inputs := pipeline.NewInputAdapterRegistry()
|
||||
chunkers := pipeline.NewChunkerRegistry()
|
||||
extractors := pipeline.NewExtractorRegistry()
|
||||
mergers := pipeline.NewMergerRegistry()
|
||||
normalizers := pipeline.NewNormalizerRegistry()
|
||||
validators := pipeline.NewValidatorRegistry()
|
||||
outputs := pipeline.NewOutputEncoderRegistry()
|
||||
|
||||
mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}})
|
||||
mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}})
|
||||
mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}})
|
||||
mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}})
|
||||
mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}})
|
||||
mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}})
|
||||
|
||||
return pipeline.ModuleCatalog{
|
||||
Inputs: inputs,
|
||||
Chunkers: chunkers,
|
||||
Extractors: extractors,
|
||||
Mergers: mergers,
|
||||
Normalizers: normalizers,
|
||||
Validators: validators,
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register chunker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register extractor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register merger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register normalizer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mapLookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user