395 lines
14 KiB
Go
395 lines
14 KiB
Go
package configkit
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
type mutableText struct {
|
|
data []byte
|
|
}
|
|
|
|
func (value mutableText) MarshalText() ([]byte, error) {
|
|
return value.data, nil
|
|
}
|
|
|
|
func (value *mutableText) UnmarshalText(text []byte) error {
|
|
value.data = append(value.data[:0], text...)
|
|
return nil
|
|
}
|
|
|
|
type failingText struct {
|
|
failUnmarshal bool
|
|
}
|
|
|
|
func (value failingText) MarshalText() ([]byte, error) {
|
|
return []byte("CANARY_CODEC_TEXT"), errors.New("CANARY_MARSHAL_ERROR")
|
|
}
|
|
|
|
func (value *failingText) UnmarshalText([]byte) error {
|
|
if value.failUnmarshal {
|
|
return errors.New("CANARY_UNMARSHAL_ERROR")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type reconstructFailText string
|
|
|
|
func (value reconstructFailText) MarshalText() ([]byte, error) {
|
|
return []byte(value), nil
|
|
}
|
|
|
|
func (value *reconstructFailText) UnmarshalText([]byte) error {
|
|
return errors.New("CANARY_RECONSTRUCT_ERROR")
|
|
}
|
|
|
|
func TestProjectionPreservesCanonicalTypesAndNilEmptyDistinctions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type nested struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
type config struct {
|
|
Zero int `yaml:"zero"`
|
|
Wide uint64 `yaml:"wide"`
|
|
Delay time.Duration `yaml:"delay"`
|
|
Pointer *nested `yaml:"pointer"`
|
|
NilPointer *nested `yaml:"nil_pointer"`
|
|
EmptyMap map[string]string `yaml:"empty_map"`
|
|
NilMap map[string]string `yaml:"nil_map"`
|
|
EmptySlice []string `yaml:"empty_slice"`
|
|
NilSlice []string `yaml:"nil_slice"`
|
|
Objects map[string]*nested `yaml:"objects"`
|
|
Fixed [2]int `yaml:"fixed"`
|
|
Custom mutableText `yaml:"custom"`
|
|
Dynamic any `yaml:"dynamic"`
|
|
Ignored *nested `yaml:"-"`
|
|
private *nested
|
|
}
|
|
value := config{
|
|
Wide: math.MaxUint64,
|
|
Delay: 1500 * time.Millisecond,
|
|
Pointer: &nested{Value: "pointer"},
|
|
EmptyMap: map[string]string{},
|
|
EmptySlice: []string{},
|
|
Objects: map[string]*nested{
|
|
"z": {Value: "last"},
|
|
"a": {Value: "first"},
|
|
},
|
|
Fixed: [2]int{1, 2},
|
|
Custom: mutableText{data: []byte("canonical")},
|
|
Dynamic: map[string]any{"count": int64(9), "items": []any{"x", false}},
|
|
Ignored: &nested{Value: "ignored"},
|
|
private: &nested{Value: "private"},
|
|
}
|
|
schema := mustSchemaFor[config](t)
|
|
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
|
|
if issue != nil {
|
|
t.Fatalf("projectConfiguration() issue = %#v", issue)
|
|
}
|
|
|
|
assertDocument(t, mappingValue(t, projected, "zero"), document.Number, "0")
|
|
assertDocument(t, mappingValue(t, projected, "wide"), document.Number, "18446744073709551615")
|
|
assertDocument(t, mappingValue(t, projected, "delay"), document.String, "1.5s")
|
|
assertDocument(t, mappingValue(t, projected, "custom"), document.String, "canonical")
|
|
if mappingValue(t, projected, "nil_pointer").Kind != document.Null || mappingValue(t, projected, "nil_map").Kind != document.Null || mappingValue(t, projected, "nil_slice").Kind != document.Null {
|
|
t.Fatal("nil values did not project as null")
|
|
}
|
|
if empty := mappingValue(t, projected, "empty_map"); empty.Kind != document.Mapping || empty.Mapping == nil || len(empty.Mapping) != 0 {
|
|
t.Fatalf("empty map = %#v", empty)
|
|
}
|
|
if empty := mappingValue(t, projected, "empty_slice"); empty.Kind != document.Sequence || empty.Sequence == nil || len(empty.Sequence) != 0 {
|
|
t.Fatalf("empty slice = %#v", empty)
|
|
}
|
|
objects := mappingValue(t, projected, "objects")
|
|
if len(objects.Mapping) != 2 || objects.Mapping[0].Key != "a" || objects.Mapping[1].Key != "z" {
|
|
t.Fatalf("map order = %#v", objects.Mapping)
|
|
}
|
|
if hasMappingKey(projected, "ignored") || hasMappingKey(projected, "private") {
|
|
t.Fatal("ignored field entered projection")
|
|
}
|
|
clonedValue, cloneIssue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
|
|
if cloneIssue != nil {
|
|
t.Fatal(cloneIssue.message)
|
|
}
|
|
cloned := clonedValue.Interface().(config)
|
|
if cloned.NilMap != nil || cloned.NilSlice != nil || cloned.NilPointer != nil || cloned.EmptyMap == nil || cloned.EmptySlice == nil {
|
|
t.Fatalf("clone lost nil/empty distinctions: %#v", cloned)
|
|
}
|
|
}
|
|
|
|
func TestCloneConfigurationDetachesAllSupportedMutableValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type nested struct {
|
|
Values []string `yaml:"values"`
|
|
}
|
|
type config struct {
|
|
First *nested `yaml:"first"`
|
|
Second *nested `yaml:"second"`
|
|
Map map[string]*nested `yaml:"map"`
|
|
Slice []map[string]int `yaml:"slice"`
|
|
Custom mutableText `yaml:"custom"`
|
|
Dynamic any `yaml:"dynamic"`
|
|
Ignored *nested `yaml:"-"`
|
|
}
|
|
shared := &nested{Values: []string{"original"}}
|
|
value := config{
|
|
First: shared,
|
|
Second: shared,
|
|
Map: map[string]*nested{"entry": {Values: []string{"map"}}},
|
|
Slice: []map[string]int{{"count": 1}},
|
|
Custom: mutableText{data: []byte("text")},
|
|
Dynamic: map[string]any{
|
|
"items": []any{map[string]any{"name": "dynamic"}},
|
|
},
|
|
Ignored: &nested{Values: []string{"ignored"}},
|
|
}
|
|
schema := mustSchemaFor[config](t)
|
|
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
|
|
if issue != nil {
|
|
t.Fatalf("cloneConfiguration() issue = %#v", issue)
|
|
}
|
|
cloned := clonedValue.Interface().(config)
|
|
if cloned.First == cloned.Second || cloned.First == shared || cloned.Custom.data == nil || &cloned.Custom.data[0] == &value.Custom.data[0] {
|
|
t.Fatal("clone retained mutable identity")
|
|
}
|
|
if cloned.Ignored != nil {
|
|
t.Fatal("ignored field was retained")
|
|
}
|
|
|
|
value.First.Values[0] = "changed"
|
|
value.Map["entry"].Values[0] = "changed"
|
|
value.Slice[0]["count"] = 2
|
|
value.Custom.data[0] = 'X'
|
|
value.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] = "changed"
|
|
if cloned.First.Values[0] != "original" || cloned.Map["entry"].Values[0] != "map" || cloned.Slice[0]["count"] != 1 || string(cloned.Custom.data) != "text" || cloned.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] != "dynamic" {
|
|
t.Fatalf("clone changed after source mutation: %#v", cloned)
|
|
}
|
|
}
|
|
|
|
func TestProjectionAndCloneRejectCyclesAndInvalidDynamicValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type recursive struct {
|
|
Next *recursive `yaml:"next"`
|
|
}
|
|
type config struct {
|
|
Tree *recursive `yaml:"tree"`
|
|
Dynamic any `yaml:"dynamic"`
|
|
}
|
|
cyclic := &recursive{}
|
|
cyclic.Next = cyclic
|
|
schema := mustSchemaFor[config](t)
|
|
acyclic := config{Tree: &recursive{Next: &recursive{}}}
|
|
if _, issue := projectConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
|
|
t.Fatalf("acyclic recursive projection issue = %#v", issue)
|
|
}
|
|
if _, issue := cloneConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
|
|
t.Fatalf("acyclic recursive clone issue = %#v", issue)
|
|
}
|
|
value := config{Tree: cyclic}
|
|
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle || issue.path.String() != "/tree/next" {
|
|
t.Fatalf("projection cycle issue = %#v", issue)
|
|
}
|
|
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
|
|
t.Fatalf("clone cycle issue = %#v", issue)
|
|
}
|
|
|
|
dynamicCycle := map[string]any{}
|
|
dynamicCycle["self"] = dynamicCycle
|
|
value = config{Dynamic: dynamicCycle}
|
|
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
|
|
t.Fatalf("dynamic cycle issue = %#v", issue)
|
|
}
|
|
value = config{Dynamic: make(chan int)}
|
|
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
|
|
t.Fatalf("dynamic type issue = %#v", issue)
|
|
}
|
|
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
|
|
t.Fatalf("dynamic clone issue = %#v", issue)
|
|
}
|
|
}
|
|
|
|
func TestProjectionRejectsNonFiniteLimitsAndCodecFailuresSafely(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type numbers struct {
|
|
Value float64 `yaml:"value"`
|
|
}
|
|
numberSchema := mustSchemaFor[numbers](t)
|
|
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
|
|
if _, issue := projectConfiguration(numberSchema, reflect.ValueOf(numbers{Value: value}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaNonFinite {
|
|
t.Fatalf("non-finite issue = %#v", issue)
|
|
}
|
|
}
|
|
|
|
type codecConfig struct {
|
|
Value failingText `yaml:"value"`
|
|
}
|
|
codecSchema := mustSchemaFor[codecConfig](t)
|
|
if _, issue := projectConfiguration(codecSchema, reflect.ValueOf(codecConfig{}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
|
|
t.Fatalf("codec projection issue = %#v", issue)
|
|
}
|
|
|
|
type reconstructConfig struct {
|
|
Value reconstructFailText `yaml:"value"`
|
|
}
|
|
reconstructSchema := mustSchemaFor[reconstructConfig](t)
|
|
if _, issue := cloneConfiguration(reconstructSchema, reflect.ValueOf(reconstructConfig{Value: "safe-name"}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
|
|
t.Fatalf("codec clone issue = %#v", issue)
|
|
}
|
|
|
|
type simple struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
simpleSchema := mustSchemaFor[simple](t)
|
|
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 3}); issue != nil {
|
|
t.Fatalf("exact node limit rejected: %#v", issue)
|
|
}
|
|
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 2}); issue == nil || issue.code != CodeSchemaProjectionLimit {
|
|
t.Fatalf("node overflow issue = %#v", issue)
|
|
}
|
|
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 0, nodes: 3}); issue == nil || issue.code != CodeSchemaProjectionLimit {
|
|
t.Fatalf("depth overflow issue = %#v", issue)
|
|
}
|
|
}
|
|
|
|
func TestInlineProjectionAndCloneUsePhysicalMembers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type Embedded struct {
|
|
Name string `yaml:"name"`
|
|
Items map[string]string `yaml:"items"`
|
|
}
|
|
type config struct {
|
|
*Embedded `yaml:",inline"`
|
|
Extras map[string]any `yaml:",inline"`
|
|
}
|
|
value := config{
|
|
Embedded: &Embedded{Name: "value", Items: map[string]string{"a": "b"}},
|
|
Extras: map[string]any{"extra": []string{"one"}},
|
|
}
|
|
schema := mustSchemaFor[config](t)
|
|
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
|
|
if issue != nil || !hasMappingKey(projected, "name") || !hasMappingKey(projected, "items") || !hasMappingKey(projected, "extra") {
|
|
t.Fatalf("inline projection = %#v, issue=%#v", projected, issue)
|
|
}
|
|
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
|
|
if issue != nil {
|
|
t.Fatal(issue.message)
|
|
}
|
|
cloned := clonedValue.Interface().(config)
|
|
value.Items["a"] = "changed"
|
|
value.Extras["extra"].([]string)[0] = "changed"
|
|
if cloned.Items["a"] != "b" || cloned.Extras["extra"].([]string)[0] != "one" {
|
|
t.Fatal("inline clone retained aliases")
|
|
}
|
|
|
|
value.Extras["name"] = "collision"
|
|
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaAmbiguousField || issue.path.String() != "/name" {
|
|
t.Fatalf("inline collision issue = %#v", issue)
|
|
}
|
|
}
|
|
|
|
func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type nested struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
type config struct {
|
|
Nested nested `yaml:"nested"`
|
|
Mapping map[string]int `yaml:"mapping"`
|
|
Sequence []string `yaml:"sequence"`
|
|
Maybe map[string]int `yaml:"maybe"`
|
|
}
|
|
schema := mustSchemaFor[config](t)
|
|
before, issue := projectConfiguration(schema, reflect.ValueOf(config{
|
|
Nested: nested{Value: "before"},
|
|
Mapping: map[string]int{"remove": 1, "same": 2},
|
|
Sequence: []string{"a", "b"},
|
|
}), defaultProjectionLimits())
|
|
if issue != nil {
|
|
t.Fatal(issue.message)
|
|
}
|
|
after, issue := projectConfiguration(schema, reflect.ValueOf(config{
|
|
Nested: nested{Value: "after"},
|
|
Mapping: map[string]int{"add": 3, "same": 2},
|
|
Sequence: []string{"a", "changed"},
|
|
Maybe: map[string]int{},
|
|
}), defaultProjectionLimits())
|
|
if issue != nil {
|
|
t.Fatal(issue.message)
|
|
}
|
|
deltas := compareConfigurationValues(before, after)
|
|
want := []struct {
|
|
path string
|
|
kind documentDeltaKind
|
|
}{
|
|
{path: "/mapping/add", kind: documentAdded},
|
|
{path: "/mapping/remove", kind: documentRemoved},
|
|
{path: "/maybe", kind: documentReplaced},
|
|
{path: "/nested/value", kind: documentReplaced},
|
|
{path: "/sequence", kind: documentReplaced},
|
|
}
|
|
if len(deltas) != len(want) {
|
|
t.Fatalf("deltas = %#v", deltas)
|
|
}
|
|
for i := range want {
|
|
if deltas[i].path.String() != want[i].path || deltas[i].kind != want[i].kind {
|
|
t.Fatalf("delta[%d] = %#v, want %#v", i, deltas[i], want[i])
|
|
}
|
|
}
|
|
for _, delta := range deltas {
|
|
if strings.HasPrefix(delta.path.String(), "/sequence/") {
|
|
t.Fatal("sequence comparison created index correspondence")
|
|
}
|
|
}
|
|
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
|
|
t.Fatalf("equal trees changed = %#v", equal)
|
|
}
|
|
}
|
|
|
|
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
|
|
t.Helper()
|
|
schema, issue := buildSchema(reflect.TypeFor[T]())
|
|
if issue != nil {
|
|
t.Fatalf("buildSchema() issue = %#v", issue)
|
|
}
|
|
return schema
|
|
}
|
|
|
|
func mappingValue(t *testing.T, node *document.Node, key string) *document.Node {
|
|
t.Helper()
|
|
for _, entry := range node.Mapping {
|
|
if entry.Key == key {
|
|
return entry.Value
|
|
}
|
|
}
|
|
t.Fatalf("mapping key %q missing", key)
|
|
return nil
|
|
}
|
|
|
|
func hasMappingKey(node *document.Node, key string) bool {
|
|
for _, entry := range node.Mapping {
|
|
if entry.Key == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func assertDocument(t *testing.T, node *document.Node, kind document.Kind, text string) {
|
|
t.Helper()
|
|
if node.Kind != kind || node.Text != text {
|
|
t.Fatalf("node = %#v, want kind=%v text=%q", node, kind, text)
|
|
}
|
|
}
|