diff --git a/configuration_diff_contract_test.go b/configuration_diff_contract_test.go index 897390d..8515aef 100644 --- a/configuration_diff_contract_test.go +++ b/configuration_diff_contract_test.go @@ -81,6 +81,34 @@ func TestConfigurationInspectionDiffIgnoresSourceHistory(t *testing.T) { } } +func TestConfigurationInspectionDiffUsesNormalizedValues(t *testing.T) { + inspect := func(endpoint string) promptkit.ConfigurationInspection { + t.Helper() + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("backend", map[string]any{ + "backends": []any{map[string]any{"id": "local", "endpoint": endpoint}}, + })}}}}}}) + if err != nil { + t.Fatal(err) + } + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatal(err) + } + return view + } + left := inspect(" HTTPS://example.invalid/v1 ") + equal := inspect("https://example.invalid/v1") + differences, err := left.Diff(equal) + if err != nil || len(differences) != 0 { + t.Fatalf("normalized diff = %+v, %v", differences, err) + } + changed := inspect("https://other.invalid/v1") + differences, err = left.Diff(changed) + if err != nil || len(differences) != 1 || differences[0].Path.String() != "/backends" { + t.Fatalf("changed diff = %+v, %v", differences, err) + } +} + func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) { left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{ "sequence": []any{"earlier", "unchanged"}, diff --git a/configuration_inspection_contract_test.go b/configuration_inspection_contract_test.go index a7d5631..a2d94bf 100644 --- a/configuration_inspection_contract_test.go +++ b/configuration_inspection_contract_test.go @@ -21,7 +21,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. Underlays: []configkit.Underlay{ {Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{ "backends": []any{map[string]any{ - "id": "local", "endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY", + "id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "api_key_env": "OPTIONAL_KEY", "extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"}, }}, "collections": map[string]any{"literal": map[string]any{ @@ -64,6 +64,11 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. t.Fatalf("literal-key path = %q", inheritedPath) } secretPath := configkit.Keys("backends").Index(0).Key("extra_params").Key("token") + endpointPath := configkit.Keys("backends").Index(0).Key("endpoint") + endpoint, found := view.Explain(endpointPath) + if !found || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") || strings.Contains(fmt.Sprintf("%+v", view.Metadata().History(endpointPath)), "private-sentinel") { + t.Fatalf("normalized endpoint explanation = %+v", endpoint) + } secret, found := view.Explain(secretPath) if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") { t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9539af7..0d5fbdb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -221,6 +221,10 @@ Scope: focused configuration tests and existing public inspection/diff tests. Validation: focused configuration, public inspection/diff, preparation, and execution tests. All new tests use deterministic offline sources/collaborators. +Completion: focused tests cover recorded target dependencies, imported content +origins, invalid identities, normalized-value diffs, and sensitive inspection. +Configuration, engine, and public API tests pass on Go 1.27.0. + ## Stage 6: Align Documentation And Verify The Sprint Scope: current contract/implementation documentation and final integration. diff --git a/internal/configuration/interpretation_test.go b/internal/configuration/interpretation_test.go index 0bed8ec..4a29c97 100644 --- a/internal/configuration/interpretation_test.go +++ b/internal/configuration/interpretation_test.go @@ -117,3 +117,36 @@ func TestPromptResolutionRecordsMessageFields(t *testing.T) { t.Fatal("null input metadata was not retained") } } + +func TestResolutionRecordsTargetDependencies(t *testing.T) { + backend := func(id string) map[string]any { + return map[string]any{"id": id, "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 2, "queue_capacity": nil} + } + state, err := load(mapping("targets", map[string]any{ + "backends": []any{backend("root")}, + "collections": map[string]any{"scoped": map[string]any{"backends": []any{backend("local")}}}, + }), nil) + if err != nil { + t.Fatal(err) + } + for _, base := range []configkit.Path{configkit.Keys("backends").Index(0), configkit.Keys("collections", "scoped", "backends").Index(0)} { + endpoint, ok := state.result.Explain(base.Key("endpoint")) + if !ok || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") { + t.Fatalf("endpoint explanation: %+v", endpoint) + } + history := state.result.Metadata().History(base.Key("queue_capacity")) + found := false + for _, change := range history { + if change.Origin.Operation == configkit.ChangeDerivedDefault { + for _, dependency := range change.Dependencies { + if dependency.String() == base.Key("concurrency_limit").String() { + found = true + } + } + } + } + if !found { + t.Fatalf("missing queue dependency: %+v", history) + } + } +} diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index 8d2bdca..e3eae5c 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -153,6 +153,50 @@ func TestIdentifiedResourcesRejectDuplicateStrictPeers(t *testing.T) { } } +func TestInvalidIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) { + for _, kind := range []string{"backends", "profiles", "prompts"} { + for _, id := range []string{" ", "bad/id"} { + t.Run(kind+"/"+id, func(t *testing.T) { + record := map[string]any{"id": id} + switch kind { + case "backends": + record["endpoint"] = " HTTPS://example.invalid/v1 " + record["concurrency_limit"] = 1 + case "profiles": + record["service_tier"] = " standard " + case "prompts": + record["description"] = " padded " + } + _, err := load(mapping("invalid", map[string]any{kind: []any{record}}), nil) + if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") { + t.Fatalf("invalid identity error = %v", err) + } + }) + } + } +} + +func TestDuplicateIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) { + for _, kind := range []string{"backends", "profiles", "prompts"} { + t.Run(kind, func(t *testing.T) { + record := map[string]any{"id": "same"} + switch kind { + case "backends": + record["endpoint"] = " HTTPS://example.invalid/v1 " + record["concurrency_limit"] = 1 + case "profiles": + record["service_tier"] = " standard " + case "prompts": + record["description"] = " padded " + } + _, err := load(mapping("duplicates", map[string]any{kind: []any{record, record}}), nil) + if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") { + t.Fatalf("duplicate identity error = %v", err) + } + }) + } +} + func TestFinalIdentitiesAreCheckedAfterApplicationResolution(t *testing.T) { for _, kind := range []string{"backends", "profiles", "prompts"} { for _, namespace := range []string{"", "*"} { diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go index cb13733..916fca6 100644 --- a/internal/configuration/prompts_test.go +++ b/internal/configuration/prompts_test.go @@ -16,7 +16,7 @@ import ( func TestLoadCapturesInheritedImportedContent(t *testing.T) { files := fstest.MapFS{ - "app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")}, + "app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n description: ' description '\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")}, "app/body.md": {Data: []byte("Replacement {{.name}}")}, "catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n description: ' description '\n session_id: ' session '\n inputs:\n - name: ' document '\n content_type: ' text/plain '\n description: ' document source '\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")}, "catalog/body.md": {Data: []byte("Captured {{.name}}")},