Ctrl K
ComunidadGuías…

internal/server/middleware_profiles_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.60.0 (issue #124): reusable middleware profiles.

func ssoProfile() *models.MiddlewareProfile {
	return &models.MiddlewareProfile{
		Name: "Internal SSO", SecurityHeaders: true, XFrameOptions: "DENY", CSPHeader: "default-src 'self'",
		ForwardAuthURL: "http://authentik:9000/outpost.goauthentik.io/auth/caddy", ForwardAuthCopyHeaders: "X-Authentik-Username",
		ForwardAuthSkipPaths: "/health", AccessList: "10.0.0.0/8", IPBlocklist: "203.0.113.0/24",
		CustomReqHeaders: `{"X-Env":"prod","X-Both":"profile"}`, CustomRespHeaders: `{"X-Robots-Tag":"noindex"}`,
	}
}

func TestProfileFillsEmptyHostFieldsAndHostWins(t *testing.T) {
	prof := ssoProfile()

	// An empty host inherits everything.
	h := models.ProxyHost{}
	applyProfileToHost(&h, prof)
	if !h.SecurityHeadersEnabled || h.XFrameOptions != "DENY" || h.CSPHeader != "default-src 'self'" ||
		h.ForwardAuthURL != prof.ForwardAuthURL || h.ForwardAuthCopyHeaders != "X-Authentik-Username" || h.ForwardAuthSkipPaths != "/health" ||
		h.AccessList != "10.0.0.0/8" || h.IPBlocklist != "203.0.113.0/24" {
		t.Fatalf("empty host did not inherit the profile: %+v", h)
	}

	// Values set on the host win; forward auth is all-or-nothing.
	own := models.ProxyHost{
		XFrameOptions: "SAMEORIGIN", AccessList: "192.168.0.0/16",
		ForwardAuthURL: "http://authelia:9091/api/verify", ForwardAuthMethod: "POST",
		CustomReqHeaders: `{"X-Both":"host"}`,
	}
	applyProfileToHost(&own, prof)
	if own.XFrameOptions != "SAMEORIGIN" || own.AccessList != "192.168.0.0/16" {
		t.Errorf("host values were overridden: %+v", own)
	}
	if own.ForwardAuthURL != "http://authelia:9091/api/verify" || own.ForwardAuthMethod != "POST" || own.ForwardAuthCopyHeaders != "" || own.ForwardAuthSkipPaths != "" {
		t.Errorf("a host with its own forward auth must keep ONLY its own settings: %+v", own)
	}
	if own.IPBlocklist != "203.0.113.0/24" || own.CSPHeader != "default-src 'self'" {
		t.Errorf("unset host fields did not inherit: %+v", own)
	}
	if !strings.Contains(own.CustomReqHeaders, `"X-Env":"prod"`) || !strings.Contains(own.CustomReqHeaders, `"X-Both":"host"`) || strings.Contains(own.CustomReqHeaders, `"profile"`) {
		t.Errorf("request headers did not merge with the host winning: %s", own.CustomReqHeaders)
	}
	if !strings.Contains(own.CustomRespHeaders, "X-Robots-Tag") {
		t.Errorf("response headers lost: %s", own.CustomRespHeaders)
	}

	// No profile: untouched.
	untouched := models.ProxyHost{Domains: "a.example.com", AccessList: "1.2.3.0/24"}
	applyProfileToHost(&untouched, nil)
	if untouched.Domains != "a.example.com" || untouched.AccessList != "1.2.3.0/24" || untouched.ForwardAuthURL != "" || untouched.SecurityHeadersEnabled {
		t.Errorf("a nil profile changed the host: %+v", untouched)
	}
}

func TestValidateMiddlewareProfile(t *testing.T) {
	ok := func() *models.MiddlewareProfile { return ssoProfile() }
	if msg := validateMiddlewareProfile(ok()); msg != "" {
		t.Fatalf("valid profile refused: %s", msg)
	}
	for name, mod := range map[string]func(*models.MiddlewareProfile){
		"no name":           func(p *models.MiddlewareProfile) { p.Name = "  " },
		"long name":         func(p *models.MiddlewareProfile) { p.Name = strings.Repeat("a", 81) },
		"bad auth URL":      func(p *models.MiddlewareProfile) { p.ForwardAuthURL = "authentik:9000" },
		"bad method":        func(p *models.MiddlewareProfile) { p.ForwardAuthMethod = "DELETE" },
		"bad allow CIDR":    func(p *models.MiddlewareProfile) { p.AccessList = "10.0.0.0/33" },
		"bad block entry":   func(p *models.MiddlewareProfile) { p.IPBlocklist = "not-an-ip" },
		"headers not JSON":  func(p *models.MiddlewareProfile) { p.CustomReqHeaders = "X-Env: prod" },
		"bad header name":   func(p *models.MiddlewareProfile) { p.CustomRespHeaders = `{"Bad Name":"x"}` },
		"newline in value":  func(p *models.MiddlewareProfile) { p.CustomRespHeaders = `{"X-A":"a\nb"}` },
		"newline in CSP":    func(p *models.MiddlewareProfile) { p.CSPHeader = "a\r\nSet-Cookie: x=1" },
		"newline in prefix": func(p *models.MiddlewareProfile) { p.ForwardAuthHeadersPrefix = "X\nY" },
	} {
		p := ok()
		mod(p)
		if msg := validateMiddlewareProfile(p); msg == "" {
			t.Errorf("%s was accepted", name)
		}
	}
	for name, mod := range map[string]func(*models.MiddlewareProfile){
		"IPv6 CIDR":         func(p *models.MiddlewareProfile) { p.AccessList = "fd00::/8, 10.1.2.3" },
		"newline separated": func(p *models.MiddlewareProfile) { p.IPBlocklist = "10.0.0.0/8\n192.168.0.0/16" },
		"empty everything":  func(p *models.MiddlewareProfile) { *p = models.MiddlewareProfile{Name: "empty"} },
		"https auth, POST": func(p *models.MiddlewareProfile) {
			p.ForwardAuthURL = "https://auth.example.com/v"
			p.ForwardAuthMethod = "POST"
		},
	} {
		p := ok()
		mod(p)
		if msg := validateMiddlewareProfile(p); msg != "" {
			t.Errorf("%s was refused: %s", name, msg)
		}
	}
}

// Profiles hold forward-auth upstreams and IP policy: admin-only to manage.
func TestMiddlewareProfilesAreAdminOnly(t *testing.T) {
	e := newSecEnv(t)
	form := url.Values{"name": {"x"}}
	for _, rt := range []struct{ method, path string }{
		{http.MethodGet, "/middleware-profiles"}, {http.MethodGet, "/middleware-profiles/new"}, {http.MethodPost, "/middleware-profiles"},
		{http.MethodGet, "/middleware-profiles/1/edit"}, {http.MethodPost, "/middleware-profiles/1"}, {http.MethodPost, "/middleware-profiles/1/delete"},
	} {
		for _, who := range []string{"alice", "viewer"} {
			var f url.Values
			if rt.method == http.MethodPost {
				f = form
			}
			if rec := e.do(t, who, rt.method, rt.path, f); rec.Code != http.StatusForbidden {
				t.Errorf("%s %s %s -> %d, want 403", who, rt.method, rt.path, rec.Code)
			}
		}
	}
	for _, p := range []string{"/middleware-profiles", "/middleware-profiles/new"} {
		if rec := e.do(t, "admin", http.MethodGet, p, nil); rec.Code != http.StatusOK {
			t.Errorf("admin GET %s -> %d", p, rec.Code)
		}
	}
	if !strings.Contains(e.do(t, "admin", http.MethodGet, "/proxy-hosts", nil).Body.String(), `href="/middleware-profiles"`) {
		t.Error("the admin's navigation is missing Middleware Profiles")
	}
	if strings.Contains(e.do(t, "alice", http.MethodGet, "/proxy-hosts", nil).Body.String(), `href="/middleware-profiles"`) {
		t.Error("a non-admin's navigation links to Middleware Profiles")
	}
}

func profileForm(name string, extra url.Values) url.Values {
	v := url.Values{"name": {name}, "security_headers": {"on"}, "forward_auth_url": {"http://authentik:9000/outpost.goauthentik.io/auth/caddy"},
		"forward_auth_copy_headers": {"X-Authentik-Username"}, "ip_blocklist": {"203.0.113.0/24"}}
	for k, vals := range extra {
		v[k] = vals
	}
	return v
}

func (f *l4Fleet) createProfile(t *testing.T, form url.Values) *models.MiddlewareProfile {
	t.Helper()
	rec := httptest.NewRecorder()
	f.s.createMiddlewareProfile(rec, cookieReq(t, "/middleware-profiles", form, f.source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("create profile -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror"))
	}
	rows, _ := models.ListMiddlewareProfiles(f.conn)
	for i := range rows {
		if rows[i].Name == form.Get("name") {
			return &rows[i]
		}
	}
	t.Fatal("profile not stored")
	return nil
}

func (f *l4Fleet) lastSrv0Routes(t *testing.T, serverID int64) string {
	t.Helper()
	fake := map[int64]*layer4FakeAdmin{f.source: f.fSource, f.edge1: f.fEdge1, f.edge2: f.fEdge2}[serverID]
	fake.mu.Lock()
	defer fake.mu.Unlock()
	last := ""
	for _, w := range fake.writes {
		if strings.HasPrefix(w, "PATCH /config/apps/http/servers/srv0/routes") || strings.HasPrefix(w, "POST /config/apps/http/servers/srv0/routes") {
			last = w
		}
	}
	return last
}

func TestProfileReachesTheLiveConfigAndEditsResyncHosts(t *testing.T) {
	f := newL4Fleet(t)
	prof := f.createProfile(t, profileForm("Internal SSO", nil))
	f.createProxy(t, "app.example.com", url.Values{"middleware_profile_id": {strconv.FormatInt(prof.ID, 10)}})

	host := f.proxyHosts(t, f.source)[0]
	if got := models.ProxyHostProfileID(f.conn, host.ID); got != prof.ID {
		t.Fatalf("attachment = %d, want %d", got, prof.ID)
	}
	// The host row itself is untouched — the profile is applied at build time.
	if host.ForwardAuthURL != "" || host.SecurityHeadersEnabled {
		t.Errorf("the profile was copied into the host row: %+v", host)
	}
	routes := f.lastSrv0Routes(t, f.source)
	for _, want := range []string{"authentik:9000", "/outpost.goauthentik.io/auth/caddy", "X-Authentik-Username", "203.0.113.0/24", "Strict-Transport-Security"} {
		if !strings.Contains(routes, want) {
			t.Errorf("the live route is missing %q from the profile:\n%.600s", want, routes)
		}
	}
	if strings.Contains(routes, `"handler":"forward_auth"`) {
		t.Error("the route still uses the nonexistent forward_auth handler")
	}

	// Editing the profile re-syncs the servers that use it.
	f.fSource.mu.Lock()
	n := len(f.fSource.writes)
	f.fSource.mu.Unlock()
	pid := strconv.FormatInt(prof.ID, 10)
	rec := httptest.NewRecorder()
	f.s.updateMiddlewareProfile(rec, withChiURLParam(cookieReq(t, "/middleware-profiles/"+pid, profileForm("Internal SSO", url.Values{"forward_auth_url": {"http://authelia:9091/api/verify"}}), f.source), "id", pid))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("update -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror"))
	}
	f.fSource.mu.Lock()
	after := len(f.fSource.writes)
	f.fSource.mu.Unlock()
	if after <= n {
		t.Fatal("editing the profile did not re-sync the server")
	}
	if now := f.lastSrv0Routes(t, f.source); !strings.Contains(now, "authelia:9091") || strings.Contains(now, "authentik:9000") {
		t.Errorf("the edit did not reach Caddy:\n%.600s", now)
	}
}

func TestHostsOwnForwardAuthWinsOverTheProfile(t *testing.T) {
	f := newL4Fleet(t)
	prof := f.createProfile(t, profileForm("Internal SSO", nil))
	f.createProxy(t, "app.example.com", url.Values{
		"middleware_profile_id": {strconv.FormatInt(prof.ID, 10)},
		"forward_auth_url":      {"http://authelia:9091/api/verify"},
	})
	routes := f.lastSrv0Routes(t, f.source)
	if !strings.Contains(routes, "authelia:9091") || strings.Contains(routes, "authentik:9000") {
		t.Errorf("the host's own forward auth must win:\n%.500s", routes)
	}
	if !strings.Contains(routes, "203.0.113.0/24") {
		t.Errorf("unset fields should still inherit the profile (blocklist missing)")
	}
}

func TestProfileCannotBeDeletedWhileInUseAndHostCleanupFreesIt(t *testing.T) {
	f := newL4Fleet(t)
	prof := f.createProfile(t, profileForm("Internal SSO", nil))
	f.createProxy(t, "app.example.com", url.Values{"middleware_profile_id": {strconv.FormatInt(prof.ID, 10)}})
	pid := strconv.FormatInt(prof.ID, 10)

	rec := httptest.NewRecorder()
	f.s.deleteMiddlewareProfile(rec, withChiURLParam(cookieReq(t, "/middleware-profiles/"+pid+"/delete", url.Values{}, f.source), "id", pid))
	if got, _ := models.GetMiddlewareProfile(f.conn, prof.ID); got == nil {
		t.Fatal("a profile in use was deleted")
	}
	if loc := rec.Header().Get("Location"); !strings.Contains(loc, "error=") {
		t.Errorf("no error shown: %s", loc)
	}
	if rows, _ := models.ListMiddlewareProfiles(f.conn); rows[0].HostCount != 1 {
		t.Errorf("host count = %d, want 1", rows[0].HostCount)
	}

	// Deleting the host drops its attachment, which frees the profile.
	f.deleteProxy(t, f.proxyHosts(t, f.source)[0].ID)
	rec = httptest.NewRecorder()
	f.s.deleteMiddlewareProfile(rec, withChiURLParam(cookieReq(t, "/middleware-profiles/"+pid+"/delete", url.Values{}, f.source), "id", pid))
	if got, _ := models.GetMiddlewareProfile(f.conn, prof.ID); got != nil {
		t.Error("the unused profile was not deleted")
	}
}

func TestProfileNamesAreUniqueAndUnknownProfileIDsAreIgnored(t *testing.T) {
	f := newL4Fleet(t)
	f.createProfile(t, profileForm("Internal SSO", nil))
	rec := httptest.NewRecorder()
	f.s.createMiddlewareProfile(rec, cookieReq(t, "/middleware-profiles", profileForm("internal sso", nil), f.source))
	if rec.Code == http.StatusSeeOther {
		t.Error("a duplicate (case-insensitive) name was accepted")
	}
	f.createProxy(t, "app.example.com", url.Values{"middleware_profile_id": {"9999"}})
	if got := models.ProxyHostProfileID(f.conn, f.proxyHosts(t, f.source)[0].ID); got != 0 {
		t.Errorf("a nonexistent profile ID was attached: %d", got)
	}
}

func TestFleetCopyUsesTheSameProfileAndFollowsChanges(t *testing.T) {
	f := newL4Fleet(t)
	setDefaultTargets(t, f.conn, f.source, f.edge1)
	prof := f.createProfile(t, profileForm("Internal SSO", nil))
	f.createProxy(t, "app.example.com", url.Values{"middleware_profile_id": {strconv.FormatInt(prof.ID, 10)}})
	target := f.proxyHosts(t, f.edge1)
	if len(target) != 1 {
		t.Fatalf("setup: target has %d hosts", len(target))
	}
	if got := models.ProxyHostProfileID(f.conn, target[0].ID); got != prof.ID {
		t.Errorf("the deployed copy has profile %d, want %d", got, prof.ID)
	}
	if routes := f.lastSrv0Routes(t, f.edge1); !strings.Contains(routes, "authentik:9000") {
		t.Errorf("the target's live route lacks the profile:\n%.400s", routes)
	}

	// Switching the host to no profile on the source reaches the copy.
	src := f.proxyHosts(t, f.source)[0]
	sid := strconv.FormatInt(src.ID, 10)
	form := url.Values{"domains": {"app.example.com"}, "forward_scheme": {"http"}, "forward_host": {"203.0.113.10"}, "forward_port": {"8080"}, "enabled": {"on"}, "middleware_profile_id": {"0"}}
	rec := httptest.NewRecorder()
	f.s.updateProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/"+sid, form, f.source), "id", sid))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("update -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror"))
	}
	if got := models.ProxyHostProfileID(f.conn, f.proxyHosts(t, f.edge1)[0].ID); got != 0 {
		t.Errorf("the copy kept profile %d after the source detached it", got)
	}
}

func TestCloneKeepsTheProfileAndFormShowsThePicker(t *testing.T) {
	f := newL4Fleet(t)
	prof := f.createProfile(t, profileForm("Internal SSO", nil))
	f.createProxy(t, "app.example.com", url.Values{"middleware_profile_id": {strconv.FormatInt(prof.ID, 10)}})
	src := f.proxyHosts(t, f.source)[0]
	sid := strconv.FormatInt(src.ID, 10)

	rec := httptest.NewRecorder()
	f.s.editProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/"+sid+"/edit", nil, f.source), "id", sid))
	body := rec.Body.String()
	if !strings.Contains(body, "data-middleware-profile") || !strings.Contains(body, `name="middleware_profile_id"`) {
		t.Fatal("the host form has no profile picker")
	}
	if !strings.Contains(body, `value="`+strconv.FormatInt(prof.ID, 10)+`" selected`) {
		t.Error("the attached profile is not preselected")
	}

	rec = httptest.NewRecorder()
	f.s.cloneProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/"+sid+"/clone", url.Values{}, f.source), "id", sid))
	hosts := f.proxyHosts(t, f.source)
	if len(hosts) != 2 {
		t.Fatalf("clone left %d hosts", len(hosts))
	}
	for _, h := range hosts {
		if got := models.ProxyHostProfileID(f.conn, h.ID); got != prof.ID {
			t.Errorf("host %d has profile %d, want %d (clone must keep it)", h.ID, got, prof.ID)
		}
	}
}