package edge import ( "context" "crypto/tls" "crypto/x509" "errors" "io" "net" "net/http" "net/http/httptest" "reflect" "strings" "testing" "time" "golang.org/x/crypto/acme/autocert" ) // steeringEdge is an Edge with the steering environment armed, built through // New so the derivation under test is the production one. func steeringEdge(t *testing.T, zone, appsDomain, fallback string) *Edge { t.Helper() t.Setenv("CARLOS_STEERING_ZONE_ID", zone) t.Setenv("PLATFORM_APPS_DOMAIN", appsDomain) e, err := New(openReg(t), nil) if err != nil { t.Fatal(err) } e.fallbackURL = fallback return e } // TestSteeringProbeHostFromEnv: the hostname is derived only where the // deployment actually steers. A missing or malformed zone id — every box // today — leaves it empty, which is the whole inertness claim. func TestSteeringProbeHostFromEnv(t *testing.T) { cases := []struct{ name, zone, domain, want string }{ {"nothing set (every deployment today)", "", "", ""}, {"apps domain alone is NOT the gate", "", "oncarlos.com", ""}, {"malformed zone id stays dormant", "/hostedzone/Z123", "oncarlos.com", ""}, {"armed", "Z0ABCDEF1234", "oncarlos.com", "edge.oncarlos.com"}, // The dotted form is the two-reads trap: it must normalize ONCE, so // that the probe host and the wildcard parent cannot disagree. Covered // end-to-end by TestSteeringArmsTheAppsDomainWildcardParent. {"armed, case and dot normalized", "Z0ABCDEF1234", " OnCarlos.com. ", "edge.oncarlos.com"}, {"armed with no apps domain: nothing to build from", "Z0ABCDEF1234", "", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Setenv("CARLOS_STEERING_ZONE_ID", c.zone) t.Setenv("PLATFORM_APPS_DOMAIN", c.domain) if got := steeringProbeHostFromEnv(); got != c.want { t.Errorf("steeringProbeHostFromEnv() = %q, want %q", got, c.want) } }) } } // TestSteeringProbeAnswersAheadOfTheFallback is the health-probe gate itself: // on a steering deployment the check's exact request (GET / with the steering // hostname) is answered 200 even though a fallback URL is configured — the // 302 that fallback would otherwise return is what Route53 scores as healthy, // which would make the withdrawal decorative. func TestSteeringProbeAnswersAheadOfTheFallback(t *testing.T) { e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net") get := func(method, host, path string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(method, "http://"+host+path, nil) r.Host = host w := httptest.NewRecorder() e.ServeHTTP(w, r) return w } w := get("GET", "edge.example.com", "/") if w.Code != http.StatusOK { t.Fatalf("GET / on the steering host = %d, want 200 (a --fallback edge must not 302 the health check)", w.Code) } if w.Body.String() != steeringProbeBody { t.Errorf("body = %q, want %q", w.Body.String(), steeringProbeBody) } if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("Content-Type = %q, want text/plain", ct) } if cc := w.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("Cache-Control = %q, want no-store", cc) } // Port and trailing dot are both legal ways for a client to name it. if w := get("GET", "EDGE.example.com.:443", "/"); w.Code != http.StatusOK { t.Errorf("case/dot/port-qualified host = %d, want 200", w.Code) } // HEAD answers, with no body (the health check may use either). if w := get("HEAD", "edge.example.com", "/"); w.Code != http.StatusOK || w.Body.Len() != 0 { t.Errorf("HEAD / = %d body %q, want 200 with no body", w.Code, w.Body.String()) } // The probe is an ANSWER, not a reserved hostname: everything else on // that host takes the ordinary path (here: the fallback redirect). for _, c := range []struct{ method, path string }{ {"GET", "/other"}, {"POST", "/"}, } { if w := get(c.method, "edge.example.com", c.path); w.Code != http.StatusFound { t.Errorf("%s %s on the steering host = %d, want the ordinary 302 fallback", c.method, c.path, w.Code) } } // :80 answers the same way, for the same reason. r := httptest.NewRequest("GET", "http://edge.example.com/", nil) r.Host = "edge.example.com" w80 := httptest.NewRecorder() e.httpFallbackHandler().ServeHTTP(w80, r) if w80.Code != http.StatusOK || w80.Body.String() != steeringProbeBody { t.Errorf(":80 GET / = %d %q, want 200 %q", w80.Code, w80.Body.String(), steeringProbeBody) } } // TestSteeringProbeInertWithoutSteering: with no steering zone the edge is // byte-identical to what it was — the steering hostname is just another // unknown host, redirected to the fallback on both listeners. func TestSteeringProbeInertWithoutSteering(t *testing.T) { e := steeringEdge(t, "", "example.com", "https://marketing.example.net") if e.steeringHost != "" { t.Fatalf("steeringHost = %q on an unsteered deployment, want empty", e.steeringHost) } for _, h := range []http.Handler{e, e.httpFallbackHandler()} { r := httptest.NewRequest("GET", "http://edge.example.com/", nil) r.Host = "edge.example.com" w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusFound || w.Header().Get("Location") != "https://marketing.example.net" { t.Errorf("unsteered edge: %d %q, want the unchanged 302 fallback", w.Code, w.Header().Get("Location")) } } } // TestSteeringArmsTheAppsDomainWildcardParent: the cert half. The health // check handshakes with SNI edge., one label SHALLOWER than the // per-account parents, so the apps domain itself must join the parent set — // and only when the deployment steers. func TestSteeringArmsTheAppsDomainWildcardParent(t *testing.T) { cache := autocert.DirCache(t.TempDir()) t.Setenv("CARLOS_WILDCARD_DOMAINS", "") // DOTTED on purpose: PLATFORM_APPS_DOMAIN with a trailing dot is legal // input, newWildcardFromEnv's own Getenv does not trim it, and the parent // must still come out as the name certFor can match. Two reads of the // environment put the probe answer on edge.oncarlos.com and the parent on // "oncarlos.com." — no cert, every health check failing. t.Setenv("PLATFORM_APPS_DOMAIN", "oncarlos.com.") t.Setenv("CARLOS_DNS_PROVIDER", "") t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok") e := &Edge{} e.upstreams.Store(&map[string]upstream{}) w := newWildcardFromEnv(e, cache) if w == nil { t.Fatal("want a manager") } if len(w.staticDomains) != 0 { t.Fatalf("unsteered staticDomains = %v, want none (the apps domain must not become a parent)", w.staticDomains) } if got := parentsFor(w.staticDomains, w.appsDomain, nil); len(got) != 0 { t.Fatalf("unsteered parents = %v, want none", got) } // Armed through the SAME derivation production uses — not a hand-set // field — so the normalization under test is the real one. t.Setenv("CARLOS_STEERING_ZONE_ID", "Z0ABCDEF1234") e.steeringHost = steeringProbeHostFromEnv() if e.steeringHost != "edge.oncarlos.com" { t.Fatalf("steeringHost = %q, want edge.oncarlos.com", e.steeringHost) } w = newWildcardFromEnv(e, cache) if !reflect.DeepEqual(w.staticDomains, []string{"oncarlos.com"}) { t.Fatalf("steered staticDomains = %v, want [oncarlos.com]", w.staticDomains) } // The parent has to cover the probe name exactly. w.parents = parentsFor(w.staticDomains, w.appsDomain, nil) cert, _ := selfSignedWildcard(t, "oncarlos.com", time.Now().Add(time.Hour)) w.certs["oncarlos.com"] = cert if w.certFor("edge.oncarlos.com") == nil { t.Error("the steering hostname must be covered by the apps-domain wildcard") } } // TestSteeringProbeOverTLS is the end-to-end shape the Route53 health check // performs: a real handshake for edge., verified by name against // the issuing certificate, followed by GET / — the pair that has to answer // 200 for a withdrawal to mean anything. func TestSteeringProbeOverTLS(t *testing.T) { e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net") cert, _ := selfSignedWildcard(t, "example.com", time.Now().Add(time.Hour)) w := &wildcardManager{ parents: []string{"example.com"}, certs: map[string]*tls.Certificate{"example.com": cert}, } srv := httptest.NewUnstartedServer(e) srv.TLS = &tls.Config{GetCertificate: wrapGetCertificate(w, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { // No per-host path in this test: if the wildcard does not cover the // name, the handshake fails exactly as it would on a live box. return nil, errors.New("no per-host certificate") })} srv.StartTLS() t.Cleanup(srv.Close) pool := x509.NewCertPool() pool.AddCert(cert.Leaf) client := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{RootCAs: pool}, DialContext: func(_ context.Context, network, _ string) (net.Conn, error) { return net.Dial(network, srv.Listener.Addr().String()) }, }, } resp, err := client.Get("https://edge.example.com/") if err != nil { t.Fatalf("handshake + GET against the steering hostname: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK || string(body) != steeringProbeBody { t.Fatalf("GET https://edge.example.com/ = %d %q, want 200 %q", resp.StatusCode, body, steeringProbeBody) } }