Files
homelab/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler_test.go
T
rock c2071da9d8 feat(iam): define a KeyRotation for every consumer except poimen-memory
Eight objects cover grafana, argocd, comfyui, minio, paperless, immich, forgejo and
api-gw. Each names its ZITADEL app, the Secret it writes, the consumer to roll and
whether the consumer reloads hot or needs a restart.

All are suspended: the controller image does not exist yet, and an unsuspended
object would claim an intent that no process acts on.

The README records the retirement target for the 34 .enc.yaml files: six
root-of-trust secrets that cannot rotate themselves survive, everything else
becomes a KeyRotation and leaves git.
2026-10-27 14:28:28 -07:00

156 lines
4.8 KiB
Go

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,
},
}
}
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")
}
}