Ctrl K
ComunidadGuías…

internal/server/caddy_sync_config_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"reflect"
	"testing"

	"github.com/X4Applegate/caddyui/internal/caddy"
	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/dns"
	"github.com/X4Applegate/caddyui/internal/models"
)

func TestHTTPRoutesReplaceUnsupportedSkipRedirectsWithoutPortConflict(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	s := &Server{DB: conn}
	proxies := []models.ProxyHost{
		{Domains: "open.example.com", Enabled: true, SSLEnabled: true, SSLForced: false, ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080},
		{Domains: "forced.example.com", Enabled: true, SSLEnabled: true, SSLForced: true, ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080},
	}

	routes := s.buildHTTPRoutes(proxies, nil, nil)
	if len(routes) != 3 { // v2.59.4: open route, forced redirect, 404 fallback
		t.Fatalf("HTTP routes = %d, want open route plus forced redirect plus the fallback", len(routes))
	}

	cfg := map[string]any{
		"apps": map[string]any{
			"http": map[string]any{
				"servers": map[string]any{
					"srv0": map[string]any{
						"automatic_https": map[string]any{
							"skip_redirects":    []any{"open.example.com"},
							"skip_certificates": []any{"plain.example.com"},
						},
					},
				},
			},
		},
	}
	removeUnsupportedSkipRedirects(cfg)
	applyPlainHTTPServer(cfg, routes)
	applyDisableAutomaticHTTPSRedirects(cfg, true)

	servers := cfg["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)
	auto := servers["srv0"].(map[string]any)["automatic_https"].(map[string]any)
	if _, exists := auto["skip_redirects"]; exists {
		t.Fatal("unsupported automatic_https.skip_redirects was not removed")
	}
	if disabled, _ := auto["disable_redirects"].(bool); !disabled {
		t.Fatal("automatic HTTPS redirects were not disabled")
	}
	httpServer := servers["caddyui_http"].(map[string]any)
	if !reflect.DeepEqual(httpServer["listen"], []any{":80"}) {
		t.Fatalf("HTTP listen = %#v, want [:80]", httpServer["listen"])
	}
	redirect := routes[1].(map[string]any)
	handler := redirect["handle"].([]any)[0].(map[string]any)
	if handler["status_code"] != 308 {
		t.Fatalf("forced HTTP status = %#v, want 308", handler["status_code"])
	}
}

func TestHTTPRoutesIncludeRawRouteForceSSLBehavior(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	s := &Server{DB: conn}
	raws := []models.RawRoute{
		{Enabled: true, ForceSSL: false, JSONData: `{"match":[{"host":["open-raw.example.com"]}],"handle":[{"handler":"static_response","status_code":200}]}`},
		{Enabled: true, ForceSSL: true, JSONData: `{"match":[{"host":["forced-raw.example.com"]}],"handle":[{"handler":"static_response","status_code":200}]}`},
	}

	routes := s.buildHTTPRoutes(nil, nil, raws)
	if len(routes) != 3 { // v2.59.4: open raw route, forced redirect, 404 fallback
		t.Fatalf("HTTP routes = %d, want open raw route plus forced redirect plus the fallback", len(routes))
	}
	redirect := routes[1].(map[string]any)
	match := redirect["match"].([]any)[0].(map[string]any)
	if !reflect.DeepEqual(match["host"], []any{"forced-raw.example.com"}) {
		t.Fatalf("forced raw redirect hosts = %#v", match["host"])
	}
}

func TestCaddyDNSProviderConfig(t *testing.T) {
	tests := []struct {
		name     string
		provider string
		creds    map[string]string
		want     map[string]any
	}{
		{"cloudflare", dns.Cloudflare, map[string]string{"cf_api_token": "token"}, map[string]any{"name": "cloudflare", "api_token": "token"}},
		{"porkbun", dns.Porkbun, map[string]string{"pb_api_key": "key", "pb_secret_key": "secret"}, map[string]any{"name": "porkbun", "api_key": "key", "api_secret_key": "secret"}},
		{"namecheap", dns.Namecheap, map[string]string{"nc_api_user": "user", "nc_api_key": "key", "nc_client_ip": "192.0.2.1"}, map[string]any{"name": "namecheap", "user": "user", "api_key": "key", "client_ip": "192.0.2.1"}},
		{"godaddy", dns.GoDaddy, map[string]string{"gd_api_key": "key", "gd_api_secret": "secret"}, map[string]any{"name": "godaddy", "api_token": "key:secret"}},
		{"digitalocean", dns.DigitalOcean, map[string]string{"do_api_token": "token"}, map[string]any{"name": "digitalocean", "auth_token": "token"}},
		{"hetzner", dns.Hetzner, map[string]string{"hetzner_api_token": "token"}, map[string]any{"name": "hetzner", "api_token": "token"}},
		{"gandi", dns.Gandi, map[string]string{"gandi_api_token": " pat "}, map[string]any{"name": "gandi", "bearer_token": "pat"}},
		{"gandi-blank", dns.Gandi, map[string]string{"gandi_api_token": ""}, nil},
		{"route53", dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE", "route53_secret_access_key": "secret"}, map[string]any{"name": "route53", "access_key_id": "AKIAEXAMPLE", "secret_access_key": "secret", "region": "us-east-1"}},
		{"route53-temporary", dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE", "route53_secret_access_key": "secret", "route53_session_token": "session", "route53_region": "us-gov-west-1"}, map[string]any{"name": "route53", "access_key_id": "AKIAEXAMPLE", "secret_access_key": "secret", "session_token": "session", "region": "us-gov-west-1"}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := caddyDNSProviderConfig(tt.provider, tt.creds, ""); !reflect.DeepEqual(got, tt.want) {
				t.Fatalf("config = %#v, want %#v", got, tt.want)
			}
		})
	}
}

func TestRoute53ProviderConfigPinsSelectedHostedZone(t *testing.T) {
	got := caddyDNSProviderConfig(dns.Route53, map[string]string{
		"route53_access_key_id":     "AKIAEXAMPLE",
		"route53_secret_access_key": "secret",
	}, "Z0123456789")
	if got["hosted_zone_id"] != "Z0123456789" {
		t.Fatalf("hosted_zone_id = %#v, want selected zone", got["hosted_zone_id"])
	}
}

func TestDNSAutomationPoliciesSeparateRoute53ZonesAndKeepDNSOnlyHosts(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	for key, value := range map[string]string{
		settingRoute53AccessKeyID:     "AKIAEXAMPLE",
		settingRoute53SecretAccessKey: "secret",
	} {
		if err := models.SetSetting(conn, key, value); err != nil {
			t.Fatal(err)
		}
	}
	s := &Server{DB: conn}
	policies := s.buildDNSAutomationPolicies([]models.ProxyHost{
		{Domains: "public.example.com", Enabled: true, SSLEnabled: true, DNSProvider: dns.Route53, DNSZoneID: "ZPUBLIC"},
		{Domains: "internal.example.net", Enabled: true, SSLEnabled: true, DNSProvider: dns.Route53, DNSZoneID: "ZINTERNAL", DNSSkipRecord: true},
	}, nil, nil, nil)
	if len(policies) != 2 {
		t.Fatalf("policies = %d, want one per Route53 hosted zone", len(policies))
	}
	want := map[string]string{"ZPUBLIC": "public.example.com", "ZINTERNAL": "internal.example.net"}
	for _, policy := range policies {
		issuers := policy["issuers"].([]any)
		issuer := issuers[0].(map[string]any)
		challenge := issuer["challenges"].(map[string]any)["dns"].(map[string]any)
		provider := challenge["provider"].(map[string]any)
		zone, _ := provider["hosted_zone_id"].(string)
		subjects := policy["subjects"].([]any)
		if len(subjects) != 1 || subjects[0] != want[zone] {
			t.Fatalf("zone %q subjects = %#v, want %q", zone, subjects, want[zone])
		}
		delete(want, zone)
	}
	if len(want) != 0 {
		t.Fatalf("missing Route53 zone policies: %#v", want)
	}
}

func TestBuildInternalTLSAutomationPolicies(t *testing.T) {
	proxies := []models.ProxyHost{
		{Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true},
		{Domains: "printer.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true},
		// Excluded: opted in but pinned to a custom certificate.
		{Domains: "custom.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true, CertificateID: 7},
		// Excluded: not opted in (default Auto ACME).
		{Domains: "public.example.com", Enabled: true, SSLEnabled: true},
		// Excluded: disabled and SSL-off hosts never issue.
		{Domains: "off.home.lan", Enabled: false, SSLEnabled: true, InternalTLS: true},
		{Domains: "plain.home.lan", Enabled: true, SSLEnabled: false, InternalTLS: true},
	}

	policies := buildInternalTLSAutomationPolicies(proxies)
	if len(policies) != 1 {
		t.Fatalf("policies = %d, want a single internal-issuer policy", len(policies))
	}
	issuers := policies[0]["issuers"].([]any)
	if module := issuers[0].(map[string]any)["module"]; module != "internal" {
		t.Fatalf("issuer module = %v, want \"internal\"", module)
	}
	got := map[string]bool{}
	for _, s := range policies[0]["subjects"].([]any) {
		got[s.(string)] = true
	}
	want := map[string]bool{"nas.home.lan": true, "printer.home.lan": true}
	if len(got) != len(want) {
		t.Fatalf("subjects = %#v, want %#v", got, want)
	}
	for name := range want {
		if !got[name] {
			t.Fatalf("missing internal-CA subject %q in %#v", name, got)
		}
	}
	for _, unexpected := range []string{"custom.home.lan", "public.example.com", "off.home.lan", "plain.home.lan"} {
		if got[unexpected] {
			t.Fatalf("subject %q should not be issued by the internal CA", unexpected)
		}
	}
}

// An internal-CA host must never be folded into automatic_https.skip_certificates
// by wildcard-managed-certificate coverage: it gets its own cert from the
// internal issuer, so skipping it would leave it with no certificate at all.
func TestBuildSkipCertificatesExcludesInternalTLSHosts(t *testing.T) {
	proxies := []models.ProxyHost{
		{Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true},
	}
	certs := []models.Certificate{
		{Source: models.CertSourceManaged, Domains: "*.home.lan"},
	}
	skip := buildSkipCertificates(proxies, nil, nil, certs)
	for _, s := range skip {
		if s == "nas.home.lan" {
			t.Fatalf("internal-CA host must not be in skip_certificates; got %#v", skip)
		}
	}
}

// buildDNSAutomationPolicies must not emit a DNS-01 policy for a host that
// opted into internal-CA issuance, even if it still carries a DNS provider.
func TestDNSAutomationPoliciesSkipInternalTLSHosts(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	for key, value := range map[string]string{
		settingRoute53AccessKeyID:     "AKIAEXAMPLE",
		settingRoute53SecretAccessKey: "secret",
	} {
		if err := models.SetSetting(conn, key, value); err != nil {
			t.Fatal(err)
		}
	}
	s := &Server{DB: conn}
	policies := s.buildDNSAutomationPolicies([]models.ProxyHost{
		{Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true, DNSProvider: dns.Route53, DNSZoneID: "ZHOME"},
	}, nil, nil, nil)
	if len(policies) != 0 {
		t.Fatalf("policies = %d, want 0 — internal-CA hosts must not get a DNS-01 policy", len(policies))
	}
}

func TestMergeAutomationPoliciesReplacesDesiredSubjectsOnly(t *testing.T) {
	existing := []any{
		map[string]any{"subjects": []any{"replace.example.com", "keep.example.com"}, "issuer": "old"},
		map[string]any{"issuer": "catch-all"},
	}
	incoming := []map[string]any{{"subjects": []any{"replace.example.com"}, "issuer": "new"}}
	got := mergeAutomationPolicies(existing, incoming)
	if len(got) != 3 {
		t.Fatalf("policies = %#v, want incoming + trimmed existing + catch-all", got)
	}
	if got[0].(map[string]any)["issuer"] != "new" {
		t.Fatalf("first policy = %#v, want incoming authoritative policy", got[0])
	}
	if !reflect.DeepEqual(got[1].(map[string]any)["subjects"], []any{"keep.example.com"}) {
		t.Fatalf("preserved subjects = %#v", got[1])
	}
	if got[2].(map[string]any)["issuer"] != "catch-all" {
		t.Fatalf("catch-all policy was not preserved: %#v", got[2])
	}
}

func TestManagedCertificateRoutesTriggerAutomaticHTTPS(t *testing.T) {
	certs := []models.Certificate{
		{ID: 1, Source: models.CertSourcePEM, Domains: "*.ignored.example.com"},
		{ID: 2, Source: models.CertSourceManaged, Domains: "*.example.com, *.sub.example.com"},
	}
	routes := buildManagedCertificateRoutes(certs, nil)
	if len(routes) != 1 {
		t.Fatalf("routes = %d, want 1 managed-certificate route", len(routes))
	}
	route := routes[0].(map[string]any)
	match := route["match"].([]any)[0].(map[string]any)
	wantHosts := []any{"*.example.com", "*.sub.example.com"}
	if !reflect.DeepEqual(match["host"], wantHosts) {
		t.Fatalf("hosts = %#v, want %#v", match["host"], wantHosts)
	}
	handler := route["handle"].([]any)[0].(map[string]any)
	if handler["status_code"] != 404 || route["terminal"] != true {
		t.Fatalf("managed fallback = %#v, want terminal 404", route)
	}
}

func TestManagedCertificatePersistsDNSSelectionAndIsNotCustomOption(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })

	id, err := models.CreateCertificate(conn, 1, 0, &models.Certificate{
		Name:         "wildcard",
		Domains:      "*.example.com",
		Source:       models.CertSourceManaged,
		DNSProvider:  dns.Cloudflare,
		DNSProfileID: "profile-1",
	})
	if err != nil {
		t.Fatal(err)
	}
	got, err := models.GetCertificate(conn, id)
	if err != nil {
		t.Fatal(err)
	}
	if got.Source != models.CertSourceManaged || got.DNSProvider != dns.Cloudflare || got.DNSProfileID != "profile-1" {
		t.Fatalf("managed certificate round trip = %#v", got)
	}
	options, err := models.ListCertificateOptionsForUser(conn, 1, 0, true, nil)
	if err != nil {
		t.Fatal(err)
	}
	if len(options) != 0 {
		t.Fatalf("custom certificate options = %#v, managed certificate must not be assignable as uploaded PEM", options)
	}
}

func TestManagedCertificateCoverageUsesSingleLabelWildcards(t *testing.T) {
	tests := []struct {
		cert string
		host string
		want bool
	}{
		{"*.example.com", "app.example.com", true},
		{"*.example.com", "app.example.com:8000", true},
		{"*.example.com", "APP.EXAMPLE.COM.", true},
		{"*.example.com", "deep.app.example.com", false},
		{"*.example.com", "example.com", false},
		{"*.sub.example.com", "app.sub.example.com", true},
		{"app.example.com", "app.example.com", true},
		{"*.example.com", "*.example.com", true},
	}
	for _, tt := range tests {
		if got := managedCertificateCovers(tt.cert, tt.host); got != tt.want {
			t.Errorf("managedCertificateCovers(%q, %q) = %v, want %v", tt.cert, tt.host, got, tt.want)
		}
	}
}

func TestManagedWildcardForHost(t *testing.T) {
	certs := []models.Certificate{
		{ID: 1, Name: "Direct managed", Domains: "app.example.com", Source: models.CertSourceManaged},
		{ID: 2, Name: "Example wildcard", Domains: "*.example.com", Source: models.CertSourceManaged},
		{ID: 3, Name: "Uploaded wildcard", Domains: "*.example.net", Source: models.CertSourcePEM},
	}

	if got := managedWildcardForHost(certs, "app.example.com"); got == nil || got.Name != "Example wildcard" {
		t.Fatalf("managedWildcardForHost() = %#v, want Example wildcard", got)
	}
	if got := managedWildcardForHost(certs, "app.example.com:8000"); got == nil || got.Name != "Example wildcard" {
		t.Fatalf("managedWildcardForHost() with port = %#v, want Example wildcard", got)
	}
	if got := managedWildcardForHost(certs, "deep.app.example.com"); got != nil {
		t.Fatalf("managedWildcardForHost() = %#v, want nil for multi-label host", got)
	}
	if got := managedWildcardForHost(certs, "app.example.net"); got != nil {
		t.Fatalf("managedWildcardForHost() = %#v, want nil for uploaded PEM wildcard", got)
	}
	if got := managedWildcardForHost(certs, "uncovered.example.org"); got != nil {
		t.Fatalf("managedWildcardForHost() = %#v, want nil for uncovered host", got)
	}
}

func TestManagedWildcardSkipsCoveredExactCertificateIssuance(t *testing.T) {
	proxies := []models.ProxyHost{
		{Domains: "project.sub.example.com", Enabled: true, SSLEnabled: true},
		{Domains: "deep.project.sub.example.com", Enabled: true, SSLEnabled: true},
		{Domains: "sub.example.com", Enabled: true, SSLEnabled: true},
	}
	certs := []models.Certificate{
		{Source: models.CertSourceManaged, Domains: "*.sub.example.com"},
	}
	got := buildSkipCertificates(proxies, nil, nil, certs)
	if !reflect.DeepEqual(got, []any{"project.sub.example.com"}) {
		t.Fatalf("skip certificates = %#v, want only wildcard-covered exact hostname", got)
	}
}

func TestManagedWildcardSubjectItselfIsNeverSkipped(t *testing.T) {
	proxies := []models.ProxyHost{
		{Domains: "*.sub.example.com", Enabled: true, SSLEnabled: true},
	}
	certs := []models.Certificate{
		{Source: models.CertSourceManaged, Domains: "*.sub.example.com"},
	}
	if got := buildSkipCertificates(proxies, nil, nil, certs); len(got) != 0 {
		t.Fatalf("skip certificates = %#v, wildcard subject must remain eligible for issuance", got)
	}
}

func TestPortQualifiedHostReusesManagedWildcard(t *testing.T) {
	proxies := []models.ProxyHost{
		{Domains: "project.sub.example.com:8000", Enabled: true, SSLEnabled: true},
	}
	certs := []models.Certificate{
		{Source: models.CertSourceManaged, Domains: "*.sub.example.com"},
	}
	if got := buildSkipCertificates(proxies, nil, nil, certs); !reflect.DeepEqual(got, []any{"project.sub.example.com"}) {
		t.Fatalf("skip certificates = %#v, want port-free wildcard-covered hostname", got)
	}

	route := caddy.BuildProxyRoute(models.ProxyHost{
		Domains:       "project.sub.example.com:8000",
		ForwardHost:   "app",
		ForwardPort:   8080,
		ForwardScheme: "http",
	}, nil)
	match := route["match"].([]any)[0].(map[string]any)
	if got := match["host"]; !reflect.DeepEqual(got, []any{"project.sub.example.com"}) {
		t.Fatalf("route host matcher = %#v, want port-free hostname", got)
	}
}

func TestManagedCertificateProbeNameUsesSyntheticWildcardChild(t *testing.T) {
	cert := models.Certificate{Domains: "example.com, *.sub.example.com"}
	if got := managedCertificateProbeName(cert); got != "caddyui-probe.sub.example.com" {
		t.Fatalf("probe name = %q", got)
	}
}

func TestEnsureManagedCertificateOnServerDeduplicatesEquivalentSubjects(t *testing.T) {
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	sourceServerID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
		Name: "source", AdminURL: "http://source:2019", Type: models.CaddyServerTypeManaged,
	})
	if err != nil {
		t.Fatal(err)
	}
	targetID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
		Name: "target", AdminURL: "http://target:2019", Type: models.CaddyServerTypeManaged,
	})
	if err != nil {
		t.Fatal(err)
	}
	s := &Server{DB: conn}
	source := models.Certificate{
		Name: "wildcard", Domains: "*.example.com, example.com", Source: models.CertSourceManaged,
		DNSProvider: dns.Cloudflare,
	}
	source.ID, err = models.CreateCertificate(conn, sourceServerID, 0, &source)
	if err != nil {
		t.Fatal(err)
	}
	created, err := s.ensureCertificateOnServer("test", sourceServerID, targetID, source, 0)
	if err != nil || !created {
		t.Fatalf("first ensure = created %v, err %v; want created", created, err)
	}
	source.Domains = "example.com, *.example.com"
	created, err = s.ensureCertificateOnServer("test", sourceServerID, targetID, source, 0)
	if err != nil || created {
		t.Fatalf("second ensure = created %v, err %v; want existing equivalent", created, err)
	}
	certs, err := models.ListCertificates(conn, targetID)
	if err != nil {
		t.Fatal(err)
	}
	if len(certs) != 1 {
		t.Fatalf("target certificates = %d, want 1", len(certs))
	}
}

func TestCaddyDNSProviderConfigRejectsIncompleteCredentials(t *testing.T) {
	if got := caddyDNSProviderConfig(dns.Porkbun, map[string]string{"pb_api_key": "key"}, ""); got != nil {
		t.Fatalf("incomplete credentials returned config %#v", got)
	}
	if got := caddyDNSProviderConfig(dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE"}, ""); got != nil {
		t.Fatalf("incomplete Route 53 credentials returned config %#v", got)
	}
}

func TestPushAutomationPoliciesCreatesMissingTLSApp(t *testing.T) {
	var postedPath string
	var posted map[string]any
	admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/config/":
			_ = json.NewEncoder(w).Encode(map[string]any{
				"apps": map[string]any{"http": map[string]any{}},
			})
		case r.Method == http.MethodPost:
			postedPath = r.URL.Path
			if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
				t.Errorf("decode POST: %v", err)
			}
			w.WriteHeader(http.StatusOK)
		default:
			http.Error(w, "unexpected request", http.StatusBadRequest)
		}
	}))
	defer admin.Close()

	incoming := []map[string]any{{
		"subjects": []any{"example.com"},
		"issuers":  []any{map[string]any{"module": "acme"}},
	}}
	if err := pushAutomationPoliciesVia(caddy.New(admin.URL, "", ""), incoming); err != nil {
		t.Fatal(err)
	}
	if postedPath != "/config/apps/tls" {
		t.Fatalf("POST path = %q, want /config/apps/tls", postedPath)
	}
	automation, _ := posted["automation"].(map[string]any)
	policies, _ := automation["policies"].([]any)
	if len(policies) != 1 {
		t.Fatalf("posted policies = %#v, want one policy", policies)
	}
}

func TestPushAutomationPoliciesPreservesExistingTLSAndUsesAutomationPath(t *testing.T) {
	var postedPath string
	var posted map[string]any
	admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodGet && r.URL.Path == "/config/":
			_ = json.NewEncoder(w).Encode(map[string]any{
				"apps": map[string]any{
					"http": map[string]any{},
					"tls": map[string]any{
						"automation": map[string]any{"on_demand": map[string]any{}},
					},
				},
			})
		case r.Method == http.MethodPost:
			postedPath = r.URL.Path
			if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
				t.Errorf("decode POST: %v", err)
			}
			w.WriteHeader(http.StatusOK)
		default:
			http.Error(w, "unexpected request", http.StatusBadRequest)
		}
	}))
	defer admin.Close()

	incoming := []map[string]any{{"subjects": []any{"example.com"}}}
	if err := pushAutomationPoliciesVia(caddy.New(admin.URL, "", ""), incoming); err != nil {
		t.Fatal(err)
	}
	if postedPath != "/config/apps/tls/automation" {
		t.Fatalf("POST path = %q, want /config/apps/tls/automation", postedPath)
	}
	if _, ok := posted["on_demand"]; !ok {
		t.Fatalf("existing automation fields were not preserved: %#v", posted)
	}
}