package reconcile import ( "testing" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" iamv1 "github.com/riotpiao/key-rotation-controller/api" ) func rotation(last *metav1.Time, activeID string, hours int32) *iamv1.KeyRotation { return &iamv1.KeyRotation{ Spec: iamv1.KeyRotationSpec{ Provider: iamv1.ProviderZitadelMachineKey, Strategy: iamv1.StrategyOverlap, IntervalHours: hours, }, Status: iamv1.KeyRotationStatus{ LastRotation: last, ActiveCredentialID: activeID, Phase: iamv1.PhaseSucceeded, }, } } func TestRequeueAfterBacksOffAfterAFailure(t *testing.T) { now := time.Unix(1_700_000_000, 0) last := metav1.NewTime(now.Add(-1 * time.Minute)) r := &Reconciler{Now: func() time.Time { return now }} kr := rotation(&last, "key-1", 24) kr.Status.Phase = iamv1.PhaseFailed if got := r.RequeueAfter(kr); got != time.Minute { t.Fatalf("a failed attempt must wait a short backoff, got %s", got) } old := metav1.NewTime(now.Add(-3 * time.Minute)) kr.Status.LastRotation = &old if got := r.RequeueAfter(kr); got != 0 { t.Fatalf("after the backoff it must rotate again, got %s", got) } } func TestRequeueAfterRotatesImmediatelyWhenNeverRotated(t *testing.T) { r := &Reconciler{Now: func() time.Time { return time.Unix(1_700_000_000, 0) }} if got := r.RequeueAfter(rotation(nil, "", 24)); got != 0 { t.Fatalf("a resource with no rotation history must rotate now, got %s", got) } if got := r.RequeueAfter(rotation(nil, "key-1", 24)); got != 0 { t.Fatalf("a resource with no lastRotation must rotate now, got %s", got) } } func TestRequeueAfterWaitsForTheInterval(t *testing.T) { now := time.Unix(1_700_000_000, 0) last := metav1.NewTime(now.Add(-6 * time.Hour)) r := &Reconciler{Now: func() time.Time { return now }} got := r.RequeueAfter(rotation(&last, "key-1", 24)) want := 18 * time.Hour if got != want { t.Fatalf("expected %s until the next rotation, got %s", want, got) } } func TestRequeueAfterRotatesWhenOverdue(t *testing.T) { now := time.Unix(1_700_000_000, 0) last := metav1.NewTime(now.Add(-30 * time.Hour)) r := &Reconciler{Now: func() time.Time { return now }} if got := r.RequeueAfter(rotation(&last, "key-1", 24)); got != 0 { t.Fatalf("an overdue resource must rotate now, got %s", got) } } func TestRequeueAfterFallsBackWhenNoInterval(t *testing.T) { now := time.Unix(1_700_000_000, 0) last := metav1.NewTime(now) r := &Reconciler{Now: func() time.Time { return now }} got := r.RequeueAfter(rotation(&last, "key-1", 0)) if got <= 0 { t.Fatalf("a zero interval must fall back to a default, got %s", got) } } func TestSecretDataKeepsThePreviousValueUnderAShadowKey(t *testing.T) { kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ Target: iamv1.RotationTarget{ SecretKeys: map[string]string{"key": "jwt-key", "key-id": "jwt-key-id"}, }, }} cred := &Credential{ID: "k2", Fields: map[string][]byte{ "key": []byte("PEM-2"), "key-id": []byte("k2"), }} previous := map[string][]byte{ "jwt-key": []byte("PEM-1"), "jwt-key-id": []byte("k1"), } data := secretData(kr, cred, previous) if string(data["jwt-key"]) != "PEM-2" { t.Fatalf("current value missing, got %q", data["jwt-key"]) } if string(data["jwt-key"+previousKeySuffix]) != "PEM-1" { t.Fatalf("previous value must be kept for rollback, got %q", data["jwt-key"+previousKeySuffix]) } if string(data["jwt-key-id"+previousKeySuffix]) != "k1" { t.Fatalf("previous key id must be kept for rollback") } } func TestSecretDataDefaultsToLogicalNames(t *testing.T) { kr := &iamv1.KeyRotation{} cred := &Credential{ID: "k1", Fields: map[string][]byte{"secret": []byte("s3cret")}} data := secretData(kr, cred, nil) if string(data["secret"]) != "s3cret" { t.Fatalf("an unmapped field must keep its logical name, got %v", data) } if _, shadow := data["secret"+previousKeySuffix]; shadow { t.Fatalf("no previous value existed, so no shadow key may be written") } } func TestSecretKeyForPrefersTheMapping(t *testing.T) { kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ Target: iamv1.RotationTarget{ SecretKeys: map[string]string{"secret": "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"}, }, }} if got := secretKeyFor(kr, "secret"); got != "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" { t.Fatalf("mapping not honoured, got %q", got) } if got := secretKeyFor(kr, "key-id"); got != "key-id" { t.Fatalf("unmapped field must default to its own name, got %q", got) } } func TestMachineKeyProviderFieldsRespectTheMapping(t *testing.T) { p := &MachineKeyProvider{} kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ Target: iamv1.RotationTarget{ SecretKeys: map[string]string{"key": "private-key", "key-id": "kid"}, }, }} if p.keyField(kr) != "private-key" || p.keyIDField(kr) != "kid" { t.Fatalf("provider must read the mapped Secret keys") } if p.Strategy() != iamv1.StrategyOverlap { t.Fatalf("machine keys must be an overlapping provider") } if (&OIDCClientProvider{}).Strategy() != iamv1.StrategyAtomic { t.Fatalf("client secrets cannot overlap and must be atomic") } } func TestShortHashIsStableAndDoesNotLeakTheValue(t *testing.T) { a := shortHash("super-secret-value") b := shortHash("super-secret-value") if a != b { t.Fatalf("the marker must be stable for the same value") } if a == "super-secret-value" { t.Fatalf("the marker must not be the value itself") } if shortHash("other") == a { t.Fatalf("different values must produce different markers") } }