Ctrl K
ComunidadGuías…

internal/server/default_deploy_targets_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 (
	"context"
	"database/sql"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"
	"testing"

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

// v2.57.0 (issue #120): persistent default deployment targets. A source
// server lists the fleet servers that every web-UI save of a proxy host,
// redirection or advanced route is automatically mirrored to, so a fleet fed
// from one configuration source does not have to re-tick "Also deploy to" on
// every create and edit.

func TestDefaultDeployTargetIDsParsing(t *testing.T) {
	cases := map[string][]int64{
		"":             nil,
		"2":            {2},
		"3, 2,4":       {3, 2, 4},
		"2,2,3,2":      {2, 3},
		"x,0,-4,,7":    {7},
		" 5 ,\t6 ,abc": {5, 6},
	}
	for in, want := range cases {
		got := models.CaddyServer{DefaultDeployTargets: in}.DefaultDeployTargetIDs()
		if !reflect.DeepEqual(got, want) {
			t.Errorf("DefaultDeployTargetIDs(%q) = %v, want %v", in, got, want)
		}
	}
	set := models.CaddyServer{DefaultDeployTargets: "2,5"}.DefaultDeployTargetSet()
	if !set[2] || !set[5] || set[3] {
		t.Errorf("DefaultDeployTargetSet = %v, want exactly {2,5}", set)
	}
	if got := models.JoinServerIDs([]int64{4, 9}); got != "4,9" {
		t.Errorf("JoinServerIDs = %q, want 4,9", got)
	}
}

// newDefaultTargetsFleet builds a source plus two edge servers, all managed.
func newDefaultTargetsFleet(t *testing.T) (s *Server, conn *sql.DB, sourceID, edge1, edge2 int64) {
	t.Helper()
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	mk := func(name string) int64 {
		admin, _ := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
		id, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: name, AdminURL: admin.URL, Type: models.CaddyServerTypeManaged})
		if err != nil {
			t.Fatal(err)
		}
		return id
	}
	sourceID, edge1, edge2 = mk("Source"), mk("Edge 1"), mk("Edge 2")
	return newRenderingTestServer(t, conn), conn, sourceID, edge1, edge2
}

func setDefaultTargets(t *testing.T, conn *sql.DB, serverID int64, targets ...int64) {
	t.Helper()
	srv, err := models.GetCaddyServer(conn, serverID)
	if err != nil {
		t.Fatal(err)
	}
	srv.DefaultDeployTargets = models.JoinServerIDs(targets)
	if err := models.UpdateCaddyServer(conn, srv); err != nil {
		t.Fatal(err)
	}
}

// --- persistence ---

func TestDefaultDeployTargetsPersistAndAreScrubbedWhenAServerIsDeleted(t *testing.T) {
	_, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge1, edge2)

	got, err := models.GetCaddyServer(conn, source)
	if err != nil {
		t.Fatal(err)
	}
	if want := []int64{edge1, edge2}; !reflect.DeepEqual(got.DefaultDeployTargetIDs(), want) {
		t.Fatalf("saved targets = %v, want %v", got.DefaultDeployTargetIDs(), want)
	}

	// Deleting a target must remove it from the source's list — otherwise a
	// later server that reuses the ID would silently start receiving the
	// source's resources.
	if err := models.DeleteCaddyServer(conn, edge1); err != nil {
		t.Fatal(err)
	}
	got, err = models.GetCaddyServer(conn, source)
	if err != nil {
		t.Fatal(err)
	}
	if want := []int64{edge2}; !reflect.DeepEqual(got.DefaultDeployTargetIDs(), want) {
		t.Fatalf("targets after deleting edge1 = %v, want %v", got.DefaultDeployTargetIDs(), want)
	}
}

func TestCreateAndUpdateServerSaveDefaultDeployTargets(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	admin, _ := newLayer4FakeAdmin(t, `{}`)

	// Update: tick edge1 + edge2, plus the layer4 picker's "deploy_to" for
	// edge2 only — the two fields must stay independent.
	form := url.Values{
		"name": {"Source"}, "admin_url": {admin.URL}, "type": {"managed"},
		"default_deploy_targets": {strconv.FormatInt(edge2, 10), strconv.FormatInt(edge1, 10)},
		"deploy_to":              {strconv.FormatInt(edge2, 10)},
	}
	req := withChiURLParam(postForm(t, "/servers/x", form), "id", strconv.FormatInt(source, 10))
	rec := httptest.NewRecorder()
	s.updateServer(rec, req)
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("update: want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	got, _ := models.GetCaddyServer(conn, source)
	if want := []int64{edge1, edge2}; !reflect.DeepEqual(got.DefaultDeployTargetIDs(), want) {
		t.Fatalf("saved defaults = %v, want %v (stored ascending)", got.DefaultDeployTargetIDs(), want)
	}

	// Unticking everything clears them.
	form.Del("default_deploy_targets")
	rec = httptest.NewRecorder()
	s.updateServer(rec, withChiURLParam(postForm(t, "/servers/x", form), "id", strconv.FormatInt(source, 10)))
	got, _ = models.GetCaddyServer(conn, source)
	if got.DefaultDeployTargets != "" {
		t.Fatalf("defaults after unticking = %q, want empty", got.DefaultDeployTargets)
	}

	// Create persists them too.
	create := url.Values{
		"name": {"Brand new"}, "admin_url": {admin.URL}, "type": {"managed"},
		"default_deploy_targets": {strconv.FormatInt(edge1, 10)},
	}
	rec = httptest.NewRecorder()
	s.createServer(rec, postForm(t, "/servers", create))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("create: want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	list, _ := models.ListCaddyServers(conn)
	last := list[len(list)-1]
	if last.Name != "Brand new" || !reflect.DeepEqual(last.DefaultDeployTargetIDs(), []int64{edge1}) {
		t.Fatalf("created server = %q defaults %v, want Brand new / [%d]", last.Name, last.DefaultDeployTargetIDs(), edge1)
	}
}

func TestParseDefaultDeployTargetsKeepsOnlyOtherManagedServers(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	external, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Monitor", AdminURL: "http://mon:2019", Type: models.CaddyServerTypeExternal})
	if err != nil {
		t.Fatal(err)
	}
	id := func(n int64) string { return strconv.FormatInt(n, 10) }
	form := url.Values{"default_deploy_targets": {
		id(edge2), id(edge1), id(edge1), // order + duplicate
		id(source),   // itself
		id(external), // monitoring-only: can't be deployed to
		"9999",       // no such server
		"nonsense", "-3", "",
	}}
	r := httptest.NewRequest(http.MethodPost, "/servers", strings.NewReader(form.Encode()))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	_ = r.ParseForm()

	if got, want := s.parseDefaultDeployTargets(r, source), id(edge1)+","+id(edge2); got != want {
		t.Fatalf("parseDefaultDeployTargets = %q, want %q", got, want)
	}
}

// --- effective targets ---

func TestEffectiveDeployTargetsUnionsFormPicksWithDefaults(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge1)

	if got, want := s.effectiveDeployTargets(source, nil), []int64{edge1}; !reflect.DeepEqual(got, want) {
		t.Errorf("defaults only = %v, want %v", got, want)
	}
	// A one-off extra pick is added; a pick that is already a default isn't
	// repeated; the source is never its own target.
	if got, want := s.effectiveDeployTargets(source, []int64{edge2, edge1, source, 0, -1}), []int64{edge2, edge1}; !reflect.DeepEqual(got, want) {
		t.Errorf("form + defaults = %v, want %v", got, want)
	}
}

// A server with no defaults must behave exactly as before this feature: only
// what the form ticked.
func TestEffectiveDeployTargetsWithoutDefaultsIsJustTheForm(t *testing.T) {
	s, _, source, edge1, edge2 := newDefaultTargetsFleet(t)
	if got := s.effectiveDeployTargets(source, nil); len(got) != 0 {
		t.Errorf("no defaults, no picks = %v, want nothing", got)
	}
	if got, want := s.effectiveDeployTargets(source, []int64{edge1, edge2}), []int64{edge1, edge2}; !reflect.DeepEqual(got, want) {
		t.Errorf("form picks only = %v, want %v", got, want)
	}
	// An unknown source row must degrade to the form's picks, not fail.
	if got, want := s.effectiveDeployTargets(4242, []int64{edge1}), []int64{edge1}; !reflect.DeepEqual(got, want) {
		t.Errorf("unknown source = %v, want %v", got, want)
	}
}

func TestOtherManagedServersFlagsDefaultTargetsForTheForms(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge2)

	r := httptest.NewRequest(http.MethodGet, "/proxy-hosts/new", nil)
	r.AddCookie(&http.Cookie{Name: serverCookie, Value: strconv.FormatInt(source, 10)})
	flags := map[int64]bool{}
	for _, srv := range s.otherManagedServers(r) {
		flags[srv.ID] = srv.IsDefaultDeployTarget
	}
	if len(flags) != 2 || flags[edge1] || !flags[edge2] {
		t.Fatalf("default flags = %v, want edge2 only", flags)
	}
}

// --- the real create handlers ---

// cookieReq is a form POST from a signed-in admin with serverID selected, the
// way the real create/edit handlers see it.
func cookieReq(t *testing.T, path string, form url.Values, serverID int64) *http.Request {
	t.Helper()
	req := postForm(t, path, form)
	req.AddCookie(&http.Cookie{Name: serverCookie, Value: strconv.FormatInt(serverID, 10)})
	admin := &models.User{ID: 1, Email: "admin@example.com", IsAdmin: true, Role: models.RoleAdmin}
	return req.WithContext(context.WithValue(req.Context(), auth.ContextUserKey, admin))
}

func TestCreateRedirectionHostDeploysToDefaultTargets(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge1) // edge2 is NOT a default

	form := url.Values{
		"domains": {"old.example.com"}, "forward_scheme": {"https"},
		"forward_domain": {"new.example.com"}, "forward_http_code": {"301"}, "enabled": {"on"},
	}
	rec := httptest.NewRecorder()
	s.createRedirectionHost(rec, cookieReq(t, "/redirection-hosts", form, source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("want 303, got %d: %s", rec.Code, rec.Body.String())
	}

	onEdge := func(id int64) int {
		rows, err := models.ListRedirectionHosts(conn, id, 0, true, nil)
		if err != nil {
			t.Fatal(err)
		}
		return len(rows)
	}
	if n := onEdge(source); n != 1 {
		t.Fatalf("source has %d redirections, want 1", n)
	}
	if n := onEdge(edge1); n != 1 {
		t.Fatalf("default target has %d redirections, want 1 (automatic fan-out)", n)
	}
	if n := onEdge(edge2); n != 0 {
		t.Fatalf("non-default target has %d redirections, want 0", n)
	}

	// The form's one-off picker still adds a non-default target on top.
	form.Set("domains", "other.example.com")
	form["deploy_to"] = []string{strconv.FormatInt(edge2, 10)}
	rec = httptest.NewRecorder()
	s.createRedirectionHost(rec, cookieReq(t, "/redirection-hosts", form, source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("second create: want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	if n := onEdge(edge1); n != 2 {
		t.Errorf("default target has %d redirections after second save, want 2", n)
	}
	if n := onEdge(edge2); n != 1 {
		t.Errorf("one-off target has %d redirections, want 1", n)
	}
}

func TestCreateProxyHostDeploysToDefaultTargetsUnlessNodeLocal(t *testing.T) {
	s, conn, source, edge1, _ := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge1)

	form := url.Values{
		"domains": {"app.example.com"}, "forward_scheme": {"http"},
		"forward_host": {"203.0.113.10"}, "forward_port": {"8080"}, "enabled": {"on"},
	}
	rec := httptest.NewRecorder()
	s.createProxyHost(rec, cookieReq(t, "/proxy-hosts", form, source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	hosts, err := models.ListProxyHosts(conn, edge1, 0, true, nil)
	if err != nil {
		t.Fatal(err)
	}
	if len(hosts) != 1 || hosts[0].Domains != "app.example.com" {
		t.Fatalf("default target proxy hosts = %+v, want the app.example.com copy", hosts)
	}

	// Node-local is the per-resource opt-out from the defaults.
	local := url.Values{
		"domains": {"db.internal.example.com"}, "forward_scheme": {"http"},
		"forward_host": {"203.0.113.11"}, "forward_port": {"5432"}, "enabled": {"on"},
		"node_local": {"on"},
	}
	rec = httptest.NewRecorder()
	s.createProxyHost(rec, cookieReq(t, "/proxy-hosts", local, source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("node-local create: want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	hosts, _ = models.ListProxyHosts(conn, edge1, 0, true, nil)
	if len(hosts) != 1 {
		t.Fatalf("default target has %d proxy hosts, want 1 — a node-local host must stay off other servers", len(hosts))
	}
}

// The forms must show default targets as always-on and keep the server form's
// new field distinct from the layer4 picker's "deploy_to".
func TestServerAndResourceFormsRenderDefaultTargets(t *testing.T) {
	s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge2)

	// Server edit form: the saved default is pre-checked under its own field.
	rec := httptest.NewRecorder()
	req := withChiURLParam(httptest.NewRequest(http.MethodGet, "/servers/x/edit", nil), "id", strconv.FormatInt(source, 10))
	s.editServerPage(rec, req)
	body := rec.Body.String()
	if !strings.Contains(body, `name="default_deploy_targets" value="`+strconv.FormatInt(edge2, 10)+`" checked`) {
		t.Errorf("server form should pre-check the saved default target:\n%s", excerpt(body, "default_deploy_targets"))
	}
	if strings.Contains(body, `name="default_deploy_targets" value="`+strconv.FormatInt(edge1, 10)+`" checked`) {
		t.Error("server form pre-checked a server that is not a default target")
	}

	// Redirection form: the default is checked + disabled with a badge.
	rec = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/redirection-hosts/new", nil)
	req.AddCookie(&http.Cookie{Name: serverCookie, Value: strconv.FormatInt(source, 10)})
	s.newRedirectionHost(rec, req)
	body = rec.Body.String()
	if !strings.Contains(body, `name="deploy_to" value="`+strconv.FormatInt(edge2, 10)+`" checked disabled`) {
		t.Errorf("redirection form should show the default target as checked and disabled:\n%s", excerpt(body, "deploy_to"))
	}
	if strings.Contains(body, `name="deploy_to" value="`+strconv.FormatInt(edge1, 10)+`" checked`) {
		t.Error("redirection form pre-checked a server that is not a default target")
	}
	if !strings.Contains(body, ">default</span>") {
		t.Error("redirection form should label the default target")
	}
}

// excerpt returns a few lines around the first occurrence of needle, for
// readable failure output from a large rendered page.
func excerpt(body, needle string) string {
	i := strings.Index(body, needle)
	if i < 0 {
		return "(" + needle + " not found in " + strconv.Itoa(len(body)) + " bytes of output)"
	}
	start, end := max(0, i-200), min(len(body), i+400)
	return body[start:end]
}

// Editing is the other half of the issue: "later changes" must reach the
// defaults too, not just the first save. Each resource is created on the source
// with no deployment at all, then saved through the real edit handler.
func TestEditHandlersDeployToDefaultTargets(t *testing.T) {
	t.Run("proxy host", func(t *testing.T) {
		s, conn, source, edge1, _ := newDefaultTargetsFleet(t)
		setDefaultTargets(t, conn, source, edge1)
		h := &models.ProxyHost{Domains: "app.example.com", ForwardScheme: "http", ForwardHost: "203.0.113.10", ForwardPort: 8080, Enabled: true}
		id, err := models.CreateProxyHost(conn, source, 0, h)
		if err != nil {
			t.Fatal(err)
		}
		if got, _ := models.ListProxyHosts(conn, edge1, 0, true, nil); len(got) != 0 {
			t.Fatalf("precondition: edge already has %d proxy hosts", len(got))
		}

		form := url.Values{
			"domains": {"app.example.com"}, "forward_scheme": {"http"},
			"forward_host": {"203.0.113.10"}, "forward_port": {"9090"}, "enabled": {"on"},
		}
		sid := strconv.FormatInt(id, 10)
		rec := httptest.NewRecorder()
		s.updateProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/"+sid, form, source), "id", sid))
		if rec.Code != http.StatusSeeOther {
			t.Fatalf("want 303, got %d: %s", rec.Code, rec.Body.String())
		}
		got, _ := models.ListProxyHosts(conn, edge1, 0, true, nil)
		if len(got) != 1 || got[0].ForwardPort != 9090 {
			t.Fatalf("edge proxy hosts = %+v, want the edited copy on port 9090", got)
		}
	})

	t.Run("redirection", func(t *testing.T) {
		s, conn, source, edge1, _ := newDefaultTargetsFleet(t)
		setDefaultTargets(t, conn, source, edge1)
		rh := &models.RedirectionHost{Domains: "old.example.com", ForwardScheme: "https", ForwardDomain: "a.example.com", ForwardHTTPCode: 301, Enabled: true}
		id, err := models.CreateRedirectionHost(conn, source, 0, rh)
		if err != nil {
			t.Fatal(err)
		}

		form := url.Values{
			"domains": {"old.example.com"}, "forward_scheme": {"https"},
			"forward_domain": {"b.example.com"}, "forward_http_code": {"301"}, "enabled": {"on"},
		}
		sid := strconv.FormatInt(id, 10)
		rec := httptest.NewRecorder()
		s.updateRedirectionHost(rec, withChiURLParam(cookieReq(t, "/redirection-hosts/"+sid, form, source), "id", sid))
		if rec.Code != http.StatusSeeOther {
			t.Fatalf("want 303, got %d: %s", rec.Code, rec.Body.String())
		}
		got, _ := models.ListRedirectionHosts(conn, edge1, 0, true, nil)
		if len(got) != 1 || got[0].ForwardDomain != "b.example.com" {
			t.Fatalf("edge redirections = %+v, want the edited copy forwarding to b.example.com", got)
		}
	})

	t.Run("advanced route create and edit", func(t *testing.T) {
		s, conn, source, edge1, edge2 := newDefaultTargetsFleet(t)
		setDefaultTargets(t, conn, source, edge1)
		route := func(body string) string {
			return `{"match":[{"host":["raw.example.com"]}],"handle":[{"handler":"static_response","body":"` + body + `"}]}`
		}

		form := url.Values{"label": {"raw"}, "json_data": {route("v1")}, "enabled": {"on"}}
		rec := httptest.NewRecorder()
		s.createRawRoute(rec, cookieReq(t, "/raw-routes", form, source))
		if rec.Code != http.StatusSeeOther {
			t.Fatalf("create: want 303, got %d: %s", rec.Code, rec.Body.String())
		}
		onSource, _ := models.ListRawRoutes(conn, source, 0, true, nil)
		onEdge, _ := models.ListRawRoutes(conn, edge1, 0, true, nil)
		if len(onSource) != 1 || len(onEdge) != 1 {
			t.Fatalf("after create: source has %d, default target has %d advanced routes, want 1 and 1", len(onSource), len(onEdge))
		}
		if other, _ := models.ListRawRoutes(conn, edge2, 0, true, nil); len(other) != 0 {
			t.Fatalf("non-default target got %d advanced routes, want 0", len(other))
		}

		form.Set("json_data", route("v2"))
		sid := strconv.FormatInt(onSource[0].ID, 10)
		rec = httptest.NewRecorder()
		s.updateRawRoute(rec, withChiURLParam(cookieReq(t, "/raw-routes/"+sid, form, source), "id", sid))
		if rec.Code != http.StatusSeeOther {
			t.Fatalf("edit: want 303, got %d: %s", rec.Code, rec.Body.String())
		}
		onEdge, _ = models.ListRawRoutes(conn, edge1, 0, true, nil)
		if len(onEdge) != 1 || !strings.Contains(onEdge[0].JSONData, "v2") {
			t.Fatalf("default target routes after edit = %+v, want a single route carrying the v2 edit", onEdge)
		}
	})
}

// Two servers that deploy to each other would overwrite one another's copies on
// every save, so the second half of the loop is refused at save time.
func TestUpdateServerRefusesMutualDefaultDeployTargets(t *testing.T) {
	s, conn, source, edge1, _ := newDefaultTargetsFleet(t)
	setDefaultTargets(t, conn, source, edge1) // Source -> Edge 1

	edge, _ := models.GetCaddyServer(conn, edge1)
	form := url.Values{
		"name": {edge.Name}, "admin_url": {edge.AdminURL}, "type": {"managed"},
		"default_deploy_targets": {strconv.FormatInt(source, 10)}, // Edge 1 -> Source: a loop
	}
	sid := strconv.FormatInt(edge1, 10)
	rec := httptest.NewRecorder()
	s.updateServer(rec, withChiURLParam(postForm(t, "/servers/"+sid, form), "id", sid))

	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Pointing two servers at each other") {
		t.Fatalf("want the form re-rendered with the loop explained, got %d: %s", rec.Code, excerpt(rec.Body.String(), "each other"))
	}
	saved, _ := models.GetCaddyServer(conn, edge1)
	if saved.DefaultDeployTargets != "" {
		t.Fatalf("a refused loop must not be saved, got %q", saved.DefaultDeployTargets)
	}

	// A one-way chain is fine: Source -> Edge 1 and Edge 2 -> Edge 1.
	edge2Row, _ := models.ListCaddyServers(conn)
	var e2 models.CaddyServer
	for _, srv := range edge2Row {
		if srv.Name == "Edge 2" {
			e2 = srv
		}
	}
	ok := url.Values{
		"name": {e2.Name}, "admin_url": {e2.AdminURL}, "type": {"managed"},
		"default_deploy_targets": {strconv.FormatInt(edge1, 10)},
	}
	sid2 := strconv.FormatInt(e2.ID, 10)
	rec = httptest.NewRecorder()
	s.updateServer(rec, withChiURLParam(postForm(t, "/servers/"+sid2, ok), "id", sid2))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("a one-way target must still be accepted, got %d: %s", rec.Code, rec.Body.String())
	}
}