diff --git a/.changelog/pr-128.txt b/.changelog/pr-128.txt new file mode 100644 index 0000000..6551fb4 --- /dev/null +++ b/.changelog/pr-128.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +`resource/pingone_davinci_flow`: Resolved DaVinci form node theme references (`theme.value` and the indirect `themeId.value` rich-text-wrapped UUID used when `theme.value` is "useThemeId") to `pingone_branding_theme`, matching the existing `form.value` behavior. Until `pingone_branding_theme` export support lands, the theme ID is emitted as an overridable Terraform variable. +``` diff --git a/contributing/ARCHITECTURE.md b/contributing/ARCHITECTURE.md index 54f32f5..de10175 100644 --- a/contributing/ARCHITECTURE.md +++ b/contributing/ARCHITECTURE.md @@ -489,6 +489,24 @@ type EmbeddedReferenceRule struct { TargetResourceType string // what the UUID references (e.g., "pingone_davinci_flow") JSONKeyPath string // path inside JSON blob (e.g., "subFlowId.value.value") ReferenceField string // TF attribute to reference (e.g., "id") + + Strategy string // "" / "reference" (default), "reference_with_fallback", or "variable" + VariablePrefix string // combined with VariableNamingPath's suffix to name a fallback variable + VariableNamingPath string // JSON key path used to derive a human-readable variable suffix + + // PreconditionKeyPath / PreconditionValue: optional sibling JSON key path + // (resolved against the same parsed blob as JSONKeyPath) that must equal + // PreconditionValue before the rule fires. Zero value ("") means no + // precondition — fires unconditionally (pre-existing behavior). + PreconditionKeyPath string + PreconditionValue string + + // UnwrapMode: "" (default, JSONKeyPath resolves directly to a plain + // string) or "rich_text" (the value at JSONKeyPath is itself a JSON + // string containing a Slate-style rich-text wrapper — + // `[{"children":[{"text":""}]}]` — unwrapped before resolution + // and re-embedded inside the wrapper on write). + UnwrapMode string } type EmbeddedReferenceRegistry struct { ... } @@ -508,11 +526,51 @@ type EmbeddedReferenceRegistry struct { ... } 1. **Walk attribute path**: Navigate `ResourceData.Attributes` following dot-notation path segments. `*` matches all map keys at that level. 2. **Extract JSON**: For each `RawHCLValue`, parse the `jsonencode(...)` expression to extract the JSON object inside. -3. **Walk JSON**: Navigate the JSON structure following `JSONKeyPath` (dot-notation) to locate the UUID string. -4. **Lookup**: Query the dependency graph for a resource matching `TargetResourceType` with the extracted UUID. -5. **Replace**: Update the JSON blob, replacing the UUID with `${resource_type.label.reference_field}` (Terraform interpolation syntax with single `$`). -6. **Serialize**: Re-marshal the JSON and update the `RawHCLValue` in the attributes map. -7. **Add Graph Edge**: Record the dependency in the graph so `--include-upstream` and cycle detection work correctly. +3. **Check precondition** (if `PreconditionKeyPath` is set): walk `PreconditionKeyPath` against the same parsed JSON; if it doesn't resolve to exactly `PreconditionValue` (including when the key is absent), the rule no-ops for this value — no change, no fallback variable, no graph edge. +4. **Walk JSON**: Navigate the JSON structure following `JSONKeyPath` (dot-notation) to locate the value. When `UnwrapMode == "rich_text"`, this locates the Slate-style wrapper string, not the raw UUID directly. +5. **Unwrap** (if `UnwrapMode == "rich_text"`): parse the wrapper `[{"children":[{"text":""}]}]` and extract the inner `text` value. Any shape mismatch (not an array, empty array, missing `children`, missing/non-string `text`) results in a no-op, mirroring `walkJSONPath`'s "return empty on mismatch" convention — never a panic. +6. **UUID-format guard** (unconditional): the extracted (or unwrapped) string is validated via `looksLikeUUID` (backed by `github.com/google/uuid`'s `Validate`). Only a UUID-shaped string is treated as a resolvable reference/fallback-variable target — anything else (a sentinel/mode-flag string such as `"useThemeId"` or `"activeTheme"`, or any future sentinel) no-ops exactly like "no value found," **regardless of `Strategy`**. This is a single check point applied uniformly, not a per-rule opt-in, so no enumerated skip-list is needed and no future rule registration can accidentally reintroduce the bug this guard closes. +7. **Lookup**: Query the dependency graph for a resource matching `TargetResourceType` with the extracted UUID. +8. **Replace**: Update the JSON blob, replacing the UUID with `${resource_type.label.reference_field}` (Terraform interpolation syntax with single `$`) or a fallback `${var.name}` reference, depending on `Strategy`. For `UnwrapMode == "rich_text"`, the replacement happens *inside* the wrapper (see below) rather than as a bare substring swap. +9. **Serialize**: Re-marshal the JSON and update the `RawHCLValue` in the attributes map. +10. **Add Graph Edge**: Record the dependency in the graph so `--include-upstream` and cycle detection work correctly (skipped for `Strategy: "variable"` or when only a fallback variable is emitted). + +**When to use `PreconditionKeyPath`**: when a rule should only fire based on a sibling key's value — e.g., a mode-flag key that switches which of two sibling keys holds the real reference (`theme.value == "useThemeId"` gates whether `themeId.value` should be resolved). Leave both fields at their zero value (`""`) for rules that should fire unconditionally, exactly like `subFlowId`/`form.value` do today. + +**When to use `UnwrapMode: "rich_text"`**: when the value at `JSONKeyPath` is not a plain UUID string but a JSON-encoded Slate/rich-text wrapper (as produced by some DaVinci node editor fields). Because the outer `properties` map is itself re-encoded via `jsonencode_raw`, the wrapper's inner quotes appear **backslash-escaped** in the final `RawHCLValue` text — a plain substring replace (as `replaceUUIDInRawHCL` does for the no-unwrap path) would not match. The rich-text path instead swaps the UUID for the resolved reference inside the *unescaped* wrapper string, then re-derives both the old and new wrapper's escaped form via `json.Marshal` (which normalizes escaping identically to how the original text was produced) before substituting inside the `RawHCLValue`. + +**Worked example — `pingone_branding_theme` reference on a `showForm` node** (registered against `internal/platform/pingone/resource_flow.go`): a node's `theme.value` may hold a direct UUID, be absent, or be the literal string `"useThemeId"` with the real UUID embedded in a rich-text wrapper at `themeId.value`. Two rules cover this: + +```go +// Case 1: direct UUID at theme.value — no precondition, no unwrap. +registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "theme.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", +}) + +// Case 3: rich-text-wrapped UUID at themeId.value, gated on theme.value == "useThemeId". +registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + UnwrapMode: "rich_text", +}) +``` + +Case 1's rule fires unconditionally on `theme.value`, but the format guard rejects non-UUID values like `"useThemeId"` or `"activeTheme"` before `Strategy` is consulted — no `SkipValues` list is needed. Case 3's rule only fires when `theme.value` is exactly `"useThemeId"`, and only ever touches the UUID nested inside `themeId.value`'s wrapper — `theme.value` itself is left untouched by this rule. #### Example: DaVinci Flow `subFlowId` Rule diff --git a/contributing/DEVELOPER_HANDBOOK.md b/contributing/DEVELOPER_HANDBOOK.md index 23fd42c..d6e07cb 100644 --- a/contributing/DEVELOPER_HANDBOOK.md +++ b/contributing/DEVELOPER_HANDBOOK.md @@ -431,6 +431,26 @@ type EmbeddedReferenceRule struct { TargetResourceType string // resource type the UUID references ("pingone_davinci_flow") JSONKeyPath string // path inside the JSON object ("subFlowId.value.value") ReferenceField string // attribute on target resource ("id") + + Strategy string // "" / "reference" (default), "reference_with_fallback", or "variable" + VariablePrefix string // combined with a VariableNamingPath-derived suffix to name a fallback variable + VariableNamingPath string // JSON key path used to derive a human-readable variable suffix + + // PreconditionKeyPath / PreconditionValue — optional. A sibling JSON key + // path (resolved against the same parsed blob as JSONKeyPath) that must + // equal PreconditionValue before the rule fires. Zero value ("") means + // no precondition — the rule fires unconditionally (matches pre-existing + // rules like subFlowId/form.value, which need no changes). + PreconditionKeyPath string + PreconditionValue string + + // UnwrapMode — optional. "" (default) means JSONKeyPath resolves + // directly to a plain string. "rich_text" means the value at + // JSONKeyPath is itself a JSON string holding a Slate-style rich-text + // wrapper (`[{"children":[{"text":""}]}]`); the inner value is + // unwrapped before resolution and the resolved reference/variable is + // re-embedded inside the wrapper on write. + UnwrapMode string } ``` @@ -445,6 +465,12 @@ type EmbeddedReferenceRule struct { - Keys must exist in the JSON for the subFlowId to be found and replaced - Missing keys are silently skipped (not an error) +**The unconditional UUID-format guard**: regardless of which fields are set, the value ultimately extracted from `JSONKeyPath` (after unwrap, if `UnwrapMode == "rich_text"`) is always validated by a `looksLikeUUID` check before any `Strategy` branch runs. If it isn't shaped like a UUID, the rule no-ops exactly as if no value had been found at all — no change, no fallback variable, no graph edge. This means you never need to enumerate sentinel/mode-flag strings (e.g., `"useThemeId"`, `"activeTheme"`) in a rule registration to keep them from being misresolved — the guard rejects any non-UUID string generically, including future sentinels no one has thought of yet. + +**Using `PreconditionKeyPath`/`PreconditionValue`**: set these when a rule should only act if a *different* key in the same JSON blob equals a specific value — for example, a mode-flag key that determines which of two sibling keys holds the live reference. Leave both unset (`""`) for rules that should fire unconditionally. + +**Using `UnwrapMode: "rich_text"`**: set this when the value at `JSONKeyPath` is not a plain UUID string but a JSON-encoded Slate/rich-text wrapper. Because the outer JSON blob is itself passed through `jsonencode_raw` a second time, the wrapper's inner quotes are backslash-escaped inside the final `RawHCLValue` text — a plain substring replace would not match, so the rich-text path re-derives the escaped form via `json.Marshal` before substituting. You do not need to do anything special in your rule literal beyond setting `UnwrapMode: "rich_text"`; the engine handles both extraction and re-embedding. + ### Step 1: Analyze the Structure Find the API struct and the YAML attribute: @@ -507,6 +533,40 @@ func init() { } ``` +**Worked example — precondition + rich-text unwrap**: a `showForm` node's theme reference can be a direct UUID at `theme.value`, absent, or an indirect mode flag (`theme.value == "useThemeId"`) pointing at a rich-text-wrapped UUID in `themeId.value`. This is expressed as two rules, both targeting `pingone_branding_theme`: + +```go +// Direct UUID case — no precondition, no unwrap. +registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "theme.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", +}) + +// Indirect mode-flag case — fires only when theme.value == "useThemeId"; +// the UUID lives inside themeId.value's rich-text wrapper. +registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + UnwrapMode: "rich_text", +}) +``` + +Neither rule needs an enumerated skip list for sentinel values like `"activeTheme"` — the unconditional UUID-format guard rejects any non-UUID string extracted from `theme.value` before `Strategy` is ever consulted. + ### Step 3: Verify Run the pipeline and check the output: @@ -551,11 +611,13 @@ resource "pingone_davinci_flow" "parent_flow_label" { 1. **Rule Matching**: After all resources are processed and added to the dependency graph, `ResolveEmbeddedReferences()` iterates all rules. 2. **Attribute Navigation**: For each rule, walk `ResourceData.Attributes` following `AttributePath`. The `*` wildcard matches all map keys at that level. 3. **JSON Extraction**: For each `RawHCLValue` at the final path, extract the JSON object from inside the `jsonencode(...)` expression. -4. **UUID Lookup**: Navigate the JSON blob via `JSONKeyPath`, find the UUID string. -5. **Graph Resolution**: Query the dependency graph for a `TargetResourceType` resource with that UUID to get its Terraform label. -6. **Replacement**: Replace the UUID with `${resource_type.label.reference_field}` (Terraform interpolation). -7. **Serialization**: Re-marshal the JSON and update the `RawHCLValue`. -8. **Graph Update**: Record the dependency edge so `--include-upstream` and cycle detection work correctly. +4. **Precondition Check** (if `PreconditionKeyPath` is set): walk `PreconditionKeyPath` against the same parsed JSON; a mismatch (or absent key) no-ops the rule for this value. +5. **JSON/UUID Extraction**: Navigate the JSON blob via `JSONKeyPath` to find the value. If `UnwrapMode == "rich_text"`, unwrap the Slate wrapper to get the inner string; any shape mismatch no-ops without a panic. +6. **UUID-Format Guard**: validate the extracted string via `looksLikeUUID`. Non-UUID-shaped strings (sentinels, mode flags, anything unexpected) no-op here, regardless of `Strategy`. +7. **Graph Resolution**: Query the dependency graph for a `TargetResourceType` resource with that UUID to get its Terraform label. +8. **Replacement**: Replace the UUID with `${resource_type.label.reference_field}` (Terraform interpolation) or a fallback `${var.name}`, depending on `Strategy`. For `UnwrapMode == "rich_text"`, the replacement is re-embedded inside the wrapper via an escaping-aware helper rather than a plain substring swap. +9. **Serialization**: Re-marshal the JSON and update the `RawHCLValue`. +10. **Graph Update**: Record the dependency edge so `--include-upstream` and cycle detection work correctly. ### Debugging Embedded References diff --git a/internal/core/embedded_references.go b/internal/core/embedded_references.go index 15feef2..9272539 100644 --- a/internal/core/embedded_references.go +++ b/internal/core/embedded_references.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/google/uuid" "github.com/pingidentity/pingcli-plugin-terraformer/internal/graph" "github.com/pingidentity/pingcli-plugin-terraformer/internal/utils" ) @@ -31,6 +32,44 @@ type EmbeddedReferenceRule struct { // a human-readable variable suffix (e.g., "nodeTitle.value"). When the key // is absent the first 8 characters of the UUID are used instead. VariableNamingPath string + + // PreconditionKeyPath is an optional JSON key path (dot-notation, resolved + // against the same parsed JSON blob as JSONKeyPath) that must resolve to + // exactly PreconditionValue before this rule fires. Zero value ("") means + // no precondition — the rule fires unconditionally, matching pre-existing + // behavior. Used, for example, to gate a rule on a sibling mode-flag key + // (e.g., only act on "themeId.value" when "theme.value" == "useThemeId"). + PreconditionKeyPath string + + // PreconditionValue is the exact string PreconditionKeyPath must resolve to + // for the rule to fire. Ignored when PreconditionKeyPath is empty. + PreconditionValue string + + // UnwrapMode controls how the value at JSONKeyPath is extracted and + // re-embedded: + // "" (default / zero value) — JSONKeyPath resolves directly to + // a plain string (pre-existing behavior). + // "rich_text" — the value at JSONKeyPath is itself a JSON string + // containing a Slate-style rich-text wrapper + // (`[{"children":[{"text":""}]}]`); the inner value + // is unwrapped before resolution and the resolved + // reference/variable is re-embedded back inside the + // wrapper on write. + UnwrapMode string +} + +// richTextUnwrapMode is the UnwrapMode value that enables Slate-style +// rich-text unwrap/rewrap of the value found at JSONKeyPath. +const richTextUnwrapMode = "rich_text" + +// looksLikeUUID reports whether s is formatted as a valid UUID. It is used as +// an unconditional guard before any extracted (or unwrapped) string is ever +// treated as a resolvable reference/fallback-variable target — this rejects +// sentinel/mode-flag strings (e.g., "useThemeId", "activeTheme") without +// needing to enumerate them. Named to avoid colliding with the common local +// variable name "uuid" used at call sites for the extracted value. +func looksLikeUUID(s string) bool { + return uuid.Validate(s) == nil } // EmbeddedReferenceRegistry collects rules @@ -179,31 +218,62 @@ func processRawHCLValue( return value } - // Walk the JSON path to find the UUID - uuid := walkJSONPath(jsonData, rule.JSONKeyPath) - if uuid == "" { + // Precondition: a sibling JSON key path must resolve to exactly + // PreconditionValue before this rule fires. Zero-value PreconditionKeyPath + // means no precondition — fires unconditionally, matching pre-existing + // behavior. + if rule.PreconditionKeyPath != "" { + if walkJSONPath(jsonData, rule.PreconditionKeyPath) != rule.PreconditionValue { + return value + } + } + + // Walk the JSON path to find the value. In "rich_text" UnwrapMode this is + // the Slate wrapper string; otherwise it is the plain UUID string. + extracted := walkJSONPath(jsonData, rule.JSONKeyPath) + if extracted == "" { + return value + } + + var wrapper string + uuidStr := extracted + if rule.UnwrapMode == richTextUnwrapMode { + wrapper = extracted + uuidStr = unwrapRichText(wrapper) + if uuidStr == "" { + // Malformed/unexpected wrapper shape — no-op, no panic. + return value + } + } + + // Unconditional UUID-format guard: only strings shaped like a UUID are + // ever treated as a resolvable reference/fallback-variable target. Any + // other string (e.g. a mode-flag sentinel such as "useThemeId" or + // "activeTheme") no-ops exactly like "no value found", regardless of + // Strategy. + if !looksLikeUUID(uuidStr) { return value } // Strategy: "variable" — always emit a variable, skip graph lookup if rule.Strategy == "variable" { - varName := deriveVariableName(rule, jsonData, uuid) + varName := deriveVariableName(rule, jsonData, uuidStr) tfRef := fmt.Sprintf("${var.%s}", varName) - newValue := replaceUUIDInRawHCL(value, uuid, tfRef) - addEmbeddedFallbackVariable(varName, rule, uuid, varSeen, fallbackVars) - return RawHCLValue(newValue) + newValue := replaceExtractedValue(value, rule, wrapper, uuidStr, tfRef) + addEmbeddedFallbackVariable(varName, rule, uuidStr, varSeen, fallbackVars) + return newValue } // Strategy: "reference" (default) or "reference_with_fallback" — try graph lookup - refName, err := g.GetReferenceName(rule.TargetResourceType, uuid) + refName, err := g.GetReferenceName(rule.TargetResourceType, uuidStr) if err != nil { // UUID not found in graph if rule.Strategy == "reference_with_fallback" { - varName := deriveVariableName(rule, jsonData, uuid) + varName := deriveVariableName(rule, jsonData, uuidStr) tfRef := fmt.Sprintf("${var.%s}", varName) - newValue := replaceUUIDInRawHCL(value, uuid, tfRef) - addEmbeddedFallbackVariable(varName, rule, uuid, varSeen, fallbackVars) - return RawHCLValue(newValue) + newValue := replaceExtractedValue(value, rule, wrapper, uuidStr, tfRef) + addEmbeddedFallbackVariable(varName, rule, uuidStr, varSeen, fallbackVars) + return newValue } // Default strategy: leave unchanged return value @@ -213,12 +283,90 @@ func processRawHCLValue( tfRef := fmt.Sprintf("${%s.%s.%s}", rule.TargetResourceType, refName, rule.ReferenceField) // Replace the UUID string in the RawHCLValue with the terraform reference - newValue := replaceUUIDInRawHCL(value, uuid, tfRef) + newValue := replaceExtractedValue(value, rule, wrapper, uuidStr, tfRef) // Add graph edge - _ = g.AddEdge(resource.ResourceType, resource.ID, rule.TargetResourceType, uuid, "properties."+rule.JSONKeyPath, "") + _ = g.AddEdge(resource.ResourceType, resource.ID, rule.TargetResourceType, uuidStr, "properties."+rule.JSONKeyPath, "") + + return newValue +} + +// replaceExtractedValue re-embeds a resolved reference/variable string in +// place of the originally extracted value. It chooses the escaping-aware +// rich-text rewrap path when the rule uses UnwrapMode == "rich_text", and +// falls back to the plain substring replace (replaceUUIDInRawHCL) otherwise. +func replaceExtractedValue(value RawHCLValue, rule EmbeddedReferenceRule, wrapper string, uuidStr string, tfRef string) RawHCLValue { + if rule.UnwrapMode == richTextUnwrapMode { + return replaceRichTextInRawHCL(value, wrapper, uuidStr, tfRef) + } + return RawHCLValue(replaceUUIDInRawHCL(value, uuidStr, tfRef)) +} + +// unwrapRichText extracts the inner "text" value from a Slate-style +// rich-text wrapper of the shape `[{"children":[{"text":""}]}]`. Only +// the first array element's first child is read, matching the confirmed +// evidence shape. Returns "" on any shape mismatch (not an array, empty +// array, missing "children", missing/non-string "text") — mirroring +// walkJSONPath's existing "return empty on mismatch" convention. Never +// panics on malformed input. +func unwrapRichText(wrapper string) string { + var elements []interface{} + if err := json.Unmarshal([]byte(wrapper), &elements); err != nil { + return "" + } + if len(elements) == 0 { + return "" + } + + elem, ok := elements[0].(map[string]interface{}) + if !ok { + return "" + } + + childrenRaw, exists := elem["children"] + if !exists { + return "" + } + children, ok := childrenRaw.([]interface{}) + if !ok || len(children) == 0 { + return "" + } + + child, ok := children[0].(map[string]interface{}) + if !ok { + return "" + } + + text, ok := child["text"].(string) + if !ok { + return "" + } + + return text +} + +// replaceRichTextInRawHCL re-embeds a resolved reference/variable string +// inside a Slate-style rich-text wrapper and substitutes the escaped wrapper +// text within the raw HCL string. Unlike replaceUUIDInRawHCL's plain +// substring replace, this accounts for the wrapper being JSON-encoded a +// second time when the outer `properties` map was marshaled by +// transformJSONEncodeRaw — so quotes inside the wrapper appear +// backslash-escaped in the RawHCLValue text. Re-deriving both the old and +// new wrapper's escaped form via json.Marshal (rather than hand-escaping) +// normalizes the escaping identically to how the original text was produced. +func replaceRichTextInRawHCL(value RawHCLValue, wrapper string, uuidStr string, tfRef string) RawHCLValue { + newWrapper := strings.Replace(wrapper, uuidStr, tfRef, 1) + + oldEscaped, err := json.Marshal(wrapper) + if err != nil { + return value + } + newEscaped, err := json.Marshal(newWrapper) + if err != nil { + return value + } - return RawHCLValue(newValue) + return RawHCLValue(strings.Replace(string(value), string(oldEscaped), string(newEscaped), 1)) } // deriveVariableName builds a Terraform variable name from the rule's VariablePrefix diff --git a/internal/core/embedded_references_test.go b/internal/core/embedded_references_test.go index c0be66b..b2fd712 100644 --- a/internal/core/embedded_references_test.go +++ b/internal/core/embedded_references_test.go @@ -1,9 +1,12 @@ package core import ( + "encoding/json" "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/graph" "github.com/pingidentity/pingcli-plugin-terraformer/internal/schema" ) @@ -69,7 +72,7 @@ func TestEmbeddedReferenceRegistry_RegisterAndRetrieve(t *testing.T) { func TestResolveEmbeddedReferences_SingleSubFlow(t *testing.T) { // Create graph with target flow and source flow g := graph.New() - g.AddResource("pingone_davinci_flow", "flow-abc123", "pingcli__My-0020-Flow") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-000000000001", "pingcli__My-0020-Flow") g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -86,7 +89,7 @@ func TestResolveEmbeddedReferences_SingleSubFlow(t *testing.T) { "nodes": map[string]interface{}{ "node1": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue("jsonencode({\n \"nodeTitle\": {\n \"value\": \"Sign On Flow\"\n },\n \"subFlowId\": {\n \"value\": {\n \"label\": \"My Flow\",\n \"value\": \"flow-abc123\"\n }\n }\n})"), + "properties": RawHCLValue("jsonencode({\n \"nodeTitle\": {\n \"value\": \"Sign On Flow\"\n },\n \"subFlowId\": {\n \"value\": {\n \"label\": \"My Flow\",\n \"value\": \"aaaaaaaa-0000-4000-8000-000000000001\"\n }\n }\n})"), }, }, }, @@ -116,7 +119,7 @@ func TestResolveEmbeddedReferences_SingleSubFlow(t *testing.T) { } // UUID string itself should no longer appear - if strings.Contains(string(resolvedValue), "\"flow-abc123\"") { + if strings.Contains(string(resolvedValue), "\"aaaaaaaa-0000-4000-8000-000000000001\"") { t.Errorf("expected raw UUID to be removed, still found in: %s", resolvedValue) } @@ -136,7 +139,7 @@ func TestResolveEmbeddedReferences_SingleSubFlow(t *testing.T) { // nodes within a single flow, some with subFlowId and some without func TestResolveEmbeddedReferences_MultipleNodes(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_flow", "flow-sub1", "pingcli__Sub-0020-Flow-1") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-000000000002", "pingcli__Sub-0020-Flow-1") g.AddResource("pingone_davinci_flow", "flow-sub2", "pingcli__Sub-0020-Flow-2") g.AddResource("pingone_davinci_flow", "parent-flow", "pingcli__Parent-Flow") @@ -155,7 +158,7 @@ func TestResolveEmbeddedReferences_MultipleNodes(t *testing.T) { "nodes": map[string]interface{}{ "node1": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue("jsonencode({\n \"subFlowId\": {\n \"value\": {\n \"label\": \"Sub Flow 1\",\n \"value\": \"flow-sub1\"\n }\n }\n})"), + "properties": RawHCLValue("jsonencode({\n \"subFlowId\": {\n \"value\": {\n \"label\": \"Sub Flow 1\",\n \"value\": \"aaaaaaaa-0000-4000-8000-000000000002\"\n }\n }\n})"), }, }, "node2": map[string]interface{}{ @@ -316,9 +319,9 @@ func TestResolveEmbeddedReferences_NoMatchingJSONPath(t *testing.T) { // (*) in AttributePath correctly iterates over all map keys at that level func TestResolveEmbeddedReferences_WildcardTraversal(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_flow", "flow-a", "pingcli__Flow-A") - g.AddResource("pingone_davinci_flow", "flow-b", "pingcli__Flow-B") - g.AddResource("pingone_davinci_flow", "flow-c", "pingcli__Flow-C") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-00000000000a", "pingcli__Flow-A") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-00000000000b", "pingcli__Flow-B") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-00000000000c", "pingcli__Flow-C") g.AddResource("pingone_davinci_flow", "parent-flow", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -336,17 +339,17 @@ func TestResolveEmbeddedReferences_WildcardTraversal(t *testing.T) { "nodes": map[string]interface{}{ "nodeA": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "flow-a"}}})`), + "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "aaaaaaaa-0000-4000-8000-00000000000a"}}})`), }, }, "nodeB": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "flow-b"}}})`), + "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "aaaaaaaa-0000-4000-8000-00000000000b"}}})`), }, }, "nodeC": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "flow-c"}}})`), + "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "aaaaaaaa-0000-4000-8000-00000000000c"}}})`), }, }, }, @@ -493,7 +496,7 @@ func TestResolveEmbeddedReferences_DifferentResourceTypeSkipped(t *testing.T) { // with different JSONKeyPath values work correctly, demonstrating extensibility func TestResolveEmbeddedReferences_ExtensibleNewRule(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_connector", "conn-custom1", "pingcli__Custom-Connector") + g.AddResource("pingone_davinci_connector", "aaaaaaaa-0000-4000-8000-000000000d01", "pingcli__Custom-Connector") g.AddResource("pingone_davinci_flow", "flow-with-connector", "pingcli__Flow-With-Connector") // Custom rule with different JSONKeyPath @@ -511,7 +514,7 @@ func TestResolveEmbeddedReferences_ExtensibleNewRule(t *testing.T) { "nodes": map[string]interface{}{ "node1": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"customConnectorId": {"value": "conn-custom1"}})`), + "properties": RawHCLValue(`jsonencode({"customConnectorId": {"value": "aaaaaaaa-0000-4000-8000-000000000d01"}})`), }, }, }, @@ -546,7 +549,7 @@ func TestResolveEmbeddedReferences_ExtensibleNewRule(t *testing.T) { // attributes outside the targeted paths are not modified func TestResolveEmbeddedReferences_PreservesOtherAttributes(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_flow", "flow-sub1", "pingcli__Sub-Flow") + g.AddResource("pingone_davinci_flow", "aaaaaaaa-0000-4000-8000-000000000e01", "pingcli__Sub-Flow") g.AddResource("pingone_davinci_flow", "parent-flow", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -568,7 +571,7 @@ func TestResolveEmbeddedReferences_PreservesOtherAttributes(t *testing.T) { "nodes": map[string]interface{}{ "node1": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "flow-sub1"}}})`), + "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "aaaaaaaa-0000-4000-8000-000000000e01"}}})`), }, }, }, @@ -633,7 +636,7 @@ func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDNotInGraph( } // Properties include both the UUID field and the naming field - propertiesJSON := `jsonencode({"form": {"value": "form-uuid-abc123"}, "nodeTitle": {"value": "Example - Sign On"}})` + propertiesJSON := `jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000001"}, "nodeTitle": {"value": "Example - Sign On"}})` attrs := map[string]interface{}{ "graph_data": map[string]interface{}{ @@ -673,7 +676,7 @@ func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDNotInGraph( } // Raw UUID string should no longer appear - if strings.Contains(string(resolvedValue), `"form-uuid-abc123"`) { + if strings.Contains(string(resolvedValue), `"bbbbbbbb-0000-4000-8000-000000000001"`) { t.Errorf("expected raw UUID to be removed, still found in: %s", resolvedValue) } @@ -700,7 +703,7 @@ func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDNotInGraph( // FallbackVariable is returned. func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDInGraph(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_form", "form-uuid-abc123", "pingcli__Example_Sign_On") + g.AddResource("pingone_davinci_form", "bbbbbbbb-0000-4000-8000-000000000001", "pingcli__Example_Sign_On") g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -714,7 +717,7 @@ func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDInGraph(t * VariableNamingPath: "nodeTitle.value", } - propertiesJSON := `jsonencode({"form": {"value": "form-uuid-abc123"}, "nodeTitle": {"value": "Example - Sign On"}})` + propertiesJSON := `jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000001"}, "nodeTitle": {"value": "Example - Sign On"}})` attrs := map[string]interface{}{ "graph_data": map[string]interface{}{ @@ -770,7 +773,7 @@ func TestResolveEmbeddedReferences_StrategyReferenceWithFallback_UUIDInGraph(t * func TestResolveEmbeddedReferences_StrategyVariable_AlwaysEmitsVariable(t *testing.T) { g := graph.New() // Target IS in the graph — but strategy "variable" should still emit a var - g.AddResource("pingone_davinci_form", "form-uuid-xyz", "pingcli__Some_Form") + g.AddResource("pingone_davinci_form", "bbbbbbbb-0000-4000-8000-000000000002", "pingcli__Some_Form") g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -784,7 +787,7 @@ func TestResolveEmbeddedReferences_StrategyVariable_AlwaysEmitsVariable(t *testi VariableNamingPath: "nodeTitle.value", } - propertiesJSON := `jsonencode({"form": {"value": "form-uuid-xyz"}, "nodeTitle": {"value": "Sign On Node"}})` + propertiesJSON := `jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000002"}, "nodeTitle": {"value": "Sign On Node"}})` attrs := map[string]interface{}{ "graph_data": map[string]interface{}{ @@ -846,7 +849,7 @@ func TestResolveEmbeddedReferences_StrategyVariable_AlwaysEmitsVariable(t *testi // - UUID not in graph → left unchanged, no variable emitted func TestResolveEmbeddedReferences_StrategyDefault_BackwardCompatible(t *testing.T) { g := graph.New() - g.AddResource("pingone_davinci_flow", "flow-in-graph", "pingcli__In-Graph-Flow") + g.AddResource("pingone_davinci_flow", "bbbbbbbb-0000-4000-8000-000000000003", "pingcli__In-Graph-Flow") g.AddResource("pingone_davinci_flow", "parent-flow", "pingcli__Parent-Flow") rule := EmbeddedReferenceRule{ @@ -864,7 +867,7 @@ func TestResolveEmbeddedReferences_StrategyDefault_BackwardCompatible(t *testing "nodes": map[string]interface{}{ "node-known": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "flow-in-graph"}}})`), + "properties": RawHCLValue(`jsonencode({"subFlowId": {"value": {"value": "bbbbbbbb-0000-4000-8000-000000000003"}}})`), }, }, "node-unknown": map[string]interface{}{ @@ -935,7 +938,7 @@ func TestResolveEmbeddedReferences_VariableNamingPath_Missing(t *testing.T) { } // Properties do NOT contain nodeTitle — only the UUID field - propertiesJSON := `jsonencode({"form": {"value": "abcde123-4567-890"}})` + propertiesJSON := `jsonencode({"form": {"value": "abcde123-0000-4000-8000-000000000000"}})` attrs := map[string]interface{}{ "graph_data": map[string]interface{}{ @@ -968,7 +971,7 @@ func TestResolveEmbeddedReferences_VariableNamingPath_Missing(t *testing.T) { resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) - // UUID "abcde123-4567-890": first 8 chars = "abcde123", SanitizeVariableName = "abcde123" + // UUID "abcde123-0000-4000-8000-000000000000": first 8 chars = "abcde123", SanitizeVariableName = "abcde123" // Variable name = "davinci_form_abcde123" const expectedVarRef = "${var.davinci_form_abcde123}" if !strings.Contains(string(resolvedValue), expectedVarRef) { @@ -1011,12 +1014,12 @@ func TestResolveEmbeddedReferences_MultipleNodesDistinctVariables(t *testing.T) "nodes": map[string]interface{}{ "node1": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"form": {"value": "form-uuid-111"}, "nodeTitle": {"value": "Login Form"}})`), + "properties": RawHCLValue(`jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000011"}, "nodeTitle": {"value": "Login Form"}})`), }, }, "node2": map[string]interface{}{ "data": map[string]interface{}{ - "properties": RawHCLValue(`jsonencode({"form": {"value": "form-uuid-222"}, "nodeTitle": {"value": "Signup Form"}})`), + "properties": RawHCLValue(`jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000022"}, "nodeTitle": {"value": "Signup Form"}})`), }, }, }, @@ -1089,7 +1092,7 @@ func TestResolveEmbeddedReferences_DuplicateUUIDs_Deduplicated(t *testing.T) { } // Two nodes share the same UUID - sharedProps := `jsonencode({"form": {"value": "form-uuid-same"}, "nodeTitle": {"value": "Shared Form"}})` + sharedProps := `jsonencode({"form": {"value": "bbbbbbbb-0000-4000-8000-000000000033"}, "nodeTitle": {"value": "Shared Form"}})` attrs := map[string]interface{}{ "graph_data": map[string]interface{}{ @@ -1142,3 +1145,579 @@ func TestResolveEmbeddedReferences_DuplicateUUIDs_Deduplicated(t *testing.T) { t.Errorf("expected FallbackVariable.Name %q, got %q", "davinci_form_shared_form", fallbackVars[0].Name) } } + +// TestLooksLikeUUID verifies the UUID-format guard helper against a mix of +// valid UUID-shaped strings and the non-UUID placeholder/sentinel strings +// used throughout this file's fixtures. +func TestLooksLikeUUID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"valid hyphenated UUID", "860b5cd5-45cc-466d-abbd-64298bb90bed", true}, + {"valid UUID uppercase", "860B5CD5-45CC-466D-ABBD-64298BB90BED", true}, + {"valid UUID no hyphens", "860b5cd545cc466dabbd64298bb90bed", true}, + {"empty string", "", false}, + {"sentinel useThemeId", "useThemeId", false}, + {"sentinel activeTheme", "activeTheme", false}, + {"legacy placeholder flow-abc123", "flow-abc123", false}, + {"legacy placeholder form-uuid-xyz", "form-uuid-xyz", false}, + {"legacy placeholder conn-custom1", "conn-custom1", false}, + {"truncated uuid-like string", "abcde123-4567-890", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikeUUID(tt.input); got != tt.want { + t.Errorf("looksLikeUUID(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestResolveEmbeddedReferences_Precondition_Match verifies that a rule with +// PreconditionKeyPath set fires when the sibling path resolves to exactly +// PreconditionValue. +func TestResolveEmbeddedReferences_Precondition_Match(t *testing.T) { + g := graph.New() + g.AddResource("pingone_branding_theme", "cccccccc-0000-4000-8000-000000000001", "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + } + + propertiesJSON := `jsonencode({"theme": {"value": "useThemeId"}, "themeId": {"value": "cccccccc-0000-4000-8000-000000000001"}, "nodeTitle": {"value": "Theme Node"}})` + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + const expectedRef = "${pingone_branding_theme.pingcli__My_Theme.id}" + if !strings.Contains(string(resolvedValue), expectedRef) { + t.Errorf("expected precondition-satisfied rule to resolve to %q, got: %s", expectedRef, resolvedValue) + } + + // theme.value (the precondition key itself) must remain unchanged + if !strings.Contains(string(resolvedValue), `"useThemeId"`) { + t.Error("expected theme.value to remain the literal string \"useThemeId\"") + } +} + +// TestResolveEmbeddedReferences_Precondition_NoMatch verifies that when the +// precondition key resolves to a value other than PreconditionValue, the +// rule no-ops: no change, no fallback variable, no graph edge. +func TestResolveEmbeddedReferences_Precondition_NoMatch(t *testing.T) { + g := graph.New() + g.AddResource("pingone_branding_theme", "cccccccc-0000-4000-8000-000000000002", "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + } + + // theme.value is a direct UUID (case 1 shape), not "useThemeId" — precondition fails + propertiesJSON := `jsonencode({"theme": {"value": "cccccccc-0000-4000-8000-000000000003"}, "themeId": {"value": "cccccccc-0000-4000-8000-000000000002"}, "nodeTitle": {"value": "Theme Node"}})` + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + originalValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + fallbackVars := ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + if resolvedValue != originalValue { + t.Errorf("expected value unchanged when precondition doesn't match, got: %s", resolvedValue) + } + if len(fallbackVars) != 0 { + t.Errorf("expected 0 FallbackVariables when precondition doesn't match, got %d", len(fallbackVars)) + } + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + if len(deps) != 0 { + t.Errorf("expected no graph edges when precondition doesn't match, got %d", len(deps)) + } +} + +// TestResolveEmbeddedReferences_Precondition_KeyAbsent verifies that when the +// precondition key is entirely absent from the JSON, it is treated as a +// non-match — the rule no-ops. +func TestResolveEmbeddedReferences_Precondition_KeyAbsent(t *testing.T) { + g := graph.New() + g.AddResource("pingone_branding_theme", "cccccccc-0000-4000-8000-000000000004", "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + } + + // "theme" key is entirely absent (customForm-capability-like shape) + propertiesJSON := `jsonencode({"themeId": {"value": "cccccccc-0000-4000-8000-000000000004"}, "nodeTitle": {"value": "Theme Node"}})` + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + originalValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + fallbackVars := ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + if resolvedValue != originalValue { + t.Errorf("expected value unchanged when precondition key absent, got: %s", resolvedValue) + } + if len(fallbackVars) != 0 { + t.Errorf("expected 0 FallbackVariables when precondition key absent, got %d", len(fallbackVars)) + } +} + +// themeRichTextRule mirrors the theme.value/themeId.value case-3 rule shape +// that Task 2 will register against pingone_branding_theme. +func themeRichTextRule() EmbeddedReferenceRule { + return EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + UnwrapMode: "rich_text", + } +} + +// richTextPropertiesJSON builds a properties RawHCLValue blob shaped like a +// showForm node using the "useThemeId" mode flag, with the UUID embedded +// inside a Slate-style rich-text wrapper at themeId.value — matching the +// double-JSON-encoding that transformJSONEncodeRaw produces in production. +func richTextPropertiesJSON(t *testing.T, uuidStr string) string { + t.Helper() + + wrapper := `[{"children":[{"text":"` + uuidStr + `"}]}]` + escapedWrapper, err := json.Marshal(wrapper) + if err != nil { + t.Fatalf("failed to marshal wrapper: %v", err) + } + + return `jsonencode({"theme": {"value": "useThemeId"}, "themeId": {"value": ` + string(escapedWrapper) + `}, "nodeTitle": {"value": "Theme Node"}})` +} + +// TestResolveEmbeddedReferences_RichTextUnwrap_ResolvesToVariable verifies +// that UnwrapMode "rich_text" correctly extracts the UUID from the Slate +// wrapper, resolves it via reference_with_fallback when not in the graph, +// and re-embeds the variable reference back inside the wrapper, preserving +// its outer JSON structure. +func TestResolveEmbeddedReferences_RichTextUnwrap_ResolvesToVariable(t *testing.T) { + g := graph.New() + // Target UUID not in graph — no pingone_branding_theme resources registered + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := themeRichTextRule() + const themeUUID = "dddddddd-0000-4000-8000-000000000001" + propertiesJSON := richTextPropertiesJSON(t, themeUUID) + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + fallbackVars := ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + // theme.value must remain the literal string "useThemeId" + if !strings.Contains(string(resolvedValue), `"useThemeId"`) { + t.Error("expected theme.value to remain the literal string \"useThemeId\"") + } + + // The raw UUID must no longer appear + if strings.Contains(string(resolvedValue), themeUUID) { + t.Errorf("expected raw UUID to be removed from wrapper, still found in: %s", resolvedValue) + } + + const expectedVarRef = "${var.davinci_theme_theme_node}" + if !strings.Contains(string(resolvedValue), expectedVarRef) { + t.Errorf("expected wrapper to contain var reference %q, got: %s", expectedVarRef, resolvedValue) + } + + // Verify the wrapper's outer JSON structure round-trips: unmarshal the + // outer properties JSON, then unmarshal themeId.value (a JSON string) + // back into the Slate array shape with the resolved value swapped in. + jsonStr := extractJSONFromRawHCL(resolvedValue) + var outer map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &outer); err != nil { + t.Fatalf("expected resolved properties to remain valid JSON: %v", err) + } + themeIDValue := outer["themeId"].(map[string]interface{})["value"].(string) + + var wrapperArr []map[string]interface{} + if err := json.Unmarshal([]byte(themeIDValue), &wrapperArr); err != nil { + t.Fatalf("expected themeId.value to round-trip as a JSON array: %v", err) + } + if len(wrapperArr) != 1 { + t.Fatalf("expected wrapper array to have exactly 1 element, got %d", len(wrapperArr)) + } + children := wrapperArr[0]["children"].([]interface{}) + text := children[0].(map[string]interface{})["text"].(string) + if text != expectedVarRef { + t.Errorf("expected wrapper inner text to be %q, got %q", expectedVarRef, text) + } + + if len(fallbackVars) != 1 { + t.Fatalf("expected 1 FallbackVariable, got %d", len(fallbackVars)) + } + if fallbackVars[0].Name != "davinci_theme_theme_node" { + t.Errorf("expected FallbackVariable.Name %q, got %q", "davinci_theme_theme_node", fallbackVars[0].Name) + } +} + +// TestResolveEmbeddedReferences_RichTextUnwrap_ResolvesToReference verifies +// that UnwrapMode "rich_text" resolves to a direct resource reference (with +// a graph edge) when the unwrapped UUID IS present in the graph. +func TestResolveEmbeddedReferences_RichTextUnwrap_ResolvesToReference(t *testing.T) { + g := graph.New() + const themeUUID = "dddddddd-0000-4000-8000-000000000002" + g.AddResource("pingone_branding_theme", themeUUID, "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := themeRichTextRule() + propertiesJSON := richTextPropertiesJSON(t, themeUUID) + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + fallbackVars := ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + if !strings.Contains(string(resolvedValue), `"useThemeId"`) { + t.Error("expected theme.value to remain the literal string \"useThemeId\"") + } + + const expectedRef = "${pingone_branding_theme.pingcli__My_Theme.id}" + if !strings.Contains(string(resolvedValue), expectedRef) { + t.Errorf("expected wrapper to contain resource reference %q, got: %s", expectedRef, resolvedValue) + } + + if strings.Contains(string(resolvedValue), themeUUID) { + t.Errorf("expected raw UUID to be removed from wrapper, still found in: %s", resolvedValue) + } + + if len(fallbackVars) != 0 { + t.Errorf("expected 0 FallbackVariables when UUID resolved to reference, got %d", len(fallbackVars)) + } + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + if len(deps) == 0 { + t.Error("expected graph edge to be created for rich-text-unwrapped reference") + } +} + +// TestResolveEmbeddedReferences_RichTextUnwrap_MalformedWrapper verifies +// that a variety of malformed/unexpected wrapper shapes leave the value +// unchanged, emit no fallback variable/graph edge, and never panic. +func TestResolveEmbeddedReferences_RichTextUnwrap_MalformedWrapper(t *testing.T) { + tests := []struct { + name string + themeIDValueJ string // JSON-encoded (escaped) themeId.value content + }{ + {"not an array", `"{\"children\":[{\"text\":\"dddddddd-0000-4000-8000-000000000003\"}]}"`}, + {"empty array", `"[]"`}, + {"missing children", `"[{}]"`}, + {"non-string text", `"[{\"children\":[{\"text\":123}]}]"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := themeRichTextRule() + propertiesJSON := `jsonencode({"theme": {"value": "useThemeId"}, "themeId": {"value": ` + tt.themeIDValueJ + `}, "nodeTitle": {"value": "Theme Node"}})` + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + originalValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + var fallbackVars []FallbackVariable + require.NotPanics(t, func() { + fallbackVars = ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + }) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + if resolvedValue != originalValue { + t.Errorf("expected value unchanged for malformed wrapper %q, got: %s", tt.name, resolvedValue) + } + if len(fallbackVars) != 0 { + t.Errorf("expected 0 FallbackVariables for malformed wrapper %q, got %d", tt.name, len(fallbackVars)) + } + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + if len(deps) != 0 { + t.Errorf("expected no graph edges for malformed wrapper %q, got %d", tt.name, len(deps)) + } + }) + } +} + +// TestResolveEmbeddedReferences_UUIDFormatGuard_NoOpPerStrategy verifies that +// the unconditional UUID-format guard rejects a non-UUID-shaped extracted +// value identically to "no value found" — regardless of Strategy. +func TestResolveEmbeddedReferences_UUIDFormatGuard_NoOpPerStrategy(t *testing.T) { + tests := []struct { + name string + strategy string + }{ + {"default strategy", ""}, + {"reference_with_fallback strategy", "reference_with_fallback"}, + {"variable strategy", "variable"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := graph.New() + // Register a resource keyed by the literal sentinel string so that, + // absent the format guard, "reference"/"reference_with_fallback" + // strategies would otherwise resolve it as a direct graph hit. + g.AddResource("pingone_branding_theme", "activeTheme", "pingcli__Should_Not_Resolve") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + rule := EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "theme.value", + ReferenceField: "id", + Strategy: tt.strategy, + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + } + + propertiesJSON := `jsonencode({"theme": {"value": "activeTheme"}, "nodeTitle": {"value": "Theme Node"}})` + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": RawHCLValue(propertiesJSON), + }, + }, + }, + }, + }, + } + + resourceData := &ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: testResourceDef("pingone_davinci_flow"), + Resources: []*ResourceData{resourceData}, + } + + originalValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + fallbackVars := ResolveEmbeddedReferences([]*ExportedResourceData{exportedData}, g, []EmbeddedReferenceRule{rule}) + + resolvedValue := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(RawHCLValue) + + if resolvedValue != originalValue { + t.Errorf("expected value unchanged for non-UUID sentinel under strategy %q, got: %s", tt.strategy, resolvedValue) + } + if len(fallbackVars) != 0 { + t.Errorf("expected 0 FallbackVariables for non-UUID sentinel under strategy %q, got %d", tt.strategy, len(fallbackVars)) + } + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + if len(deps) != 0 { + t.Errorf("expected no graph edges for non-UUID sentinel under strategy %q, got %d", tt.strategy, len(deps)) + } + }) + } +} diff --git a/internal/platform/pingone/dispatch_test.go b/internal/platform/pingone/dispatch_test.go index 36e7f3f..8bdf6f5 100644 --- a/internal/platform/pingone/dispatch_test.go +++ b/internal/platform/pingone/dispatch_test.go @@ -74,6 +74,47 @@ func TestRegisterCustomHandlersLoadsAll(t *testing.T) { assert.False(t, reg.HasTransform("handleFlowSettings"), "flow stubs should be removed") } +// ── Embedded reference rule dispatch tests ────────────────────── + +// TestEmbeddedReferenceRulesRegistered confirms the pingone_branding_theme +// rules for theme.value (case 1) and themeId.value (case 3) are queued in +// embeddedRefRules, mirroring TestRegisteredHandlerNames/TestRegisteredTransformNames. +func TestEmbeddedReferenceRulesRegistered(t *testing.T) { + reg := NewEmbeddedReferenceRegistry() + rules := reg.Rules() + + var themeValueRule, themeIDValueRule *core.EmbeddedReferenceRule + for i := range rules { + r := &rules[i] + if r.TargetResourceType != "pingone_branding_theme" { + continue + } + switch r.JSONKeyPath { + case "theme.value": + themeValueRule = r + case "themeId.value": + themeIDValueRule = r + } + } + + require.NotNil(t, themeValueRule, "expected a pingone_branding_theme rule with JSONKeyPath \"theme.value\" to be registered") + assert.Equal(t, "pingone_davinci_flow", themeValueRule.ResourceType) + assert.Equal(t, "reference_with_fallback", themeValueRule.Strategy) + assert.Equal(t, "davinci_theme", themeValueRule.VariablePrefix) + assert.Equal(t, "nodeTitle.value", themeValueRule.VariableNamingPath) + assert.Empty(t, themeValueRule.PreconditionKeyPath, "case 1 rule must have no precondition") + assert.Empty(t, themeValueRule.UnwrapMode, "case 1 rule must not use rich-text unwrap") + + require.NotNil(t, themeIDValueRule, "expected a pingone_branding_theme rule with JSONKeyPath \"themeId.value\" to be registered") + assert.Equal(t, "pingone_davinci_flow", themeIDValueRule.ResourceType) + assert.Equal(t, "reference_with_fallback", themeIDValueRule.Strategy) + assert.Equal(t, "davinci_theme", themeIDValueRule.VariablePrefix) + assert.Equal(t, "nodeTitle.value", themeIDValueRule.VariableNamingPath) + assert.Equal(t, "theme.value", themeIDValueRule.PreconditionKeyPath) + assert.Equal(t, "useThemeId", themeIDValueRule.PreconditionValue) + assert.Equal(t, "rich_text", themeIDValueRule.UnwrapMode) +} + func TestHandleConnectorPropertiesRealTransform(t *testing.T) { reg := core.NewCustomHandlerRegistry() RegisterCustomHandlers(reg) diff --git a/internal/platform/pingone/resource_flow.go b/internal/platform/pingone/resource_flow.go index d8c524c..4f372fe 100644 --- a/internal/platform/pingone/resource_flow.go +++ b/internal/platform/pingone/resource_flow.go @@ -63,6 +63,47 @@ func init() { VariablePrefix: "davinci_form", VariableNamingPath: "nodeTitle.value", }) + + // Embedded reference with fallback: theme.value inside node properties + // (showForm capability) references a DaVinci UI template. Mirrors the + // form.value rule above. theme.value may also hold a non-UUID mode-flag + // sentinel (e.g. "useThemeId", or the unconfirmed "activeTheme") instead + // of a direct UUID — those are left untouched by the unconditional + // UUID-format guard in core.processRawHCLValue, not by an enumerated + // skip list here. The pingone_branding_theme resource is not yet + // exported, so the UUID is emitted as a Terraform variable; once + // pingone_branding_theme is added, the graph lookup will succeed and the + // variable will be automatically promoted to a resource reference. + registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "theme.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + }) + + // Embedded reference with fallback: themeId.value inside node properties + // holds the theme UUID rich-text/Slate-wrapped + // (`[{"children":[{"text":""}]}]`) when theme.value is the mode + // flag "useThemeId". Gated on the sibling theme.value precondition so it + // only fires in that indirect mode; theme.value itself is left + // unmodified by this rule. + registerEmbeddedReferenceRule(core.EmbeddedReferenceRule{ + ResourceType: "pingone_davinci_flow", + AttributePath: "graph_data.elements.nodes.*.data.properties", + TargetResourceType: "pingone_branding_theme", + JSONKeyPath: "themeId.value", + ReferenceField: "id", + Strategy: "reference_with_fallback", + VariablePrefix: "davinci_theme", + VariableNamingPath: "nodeTitle.value", + PreconditionKeyPath: "theme.value", + PreconditionValue: "useThemeId", + UnwrapMode: "rich_text", + }) } // listFlowIDs fetches only flow IDs and names from the list endpoint via raw HTTP diff --git a/internal/platform/pingone/resource_flow_test.go b/internal/platform/pingone/resource_flow_test.go index cce12ae..5a70051 100644 --- a/internal/platform/pingone/resource_flow_test.go +++ b/internal/platform/pingone/resource_flow_test.go @@ -6,11 +6,16 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" pingone "github.com/pingidentity/pingone-go-client/pingone" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/pingidentity/pingcli-plugin-terraformer/internal/core" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/graph" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/schema" ) // exportRespBody builds a minimal valid DaVinciExportFlowVersionResponse JSON body. @@ -356,3 +361,350 @@ func TestFetchFlowIDs(t *testing.T) { }) } } + +// ── Embedded theme reference behavioral tests ────────────────── +// +// These exercise the two pingone_branding_theme rules registered in init() +// (theme.value / case 1, themeId.value / case 3) end-to-end via +// core.ResolveEmbeddedReferences, using realistic showForm node JSON shaped +// like the three evidence samples in context.md. + +// themeFlowResourceDef returns a minimal *schema.ResourceDefinition for +// "pingone_davinci_flow", matching the shape used by the generic core tests. +func themeFlowResourceDef() *schema.ResourceDefinition { + return &schema.ResourceDefinition{ + Metadata: schema.ResourceMetadata{ + ResourceType: "pingone_davinci_flow", + }, + } +} + +// buildShowFormPropertiesJSON builds a jsonencode(...) RawHCLValue blob for a +// single showForm node, given raw (unescaped) JSON fragments for the "form", +// "theme", and "themeId" keys. Any of formJSON/themeJSON/themeIDJSON may be +// empty, in which case that key is omitted entirely (case 2 / customForm). +func buildShowFormPropertiesJSON(formJSON, themeJSON, themeIDJSON, nodeTitle string) core.RawHCLValue { + var parts []string + if formJSON != "" { + parts = append(parts, `"form": `+formJSON) + } + if themeJSON != "" { + parts = append(parts, `"theme": `+themeJSON) + } + if themeIDJSON != "" { + parts = append(parts, `"themeId": `+themeIDJSON) + } + if nodeTitle != "" { + parts = append(parts, `"nodeTitle": {"value": "`+nodeTitle+`"}`) + } + return core.RawHCLValue("jsonencode({" + strings.Join(parts, ", ") + "})") +} + +// richTextWrapperJSON builds the escaped-string JSON value for a Slate-style +// rich-text wrapper embedding uuidStr, matching the double-JSON-encoding +// transformJSONEncodeRaw produces in production (mirrors context.md's +// themeId.value evidence sample). +func richTextWrapperJSON(t *testing.T, uuidStr string) string { + t.Helper() + wrapper := `[{"children":[{"text":"` + uuidStr + `"}]}]` + escaped, err := json.Marshal(wrapper) + require.NoError(t, err) + return string(escaped) +} + +// resolveShowFormNode runs core.ResolveEmbeddedReferences against a single +// showForm node's properties using the real registered pingone rules, and +// returns the resolved RawHCLValue plus any FallbackVariables produced. +func resolveShowFormNode(t *testing.T, g *graph.DependencyGraph, properties core.RawHCLValue) (core.RawHCLValue, []core.FallbackVariable) { + t.Helper() + + attrs := map[string]interface{}{ + "graph_data": map[string]interface{}{ + "elements": map[string]interface{}{ + "nodes": map[string]interface{}{ + "node1": map[string]interface{}{ + "data": map[string]interface{}{ + "properties": properties, + }, + }, + }, + }, + }, + } + + resourceData := &core.ResourceData{ + ResourceType: "pingone_davinci_flow", + ID: "parent-flow-id", + Label: "pingcli__Parent-Flow", + Attributes: attrs, + } + + exportedData := &core.ExportedResourceData{ + ResourceType: "pingone_davinci_flow", + Definition: themeFlowResourceDef(), + Resources: []*core.ResourceData{resourceData}, + } + + fallbackVars := core.ResolveEmbeddedReferences([]*core.ExportedResourceData{exportedData}, g, embeddedRefRules) + + resolved := attrs["graph_data"].(map[string]interface{})["elements"].(map[string]interface{})["nodes"].(map[string]interface{})["node1"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(core.RawHCLValue) + return resolved, fallbackVars +} + +// TestThemeRule_Case1_UUIDNotInGraph_FallsBackToVariable covers acceptance +// criterion: a showForm node with theme.value set to a UUID not present in +// the graph resolves to a Terraform variable fallback. +func TestThemeRule_Case1_UUIDNotInGraph_FallsBackToVariable(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + const themeUUID = "e6fd37f9-11dd-40f3-90f6-eaeb971ee3db" + properties := buildShowFormPropertiesJSON( + "", + `{"value": "`+themeUUID+`"}`, + "", + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Contains(t, string(resolved), "${var.davinci_theme_sign_on}") + assert.NotContains(t, string(resolved), themeUUID) + require.Len(t, fallbackVars, 1) + assert.Equal(t, "davinci_theme_sign_on", fallbackVars[0].Name) + assert.Equal(t, "pingone_branding_theme", fallbackVars[0].ResourceType) + assert.Equal(t, themeUUID, fallbackVars[0].Default) +} + +// TestThemeRule_Case1_UUIDInGraph_ResolvesToReference covers acceptance +// criterion: the same case-1 scenario but with a matching pingone_branding_theme +// resource present in the graph resolves to a resource reference with a graph +// edge, and no FallbackVariable. +func TestThemeRule_Case1_UUIDInGraph_ResolvesToReference(t *testing.T) { + g := graph.New() + const themeUUID = "e6fd37f9-11dd-40f3-90f6-eaeb971ee3db" + g.AddResource("pingone_branding_theme", themeUUID, "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + properties := buildShowFormPropertiesJSON( + "", + `{"value": "`+themeUUID+`"}`, + "", + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Contains(t, string(resolved), "${pingone_branding_theme.pingcli__My_Theme.id}") + assert.NotContains(t, string(resolved), themeUUID) + assert.Empty(t, fallbackVars) + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + require.NotEmpty(t, deps) + found := false + for _, d := range deps { + if d.To.ResourceType == "pingone_branding_theme" && d.To.ID == themeUUID { + found = true + } + } + assert.True(t, found, "expected a graph edge to the pingone_branding_theme resource") +} + +// TestThemeRule_Case2_ThemeAbsent_Unchanged covers acceptance criterion: a +// showForm node where theme is absent entirely is emitted with byte-for-byte +// unchanged properties JSON. +func TestThemeRule_Case2_ThemeAbsent_Unchanged(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + properties := buildShowFormPropertiesJSON( + "", + "", + "", + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Equal(t, properties, resolved, "expected properties JSON to remain byte-for-byte unchanged") + assert.Empty(t, fallbackVars) +} + +// TestThemeRule_Case3_UUIDNotInGraph_FallsBackToVariable covers acceptance +// criterion: theme.value == "useThemeId" with themeId.value holding the +// rich-text-wrapped UUID resolves the UUID inside the wrapper to a variable +// fallback when not in the graph, preserves the wrapper structure, and +// leaves theme.value unchanged. +func TestThemeRule_Case3_UUIDNotInGraph_FallsBackToVariable(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + const themeUUID = "abc12300-0000-4000-8000-000000000123" + properties := buildShowFormPropertiesJSON( + "", + `{"value": "useThemeId"}`, + `{"value": `+richTextWrapperJSON(t, themeUUID)+`}`, + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Contains(t, string(resolved), `"useThemeId"`, "theme.value must remain the literal string \"useThemeId\"") + assert.Contains(t, string(resolved), "${var.davinci_theme_sign_on}") + assert.NotContains(t, string(resolved), themeUUID) + require.Len(t, fallbackVars, 1) + assert.Equal(t, "davinci_theme_sign_on", fallbackVars[0].Name) + + // Verify the wrapper's outer JSON structure round-trips with only the + // inner value swapped. + jsonStr := extractPropertiesJSON(t, resolved) + var outer map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(jsonStr), &outer)) + assert.Equal(t, "useThemeId", outer["theme"].(map[string]interface{})["value"]) + + themeIDValue := outer["themeId"].(map[string]interface{})["value"].(string) + var wrapperArr []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(themeIDValue), &wrapperArr)) + require.Len(t, wrapperArr, 1) + children := wrapperArr[0]["children"].([]interface{}) + text := children[0].(map[string]interface{})["text"].(string) + assert.Equal(t, "${var.davinci_theme_sign_on}", text) +} + +// TestThemeRule_Case3_UUIDInGraph_ResolvesToReference covers acceptance +// criterion: the same case-3 scenario but with a matching pingone_branding_theme +// resource in the graph resolves the wrapped UUID to a resource reference +// with a graph edge, and theme.value still remains "useThemeId". +func TestThemeRule_Case3_UUIDInGraph_ResolvesToReference(t *testing.T) { + g := graph.New() + const themeUUID = "abc12300-0000-4000-8000-000000000456" + g.AddResource("pingone_branding_theme", themeUUID, "pingcli__My_Theme") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + properties := buildShowFormPropertiesJSON( + "", + `{"value": "useThemeId"}`, + `{"value": `+richTextWrapperJSON(t, themeUUID)+`}`, + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Contains(t, string(resolved), `"useThemeId"`) + assert.Contains(t, string(resolved), "${pingone_branding_theme.pingcli__My_Theme.id}") + assert.NotContains(t, string(resolved), themeUUID) + assert.Empty(t, fallbackVars) + + deps := g.GetDependencies("pingone_davinci_flow", "parent-flow-id") + found := false + for _, d := range deps { + if d.To.ResourceType == "pingone_branding_theme" && d.To.ID == themeUUID { + found = true + } + } + assert.True(t, found, "expected a graph edge to the pingone_branding_theme resource") +} + +// TestThemeRule_ActiveThemeSentinel_NoThemeId_Unchanged covers acceptance +// criterion: theme.value == "activeTheme" with no themeId key present is +// emitted completely unchanged, because "activeTheme" is not UUID-shaped and +// is rejected by the unconditional format guard, not an enumerated skip list. +func TestThemeRule_ActiveThemeSentinel_NoThemeId_Unchanged(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + // Deliberately register a resource keyed by the literal sentinel string: + // if the format guard were bypassed, this would let the rule resolve to + // a reference, which would make the test fail loudly rather than + // silently passing for the wrong reason. + g.AddResource("pingone_branding_theme", "activeTheme", "pingcli__Should_Not_Resolve") + + properties := buildShowFormPropertiesJSON( + "", + `{"value": "activeTheme"}`, + "", + "Sign On", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + assert.Equal(t, properties, resolved, "expected properties JSON to remain byte-for-byte unchanged") + assert.Empty(t, fallbackVars) +} + +// TestThemeRule_FormValueUnaffectedByThemeCase covers acceptance criterion: a +// node with both form.value and any of the three theme shapes resolves +// form.value identically regardless of which theme case is present. +func TestThemeRule_FormValueUnaffectedByThemeCase(t *testing.T) { + const formUUID = "860b5cd5-45cc-466d-abbd-64298bb90bed" + + cases := []struct { + name string + themeJSON string + themeIDVal string + }{ + {"case1 direct theme UUID", `{"value": "e6fd37f9-11dd-40f3-90f6-eaeb971ee3db"}`, ""}, + {"case2 theme absent", "", ""}, + {"case3 useThemeId indirect", `{"value": "useThemeId"}`, "abc12300-0000-4000-8000-000000000789"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_form", formUUID, "pingcli__Example_Sign_On") + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + + themeIDJSON := "" + if tc.themeIDVal != "" { + themeIDJSON = `{"value": ` + richTextWrapperJSON(t, tc.themeIDVal) + `}` + } + + properties := buildShowFormPropertiesJSON( + `{"value": "`+formUUID+`"}`, + tc.themeJSON, + themeIDJSON, + "Sign On", + ) + + resolved, _ := resolveShowFormNode(t, g, properties) + + assert.Contains(t, string(resolved), "${pingone_davinci_form.pingcli__Example_Sign_On.id}") + assert.NotContains(t, string(resolved), formUUID) + }) + } +} + +// TestThemeRule_CustomFormCapability_Unchanged covers acceptance criterion: +// a customForm-capability node (no theme/themeId keys) is emitted unchanged +// — no rule fires, no capability-aware branching. +func TestThemeRule_CustomFormCapability_Unchanged(t *testing.T) { + g := graph.New() + g.AddResource("pingone_davinci_flow", "parent-flow-id", "pingcli__Parent-Flow") + g.AddResource("pingone_davinci_form", "ba30f833-e6a5-4fda-9ff1-2576ece5108c", "pingcli__Legacy_Form") + + // customForm's property set has no theme/themeId keys. + properties := buildShowFormPropertiesJSON( + `{"value": "ba30f833-e6a5-4fda-9ff1-2576ece5108c"}`, + "", + "", + "", + ) + + resolved, fallbackVars := resolveShowFormNode(t, g, properties) + + // form.value still resolves (unrelated to the theme rules); theme rules + // simply never fire because theme/themeId keys don't exist. + assert.Contains(t, string(resolved), "${pingone_davinci_form.pingcli__Legacy_Form.id}") + assert.NotContains(t, string(resolved), "davinci_theme") + assert.NotContains(t, string(resolved), "pingone_branding_theme") + assert.Empty(t, fallbackVars) +} + +// extractPropertiesJSON strips the jsonencode(...) wrapper from a resolved +// RawHCLValue, returning the inner JSON string. +func extractPropertiesJSON(t *testing.T, value core.RawHCLValue) string { + t.Helper() + str := string(value) + const prefix = "jsonencode(" + require.True(t, strings.HasPrefix(str, prefix), "expected jsonencode(...) wrapper, got: %s", str) + return str[len(prefix) : len(str)-1] +}