Files
zeekay 04e5aac8c4 feat(operator): Cloudflare connector + Pages CR
Implement the stubbed CloudflareReconciler with real CF DNS sync (create/
adopt/update/delete via a finalizer) so DnsRecords with syncToCloudflare=true
land at the edge, and add a PagesProject CR + reconciler that deploys a CF
Pages site, attaches its custom domain, and wires DNS by composing the DNS
layer (emits an owned CNAME DnsRecord rather than a second CF-DNS code path).

- internal/cloudflare: minimal CF API v4 client (DNS CRUD + Pages), one do()
  HTTP path, scoped Bearer auth, idempotent delete/add. httptest-covered.
- CloudflareReconciler: real upsert + finalizer cleanup; nil client => no-op.
- PagesProject{,Spec,Status} CR + reconciler (Owns child DnsRecord for GC).
- main.go: one shared cloudflare.Client injected into both reconcilers.
- CRDs, RBAC, DeepCopy regenerated; deployable manager manifest (CF_API_TOKEN
  from KMS-synced secret, optional); hanzo.bot sample.

Tests: go test ./... green (CF client + both reconcilers, fake client + mock CF).
2026-06-16 12:11:20 -07:00

150 lines
4.2 KiB
Go

package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// newTestClient returns a Client pointed at a mock server with the given handler.
func newTestClient(h http.HandlerFunc) (*Client, *httptest.Server) {
srv := httptest.NewServer(h)
c := New("test-token")
c.BaseURL = srv.URL
return c, srv
}
func wrap(result any) []byte {
b, _ := json.Marshal(map[string]any{"success": true, "errors": []any{}, "result": result})
return b
}
func TestCreateRecord(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/zones/z1/dns_records" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("auth = %q", got)
}
var in Record
_ = json.NewDecoder(r.Body).Decode(&in)
if in.Name != "www.example.com" || in.Type != "A" || in.Content != "1.2.3.4" {
t.Errorf("body = %+v", in)
}
in.ID = "rec123"
_, _ = w.Write(wrap(in))
})
defer srv.Close()
id, err := c.CreateRecord(context.Background(), "z1", Record{Type: "A", Name: "www.example.com", Content: "1.2.3.4", Proxied: true})
if err != nil {
t.Fatal(err)
}
if id != "rec123" {
t.Errorf("id = %q, want rec123", id)
}
}
func TestFindRecord(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("name") != "www.example.com" || r.URL.Query().Get("type") != "CNAME" {
t.Errorf("query = %s", r.URL.RawQuery)
}
_, _ = w.Write(wrap([]Record{{ID: "rec9", Type: "CNAME", Name: "www.example.com", Content: "target.pages.dev"}}))
})
defer srv.Close()
got, err := c.FindRecord(context.Background(), "z1", "www.example.com", "CNAME")
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != "rec9" || got.Content != "target.pages.dev" {
t.Errorf("got = %+v", got)
}
}
func TestFindRecordAbsent(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(wrap([]Record{}))
})
defer srv.Close()
got, err := c.FindRecord(context.Background(), "z1", "missing.example.com", "A")
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("want nil, got %+v", got)
}
}
func TestDeleteRecordIdempotent(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
// Simulate "record does not exist" — must be swallowed.
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errors": []apiError{{Code: 81044, Message: "Record does not exist."}},
})
})
defer srv.Close()
if err := c.DeleteRecord(context.Background(), "z1", "gone"); err != nil {
t.Errorf("delete of absent record should be nil, got %v", err)
}
}
func TestAPIErrorSurfaced(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errors": []apiError{{Code: 10000, Message: "Authentication error"}},
})
})
defer srv.Close()
_, err := c.CreateRecord(context.Background(), "z1", Record{Type: "A", Name: "x", Content: "1.1.1.1"})
if err == nil {
t.Fatal("expected error")
}
}
func TestPagesProjectLifecycle(t *testing.T) {
created := false
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && !created:
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false, "errors": []apiError{{Code: 8000007, Message: "not found"}},
})
case r.Method == http.MethodPost && r.URL.Path == "/accounts/acc1/pages/projects":
created = true
var in PagesProject
_ = json.NewDecoder(r.Body).Decode(&in)
if in.ProductionBranch != "main" {
t.Errorf("default branch = %q", in.ProductionBranch)
}
in.Subdomain = in.Name + ".pages.dev"
_, _ = w.Write(wrap(in))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
})
defer srv.Close()
ctx := context.Background()
p, err := c.GetPagesProject(ctx, "acc1", "site")
if err != nil || p != nil {
t.Fatalf("get absent: p=%v err=%v", p, err)
}
got, err := c.CreatePagesProject(ctx, "acc1", PagesProject{Name: "site"})
if err != nil {
t.Fatal(err)
}
if got.Subdomain != "site.pages.dev" {
t.Errorf("subdomain = %q", got.Subdomain)
}
}