Ctrl K
ComunidadGuías…

internal/server/fleet_deletions_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 (
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"testing"

	"github.com/X4Applegate/caddyui/internal/models"
)

// v2.59.0 (issue #120): opt-in deletion propagation to automatic targets.

func enablePropagation(t *testing.T, f *l4Fleet, serverID int64, on bool) {
	t.Helper()
	srv, err := models.GetCaddyServer(f.conn, serverID)
	if err != nil {
		t.Fatal(err)
	}
	srv.PropagateDeletions = on
	if err := models.UpdateCaddyServer(f.conn, srv); err != nil {
		t.Fatal(err)
	}
}

func (f *l4Fleet) proxyHosts(t *testing.T, serverID int64) []models.ProxyHost {
	t.Helper()
	rows, err := models.ListProxyHosts(f.conn, serverID, 0, true, nil)
	if err != nil {
		t.Fatal(err)
	}
	return rows
}

func (f *l4Fleet) redirects(t *testing.T, serverID int64) []models.RedirectionHost {
	t.Helper()
	rows, err := models.ListRedirectionHosts(f.conn, serverID, 0, true, nil)
	if err != nil {
		t.Fatal(err)
	}
	return rows
}

func (f *l4Fleet) createProxy(t *testing.T, domain string, extra url.Values) {
	t.Helper()
	form := url.Values{"domains": {domain}, "forward_scheme": {"http"}, "forward_host": {"203.0.113.10"}, "forward_port": {"8080"}, "enabled": {"on"}}
	for k, v := range extra {
		form[k] = v
	}
	rec := httptest.NewRecorder()
	f.s.createProxyHost(rec, cookieReq(t, "/proxy-hosts", form, f.source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("create proxy host %s -> %d: %s", domain, rec.Code, excerpt(rec.Body.String(), "rror"))
	}
}

func (f *l4Fleet) deleteProxy(t *testing.T, id int64) {
	t.Helper()
	sid := strconv.FormatInt(id, 10)
	rec := httptest.NewRecorder()
	f.s.deleteProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/"+sid+"/delete", url.Values{}, f.source), "id", sid))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("delete proxy host -> %d", rec.Code)
	}
}

func hasDomain(hosts []models.ProxyHost, domain string) bool {
	for _, h := range hosts {
		if h.Domains == domain {
			return true
		}
	}
	return false
}

func TestDeletionIsNotPropagatedUnlessEnabled(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	f.createProxy(t, "app.example.com", nil)
	if !hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") {
		t.Fatal("setup: the host was not deployed")
	}
	src := f.proxyHosts(t, f.source)[0]
	f.deleteProxy(t, src.ID)
	if len(f.proxyHosts(t, f.source)) != 0 {
		t.Fatal("source row not deleted")
	}
	if !hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") {
		t.Error("with propagation off, a source deletion removed the target's copy")
	}
	if p, _ := models.ListFleetPendingDeletions(f.conn, 0); len(p) != 0 {
		t.Errorf("queued %d deletions with propagation off", len(p))
	}
	if m, _ := models.FleetDeploymentsForSource(f.conn, f.source, models.FleetResourceProxy, src.ID); len(m) != 0 {
		t.Error("the pairing of a deleted source row must be forgotten")
	}
}

func TestEnabledPropagationDeletesOnlyPairedCopiesOnAutomaticTargets(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1) // edge2 is not automatic
	enablePropagation(t, f, f.source, true)

	// Independent rows on the target: never touched.
	if _, err := models.CreateProxyHost(f.conn, f.edge1, 0, &models.ProxyHost{Domains: "target-local.example.com", ForwardScheme: "http", ForwardHost: "10.1.1.1", ForwardPort: 80, Enabled: true}); err != nil {
		t.Fatal(err)
	}
	// A one-off "Also deploy to" copy on a non-automatic server.
	f.createProxy(t, "app.example.com", url.Values{"deploy_to": {strconv.FormatInt(f.edge2, 10)}})
	// Node-local on the source: never deployed at all.
	f.createProxy(t, "local-only.example.com", url.Values{"node_local": {"on"}})
	if !hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") || !hasDomain(f.proxyHosts(t, f.edge2), "app.example.com") {
		t.Fatal("setup: expected copies on both targets")
	}

	var appID int64
	for _, h := range f.proxyHosts(t, f.source) {
		if h.Domains == "app.example.com" {
			appID = h.ID
		}
	}
	f.deleteProxy(t, appID)

	if hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") {
		t.Error("the paired copy on the automatic target was not deleted")
	}
	if !hasDomain(f.proxyHosts(t, f.edge1), "target-local.example.com") {
		t.Error("an independent target-local row was deleted")
	}
	if !hasDomain(f.proxyHosts(t, f.edge2), "app.example.com") {
		t.Error("a copy on a one-off (non-automatic) target was deleted")
	}
	if pend, _ := models.ListFleetPendingDeletions(f.conn, 0); len(pend) != 0 {
		t.Errorf("a reachable target left %d pending deletions", len(pend))
	}
	f.fEdge1.mu.Lock()
	defer f.fEdge1.mu.Unlock()
	if f.fEdge1.loadCalls == 0 && f.fEdge1.putCalls == 0 {
		t.Log("target Caddy was not pushed (it has no entries left: the empty-config guard applies)")
	}
}

func TestEnabledPropagationCoversRedirectsAndLayer4(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	enablePropagation(t, f, f.source, true)

	form := url.Values{"domains": {"old.example.com"}, "forward_scheme": {"https"}, "forward_domain": {"new.example.com"}, "forward_http_code": {"301"}, "enabled": {"on"}}
	rec := httptest.NewRecorder()
	f.s.createRedirectionHost(rec, cookieReq(t, "/redirection-hosts", form, f.source))
	f.create(t, f.source, f.form(nil))
	if len(f.redirects(t, f.edge1)) != 1 || len(f.rows(t, f.edge1)) != 1 {
		t.Fatalf("setup: redirects=%d layer4=%d on target", len(f.redirects(t, f.edge1)), len(f.rows(t, f.edge1)))
	}

	rid := strconv.FormatInt(f.redirects(t, f.source)[0].ID, 10)
	rec = httptest.NewRecorder()
	f.s.deleteRedirectionHost(rec, withChiURLParam(cookieReq(t, "/redirection-hosts/"+rid+"/delete", url.Values{}, f.source), "id", rid))
	lid := strconv.FormatInt(f.rows(t, f.source)[0].ID, 10)
	rec = httptest.NewRecorder()
	f.s.deleteLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+lid+"/delete", url.Values{}, f.source), "id", lid))

	if n := len(f.redirects(t, f.edge1)); n != 0 {
		t.Errorf("redirect copy still on target (%d)", n)
	}
	if n := len(f.rows(t, f.edge1)); n != 0 {
		t.Errorf("layer4 copy still on target (%d)", n)
	}
	if loc := rec.Header().Get("Location"); strings.Contains(loc, "were+not+removed") || strings.Contains(loc, "not%20removed") {
		t.Errorf("the flash claims copies were kept: %s", loc)
	}
}

func TestBulkDeleteAlsoPropagates(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	enablePropagation(t, f, f.source, true)
	f.createProxy(t, "a.example.com", nil)
	f.createProxy(t, "b.example.com", nil)
	var ids []string
	for _, h := range f.proxyHosts(t, f.source) {
		ids = append(ids, strconv.FormatInt(h.ID, 10))
	}
	rec := httptest.NewRecorder()
	f.s.bulkDeleteProxyHosts(rec, cookieReq(t, "/proxy-hosts/bulk-delete", url.Values{"ids[]": ids}, f.source))
	if len(f.proxyHosts(t, f.source)) != 0 || len(f.proxyHosts(t, f.edge1)) != 0 {
		t.Errorf("bulk delete left rows: source=%d target=%d", len(f.proxyHosts(t, f.source)), len(f.proxyHosts(t, f.edge1)))
	}
}

// An unreachable target keeps the deletion pending (the pairing is not
// forgotten) and the reconciler finishes it once the target answers again.
func TestUnreachableTargetKeepsTheDeletionPendingUntilItConverges(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	enablePropagation(t, f, f.source, true)
	f.createProxy(t, "app.example.com", nil)
	f.createProxy(t, "keep.example.com", nil) // keeps the target non-empty so a sync really pushes

	edge, _ := models.GetCaddyServer(f.conn, f.edge1)
	liveURL := edge.AdminURL
	edge.AdminURL = "http://127.0.0.1:1" // nothing listens here
	if err := models.UpdateCaddyServer(f.conn, edge); err != nil {
		t.Fatal(err)
	}

	var appID int64
	for _, h := range f.proxyHosts(t, f.source) {
		if h.Domains == "app.example.com" {
			appID = h.ID
		}
	}
	f.deleteProxy(t, appID)

	pend, err := models.ListFleetPendingDeletions(f.conn, f.source)
	if err != nil || len(pend) != 1 {
		t.Fatalf("pending = %+v err=%v, want exactly one", pend, err)
	}
	p := pend[0]
	if p.TargetServerID != f.edge1 || p.Attempts < 1 || p.LastError == "" || p.Label != "app.example.com" {
		t.Errorf("pending entry not recorded properly: %+v", p)
	}
	if hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") {
		t.Error("the copy should already be gone from CaddyUI's own database")
	}
	if !hasDomain(f.proxyHosts(t, f.edge1), "keep.example.com") {
		t.Error("an unrelated copy was deleted")
	}

	// Still down: a retry keeps it pending and counts the attempt.
	if done, pending := f.s.processFleetPendingDeletions("system", 0); done != 0 || pending != 1 {
		t.Errorf("retry while down: done=%d pending=%d", done, pending)
	}
	if pend, _ = models.ListFleetPendingDeletions(f.conn, f.source); len(pend) != 1 || pend[0].Attempts < 2 {
		t.Errorf("attempt not counted: %+v", pend)
	}

	// Back up: converges and the entry disappears.
	edge, _ = models.GetCaddyServer(f.conn, f.edge1)
	edge.AdminURL = liveURL
	if err := models.UpdateCaddyServer(f.conn, edge); err != nil {
		t.Fatal(err)
	}
	if done, pending := f.s.processFleetPendingDeletions("system", 0); done != 1 || pending != 0 {
		t.Errorf("retry when up: done=%d pending=%d", done, pending)
	}
	if pend, _ = models.ListFleetPendingDeletions(f.conn, 0); len(pend) != 0 {
		t.Errorf("queue not empty after convergence: %+v", pend)
	}
}

// Two source resources paired to the same target row (identity adoption): the
// row stays while another source still wants it.
func TestSharedTargetRowIsNotDeleted(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	enablePropagation(t, f, f.source, true)
	f.createProxy(t, "app.example.com", nil)
	src := f.proxyHosts(t, f.source)[0]
	tgt := f.proxyHosts(t, f.edge1)[0]
	// A second source row (id 999) is also paired to the same target row.
	if err := models.SaveFleetDeployment(f.conn, f.source, models.FleetResourceProxy, 999, f.edge1, tgt.ID); err != nil {
		t.Fatal(err)
	}
	f.deleteProxy(t, src.ID)
	if !hasDomain(f.proxyHosts(t, f.edge1), "app.example.com") {
		t.Error("a target row that another source resource is still paired with was deleted")
	}
}

func TestPendingDeletionHelpersAreScopedAndIdempotent(t *testing.T) {
	f := newL4Fleet(t)
	p := models.FleetPendingDeletion{SourceServerID: f.source, ResourceKind: models.FleetResourceProxy, SourceResourceID: 5, TargetServerID: f.edge1, TargetResourceID: 7, Label: "x"}
	for i := 0; i < 2; i++ {
		if err := models.EnqueueFleetPendingDeletion(f.conn, p); err != nil {
			t.Fatal(err)
		}
	}
	if got, _ := models.ListFleetPendingDeletions(f.conn, f.source); len(got) != 1 {
		t.Fatalf("enqueue twice -> %d rows, want 1", len(got))
	}
	if err := models.EnqueueFleetPendingDeletion(f.conn, models.FleetPendingDeletion{ResourceKind: models.FleetResourceCertificate}); err == nil {
		t.Error("certificates must not be queueable")
	}
	// A row can only be removed from the server it lives on.
	if ok, err := models.DeleteFleetResourceRow(f.conn, models.FleetResourceProxy, 1, f.edge2); err != nil || ok {
		t.Errorf("wrong-server delete: ok=%v err=%v", ok, err)
	}
	// Deleting the target server drops its queue entries.
	if err := models.DeleteCaddyServer(f.conn, f.edge1); err != nil {
		t.Fatal(err)
	}
	if got, _ := models.ListFleetPendingDeletions(f.conn, 0); len(got) != 0 {
		t.Errorf("%d queue entries outlived their target server", len(got))
	}
}

func TestServerFormSavesAndShowsPropagationAndPendingList(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)

	srvID := strconv.FormatInt(f.source, 10)
	form := url.Values{"name": {"Source"}, "admin_url": {"http://x"}, "type": {"managed"}, "default_deploy_targets": {strconv.FormatInt(f.edge1, 10)}, "propagate_deletions": {"1"}}
	rec := httptest.NewRecorder()
	f.s.updateServer(rec, withChiURLParam(cookieReq(t, "/servers/"+srvID, form, f.source), "id", srvID))
	if got, _ := models.GetCaddyServer(f.conn, f.source); got == nil || !got.PropagateDeletions {
		t.Fatalf("the checkbox was not saved (status %d: %s)", rec.Code, excerpt(rec.Body.String(), "rror"))
	}
	form.Del("propagate_deletions")
	rec = httptest.NewRecorder()
	f.s.updateServer(rec, withChiURLParam(cookieReq(t, "/servers/"+srvID, form, f.source), "id", srvID))
	if got, _ := models.GetCaddyServer(f.conn, f.source); got.PropagateDeletions {
		t.Error("unticking the box did not turn propagation off")
	}

	_ = models.EnqueueFleetPendingDeletion(f.conn, models.FleetPendingDeletion{SourceServerID: f.source, ResourceKind: models.FleetResourceProxy, SourceResourceID: 5, TargetServerID: f.edge1, TargetResourceID: 7, Label: "stuck.example.com"})
	rec = httptest.NewRecorder()
	f.s.editServerPage(rec, withChiURLParam(cookieReq(t, "/servers/"+srvID+"/edit", nil, f.source), "id", srvID))
	body := rec.Body.String()
	for _, want := range []string{`name="propagate_deletions"`, "data-pending-deletions", "stuck.example.com", "pending-deletions/retry", "pending-deletions/discard"} {
		if !strings.Contains(body, want) {
			t.Errorf("edit page is missing %q", want)
		}
	}

	rec = httptest.NewRecorder()
	dis := url.Values{"kind": {models.FleetResourceProxy}, "source_resource_id": {"5"}, "target_server_id": {strconv.FormatInt(f.edge1, 10)}}
	f.s.discardFleetPendingDeletion(rec, withChiURLParam(cookieReq(t, "/servers/"+srvID+"/pending-deletions/discard", dis, f.source), "id", srvID))
	if got, _ := models.ListFleetPendingDeletions(f.conn, f.source); len(got) != 0 {
		t.Errorf("discard left %d entries", len(got))
	}
}

func TestPendingDeletionControlsAreAdminOnly(t *testing.T) {
	e := newSecEnv(t)
	for _, p := range []string{"/servers/1/pending-deletions/retry", "/servers/1/pending-deletions/discard"} {
		for _, who := range []string{"alice", "viewer"} {
			if rec := e.do(t, who, http.MethodPost, p, url.Values{"kind": {"proxy"}}); rec.Code != http.StatusForbidden {
				t.Errorf("%s POST %s -> %d, want 403", who, p, rec.Code)
			}
		}
	}
}