diff --git a/docs/plans-executions/2026-10-08-2028-house-conventions.md b/docs/plans-executions/2026-10-08-2028-house-conventions.md index ac9d386..b27af46 100644 --- a/docs/plans-executions/2026-10-08-2028-house-conventions.md +++ b/docs/plans-executions/2026-10-08-2028-house-conventions.md @@ -7,7 +7,7 @@ Plan: `docs/plans/2026-10-08-2028-house-conventions.md` - [x] Step 2 — `.claude/` and docs scaffolding - [x] Step 3 — Makefile, lint, formatting - [x] Step 4 — Module path and version identity -- [ ] Step 5 — Baseline tests +- [x] Step 5 — Baseline tests - [ ] Step 6 — CI build workflow - [ ] Step 7 — Close out @@ -113,3 +113,20 @@ flag wiring is verified by `--help` output and the build only. `GOARCH` defaults `amd64` when `TARGETARCH` is empty (legacy builder, as on the CI runner) and follows the host under BuildKit, so a local build on an Apple Silicon machine is arm64. `docs/flows.md` still cites pre-change `main.go` line numbers in §2. + +## Step 5 — Baseline tests + +`internal/gateway/patch_test.go`: five table-driven tests over the pure functions. + +``` +$ go test -race -cover ./... +ok .../internal/gateway 1.870s coverage: 96.9% of statements +ok .../internal/version 2.196s coverage: 94.7% of statements +``` + +Worth noting: `internal/controller` and `main` are at 0%; reconciler tests with the +fake client come with the first reconcile fix. Wildcard names, name collisions and +truncation are deliberately not asserted, so the suite does not pin behaviour that +the bug-fix phase will change. `TestListenersEqual_managedFields` only asserts the +four fields the comparison covers today; it says nothing about protocol, allowed +routes or TLS mode. diff --git a/internal/gateway/patch_test.go b/internal/gateway/patch_test.go new file mode 100644 index 0000000..d5da493 --- /dev/null +++ b/internal/gateway/patch_test.go @@ -0,0 +1,279 @@ +package gateway + +import ( + "testing" + "time" + + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const testPrefix = "auto-" + +func cert(namespace, name, secret string, dnsNames ...string) cmv1.Certificate { + return cmv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: cmv1.CertificateSpec{SecretName: secret, DNSNames: dnsNames}, + } +} + +func listener(name, hostname, secretNamespace, secret string, port gwapiv1.PortNumber) gwapiv1.Listener { + h := gwapiv1.Hostname(hostname) + ns := gwapiv1.Namespace(secretNamespace) + return gwapiv1.Listener{ + Name: gwapiv1.SectionName(name), + Hostname: &h, + Port: port, + Protocol: gwapiv1.HTTPSProtocolType, + TLS: &gwapiv1.ListenerTLSConfig{ + CertificateRefs: []gwapiv1.SecretObjectReference{ + {Name: gwapiv1.ObjectName(secret), Namespace: &ns}, + }, + }, + } +} + +func names(listeners []gwapiv1.Listener) []string { + out := make([]string, 0, len(listeners)) + for _, l := range listeners { + out = append(out, string(l.Name)) + } + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestBuildListenersFromCertificates_names(t *testing.T) { + t.Parallel() + + deleted := cert("default", "gone", "gone-tls", "gone.example.com") + now := metav1.NewTime(time.Unix(0, 0)) + deleted.DeletionTimestamp = &now + + tests := []struct { + name string + certs []cmv1.Certificate + want []string + }{ + { + name: "no certificates", + }, + { + name: "one listener per DNS name", + certs: []cmv1.Certificate{cert("default", "web", "web-tls", "a.example.com", "b.example.com")}, + want: []string{"auto-web-a-example-com", "auto-web-b-example-com"}, + }, + { + name: "sorted by listener name across certificates", + certs: []cmv1.Certificate{ + cert("default", "zeta", "zeta-tls", "z.example.com"), + cert("default", "alpha", "alpha-tls", "a.example.com"), + }, + want: []string{"auto-alpha-a-example-com", "auto-zeta-z-example-com"}, + }, + { + name: "certificate being deleted is skipped", + certs: []cmv1.Certificate{deleted, cert("default", "web", "web-tls", "a.example.com")}, + want: []string{"auto-web-a-example-com"}, + }, + { + name: "certificate without secret name is skipped", + certs: []cmv1.Certificate{cert("default", "web", "", "a.example.com")}, + }, + { + name: "certificate without DNS names yields nothing", + certs: []cmv1.Certificate{cert("default", "web", "web-tls")}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := names(BuildListenersFromCertificates(tc.certs, testPrefix)) + if !equalStrings(got, tc.want) { + t.Errorf("listener names = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBuildListenersFromCertificates_listenerFields(t *testing.T) { + t.Parallel() + + got := BuildListenersFromCertificates( + []cmv1.Certificate{cert("apps", "web", "web-tls", "app.example.com")}, testPrefix) + if len(got) != 1 { + t.Fatalf("got %d listeners, want 1", len(got)) + } + l := got[0] + + if l.Hostname == nil || *l.Hostname != "app.example.com" { + t.Errorf("hostname = %v, want app.example.com", l.Hostname) + } + if l.Port != 443 { + t.Errorf("port = %d, want 443", l.Port) + } + if l.Protocol != gwapiv1.HTTPSProtocolType { + t.Errorf("protocol = %q, want HTTPS", l.Protocol) + } + if l.TLS == nil || l.TLS.Mode == nil || *l.TLS.Mode != gwapiv1.TLSModeTerminate { + t.Fatalf("TLS mode is not Terminate: %+v", l.TLS) + } + if len(l.TLS.CertificateRefs) != 1 { + t.Fatalf("got %d certificate refs, want 1", len(l.TLS.CertificateRefs)) + } + ref := l.TLS.CertificateRefs[0] + if ref.Name != "web-tls" { + t.Errorf("secret name = %q, want web-tls", ref.Name) + } + if ref.Namespace == nil || *ref.Namespace != "apps" { + t.Errorf("secret namespace = %v, want the certificate's namespace apps", ref.Namespace) + } + if ref.Kind == nil || *ref.Kind != "Secret" { + t.Errorf("ref kind = %v, want Secret", ref.Kind) + } + if l.AllowedRoutes == nil || l.AllowedRoutes.Namespaces == nil || + l.AllowedRoutes.Namespaces.From == nil || + *l.AllowedRoutes.Namespaces.From != gwapiv1.NamespacesFromAll { + t.Errorf("allowed routes = %+v, want namespaces from All", l.AllowedRoutes) + } +} + +func TestMergeListeners_ownership(t *testing.T) { + t.Parallel() + + manual := listener("https", "manual.example.com", "default", "manual-tls", 443) + stale := listener("auto-old-old-example-com", "old.example.com", "default", "old-tls", 443) + desired := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443) + + tests := []struct { + name string + existing []gwapiv1.Listener + desired []gwapiv1.Listener + want []string + }{ + { + name: "both empty", + }, + { + name: "manual listeners are kept", + existing: []gwapiv1.Listener{manual}, + desired: []gwapiv1.Listener{desired}, + want: []string{"https", "auto-web-a-example-com"}, + }, + { + name: "stale managed listeners are dropped", + existing: []gwapiv1.Listener{manual, stale}, + desired: []gwapiv1.Listener{desired}, + want: []string{"https", "auto-web-a-example-com"}, + }, + { + name: "no desired listeners removes every managed one", + existing: []gwapiv1.Listener{stale, manual}, + want: []string{"https"}, + }, + { + name: "existing managed listener is not duplicated", + existing: []gwapiv1.Listener{desired}, + desired: []gwapiv1.Listener{desired}, + want: []string{"auto-web-a-example-com"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := names(MergeListeners(tc.existing, tc.desired, testPrefix)) + if !equalStrings(got, tc.want) { + t.Errorf("merged names = %v, want %v", got, tc.want) + } + }) + } +} + +func TestListenersEqual_managedFields(t *testing.T) { + t.Parallel() + + a := listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 443) + b := listener("auto-web-b-example-com", "b.example.com", "default", "web-tls", 443) + + tests := []struct { + name string + x, y []gwapiv1.Listener + want bool + }{ + {name: "both empty", want: true}, + {name: "identical", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a, b}, want: true}, + {name: "order is ignored", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{b, a}, want: true}, + {name: "different length", x: []gwapiv1.Listener{a, b}, y: []gwapiv1.Listener{a}, want: false}, + {name: "different name", x: []gwapiv1.Listener{a}, y: []gwapiv1.Listener{b}, want: false}, + { + name: "different hostname", + x: []gwapiv1.Listener{a}, + y: []gwapiv1.Listener{listener("auto-web-a-example-com", "other.example.com", "default", "web-tls", 443)}, + want: false, + }, + { + name: "different port", + x: []gwapiv1.Listener{a}, + y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "web-tls", 8443)}, + want: false, + }, + { + name: "different secret name", + x: []gwapiv1.Listener{a}, + y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "default", "other-tls", 443)}, + want: false, + }, + { + name: "different secret namespace", + x: []gwapiv1.Listener{a}, + y: []gwapiv1.Listener{listener("auto-web-a-example-com", "a.example.com", "other", "web-tls", 443)}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := ListenersEqual(tc.x, tc.y); got != tc.want { + t.Errorf("ListenersEqual() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestListenerNameFor_sanitizesDots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + certName string + dnsName string + want string + }{ + {name: "dots become dashes", certName: "web", dnsName: "app.example.com", want: "auto-web-app-example-com"}, + {name: "single label", certName: "web", dnsName: "localhost", want: "auto-web-localhost"}, + {name: "existing dashes are kept", certName: "my-cert", dnsName: "my-app.example.com", want: "auto-my-cert-my-app-example-com"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := listenerNameFor(testPrefix, tc.certName, tc.dnsName); got != tc.want { + t.Errorf("listenerNameFor() = %q, want %q", got, tc.want) + } + }) + } +}