281 lines
12 KiB
Go
281 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"reflect"
|
|
"slices"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
func TestSensitivityCandidatesMergeRepeatedParsedPaths(t *testing.T) {
|
|
t.Parallel()
|
|
builder := newHistoryBuilder()
|
|
key := Keys("shape", "0")
|
|
index := Keys("shape").Index(0)
|
|
parsed := parseOverrideTestPath(t, "/shape/0")
|
|
dependency := parseOverrideTestPath(t, "/dependency")
|
|
attribution := builder.addAttribution(Origin{Layer: LayerResolution}, nil, []Path{dependency})
|
|
payload := builder.nextPayload(historyPhaseResolution)
|
|
for repeat := 0; repeat < 3; repeat++ {
|
|
for _, path := range []Path{key, index} {
|
|
builder.addObservation(historyObservation{
|
|
path: path, phase: historyPhaseResolution, payload: payload,
|
|
reportedPath: Keys("shape"), role: historyRoleOperation,
|
|
afterPresent: true, sensitive: repeat == 2,
|
|
attributionRef: attribution,
|
|
})
|
|
}
|
|
}
|
|
candidates := builder.freeze().sensitivityCandidates()
|
|
for _, path := range []Path{key, index, parsed, dependency} {
|
|
count := 0
|
|
for _, candidate := range candidates {
|
|
if slices.Equal(candidate.path.segments, path.segments) {
|
|
count++
|
|
if path.String() == "/shape/0" && !candidate.sensitive {
|
|
t.Fatal("merged candidate lost a later sensitivity fact")
|
|
}
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("semantic candidate %#v occurs %d times; want one", path, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHistorySelectionSeparatesNumericShapesAndGroupsParsedSides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
builder := newHistoryBuilder()
|
|
mapping := parseDocumentForDecode(t, "shape: {'0': old}\n")
|
|
sequence := parseDocumentForDecode(t, "shape: [new]\n")
|
|
shapeNode, _ := documentAtPath(mapping, Keys("shape"))
|
|
shapeNode.Sensitive = true
|
|
builder.observeDocument(mapping, historyPhaseSources)
|
|
builder.observeDocument(mapping, historyPhaseBaseline)
|
|
builder.observeDocument(sequence, historyPhaseResolution)
|
|
|
|
baseOrigin := Origin{Layer: LayerBase}
|
|
baseAttribution := builder.addAttribution(baseOrigin, nil, nil)
|
|
keyPath := Keys("shape", "0")
|
|
indexPath := Keys("shape").Index(0)
|
|
definition := builder.nextPayload(historyPhaseSources)
|
|
builder.addObservation(historyObservation{
|
|
path: keyPath, phase: historyPhaseSources, payload: definition,
|
|
reportedPath: keyPath, role: historyRoleDefinition,
|
|
afterPresent: true, attributionRef: baseAttribution,
|
|
})
|
|
|
|
replacementAttribution := builder.addAttribution(
|
|
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
|
[]string{"replace dynamic shape"}, []Path{Keys("shape")},
|
|
)
|
|
replacement := builder.nextPayload(historyPhaseResolution)
|
|
for _, observation := range []historyObservation{
|
|
{
|
|
path: keyPath, phase: historyPhaseResolution, payload: replacement,
|
|
reportedPath: Keys("shape"), role: historyRoleOperation,
|
|
beforePresent: true, attributionRef: replacementAttribution,
|
|
},
|
|
{
|
|
path: indexPath, phase: historyPhaseResolution, payload: replacement,
|
|
reportedPath: Keys("shape"), role: historyRoleOperation,
|
|
afterPresent: true, attributionRef: replacementAttribution,
|
|
},
|
|
} {
|
|
if got := builder.addObservation(observation); got < 0 {
|
|
t.Fatal("failed to add concrete history observation")
|
|
}
|
|
}
|
|
|
|
model := builder.freeze()
|
|
shapes := model.concreteShapes("/shape/0")
|
|
if len(shapes) != 2 || shapes[0].segments[1].kind != pathSegmentKey || shapes[1].segments[1].kind != pathSegmentIndex {
|
|
t.Fatalf("concrete shapes = %#v", shapes)
|
|
}
|
|
if !reflect.DeepEqual(model.observedPaths()[:2], []Path{Path{}, Keys("shape")}) {
|
|
t.Fatalf("first observed paths = %#v", model.observedPaths()[:2])
|
|
}
|
|
historicalPaths := model.historicalPaths()
|
|
var historicalPointers []string
|
|
for _, path := range historicalPaths {
|
|
historicalPointers = append(historicalPointers, path.String())
|
|
}
|
|
if !reflect.DeepEqual(historicalPointers, []string{"", "/shape", "/shape/0"}) {
|
|
t.Fatalf("historical paths = %#v", historicalPointers)
|
|
}
|
|
if len(model.concreteShapes("/shape/0")) != 2 {
|
|
t.Fatalf("repeated inventory added a duplicate concrete shape: %#v", model.concreteShapes("/shape/0"))
|
|
}
|
|
foundSensitive := false
|
|
for _, observation := range model.observations {
|
|
if observation.role == historyRoleInventory && observation.path.String() == "/shape" && observation.sensitive {
|
|
foundSensitive = true
|
|
break
|
|
}
|
|
}
|
|
if !foundSensitive {
|
|
t.Fatal("sensitive document fact was not retained with inventory evidence")
|
|
}
|
|
|
|
keySelections := model.selectHistory(keyPath)
|
|
if len(keySelections) != 2 || keySelections[0].role != historyRoleDefinition || keySelections[1].before != true || keySelections[1].after {
|
|
t.Fatalf("mapping-key selections = %#v", keySelections)
|
|
}
|
|
if len(keySelections[1].previous) != 1 || keySelections[1].previous[0].Layer != LayerBase {
|
|
t.Fatalf("mapping-key previous origins = %#v", keySelections[1].previous)
|
|
}
|
|
|
|
indexSelections := model.selectHistory(indexPath)
|
|
if len(indexSelections) != 1 || indexSelections[0].before || !indexSelections[0].after || len(indexSelections[0].previous) != 0 {
|
|
t.Fatalf("sequence-index selections = %#v", indexSelections)
|
|
}
|
|
|
|
parsed, err := ParsePath("/shape/0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsedSelections := model.selectHistory(parsed)
|
|
if len(parsedSelections) != 2 || !parsedSelections[1].before || !parsedSelections[1].after {
|
|
t.Fatalf("parsed-path selections = %#v", parsedSelections)
|
|
}
|
|
if !reflect.DeepEqual(parsedSelections[1].attribution.reasons, []string{"replace dynamic shape"}) ||
|
|
len(parsedSelections[1].attribution.dependencies) != 1 || parsedSelections[1].attribution.dependencies[0].String() != "/shape" {
|
|
t.Fatalf("resolved attribution = %#v", parsedSelections[1].attribution)
|
|
}
|
|
parsedSelections[1].attribution.reasons[0] = "mutated"
|
|
parsedSelections[1].attribution.dependencies[0] = Path{}
|
|
again := model.selectHistory(parsed)
|
|
if again[1].attribution.reasons[0] != "replace dynamic shape" || again[1].attribution.dependencies[0].String() != "/shape" {
|
|
t.Fatalf("selection mutation changed model attribution: %#v", again[1].attribution)
|
|
}
|
|
if selections := model.selectHistory(Keys("shape").Index(9)); len(selections) != 0 {
|
|
t.Fatalf("never-observed index selections = %#v", selections)
|
|
}
|
|
}
|
|
|
|
func TestHistorySelectionMatchesDeepSidesOfAncestorShapeChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
builder := newHistoryBuilder()
|
|
builder.observeDocument(parseDocumentForDecode(t, "shape: {'0': {leaf: old}}\n"), historyPhaseSources)
|
|
builder.observeDocument(parseDocumentForDecode(t, "shape: [{leaf: new}]\n"), historyPhaseResolution)
|
|
base := builder.addAttribution(Origin{Layer: LayerBase}, nil, nil)
|
|
oldPath := Keys("shape", "0", "leaf")
|
|
newPath := Keys("shape").Index(0).Key("leaf")
|
|
definition := builder.nextPayload(historyPhaseSources)
|
|
builder.addObservation(historyObservation{
|
|
path: oldPath, phase: historyPhaseSources, payload: definition,
|
|
reportedPath: oldPath, role: historyRoleDefinition,
|
|
afterPresent: true, attributionRef: base,
|
|
})
|
|
operation := builder.nextPayload(historyPhaseResolution)
|
|
operationAttribution := builder.addAttribution(Origin{Layer: LayerResolution, Operation: ChangeNormalization}, nil, nil)
|
|
for _, observation := range []historyObservation{
|
|
{path: oldPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, beforePresent: true, attributionRef: operationAttribution},
|
|
{path: newPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, afterPresent: true, attributionRef: operationAttribution},
|
|
} {
|
|
builder.addObservation(observation)
|
|
}
|
|
model := builder.freeze()
|
|
|
|
newSelections := model.selectHistory(newPath)
|
|
if len(newSelections) != 1 || newSelections[0].role != historyRoleOperation || newSelections[0].before || !newSelections[0].after || len(newSelections[0].previous) != 0 {
|
|
t.Fatalf("new descendant selections = %#v", newSelections)
|
|
}
|
|
oldSelections := model.selectHistory(oldPath)
|
|
if len(oldSelections) != 2 || !oldSelections[1].before || oldSelections[1].after || len(oldSelections[1].previous) != 1 || oldSelections[1].previous[0].Layer != LayerBase {
|
|
t.Fatalf("removed descendant selections = %#v", oldSelections)
|
|
}
|
|
if selections := model.selectHistory(Keys("shape").Index(0).Key("missing")); len(selections) != 0 {
|
|
t.Fatalf("never-observed descendant selections = %#v", selections)
|
|
}
|
|
}
|
|
|
|
func TestHistoryModelFinalizesLocationAndSensitivityWithoutRetainingInventoryTrees(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := parseDocumentForDecode(t, "shape: {'0': {value: old}}\n")
|
|
value, _ := documentAtPath(root, Keys("shape", "0", "value"))
|
|
value.Sensitive = true
|
|
keyPath := Keys("shape", "0", "value")
|
|
dependency := Keys("credentials", "source")
|
|
|
|
builder := newHistoryBuilder()
|
|
builder.observeDocument(root, historyPhaseSources)
|
|
definitionAttribution := builder.addAttribution(Origin{
|
|
Layer: LayerBase, Locations: []Location{{SourceID: "base.yml", Line: 4, Column: 2}},
|
|
}, nil, nil)
|
|
definition := builder.nextPayload(historyPhaseSources)
|
|
builder.addObservation(historyObservation{
|
|
path: keyPath, phase: historyPhaseSources, payload: definition,
|
|
reportedPath: keyPath, role: historyRoleDefinition,
|
|
afterPresent: true, attributionRef: definitionAttribution,
|
|
})
|
|
resolutionAttribution := builder.addAttribution(
|
|
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
|
[]string{"normalized credential"}, []Path{dependency},
|
|
)
|
|
resolution := builder.nextPayload(historyPhaseResolution)
|
|
builder.addObservation(historyObservation{
|
|
path: keyPath, phase: historyPhaseResolution, payload: resolution,
|
|
reportedPath: Keys("shape"), role: historyRoleOperation,
|
|
beforePresent: true, afterPresent: true, sensitive: true,
|
|
attributionRef: resolutionAttribution,
|
|
})
|
|
model := builder.freeze()
|
|
|
|
if got := model.documentRoots(); len(got) != 0 {
|
|
t.Fatalf("inventory-only document was retained: %#v", got)
|
|
}
|
|
if got, ok := model.documentAt(root, Keys("shape", "0")); ok || got != nil {
|
|
t.Fatalf("lookup retained an inventory-only document = %#v, %v", got, ok)
|
|
}
|
|
if got, ok := model.latestHistoricalLocation(keyPath); !ok || got.SourceID != "base.yml" || got.Line != 4 || got.Column != 2 {
|
|
t.Fatalf("latest historical location = %#v, %v", got, ok)
|
|
}
|
|
|
|
candidates := model.sensitivityCandidates()
|
|
var sawCandidate bool
|
|
for _, candidate := range candidates {
|
|
if !sameConcreteHistoryPath(candidate.path, keyPath) || !candidate.sensitive || len(candidate.dependencies) == 0 {
|
|
continue
|
|
}
|
|
if len(candidate.dependencies) != 1 || candidate.dependencies[0].String() != dependency.String() {
|
|
t.Fatalf("sensitivity dependencies = %#v", candidate.dependencies)
|
|
}
|
|
sawCandidate = true
|
|
}
|
|
if !sawCandidate {
|
|
t.Fatal("sensitivity candidate omitted the concrete historical path")
|
|
}
|
|
}
|
|
|
|
func TestHistoryBuilderScratchUsesOneDetachedWorkingDocument(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := parseDocumentForDecode(t, "shape: {value: original}\n")
|
|
builder := newHistoryBuilder()
|
|
builder.setScratch(input)
|
|
inputValue, _ := documentAtPath(input, Keys("shape", "value"))
|
|
inputValue.Text = "producer mutation"
|
|
scratchValue, ok := builder.scratchAt(Keys("shape", "value"))
|
|
if !ok || scratchValue.Text != "original" {
|
|
t.Fatalf("scratch value after producer mutation = %#v, %v", scratchValue, ok)
|
|
}
|
|
|
|
next := document.Clone(builder.scratch)
|
|
nextValue, _ := documentAtPath(next, Keys("shape", "value"))
|
|
nextValue.Text = "replayed"
|
|
builder.advanceScratch(next)
|
|
scratchValue, ok = builder.scratchAt(Keys("shape", "value"))
|
|
if !ok || scratchValue.Text != "replayed" {
|
|
t.Fatalf("advanced scratch value = %#v, %v", scratchValue, ok)
|
|
}
|
|
model := builder.freeze()
|
|
if !model.frozen || builder.scratch != nil {
|
|
t.Fatal("freeze retained mutable scratch state")
|
|
}
|
|
}
|