use indoc::indoc;
use test_util::prelude::sim_assert_eq;
use super::*;
#[test]
fn guarded_range_member_string_contract_stays_branch_scoped() {
let src = indoc! {r"
{{- if .Values.enabled }}
{{- with .Values.config }}
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
{{- range $key, $value := .Values.templates }}
{{ $key }}: |-
{{- $value | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
"};
let values_yaml = indoc! {"
enabled: false
config: {}
templates: {}
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
let live_guard = serde_json::json!({
"allOf": [helm_truthy_guard("config"), helm_truthy_guard("enabled")]
});
let mut properties = serde_json::Map::new();
properties.insert("config".to_string(), serde_json::json!({}));
properties.insert("enabled".to_string(), serde_json::json!({}));
properties.insert("templates".to_string(), serde_json::json!({}));
let all_of = vec![serde_json::json!({
"if": live_guard.clone(),
"then": { "allOf": [
root_property_schema(
"templates",
serde_json::json!({
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{
"additionalProperties": { "type": "string" },
"type": "object",
},
{ "type": "null" },
]
}),
),
root_property_schema(
"templates",
serde_json::json!({
"anyOf": [
{
"propertyNames": { "allOf": plain_token_exclusions(true) },
"type": "object",
},
{ "type": "array" },
{ "type": "null" },
]
}),
),
] },
})];
sim_assert_eq!(
have: &schema,
want: &expected_values_schema(properties, all_of, true)
);
for instance in [
serde_json::json!({ "enabled": false, "config": {}, "templates": "audit" }),
serde_json::json!({ "enabled": true, "config": {}, "templates": "audit" }),
serde_json::json!({ "enabled": true, "config": { "route": "x" }, "templates": { "audit": "body" } }),
serde_json::json!({ "enabled": true, "config": { "route": "x" }, "templates": ["body"] }),
] {
assert!(
schema_accepts_instance(&schema, &instance),
"dead consumers and live string members render: instance={instance}; schema={schema}"
);
}
for instance in [
serde_json::json!({ "enabled": true, "config": { "route": "x" }, "templates": "audit" }),
serde_json::json!({ "enabled": true, "config": { "route": "x" }, "templates": { "audit": 7 } }),
serde_json::json!({ "enabled": true, "config": { "route": "x" }, "templates": [7] }),
] {
assert!(
!schema_accepts_instance(&schema, &instance),
"a live non-iterable or non-string member aborts rendering: instance={instance}; schema={schema}"
);
}
}
#[test]
fn range_key_string_contract_preserves_only_the_empty_array_lane() {
let src = indoc! {r"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
keys: |-
{{- range $key, $value := .Values.extraPorts }}
{{ $key | lower }}
{{- end }}
"};
let schema = schema_for_values_yaml(parse_ir(src), Some("extraPorts: {}\n"));
sim_assert_eq!(
have: &schema,
want: &expected_range_key_string_schema("extraPorts", true)
);
for instance in [
serde_json::json!({ "extraPorts": { "syslog": 1514 } }),
serde_json::json!({ "extraPorts": [] }),
serde_json::json!({ "extraPorts": null }),
] {
assert!(
schema_accepts_instance(&schema, &instance),
"map keys are strings and empty collections execute no body: instance={instance}; schema={schema}"
);
}
assert!(
!schema_accepts_instance(&schema, &serde_json::json!({ "extraPorts": [1514] })),
"a nonempty array sends an integer index to lower: {schema}"
);
}
#[test]
fn helper_string_contract_on_range_key_does_not_constrain_members() {
let helpers = indoc! {r#"
{{- define "normalize-key" -}}
{{- . | lower -}}
{{- end -}}
"#};
let src = indoc! {r#"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
keys: |-
{{- range $key, $value := .Values.extraPorts }}
{{ include "normalize-key" $key }}
{{- end }}
"#};
let schema = schema_for_values_yaml(
parse_ir_with_helpers(src, helpers),
Some("extraPorts: {}\n"),
);
sim_assert_eq!(
have: &schema,
want: &expected_range_key_string_schema("extraPorts", false)
);
assert!(
schema_accepts_instance(
&schema,
&serde_json::json!({ "extraPorts": { "web": { "port": 8000 } } })
),
"a string key contract must not retype its object member: {schema}"
);
assert!(
!schema_accepts_instance(
&schema,
&serde_json::json!({ "extraPorts": [{ "port": 8000 }] })
),
"a nonempty array sends its integer index through lower: {schema}"
);
}
#[test]
fn range_key_string_predicate_constrains_the_array_lane() {
let src = indoc! {r#"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
keys: |-
{{- range $key, $value := .Values.extraPorts }}
{{- if hasPrefix "sys" $key }}
{{ $key }}
{{- end }}
{{- end }}
"#};
let schema = schema_for_values_yaml(parse_ir(src), Some("extraPorts: {}\n"));
sim_assert_eq!(
have: &schema,
want: &expected_range_key_string_schema("extraPorts", true)
);
assert!(
schema_accepts_instance(
&schema,
&serde_json::json!({ "extraPorts": { "syslog": 1514 } })
),
"map keys satisfy hasPrefix: {schema}"
);
assert!(
!schema_accepts_instance(&schema, &serde_json::json!({ "extraPorts": [1514] })),
"an array index is not a string predicate operand: {schema}"
);
}
#[test]
fn non_string_range_key_operand_does_not_infer_a_string_contract() {
let src = indoc! {r#"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
output: |-
{{- range $key, $value := .Values.items }}
{{ trunc $key "hello" }}
{{- end }}
"#};
let schema = schema_for_values_yaml(parse_ir(src), Some("items: []\n"));
let mut properties = serde_json::Map::new();
properties.insert(
"items".to_string(),
serde_json::json!({ "type": ["array", "null", "object"] }),
);
let all_of = vec![root_property_schema(
"items",
serde_json::json!({ "type": ["array", "null", "object"] }),
)];
sim_assert_eq!(
have: &schema,
want: &expected_values_schema(properties, all_of, false)
);
assert!(
schema_accepts_instance(&schema, &serde_json::json!({ "items": ["value"] })),
"array indices satisfy trunc's numeric width operand: {schema}"
);
}
#[test]
fn raw_range_key_occurrence_survives_a_derived_sibling() {
let src = indoc! {r#"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
output: |-
{{- range $key, $value := .Values.items }}
{{ replace ($key | quote) $key "x" }}
{{- end }}
"#};
let schema = schema_for_values_yaml(parse_ir(src), Some("items: {}\n"));
sim_assert_eq!(
have: &schema,
want: &expected_range_key_string_schema("items", true)
);
assert!(
schema_accepts_instance(&schema, &serde_json::json!({ "items": { "key": 1 } })),
"the raw map key is a valid replace operand: {schema}"
);
assert!(
!schema_accepts_instance(&schema, &serde_json::json!({ "items": [1] })),
"quoting one occurrence does not stringify the separate raw array index: {schema}"
);
}
#[test]
fn block_scalar_range_variable_projects_its_string_contract() {
let src = indoc! {r"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
plugins.txt: |-
{{- if .Values.enabled }}
{{- if .Values.plugins }}
{{- range $plugin := .Values.plugins }}
{{- $plugin | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
"};
let values_yaml = indoc! {"
enabled: false
plugins: ~
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
for instance in [
serde_json::json!({ "enabled": true, "plugins": ["git:latest"] }),
serde_json::json!({ "enabled": false, "plugins": [7] }),
serde_json::json!({ "enabled": true, "plugins": false }),
serde_json::json!({ "enabled": true, "plugins": 0 }),
serde_json::json!({ "enabled": true, "plugins": -1 }),
] {
assert!(
schema_accepts_instance(&schema, &instance),
"string members render and a disabled consumer imposes no member type: instance={instance}; schema={schema}"
);
}
for plugins in [serde_json::json!([7]), serde_json::json!(2)] {
let instance = serde_json::json!({ "enabled": true, "plugins": plugins });
assert!(
!schema_accepts_instance(&schema, &instance),
"a live non-string member reaches nindent: instance={instance}; schema={schema}"
);
}
}
#[test]
fn guarded_ranged_member_access_constrains_collection_lanes() {
let src = indoc! {r"
{{- if .Values.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
{{- range .Values.accounts }}
{{ .tls }}: enabled
{{- end }}
{{- end }}
"};
let schema = schema_for_values_yaml(
parse_ir(src),
Some(indoc! {"
enabled: false
accounts: ~
"}),
);
let mut properties = serde_json::Map::new();
properties.insert("accounts".to_string(), serde_json::json!({}));
properties.insert("enabled".to_string(), serde_json::json!({}));
let guard = helm_truthy_guard("enabled");
let member = serde_json::json!({
"additionalProperties": {},
"properties": { "tls": { "type": ["boolean", "integer", "number", "string"] } },
"type": "object",
});
let all_of = vec![serde_json::json!({
"if": guard,
"then": root_property_schema(
"accounts",
serde_json::json!({
"anyOf": [
{ "items": member.clone(), "type": "array" },
{
"additionalProperties": member,
"type": "object",
},
{ "maximum": 0, "type": "integer" },
{ "type": "null" },
]
}),
),
})];
sim_assert_eq!(
have: &schema,
want: &expected_values_schema(properties, all_of, true)
);
for instance in [
serde_json::json!({ "enabled": false, "accounts": [7] }),
serde_json::json!({ "enabled": true, "accounts": [{ "tls": "on" }] }),
serde_json::json!({ "enabled": true, "accounts": { "A": { "tls": "on" } } }),
serde_json::json!({ "enabled": true, "accounts": 0 }),
serde_json::json!({ "enabled": true, "accounts": -1 }),
] {
assert!(
schema_accepts_instance(&schema, &instance),
"dead access or live object members render: instance={instance}; schema={schema}"
);
}
for accounts in [
serde_json::json!([7]),
serde_json::json!({ "A": 7 }),
serde_json::json!(2),
] {
let instance = serde_json::json!({ "enabled": true, "accounts": accounts });
assert!(
!schema_accepts_instance(&schema, &instance),
"a live scalar member cannot host field access: instance={instance}; schema={schema}"
);
}
}
#[test]
fn same_map_pluck_of_ranged_key_projects_member_identity() {
let helpers = indoc! {r#"
{{- define "test.renderEnv" -}}
{{- $dict := . -}}
{{- range keys . | sortAlpha }}
{{- $val := pluck . $dict | first -}}
{{- $key := upper . -}}
{{- $valueType := printf "%T" $val -}}
{{- if eq $valueType "map[string]interface {}" }}
- name: {{ $key }}
{{ toYaml $val | indent 2 -}}
{{- else }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- end -}}
{{- end -}}
"#};
let src = indoc! {r#"
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
template:
spec:
containers:
- name: main
env:
{{- include "test.renderEnv" .Values.additionalEnvs | nindent 20 }}
"#};
let schema = schema_for_values_yaml(
parse_ir_with_helpers(src, helpers),
Some("additionalEnvs: {}\n"),
);
for (instance, want) in [
(
serde_json::json!({ "additionalEnvs": { "AUDIT": { "value": 7 } } }),
false,
),
(
serde_json::json!({ "additionalEnvs": { "AUDIT": { "value": "ok" } } }),
true,
),
(
serde_json::json!({ "additionalEnvs": { "AUDIT": 7 } }),
true,
),
(
serde_json::json!({ "additionalEnvs": { "AUDIT": "ok" } }),
true,
),
] {
assert!(
schema_accepts_instance(&schema, &instance) == want,
"same-map pluck member projection: instance={instance}; schema={schema}"
);
}
}
#[test]
fn range_key_at_string_slot_excludes_integer_key_lanes() {
let src = indoc! {r"
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
template:
spec:
containers:
- name: main
env:
{{- range $key, $val := .Values.environment }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
"};
let schema = schema_for_values_yaml(parse_ir(src), Some("environment: {}\n"));
for (instance, want) in [
(serde_json::json!({ "environment": ["audit"] }), false),
(
serde_json::json!({ "environment": { "AUDIT": "ok" } }),
true,
),
(serde_json::json!({ "environment": [] }), true),
(serde_json::json!({}), true),
] {
assert!(
schema_accepts_instance(&schema, &instance) == want,
"range keys at a string-only slot exclude integer key lanes: \
instance={instance}; schema={schema}"
);
}
}
#[test]
fn dedup_accumulator_binds_member_typing_to_singleton_maps() {
let helpers = indoc! {r#"
{{- define "test.renderEnv" -}}
{{- $dict := . -}}
{{- $processedKeys := dict -}}
{{- range keys . | sortAlpha }}
{{- $val := pluck . $dict | first -}}
{{- $key := upper . -}}
{{- if not (hasKey $processedKeys $key) }}
{{- $processedKeys = merge $processedKeys (dict $key true) -}}
{{- $valueType := printf "%T" $val -}}
{{- if eq $valueType "map[string]interface {}" }}
- name: {{ $key }}
{{ toYaml $val | indent 2 -}}
{{- else }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- end -}}
{{- end -}}
{{- end -}}
"#};
let src = indoc! {r#"
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
template:
spec:
containers:
- name: main
env:
{{- include "test.renderEnv" .Values.additionalEnvs | nindent 20 }}
"#};
let schema = schema_for_values_yaml(
parse_ir_with_helpers(src, helpers),
Some("additionalEnvs: {}\n"),
);
for (instance, want) in [
(
serde_json::json!({ "additionalEnvs": { "AUDIT": { "value": 7 } } }),
false,
),
(
serde_json::json!({ "additionalEnvs": { "AUDIT": { "value": "ok" } } }),
true,
),
(
serde_json::json!({ "additionalEnvs": { "AUDIT": 7 } }),
true,
),
(
serde_json::json!({ "additionalEnvs": {
"AUDIT": { "value": "ok" }, "audit": { "value": 7 }
} }),
true,
),
(serde_json::json!({ "additionalEnvs": {} }), true),
] {
assert!(
schema_accepts_instance(&schema, &instance) == want,
"dedup accumulator singleton lane: instance={instance}; want={want}; schema={schema}"
);
}
}
#[test]
fn guard_scoped_omit_scopes_removed_member_typing() {
let helpers = indoc! {r#"
{{- define "test.renderSecurityContext" -}}
{{- $adaptedContext := .securityContext -}}
{{- if .context.Values.global.compatibility -}}
{{- if .context.Values.global.compatibility.openshift -}}
{{- if or (eq .context.Values.global.compatibility.openshift.adaptSecurityContext "force") (and (eq .context.Values.global.compatibility.openshift.adaptSecurityContext "auto") (include "test.isOpenShift" .context)) -}}
{{- $adaptedContext = omit $adaptedContext "fsGroup" "runAsUser" "runAsGroup" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- omit $adaptedContext "enabled" | toYaml -}}
{{- end -}}
{{- define "test.isOpenShift" -}}
{{- if .Capabilities.APIVersions.Has "security.openshift.io/v1" -}}
{{- true -}}
{{- end -}}
{{- end -}}
"#};
let src = indoc! {r#"
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
template:
spec:
containers:
- name: main
{{- with .Values.securityContext }}
{{- if and (.enabled) (gt (keys . | len) 1) }}
securityContext:
{{- include "test.renderSecurityContext" (dict "securityContext" . "context" $) | nindent 20 }}
{{- end }}
{{- end }}
"#};
let schema = schema_for_values_yaml(
parse_ir_with_helpers(src, helpers),
Some(indoc! {"
securityContext:
enabled: true
global:
compatibility:
openshift:
adaptSecurityContext: auto
"}),
);
for (mode, member, value, enabled, want) in [
("force", "runAsUser", serde_json::json!("audit"), true, true),
("auto", "runAsUser", serde_json::json!("audit"), true, true),
(
"disabled",
"runAsUser",
serde_json::json!("audit"),
true,
false,
),
("disabled", "runAsUser", serde_json::json!(1000), true, true),
(
"disabled",
"runAsUser",
serde_json::json!("audit"),
false,
true,
),
(
"force",
"runAsNonRoot",
serde_json::json!("audit"),
true,
false,
),
(
"disabled",
"runAsNonRoot",
serde_json::json!("audit"),
true,
false,
),
] {
let instance = serde_json::json!({
"securityContext": { "enabled": enabled, "runAsNonRoot": true, member: value },
"global": { "compatibility": { "openshift": { "adaptSecurityContext": mode } } },
});
assert!(
schema_accepts_instance(&schema, &instance) == want,
"guard-scoped omit member typing: mode={mode} member={member} value={value} \
enabled={enabled}; want={want}; schema={schema}"
);
}
}
#[test]
fn capability_dispatch_scoped_member_field_fail_lowers() {
let helpers = indoc! {r#"
{{- define "capabilities.ingress.apiVersion" -}}
{{- if semverCompare "<1.14-0" ( .Values.kubeVersion | default .Capabilities.KubeVersion.Version ) -}}
{{- print "extensions/v1beta1" -}}
{{- else if semverCompare "<1.19-0" ( .Values.kubeVersion | default .Capabilities.KubeVersion.Version ) -}}
{{- print "networking.k8s.io/v1beta1" -}}
{{- else -}}
{{- print "networking.k8s.io/v1" -}}
{{- end -}}
{{- end -}}
"#};
let src = indoc! {r#"
{{- if .Values.checkDeprecation }}
{{- if eq ( include "capabilities.ingress.apiVersion" . ) "networking.k8s.io/v1" -}}
{{- range .Values.ingress.extraPaths }}
{{- if or (.backend.serviceName) (.backend.servicePort) }}
{{ fail "Please update the format of your `ingress.extraPaths`" }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
"#};
let schema = schema_for_values_yaml(
parse_ir_with_helpers_and_kubernetes_version(src, helpers, Some("1.29.0")),
Some(indoc! {"
checkDeprecation: true
ingress:
extraPaths: []
"}),
);
for (instance, want) in [
(
serde_json::json!({ "checkDeprecation": true, "kubeVersion": "1.30.0", "ingress": { "extraPaths": [
{ "path": "/*", "backend": { "serviceName": "x" } }
] } }),
false,
),
(
serde_json::json!({ "checkDeprecation": true, "kubeVersion": "1.30.0", "ingress": { "extraPaths": [
{ "path": "/*", "backend": { "servicePort": "y" } }
] } }),
false,
),
(
serde_json::json!({ "kubeVersion": "1.18.0", "ingress": { "extraPaths": [
{ "path": "/*", "backend": { "serviceName": "x" } }
] } }),
true,
),
(
serde_json::json!({ "checkDeprecation": true, "ingress": { "extraPaths": [
{ "path": "/*", "backend": { "serviceName": "x" } }
] } }),
false,
),
(
serde_json::json!({ "kubeVersion": "1.30.0", "ingress": { "extraPaths": [
{ "path": "/*", "backend": { "service": { "name": "x" } } }
] } }),
true,
),
(
serde_json::json!({ "checkDeprecation": false, "kubeVersion": "1.30.0",
"ingress": { "extraPaths": [
{ "path": "/*", "backend": { "serviceName": "x" } }
] } }),
true,
),
] {
assert!(
schema_accepts_instance(&schema, &instance) == want,
"capability-scoped member field fail: instance={instance}; want={want}; schema={schema}"
);
}
}