Files
capsule/pkg/template/fast_test.go
Oliver Bähler a6b830b1af feat: add ruleset api(#1844)
* fix(controller): decode old object for delete requests

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix(config): remove usergroups default

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix(config): remove usergroups default

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* sec(ghsa-2ww6-hf35-mfjm): intercept namespace subresource

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
2026-01-27 14:28:48 +01:00

568 lines
14 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package template_test
import (
"sync"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
tpl "github.com/projectcapsule/capsule/pkg/template"
)
func TestRequiresFastTemplate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected bool
}{
{
name: "no braces",
input: "plain text with no template markers",
expected: false,
},
{
name: "only opening braces",
input: "value with {{ but no closing",
expected: false,
},
{
name: "only closing braces",
input: "value with }} but no opening",
expected: false,
},
{
name: "proper template expression",
input: "hello {{ .Name }}",
expected: true,
},
{
name: "multiple template expressions",
input: "{{ .A }} and {{ .B }}",
expected: true,
},
{
name: "braces without spaces",
input: "{{.Value}}",
expected: true,
},
{
name: "empty string",
input: "",
expected: false,
},
{
name: "only opening and closing braces but separated",
input: "text {{ middle }} end",
expected: true,
},
{
name: "single braces not considered template",
input: "{ value }",
expected: false,
},
{
name: "nested braces",
input: "{{ {{ .Nested }} }}",
expected: true,
},
}
for _, tt := range tests {
tt := tt // capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tpl.RequiresFastTemplate(tt.input)
if got != tt.expected {
t.Fatalf(
"RequiresFastTemplate(%q) = %v, expected %v",
tt.input,
got,
tt.expected,
)
}
})
}
}
func TestTemplateForTenantAndNamespace_ReplacesPlaceholders(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
got := tpl.FastTemplate(
"tenant={{tenant.name}}, ns={{namespace}}",
tplContext,
)
want := "tenant=tenant-a, ns=ns-1"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_ReplacesPlaceholdersSpaces(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
got := tpl.FastTemplate(
"tenant={{ tenant.name }}, ns={{ namespace }}",
tplContext,
)
want := "tenant=tenant-a, ns=ns-1"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_OnlyTenant(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-x",
"namespace": "ns-y",
}
got := tpl.FastTemplate("T={{tenant.name}}", tplContext)
want := "T=tenant-x"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_OnlyNamespace(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-x",
"namespace": "ns-y",
}
got := tpl.FastTemplate("N={{namespace}}", tplContext)
want := "N=ns-y"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_NoDelimiters_ReturnsInput(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
in := "plain-value-without-templates"
got := tpl.FastTemplate(in, tplContext)
if got != in {
t.Fatalf("expected %q, got %q", in, got)
}
}
func TestTemplateForTenantAndNamespace_UnknownKeyBecomesEmpty(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
got := tpl.FastTemplate("X={{unknown.key}}", tplContext)
want := "X="
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholders(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
orig := map[string]string{
"key1": "tenant={{tenant.name}}, ns={{namespace}}",
"key2": "plain-value",
}
out := tpl.FastTemplateMap(orig, tplContext)
// output is templated
if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
}
if got := out["key2"]; got != "plain-value" {
t.Fatalf("key2: expected %q, got %q", "plain-value", got)
}
// input map must remain unchanged (new behavior)
if got := orig["key1"]; got != "tenant={{tenant.name}}, ns={{namespace}}" {
t.Fatalf("input map must not be mutated; key1 got %q", got)
}
}
func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholdersSpaces(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
orig := map[string]string{
"key1": "tenant={{ tenant.name }}, ns={{ namespace }}",
"key2": "plain-value",
}
out := tpl.FastTemplateMap(orig, tplContext)
if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
}
if got := out["key2"]; got != "plain-value" {
t.Fatalf("key2: expected %q, got %q", "plain-value", got)
}
// input map must remain unchanged
if got := orig["key1"]; got != "tenant={{ tenant.name }}, ns={{ namespace }}" {
t.Fatalf("input map must not be mutated; key1 got %q", got)
}
}
func TestTemplateForTenantAndNamespaceMap_TransformsValuesWithDelimiters(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
orig := map[string]string{
"t1": "hello {{tenant.name}}",
"t2": "namespace {{namespace}}",
"t3": "static",
}
out := tpl.FastTemplateMap(orig, tplContext)
if got := out["t1"]; got != "hello tenant-a" {
t.Fatalf("t1: expected %q, got %q", "hello tenant-a", got)
}
if got := out["t2"]; got != "namespace ns-1" {
t.Fatalf("t2: expected %q, got %q", "namespace ns-1", got)
}
if got := out["t3"]; got != "static" {
t.Fatalf("t3: expected %q, got %q", "static", got)
}
}
func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-x",
"namespace": "ns-x",
}
orig := map[string]string{
"onlyTenant": "T={{ tenant.name }}",
"onlyNS": "N={{ namespace }}",
"none": "static",
}
out := tpl.FastTemplateMap(orig, tplContext)
if got := out["onlyTenant"]; got != "T=tenant-x" {
t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", got)
}
if got := out["onlyNS"]; got != "N=ns-x" {
t.Fatalf("onlyNS: expected %q, got %q", "N=ns-x", got)
}
if got := out["none"]; got != "static" {
t.Fatalf("none: expected %q, got %q", "static", got)
}
}
func TestTemplateForTenantAndNamespaceMap_UnknownKeyBecomesEmpty(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
orig := map[string]string{
"unknown": "X={{ unknown.key }}",
}
out := tpl.FastTemplateMap(orig, tplContext)
if got := out["unknown"]; got != "X=" {
t.Fatalf("unknown: expected %q, got %q", "X=", got)
}
}
func TestTemplateForTenantAndNamespaceMap_EmptyOrNilInput(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
// nil map
outNil := tpl.FastTemplateMap(nil, tplContext)
if outNil == nil {
t.Fatalf("expected non-nil map for nil input")
}
if len(outNil) != 0 {
t.Fatalf("expected empty map for nil input, got %v", outNil)
}
// empty map
outEmpty := tpl.FastTemplateMap(map[string]string{}, tplContext)
if outEmpty == nil || len(outEmpty) != 0 {
t.Fatalf("expected empty map, got %v", outEmpty)
}
}
// Concurrency test: should never panic with "concurrent map writes"
// Run with: go test -race ./...
func TestTemplateForTenantAndNamespaceMap_Concurrency(t *testing.T) {
tplContext := map[string]string{
"tenant.name": "tenant-a",
"namespace": "ns-1",
}
// Shared input map across goroutines (this used to be unsafe if the function mutated in-place)
shared := map[string]string{
"k1": "tenant={{tenant.name}}",
"k2": "ns={{namespace}}",
"k3": "static",
}
const goroutines = 50
const iterations = 200
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
out := tpl.FastTemplateMap(shared, tplContext)
// sanity checks
if out["k1"] != "tenant=tenant-a" {
t.Errorf("unexpected k1: %q", out["k1"])
return
}
if out["k2"] != "ns=ns-1" {
t.Errorf("unexpected k2: %q", out["k2"])
return
}
if out["k3"] != "static" {
t.Errorf("unexpected k3: %q", out["k3"])
return
}
}
}()
}
wg.Wait()
// verify input map was not mutated
if shared["k1"] != "tenant={{tenant.name}}" {
t.Fatalf("input map mutated under concurrency: k1=%q", shared["k1"])
}
if shared["k2"] != "ns={{namespace}}" {
t.Fatalf("input map mutated under concurrency: k2=%q", shared["k2"])
}
}
func TestFastTemplateLabelSelector(t *testing.T) {
t.Parallel()
t.Run("nil selector returns nil, nil", func(t *testing.T) {
t.Parallel()
got, err := tpl.FastTemplateLabelSelector(nil, map[string]string{"x": "y"})
if err != nil {
t.Fatalf("expected err=nil, got %v", err)
}
if got != nil {
t.Fatalf("expected selector=nil, got %#v", got)
}
})
t.Run("does not mutate input (deep copy)", func(t *testing.T) {
t.Parallel()
in := &metav1.LabelSelector{
MatchLabels: map[string]string{
"created-by": "{{ controller }}",
},
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "{{ key }}",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"{{ v1 }}", "{{ v2 }}"},
},
},
}
ctx := map[string]string{
"controller": "capsule",
"key": "env",
"v1": "prod",
"v2": "staging",
}
orig := in.DeepCopy()
got, err := tpl.FastTemplateLabelSelector(in, ctx)
if err != nil {
t.Fatalf("expected err=nil, got %v", err)
}
if got == nil {
t.Fatalf("expected non-nil selector")
}
// Input must remain unchanged
if in.MatchLabels["created-by"] != orig.MatchLabels["created-by"] {
t.Fatalf("input was mutated: MatchLabels value changed from %q to %q", orig.MatchLabels["created-by"], in.MatchLabels["created-by"])
}
if in.MatchExpressions[0].Key != orig.MatchExpressions[0].Key {
t.Fatalf("input was mutated: MatchExpressions[0].Key changed from %q to %q", orig.MatchExpressions[0].Key, in.MatchExpressions[0].Key)
}
if in.MatchExpressions[0].Values[0] != orig.MatchExpressions[0].Values[0] ||
in.MatchExpressions[0].Values[1] != orig.MatchExpressions[0].Values[1] {
t.Fatalf("input was mutated: MatchExpressions[0].Values changed from %#v to %#v", orig.MatchExpressions[0].Values, in.MatchExpressions[0].Values)
}
// Output should be templated
if got.MatchLabels["created-by"] != "capsule" {
t.Fatalf("expected templated MatchLabels[created-by]=capsule, got %q", got.MatchLabels["created-by"])
}
if got.MatchExpressions[0].Key != "env" {
t.Fatalf("expected templated MatchExpressions[0].Key=env, got %q", got.MatchExpressions[0].Key)
}
if len(got.MatchExpressions[0].Values) != 2 || got.MatchExpressions[0].Values[0] != "prod" || got.MatchExpressions[0].Values[1] != "staging" {
t.Fatalf("expected templated values [prod staging], got %#v", got.MatchExpressions[0].Values)
}
})
t.Run("templates matchLabels keys and values via FastTemplateMap", func(t *testing.T) {
t.Parallel()
in := &metav1.LabelSelector{
MatchLabels: map[string]string{
"{{ k1 }}": "{{ v1 }}",
"static": "{{ v2 }}",
},
}
ctx := map[string]string{
"k1": "app",
"v1": "demo",
"v2": "x",
}
got, err := tpl.FastTemplateLabelSelector(in, ctx)
if err != nil {
t.Fatalf("expected err=nil, got %v", err)
}
if got == nil {
t.Fatalf("expected non-nil selector")
}
if _, ok := got.MatchLabels["app"]; !ok {
t.Fatalf("expected templated key 'app' to exist; got keys: %#v", got.MatchLabels)
}
if got.MatchLabels["app"] != "demo" {
t.Fatalf("expected MatchLabels[app]=demo, got %q", got.MatchLabels["app"])
}
if got.MatchLabels["static"] != "x" {
t.Fatalf("expected MatchLabels[static]=x, got %q", got.MatchLabels["static"])
}
})
t.Run("templates matchExpressions key and values", func(t *testing.T) {
t.Parallel()
in := &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "tier-{{ t }}",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"{{ a }}", "{{ b }}"},
},
},
}
ctx := map[string]string{"t": "id", "a": "gold", "b": "silver"}
got, err := tpl.FastTemplateLabelSelector(in, ctx)
if err != nil {
t.Fatalf("expected err=nil, got %v", err)
}
if got.MatchExpressions[0].Key != "tier-id" {
t.Fatalf("expected key=tier-id, got %q", got.MatchExpressions[0].Key)
}
if got.MatchExpressions[0].Values[0] != "gold" || got.MatchExpressions[0].Values[1] != "silver" {
t.Fatalf("expected values [gold silver], got %#v", got.MatchExpressions[0].Values)
}
})
t.Run("returns error when templating produces invalid selector (empty key)", func(t *testing.T) {
t.Parallel()
// After templating, Key becomes empty which is invalid for a selector.
in := &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "{{ missing }}",
Operator: metav1.LabelSelectorOpExists,
},
},
}
got, err := tpl.FastTemplateLabelSelector(in, map[string]string{})
if err == nil {
t.Fatalf("expected error, got nil (selector=%#v)", got)
}
})
t.Run("key overwrite risk: two templated keys collapse into one without error", func(t *testing.T) {
t.Parallel()
// This test documents current behavior (no collision protection).
// Both keys template to "app". The resulting map will have a single entry.
in := &metav1.LabelSelector{
MatchLabels: map[string]string{
"{{ k1 }}": "v1",
"{{ k2 }}": "v2",
},
}
ctx := map[string]string{"k1": "app", "k2": "app"}
got, err := tpl.FastTemplateLabelSelector(in, ctx)
if err != nil {
t.Fatalf("expected err=nil, got %v", err)
}
if got == nil {
t.Fatalf("expected non-nil selector")
}
// Only one key should remain due to collision overwrite behavior.
if len(got.MatchLabels) != 1 {
t.Fatalf("expected 1 key after collision, got %d (%#v)", len(got.MatchLabels), got.MatchLabels)
}
if _, ok := got.MatchLabels["app"]; !ok {
t.Fatalf("expected final key 'app' to exist, got %#v", got.MatchLabels)
}
// We intentionally do NOT assert which value wins since map iteration order is randomized.
// This is exactly the risk you mentioned; the test makes it visible.
})
}