308 lines
14 KiB
Go
308 lines
14 KiB
Go
package configkit
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
type overrideLifecycleSettings struct {
|
|
Target string `yaml:"target"`
|
|
Count int `yaml:"count"`
|
|
Enabled bool `yaml:"enabled"`
|
|
Text string `yaml:"text"`
|
|
Items []string `yaml:"items"`
|
|
Maybe *string `yaml:"maybe"`
|
|
Labels map[string]string `yaml:"labels"`
|
|
APIKey Secret `yaml:"api_key"`
|
|
}
|
|
|
|
type overrideLifecycleConfig struct {
|
|
Version string `yaml:"version"`
|
|
Settings overrideLifecycleSettings `yaml:"settings"`
|
|
}
|
|
|
|
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
|
directory := t.TempDir()
|
|
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
|
writeImportFile(t, directory, "base.yml", `settings:
|
|
target: base
|
|
count: 4
|
|
enabled: true
|
|
text: base
|
|
items: [base]
|
|
maybe: base
|
|
labels: {base: retained}
|
|
api_key: BASE_SECRET
|
|
`)
|
|
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
|
|
target: profile
|
|
labels: {profile: retained}
|
|
`)
|
|
validated := false
|
|
overrides := []Override{
|
|
{Path: Keys("settings", "target"), Value: " cli ", Flag: "--target"},
|
|
{Path: Keys("settings", "count"), Value: 0, Flag: "--count"},
|
|
{Path: Keys("settings", "enabled"), Value: false, Flag: "--enabled"},
|
|
{Path: Keys("settings", "text"), Value: "", Flag: "--text"},
|
|
{Path: Keys("settings", "items"), Value: []any{}, Flag: "--items"},
|
|
{Path: Keys("settings", "maybe"), Value: nil, Flag: "--maybe"},
|
|
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
|
|
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
|
|
}
|
|
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
|
if name != "CLI_SECRET" {
|
|
t.Fatalf("looked up superseded secret name %q", name)
|
|
}
|
|
return "credential-value", true
|
|
}, Options[overrideLifecycleConfig]{
|
|
Imports: true, Profile: "testing", Overrides: overrides,
|
|
Definition: Definition[overrideLifecycleConfig]{
|
|
Defaults: func() overrideLifecycleConfig {
|
|
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
|
|
},
|
|
Version: &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
|
|
if version.Text != "v1" {
|
|
validation.Error(Keys("version"), "unsupported version")
|
|
}
|
|
return nil
|
|
}},
|
|
Resolve: func(configuration *overrideLifecycleConfig, resolution *Resolution) error {
|
|
Normalize(resolution, Keys("settings", "target"), &configuration.Settings.Target, strings.TrimSpace(configuration.Settings.Target), "trimmed target")
|
|
return nil
|
|
},
|
|
Validate: func(configuration *overrideLifecycleConfig, validation *Validation) {
|
|
validated = configuration.Settings.Target == "cli" && configuration.Settings.APIKey.Value() == "credential-value"
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loadWithSecretLookup() error = %v", err)
|
|
}
|
|
want := overrideLifecycleSettings{
|
|
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
|
|
APIKey: result.Config.Settings.APIKey,
|
|
}
|
|
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
|
|
len(result.Config.Settings.Items) != 0 || result.Config.Settings.Items == nil || result.Config.Settings.Maybe != nil ||
|
|
result.Config.Settings.Target != want.Target || !reflect.DeepEqual(result.Config.Settings.Labels, want.Labels) {
|
|
t.Fatalf("effective settings = %#v", result.Config.Settings)
|
|
}
|
|
sources := result.Sources()
|
|
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
|
|
t.Fatalf("sources = %#v", sources)
|
|
}
|
|
for index, override := range overrides {
|
|
if sources[index+3].Layer != LayerCLI || sources[index+3].Label != override.Flag || sources[index+3].Path != "" {
|
|
t.Fatalf("CLI source %d = %#v", index, sources[index+3])
|
|
}
|
|
}
|
|
history := result.Metadata().History(Keys("settings", "target"))
|
|
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
|
|
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
|
|
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
|
|
}
|
|
}
|
|
|
|
func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *testing.T) {
|
|
type config struct {
|
|
Labels map[string]string `yaml:"labels"`
|
|
Items []int `yaml:"items"`
|
|
Numbers map[string]int `yaml:"numbers"`
|
|
Dynamic any `yaml:"dynamic"`
|
|
}
|
|
labels := map[string]any{"0": "zero", "new": "value"}
|
|
items := []any{0, 2}
|
|
dynamic := map[string]any{
|
|
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
|
|
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
|
|
}
|
|
result, err := Load(NoSource(), Options[config]{
|
|
Definition: Definition[config]{Defaults: func() config {
|
|
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
|
|
}},
|
|
Overrides: []Override{
|
|
{Path: parseOverrideTestPath(t, "/labels"), Value: labels, Flag: "--labels"},
|
|
{Path: Keys("items"), Value: items, Flag: "--items"},
|
|
{Path: parseOverrideTestPath(t, "/numbers/0"), Value: 7, Flag: "--number"},
|
|
{Path: Keys("dynamic"), Value: dynamic, Flag: "--dynamic"},
|
|
},
|
|
})
|
|
labels["new"] = "mutated"
|
|
items[0] = 99
|
|
dynamic["int"] = 99
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(result.Config.Labels, map[string]string{"kept": "default", "0": "zero", "new": "value"}) || !reflect.DeepEqual(result.Config.Items, []int{0, 2}) || result.Config.Numbers["0"] != 7 || result.Config.Dynamic.(map[string]any)["int"] != int64(1) {
|
|
t.Fatalf("defaults-only overrides = %#v", result.Config)
|
|
}
|
|
var effective strings.Builder
|
|
if err := result.WriteEffective(&effective); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "new: value") {
|
|
t.Fatalf("detached effective output = %q", effective.String())
|
|
}
|
|
if info, ok := result.Metadata().Lookup(Keys("labels", "0")); !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
|
t.Fatalf("numeric mapping key metadata = %#v, %v", info, ok)
|
|
}
|
|
}
|
|
|
|
func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testing.T) {
|
|
type config struct {
|
|
Version string `yaml:"version"`
|
|
Scalar string `yaml:"scalar"`
|
|
Items []string `yaml:"items"`
|
|
Nested map[string]any `yaml:"nested"`
|
|
Unknown string `yaml:"unknown"`
|
|
}
|
|
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
|
|
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
|
cycle := map[string]any{}
|
|
cycle["self"] = cycle
|
|
deep := any("leaf")
|
|
for range maxDocumentDepth + 1 {
|
|
deep = []any{deep}
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
overrides []Override
|
|
code Code
|
|
}{
|
|
{name: "root", overrides: []Override{{Path: Keys(), Value: "x", Flag: "--root"}}, code: CodeOverrideInvalidPath},
|
|
{name: "index", overrides: []Override{{Path: Keys("items").Index(0), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
|
|
{name: "parsed sequence index", overrides: []Override{{Path: parseOverrideTestPath(t, "/items/0"), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
|
|
{name: "scalar ancestor", overrides: []Override{{Path: Keys("scalar", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideInvalidPath},
|
|
{name: "duplicate", overrides: []Override{{Path: Keys("scalar"), Value: "a", Flag: "--first"}, {Path: Keys("scalar"), Value: "b", Flag: "--second"}}, code: CodeOverrideDuplicate},
|
|
{name: "overlap", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"}, {Path: Keys("nested", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideOverlap},
|
|
{name: "unsupported", overrides: []Override{{Path: Keys("scalar"), Value: time.Second, Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
|
{name: "cycle", overrides: []Override{{Path: Keys("nested"), Value: cycle, Flag: "--nested"}}, code: CodeOverrideInvalidValue},
|
|
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
|
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
|
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
|
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
|
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
|
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
|
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
|
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
|
|
if result != nil {
|
|
t.Fatalf("result = %#v, want nil", result)
|
|
}
|
|
loadError := requireSecretLoadCode(t, err, test.code)
|
|
if strings.Contains(fmtLoadError(loadError), strings.Repeat("x", 64)) {
|
|
t.Fatal("override diagnostic leaked a raw value")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
|
type schemaConfig struct {
|
|
Version string `yaml:"version"`
|
|
}
|
|
type config struct {
|
|
Schema schemaConfig `yaml:"schema"`
|
|
}
|
|
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
|
|
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
|
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
|
})
|
|
requireSecretLoadCode(t, err, CodeOverrideVersion)
|
|
}
|
|
|
|
func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
|
|
type config struct {
|
|
Count int `yaml:"count"`
|
|
}
|
|
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
|
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
|
|
}}})
|
|
if result != nil {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
var loadError *LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("error = %T", err)
|
|
}
|
|
diagnostics := loadError.Diagnostics()
|
|
sources := loadError.Sources()
|
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeDecodeUnknownField || diagnostics[0].Primary == nil ||
|
|
len(sources) != 2 || sources[1].Label != "--missing" || diagnostics[0].Primary.SourceID != sources[1].ID {
|
|
t.Fatalf("diagnostics/sources = %#v / %#v", diagnostics, sources)
|
|
}
|
|
if strings.Contains(fmtLoadError(loadError), "RAW-ARG-CANARY") {
|
|
t.Fatal("decode failure leaked override value")
|
|
}
|
|
}
|
|
|
|
func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
|
|
type config struct {
|
|
Count int `yaml:"count"`
|
|
}
|
|
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
|
|
Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
|
|
}}})
|
|
loadError := requireSecretLoadCode(t, err, CodeDecodeType)
|
|
if loadError.Diagnostics()[0].Primary == nil || loadError.Sources()[1].Label != "--count" || strings.Contains(fmtLoadError(loadError), "not-an-integer") {
|
|
t.Fatalf("type mismatch diagnostics/sources = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
|
|
}
|
|
}
|
|
|
|
func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
|
|
type config struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := historyLayers(result.Metadata().History(Keys("value"))); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerCLI}) {
|
|
t.Fatalf("equal override history = %#v", result.Metadata().History(Keys("value")))
|
|
}
|
|
}
|
|
|
|
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
|
|
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
|
|
if len(diagnostics) != 0 {
|
|
t.Fatal(diagnostics)
|
|
}
|
|
base.inputNodes = 1
|
|
result, sources, diagnostics := applyOverrides(base, nil, []Override{{Path: Keys("value"), Value: "x", Flag: "--value"}}, nil, importLimits{sources: 1, bytes: 1, nodes: 2})
|
|
if result != nil || len(sources) != 1 || len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded || len(base.root.Mapping) != 0 {
|
|
t.Fatalf("bounded apply = %#v, %#v, %#v; base %#v", result, sources, diagnostics, base.root)
|
|
}
|
|
}
|
|
|
|
func historyLayers(history []Change) []Layer {
|
|
layers := make([]Layer, len(history))
|
|
for index, change := range history {
|
|
layers[index] = change.Origin.Layer
|
|
}
|
|
return layers
|
|
}
|
|
|
|
func parseOverrideTestPath(t *testing.T, value string) Path {
|
|
t.Helper()
|
|
path, err := ParsePath(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func fmtLoadError(loadError *LoadError) string {
|
|
var output strings.Builder
|
|
_ = WriteDiagnostics(&output, loadError.Diagnostics())
|
|
return loadError.Error() + output.String()
|
|
}
|