package tsigapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" ) const testNS = "bind-system" // fakeOperator simulates the BindTSIGKey controller: whenever a key exists // without its material Secret, it creates one. It stops when ctx is cancelled. func fakeOperator(ctx context.Context, c client.Client) { seq := 0 for { select { case <-ctx.Done(): return case <-time.After(20 * time.Millisecond): } var keys bindv1alpha1.BindTSIGKeyList if err := c.List(ctx, &keys); err != nil { continue } for i := range keys.Items { k := &keys.Items[i] secretName := k.Name + "-tsig" var existing corev1.Secret if err := c.Get(ctx, client.ObjectKey{Namespace: k.Namespace, Name: secretName}, &existing); err == nil { continue } seq++ material := "secret-material-" + itoa(seq) _ = c.Create(ctx, &corev1.Secret{ // Fake client does not assign UIDs; set one so rotation // (which detects a Secret with a new UID) is observable. ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: k.Namespace, UID: types.UID("uid-" + itoa(seq))}, Data: map[string][]byte{ "algorithm": []byte(k.Spec.Algorithm), "keyName": []byte(k.Name), "secret": []byte(material), }, }) } } } func itoa(n int) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) } func newTestServer(t *testing.T) (*Server, context.CancelFunc) { t.Helper() scheme := runtime.NewScheme() if err := clientgoscheme.AddToScheme(scheme); err != nil { t.Fatalf("clientgo scheme: %v", err) } if err := bindv1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("bind scheme: %v", err) } c := fake.NewClientBuilder().WithScheme(scheme).Build() ctx, cancel := context.WithCancel(context.Background()) go fakeOperator(ctx, c) return &Server{ Client: c, Namespace: testNS, Token: "s3cr3t", Log: logr.Discard(), WaitTimeout: 3 * time.Second, }, cancel } func TestHealthzAndAuth(t *testing.T) { srv, cancel := newTestServer(t) defer cancel() h := srv.Handler() // healthz needs no auth. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rr.Code != http.StatusOK { t.Fatalf("healthz: want 200, got %d", rr.Code) } // missing token is rejected. rr = httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/keys", strings.NewReader(`{"name":"x"}`))) if rr.Code != http.StatusUnauthorized { t.Fatalf("no-auth: want 401, got %d", rr.Code) } } func TestKeyLifecycle(t *testing.T) { srv, cancel := newTestServer(t) defer cancel() h := srv.Handler() // create created := do(t, h, http.MethodPost, "/v1/keys", `{"name":"host-a","cluster_ref":"bind-authoritative"}`, http.StatusOK) if created.Secret == "" { t.Fatalf("create returned empty secret") } if created.ClusterRef != "bind-authoritative" { t.Fatalf("cluster_ref not propagated: %q", created.ClusterRef) } // read returns the same material got := do(t, h, http.MethodGet, "/v1/keys/host-a", "", http.StatusOK) if got.Secret != created.Secret { t.Fatalf("read secret %q != created %q", got.Secret, created.Secret) } // rotate returns new material rotated := do(t, h, http.MethodPost, "/v1/keys/host-a/rotate", "", http.StatusOK) if rotated.Secret == created.Secret { t.Fatalf("rotate did not change secret") } // delete rr := httptest.NewRecorder() req := authed(http.MethodDelete, "/v1/keys/host-a", "") h.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("delete: want 204, got %d", rr.Code) } // read after delete → 404 rr = httptest.NewRecorder() h.ServeHTTP(rr, authed(http.MethodGet, "/v1/keys/host-a", "")) if rr.Code != http.StatusNotFound { t.Fatalf("read-after-delete: want 404, got %d", rr.Code) } } func authed(method, path, body string) *http.Request { var r *http.Request if body == "" { r = httptest.NewRequest(method, path, nil) } else { r = httptest.NewRequest(method, path, strings.NewReader(body)) } r.Header.Set("Authorization", "Bearer s3cr3t") return r } func do(t *testing.T, h http.Handler, method, path, body string, wantCode int) keyResponse { t.Helper() rr := httptest.NewRecorder() h.ServeHTTP(rr, authed(method, path, body)) if rr.Code != wantCode { t.Fatalf("%s %s: want %d, got %d (%s)", method, path, wantCode, rr.Code, rr.Body.String()) } var resp keyResponse if rr.Body.Len() > 0 { if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } } return resp }