Validate source versions in recursive imports
This commit is contained in:
@@ -19,6 +19,17 @@ type sourceVersionNullConfig struct {
|
||||
Version *string `yaml:"version"`
|
||||
}
|
||||
|
||||
type sourceVersionImportGroup struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
type sourceVersionImportConfig struct {
|
||||
Service struct {
|
||||
Version string `yaml:"version"`
|
||||
Groups map[string]sourceVersionImportGroup `yaml:"groups"`
|
||||
} `yaml:"service"`
|
||||
}
|
||||
|
||||
func sourceVersionTestDocument(t *testing.T, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes("source-version.yml", []byte(input)), "source-version", defaultParseLimits())
|
||||
@@ -592,6 +603,213 @@ func TestSourceVersionLoadRejectsOptionsBeforeReadingMain(t *testing.T) {
|
||||
requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "")
|
||||
}
|
||||
|
||||
func TestSourceVersionImportsCheckNestedAndGlobSourcesBeforeComposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `version: main
|
||||
configkit:
|
||||
imports:
|
||||
- glob: fragments/*.yml
|
||||
destination: [groups]
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "fragments/a.yml", `version: first
|
||||
configkit:
|
||||
imports:
|
||||
- path: nested/child.yml
|
||||
destination: [nested]
|
||||
a: {name: A}
|
||||
`)
|
||||
nestedPath := writeImportFile(t, directory, "fragments/nested/child.yml", "version: nested\nname: N\n")
|
||||
secondPath := writeImportFile(t, directory, "fragments/b.yml", "version: second\nb: {name: B}\n")
|
||||
|
||||
checked := make(map[string]int)
|
||||
loaded, err := LoadLayered(Input{
|
||||
Source: File(mainPath), Imports: true, Destination: Keys("service"),
|
||||
}, testSecretBinding(), LayeredOptions[sourceVersionImportConfig]{
|
||||
SourceVersionMode: sourceVersionModeRequired,
|
||||
Definition: Definition[sourceVersionImportConfig]{Version: &VersionRule{
|
||||
Path: Keys("version"),
|
||||
Check: func(version Version, validation *Validation) error {
|
||||
checked[version.Text]++
|
||||
validation.Warning(Path{}, "accepted")
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
var loadErr *LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
t.Fatalf("source-local import load = %#v, diagnostics %#v", loaded, loadErr.Diagnostics())
|
||||
}
|
||||
t.Fatalf("source-local import load = %#v, %v", loaded, err)
|
||||
}
|
||||
if loaded.Config.Service.Version != "main" || loaded.Config.Service.Groups["a"].Name != "A" ||
|
||||
loaded.Config.Service.Groups["b"].Name != "B" || loaded.Config.Service.Groups["nested"].Name != "N" {
|
||||
t.Fatalf("mounted source-local imports = %#v", loaded.Config.Service)
|
||||
}
|
||||
if len(checked) != 4 || checked["main"] != 1 || checked["first"] != 1 || checked["nested"] != 1 || checked["second"] != 1 {
|
||||
t.Fatalf("checked declarations = %#v, want each independent source once", checked)
|
||||
}
|
||||
warnings := testLoaded(loaded).Diagnostics()
|
||||
if len(warnings) != 4 {
|
||||
t.Fatalf("accepted source warnings = %#v, want one per checked source", warnings)
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
if warning.Code != CodeVersionWarning || warning.Severity != SeverityWarning {
|
||||
t.Fatalf("accepted source warning = %#v", warning)
|
||||
}
|
||||
}
|
||||
|
||||
wantDestinations := map[string]string{
|
||||
canonicalTestPath(t, mainPath): "/service",
|
||||
canonicalTestPath(t, firstPath): "/service/groups",
|
||||
canonicalTestPath(t, secondPath): "/service/groups",
|
||||
canonicalTestPath(t, nestedPath): "/service/groups/nested",
|
||||
}
|
||||
for _, source := range testLoaded(loaded).Sources() {
|
||||
want, found := wantDestinations[source.Path]
|
||||
if !found || source.Destination.String() != want {
|
||||
t.Errorf("source %q destination = %q, want %q", source.Path, source.Destination.String(), want)
|
||||
}
|
||||
delete(wantDestinations, source.Path)
|
||||
}
|
||||
if len(wantDestinations) != 0 {
|
||||
t.Errorf("sources not retained: %#v", wantDestinations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("rejected child is not hidden by the main declaration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n")
|
||||
writeImportFile(t, directory, "child.yml", "version: rejected\n")
|
||||
checks := 0
|
||||
_, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
||||
Imports: true, SourceVersionMode: sourceVersionModeIfPresent,
|
||||
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
|
||||
Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||
checks++
|
||||
if version.Text == "rejected" {
|
||||
return errors.New("rejected")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("rejected child error = %T %v, want *LoadError", err, err)
|
||||
}
|
||||
diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionHookFailed, "/version")
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || checks != 2 {
|
||||
t.Fatalf("rejected child diagnostic/callbacks = %#v, %d", diagnostic, checks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("required child omission reports its nearest source location", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n")
|
||||
writeImportFile(t, directory, "child.yml", "name: child\n")
|
||||
checks := 0
|
||||
_, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
||||
Imports: true, SourceVersionMode: sourceVersionModeRequired,
|
||||
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
|
||||
Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil },
|
||||
}},
|
||||
})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("required child omission = %T %v, want *LoadError", err, err)
|
||||
}
|
||||
diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionError, "/version")
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 || checks != 1 {
|
||||
t.Fatalf("required omission diagnostic/callbacks = %#v, %d", diagnostic, checks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("optional missing child remains absent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `version: main
|
||||
configkit:
|
||||
imports:
|
||||
- path: missing.yml
|
||||
optional: true
|
||||
`)
|
||||
checks := 0
|
||||
loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
||||
Imports: true, SourceVersionMode: sourceVersionModeRequired,
|
||||
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
|
||||
Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil },
|
||||
}},
|
||||
})
|
||||
if err != nil || loaded == nil || checks != 1 || len(testLoaded(loaded).Sources()) != 1 {
|
||||
t.Fatalf("optional missing source = %#v, checks %d, %v", loaded, checks, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||
childPath := writeImportFile(t, directory, "child.yml", "version: accepted\nname: child\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
child, diagnostics := parseSource(File(childPath), "source-child", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("child parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
||||
state := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
|
||||
wantNodes := countInputNodes(main.document) + countInputNodes(child.document)
|
||||
wantBytes := main.bytes + child.bytes
|
||||
limits := defaultImportLimits()
|
||||
limits.bytes = wantBytes
|
||||
limits.nodes = wantNodes
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, foundDiagnostics := loadImportedDocumentsTrackedWithVersion(
|
||||
File(mainPath), nil, main, Path{}, rule, limits, accounting, nil, nil, state,
|
||||
)
|
||||
if len(foundDiagnostics) != 0 || composed == nil || len(sources) != 2 || accounting.nodes != wantNodes || accounting.bytes != wantBytes {
|
||||
t.Fatalf("exact raw byte/node accounting = %#v, %d/%d, want %d/%d, %#v", composed, accounting.bytes, accounting.nodes, wantBytes, wantNodes, foundDiagnostics)
|
||||
}
|
||||
if len(state.evidence) != 1 || state.evidence[0].sourceID != "source-2" || !state.evidence[0].checked || state.evidence[0].version.Text != "accepted" {
|
||||
t.Fatalf("acquired child evidence = %#v", state.evidence)
|
||||
}
|
||||
if _, present := documentAtPath(composed.root, Keys("version")); present {
|
||||
t.Fatalf("consumed child header remains in application document: %#v", composed.root)
|
||||
}
|
||||
|
||||
below := defaultImportLimits()
|
||||
below.nodes = wantNodes - 1
|
||||
accounting = &importAccounting{}
|
||||
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
|
||||
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
|
||||
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
|
||||
)
|
||||
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics)
|
||||
}
|
||||
|
||||
below = defaultImportLimits()
|
||||
below.bytes = wantBytes - 1
|
||||
accounting = &importAccounting{}
|
||||
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
|
||||
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
|
||||
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
|
||||
)
|
||||
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseSourceVersionPath(t *testing.T, input string) Path {
|
||||
t.Helper()
|
||||
path, err := ParsePath(input)
|
||||
|
||||
Reference in New Issue
Block a user