Files
capsule/internal/cache/registries_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

525 lines
13 KiB
Go

package cache
import (
"sync"
"testing"
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/api"
)
func set(ids ...string) map[string]struct{} {
m := make(map[string]struct{}, len(ids))
for _, id := range ids {
m[id] = struct{}{}
}
return m
}
func TestRegistryRuleSetCache_GetOrBuild_ReturnsFromCacheFlag(t *testing.T) {
c := NewRegistryRuleSetCache()
rules := []api.OCIRegistry{
{
Registry: "harbor/.*",
Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
Policy: []corev1.PullPolicy{corev1.PullNever},
},
}
rs1, fromCache1, err := c.GetOrBuild(rules)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if rs1 == nil {
t.Fatalf("expected ruleset, got nil")
}
if fromCache1 {
t.Fatalf("expected fromCache=false on first build, got true")
}
rs2, fromCache2, err := c.GetOrBuild(rules)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if rs2 == nil {
t.Fatalf("expected ruleset, got nil")
}
if !fromCache2 {
t.Fatalf("expected fromCache=true on second call, got false")
}
if rs1 != rs2 {
t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2)
}
}
func TestRuleSetCache_GetOrBuild_EmptyReturnsNil(t *testing.T) {
c := NewRegistryRuleSetCache()
rs, _, err := c.GetOrBuild(nil)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if rs != nil {
t.Fatalf("expected nil ruleset, got %#v", rs)
}
rs, _, err = c.GetOrBuild([]api.OCIRegistry{})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if rs != nil {
t.Fatalf("expected nil ruleset, got %#v", rs)
}
if got := c.Stats(); got != 0 {
t.Fatalf("expected Stats()=0, got %d", got)
}
}
func TestRuleSetCache_GetOrBuild_InvalidRegexReturnsError(t *testing.T) {
c := NewRegistryRuleSetCache()
// invalid regex
rules := []api.OCIRegistry{
{
Registry: "([",
Validation: []api.RegistryValidationTarget{api.ValidateImages},
Policy: []corev1.PullPolicy{corev1.PullAlways},
},
}
rs, _, err := c.GetOrBuild(rules)
if err == nil {
t.Fatalf("expected error, got nil")
}
if rs != nil {
t.Fatalf("expected nil ruleset on error, got %#v", rs)
}
if got := c.Stats(); got != 0 {
t.Fatalf("expected Stats()=0 after failing build, got %d", got)
}
}
func TestRuleSetCache_GetOrBuild_DeduplicatesByContent(t *testing.T) {
c := NewRegistryRuleSetCache()
rulesA := []api.OCIRegistry{
{
Registry: "harbor/.*",
Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
Policy: []corev1.PullPolicy{corev1.PullNever},
},
}
// same content but different backing slice
rulesB := []api.OCIRegistry{
{
Registry: "harbor/.*",
Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
Policy: []corev1.PullPolicy{corev1.PullNever},
},
}
rs1, _, err := c.GetOrBuild(rulesA)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rs2, _, err := c.GetOrBuild(rulesB)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
// the whole point: should be the exact same pointer
if rs1 != rs2 {
t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2)
}
if got := c.Stats(); got != 1 {
t.Fatalf("expected Stats()=1, got %d", got)
}
// sanity: compiled fields are correct (no DeepEqual; check specific invariants)
if rs1.ID == "" {
t.Fatalf("expected non-empty ruleset ID")
}
if len(rs1.Compiled) != 1 {
t.Fatalf("expected 1 compiled rule, got %d", len(rs1.Compiled))
}
cr := rs1.Compiled[0]
if cr.RE == nil {
t.Fatalf("expected compiled regexp, got nil")
}
if cr.Registry != "harbor/.*" {
t.Fatalf("expected Registry to match input, got %q", cr.Registry)
}
if !cr.ValidateImages || !cr.ValidateVolumes {
t.Fatalf("expected ValidateImages and ValidateVolumes true, got images=%v volumes=%v", cr.ValidateImages, cr.ValidateVolumes)
}
if rs1.HasImages != true || rs1.HasVolumes != true {
t.Fatalf("expected ruleset flags HasImages/HasVolumes true, got images=%v volumes=%v", rs1.HasImages, rs1.HasVolumes)
}
if cr.AllowedPolicy == nil {
t.Fatalf("expected AllowedPolicy map non-nil")
}
if _, ok := cr.AllowedPolicy[corev1.PullNever]; !ok {
t.Fatalf("expected AllowedPolicy to contain PullNever")
}
}
func TestRuleSetCache_GetOrBuild_OrderMatters_LaterWins(t *testing.T) {
c := NewRegistryRuleSetCache()
// Two rules with same items but swapped order
// hashRules preserves rule order, so the IDs must differ.
rules1 := []api.OCIRegistry{
{Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}},
{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
}
rules2 := []api.OCIRegistry{
{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
{Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}},
}
rs1, _, err := c.GetOrBuild(rules1)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rs2, _, err := c.GetOrBuild(rules2)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if rs1 == rs2 {
t.Fatalf("expected different cached entries due to different rule order, got same pointer %p", rs1)
}
if rs1.ID == rs2.ID {
t.Fatalf("expected different IDs for different order, got same %q", rs1.ID)
}
if got := c.Stats(); got != 2 {
t.Fatalf("expected Stats()=2, got %d", got)
}
// Verify compiled slice preserves the rule order we provided
if len(rs1.Compiled) != 2 {
t.Fatalf("expected 2 compiled rules, got %d", len(rs1.Compiled))
}
if rs1.Compiled[0].Registry != ".*" || rs1.Compiled[1].Registry != "harbor/.*" {
t.Fatalf("expected compiled order to match input for rules1, got %q then %q",
rs1.Compiled[0].Registry, rs1.Compiled[1].Registry)
}
}
func TestRuleSetCache_GetOrBuild_ConcurrentReturnsSamePointer(t *testing.T) {
c := NewRegistryRuleSetCache()
rules := []api.OCIRegistry{
{
Registry: "harbor/.*",
Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes},
Policy: []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent},
},
}
const workers = 32
var wg sync.WaitGroup
wg.Add(workers)
results := make([]*RuleSet, workers)
errs := make([]error, workers)
for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()
rs, _, err := c.GetOrBuild(rules)
results[i] = rs
errs[i] = err
}(i)
}
wg.Wait()
for i := 0; i < workers; i++ {
if errs[i] != nil {
t.Fatalf("worker %d got err: %v", i, errs[i])
}
if results[i] == nil {
t.Fatalf("worker %d got nil ruleset", i)
}
}
// all pointers must match the first
first := results[0]
for i := 1; i < workers; i++ {
if results[i] != first {
t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i])
}
}
}
func TestRegistryRuleSetCache_GetOrBuild_ConcurrentPointersAndFlags(t *testing.T) {
c := NewRegistryRuleSetCache()
rules := []api.OCIRegistry{
{Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}},
}
const workers = 32
var wg sync.WaitGroup
wg.Add(workers)
results := make([]*RuleSet, workers)
flags := make([]bool, workers)
errs := make([]error, workers)
for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()
rs, fromCache, err := c.GetOrBuild(rules)
results[i] = rs
flags[i] = fromCache
errs[i] = err
}(i)
}
wg.Wait()
for i := 0; i < workers; i++ {
if errs[i] != nil {
t.Fatalf("worker %d err: %v", i, errs[i])
}
if results[i] == nil {
t.Fatalf("worker %d got nil ruleset", i)
}
}
first := results[0]
for i := 1; i < workers; i++ {
if results[i] != first {
t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i])
}
}
seenFalse := false
seenTrue := false
for i := 0; i < workers; i++ {
if flags[i] {
seenTrue = true
} else {
seenFalse = true
}
}
if !seenFalse {
t.Fatalf("expected at least one fromCache=false (builder), got none")
}
if !seenTrue {
t.Fatalf("expected at least one fromCache=true (builder), got none")
}
}
func TestRegistryRuleSetCache_InsertForTest_ThenHasAndLen(t *testing.T) {
c := NewRegistryRuleSetCache()
if got := c.Stats(); got != 0 {
t.Fatalf("expected Len()=0, got %d", got)
}
if c.Has("x") {
t.Fatalf("expected Has(x)=false on empty cache")
}
c.insertForTest("x")
if !c.Has("x") {
t.Fatalf("expected Has(x)=true after insert")
}
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len()=1 after insert, got %d", got)
}
}
func TestRegistryRuleSetCache_InsertForTest_DuplicateDoesNotIncreaseLen(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("x")
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len()=1 after first insert, got %d", got)
}
c.insertForTest("x")
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len() to remain 1 after duplicate insert, got %d", got)
}
if !c.Has("x") {
t.Fatalf("expected Has(x)=true after duplicate insert")
}
}
func TestRegistryRuleSetCache_HasFalseForMissingKey(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
if c.Has("b") {
t.Fatalf("expected Has(b)=false when only a exists")
}
}
func TestRegistryRuleSetCache_PruneActive_RemovesOnlyInactive(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
c.insertForTest("b")
c.insertForTest("c")
removed := c.PruneActive(set("b"))
if removed != 2 {
t.Fatalf("expected removed=2, got %d", removed)
}
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len()=1 after prune, got %d", got)
}
if !c.Has("b") {
t.Fatalf("expected b to remain")
}
if c.Has("a") || c.Has("c") {
t.Fatalf("expected a and c to be removed")
}
}
func TestRegistryRuleSetCache_PruneActive_AllActiveNoChange(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
c.insertForTest("b")
removed := c.PruneActive(set("a", "b"))
if removed != 0 {
t.Fatalf("expected removed=0, got %d", removed)
}
if got := c.Stats(); got != 2 {
t.Fatalf("expected Len()=2, got %d", got)
}
if !c.Has("a") || !c.Has("b") {
t.Fatalf("expected both a and b to remain")
}
}
func TestRegistryRuleSetCache_PruneActive_EmptyActivePrunesAll(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
c.insertForTest("b")
removed := c.PruneActive(set())
if removed != 2 {
t.Fatalf("expected removed=2, got %d", removed)
}
if got := c.Stats(); got != 0 {
t.Fatalf("expected Len()=0 after prune all, got %d", got)
}
if c.Has("a") || c.Has("b") {
t.Fatalf("expected cache to be empty after prune all")
}
}
func TestRegistryRuleSetCache_PruneActive_NilActivePrunesAll(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
removed := c.PruneActive(nil)
if removed != 1 {
t.Fatalf("expected removed=1, got %d", removed)
}
if got := c.Stats(); got != 0 {
t.Fatalf("expected Len()=0 after prune, got %d", got)
}
if c.Has("a") {
t.Fatalf("expected a to be removed")
}
}
func TestRegistryRuleSetCache_PruneActive_EmptyCacheNoop(t *testing.T) {
c := NewRegistryRuleSetCache()
removed := c.PruneActive(set("a"))
if removed != 0 {
t.Fatalf("expected removed=0 on empty cache, got %d", removed)
}
if got := c.Stats(); got != 0 {
t.Fatalf("expected Len()=0, got %d", got)
}
}
func TestRegistryRuleSetCache_PruneActive_Idempotent(t *testing.T) {
c := NewRegistryRuleSetCache()
c.insertForTest("a")
c.insertForTest("b")
c.insertForTest("c")
active := set("a")
removed1 := c.PruneActive(active)
if removed1 != 2 {
t.Fatalf("expected first prune removed=2, got %d", removed1)
}
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len()=1 after first prune, got %d", got)
}
if !c.Has("a") {
t.Fatalf("expected a to remain after first prune")
}
removed2 := c.PruneActive(active)
if removed2 != 0 {
t.Fatalf("expected second prune removed=0, got %d", removed2)
}
if got := c.Stats(); got != 1 {
t.Fatalf("expected Len()=1 after second prune, got %d", got)
}
}
func TestRegistryRuleSetCache_PruneActive_RemovesCorrectCountWithLargerSet(t *testing.T) {
c := NewRegistryRuleSetCache()
// Insert 10 IDs: id0..id9
for i := 0; i < 10; i++ {
c.insertForTest("id" + itoa(i))
}
// Keep 3: id0,id4,id9
removed := c.PruneActive(set("id0", "id4", "id9"))
if removed != 7 {
t.Fatalf("expected removed=7, got %d", removed)
}
if got := c.Stats(); got != 3 {
t.Fatalf("expected Len()=3, got %d", got)
}
if !c.Has("id0") || !c.Has("id4") || !c.Has("id9") {
t.Fatalf("expected id0,id4,id9 to remain")
}
}
// tiny int->string without fmt (faster, no allocations beyond result)
func itoa(i int) string {
// Enough for small test numbers
if i == 0 {
return "0"
}
var buf [20]byte
n := len(buf)
for i > 0 {
n--
buf[n] = byte('0' + (i % 10))
i /= 10
}
return string(buf[n:])
}