Add configuration validation and resolution contracts
This commit is contained in:
446
internal/core/config/validation_contract_test.go
Normal file
446
internal/core/config/validation_contract_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidateConcurrencyRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non-positive total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
},
|
||||
want: "total LLM concurrency must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "worker below one",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker above total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker lower boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker upper boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"worker": 1}
|
||||
},
|
||||
want: "stage_workers key \"worker\" is not supported",
|
||||
},
|
||||
{
|
||||
name: "blank worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{" ": 1}
|
||||
},
|
||||
want: "stage_workers key must not be empty",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"}
|
||||
assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive")
|
||||
}
|
||||
|
||||
func TestValidateStateSurfaceRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "blank output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = " "
|
||||
},
|
||||
want: "output.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "blank debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = " "
|
||||
},
|
||||
want: "debug.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "NUL in output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = "./out\x00put"
|
||||
},
|
||||
want: "output.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in chunk plan root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Directory = "./plans\x00"
|
||||
},
|
||||
want: "cache.chunk_plans.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in checkpoint root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.Checkpoints.Directory = "./checkpoints\x00"
|
||||
},
|
||||
want: "cache.checkpoints.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = "./debug\x00"
|
||||
},
|
||||
want: "debug.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "invalid chunk plan mode",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Mode = pipeline.ChunkCacheMode("invalid")
|
||||
},
|
||||
want: "cache.chunk_plans.mode:",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentifiersAfterTrimming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty pipeline id",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{" ": {}}
|
||||
},
|
||||
want: "pipeline id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate pipeline ids",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": {}, " main ": {}}
|
||||
},
|
||||
want: "pipeline id \"main\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty lane id",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{" ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate lane ids",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"spells": {}, " spells ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id \"spells\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty reference slot",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = map[string]string{" ": "source.txt"}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot name must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate reference slots",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = map[string]string{"slot": "one.txt", " slot ": "two.txt"}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot \"slot\" is duplicated after trimming",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBindingRetriesAndProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "negative retries",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.Retries = -1
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input retries must be greater than or equal to zero",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only input profile",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.LLMProfile = " "
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input llm_profile must not be empty when set",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReferencesAreUnsupportedOnInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.References = map[string]string{"slot": "source.txt"}
|
||||
},
|
||||
want: "input references are not supported",
|
||||
},
|
||||
{
|
||||
name: "output references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.References = map[string]string{"slot": "source.txt"}
|
||||
},
|
||||
want: "output references are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.set(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValidatorBindingRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty validator module",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] module must not be empty",
|
||||
},
|
||||
{
|
||||
name: "validator retries",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Retries: 1,
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] retries are not supported",
|
||||
},
|
||||
{
|
||||
name: "validator references",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
References: map[string]string{"slot": "source.txt"},
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] references are not supported",
|
||||
},
|
||||
{
|
||||
name: "nested validators",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Validators: pipeline.ValidatorOverride{Set: true},
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] nested validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "input validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "input validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "output validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "output validators are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.setup(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLaneValidatorCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lane func(*pipeline.ArtifactLaneProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "deprecated non-empty lane validators",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Validators = []pipeline.ModuleBinding{{Module: "old-validator"}}
|
||||
},
|
||||
want: "validators are not supported at artifact lane level",
|
||||
},
|
||||
{
|
||||
name: "stage validators omitted",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators explicitly empty",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators configured",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{Module: "validator"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
lane := profile.Artifacts["lane"]
|
||||
tt.lane(&lane)
|
||||
profile.Artifacts["lane"] = lane
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validationProfile() pipeline.PipelineProfile {
|
||||
return pipeline.PipelineProfile{
|
||||
ID: "main",
|
||||
Input: pipeline.Binding("input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"lane": {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertValidationContains(t *testing.T, cfg Config, want string) {
|
||||
t.Helper()
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user