Ctrl K
ComunidadGuías…

internal/server/app_health_monitor_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"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sync/atomic"
	"testing"
	"time"

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

// hostportOf extracts "127.0.0.1:port" from an httptest server URL so it can
// be used as a ProxyHost domain. probeApp builds its own scheme, so the
// domain must not carry one.
func hostportOf(t *testing.T, rawURL string) string {
	t.Helper()
	u, err := url.Parse(rawURL)
	if err != nil {
		t.Fatalf("parse test server URL %q: %v", rawURL, err)
	}
	return u.Host
}

// v2.28.0 (issue #39): per-host control over CaddyUI's own monitoring probes.

func TestMonitorSettingsDefaultsMatchPreIssue39Behaviour(t *testing.T) {
	for _, mode := range []string{"", "auto", "AUTO", "nonsense"} {
		p := models.ProxyHost{MonitorMode: mode, MonitorPath: "/ignored", MonitorMethod: "HEAD", MonitorIntervalSec: 900}
		path, method, expect, interval, timeout := p.MonitorSettings(60*time.Second, 5*time.Second)
		if path != "/" || method != "GET" || expect != 0 {
			t.Errorf("mode %q: got %s %s expect=%d, want GET / expect=0", mode, method, path, expect)
		}
		if interval != 60*time.Second || timeout != 5*time.Second {
			t.Errorf("mode %q: got interval=%v timeout=%v, want 60s/5s", mode, interval, timeout)
		}
	}
}

func TestMonitorSettingsCustomOverrides(t *testing.T) {
	p := models.ProxyHost{
		MonitorMode:         "custom",
		MonitorPath:         "healthz", // no leading slash on purpose
		MonitorMethod:       "head",
		MonitorExpectStatus: 204,
		MonitorIntervalSec:  300,
		MonitorTimeoutSec:   12,
	}
	path, method, expect, interval, timeout := p.MonitorSettings(60*time.Second, 5*time.Second)
	if path != "/healthz" {
		t.Errorf("path = %q, want /healthz (leading slash added)", path)
	}
	if method != "HEAD" {
		t.Errorf("method = %q, want HEAD", method)
	}
	if expect != 204 || interval != 300*time.Second || timeout != 12*time.Second {
		t.Errorf("expect=%d interval=%v timeout=%v, want 204/300s/12s", expect, interval, timeout)
	}
}

func TestMonitorSettingsCustomFallsBackPerField(t *testing.T) {
	// A custom-mode host that overrides only the path keeps every other default.
	p := models.ProxyHost{MonitorMode: "custom", MonitorPath: "/up"}
	path, method, expect, interval, timeout := p.MonitorSettings(60*time.Second, 5*time.Second)
	if path != "/up" || method != "GET" || expect != 0 || interval != 60*time.Second || timeout != 5*time.Second {
		t.Errorf("got %s %s expect=%d interval=%v timeout=%v; only the path should change",
			method, path, expect, interval, timeout)
	}
}

// v2.36.0 (issue #59): any HTTP method, not just GET/HEAD. A route that only
// accepts POST answers 405 to a GET probe from Caddy itself, so the probe never
// reaches the backend; letting the operator pick POST is what makes such a
// route monitorable. Blank or unrecognised input must still resolve to GET,
// and TRACE/CONNECT are deliberately not offered.
func TestMonitorSettingsAcceptsEveryHTTPMethod(t *testing.T) {
	for input, want := range map[string]string{
		"": "GET", "get": "GET", "HEAD": "HEAD", "post": "POST", " Put ": "PUT",
		"patch": "PATCH", "DELETE": "DELETE", "options": "OPTIONS",
		"TRACE": "GET", "CONNECT": "GET", "bogus": "GET",
	} {
		p := models.ProxyHost{MonitorMode: "custom", MonitorMethod: input}
		_, method, _, _, _ := p.MonitorSettings(60*time.Second, 5*time.Second)
		if method != want {
			t.Errorf("custom host with MonitorMethod %q probes with %q, want %q", input, method, want)
		}
		if got := models.NormalizeMonitorMethod(input); got != want {
			t.Errorf("NormalizeMonitorMethod(%q) = %q, want %q", input, got, want)
		}
	}
	// An auto-mode host ignores the field entirely, whatever it holds.
	p := models.ProxyHost{MonitorMode: "auto", MonitorMethod: "POST"}
	if _, method, _, _, _ := p.MonitorSettings(60*time.Second, 5*time.Second); method != "GET" {
		t.Errorf("auto-mode host probes with %q, want GET regardless of MonitorMethod", method)
	}
	if len(models.MonitorMethods) != 7 || models.MonitorMethods[0] != "GET" {
		t.Errorf("MonitorMethods = %v; want seven entries with GET first, matching the form's select", models.MonitorMethods)
	}
}

func TestMonitoringDisabledOnlyForOff(t *testing.T) {
	for mode, want := range map[string]bool{"": false, "auto": false, "custom": false, "off": true, "OFF": true, " off ": true} {
		if got := (&models.ProxyHost{MonitorMode: mode}).MonitoringDisabled(); got != want {
			t.Errorf("MonitoringDisabled(%q) = %v, want %v", mode, got, want)
		}
	}
}

// probeApp must emit no outbound request at all when monitoring is off. The
// whole point of the off switch is to stop traffic toward a backend that is
// deliberately unreachable, not merely to hide the dot.
func TestProbeAppMakesNoRequestWhenMonitoringOff(t *testing.T) {
	var hits int64
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt64(&hits, 1)
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	s := &Server{appHealth: map[int64]appHealthEntry{}}
	host := models.ProxyHost{
		ID:          1,
		Enabled:     true,
		Domains:     hostportOf(t, srv.URL),
		SSLEnabled:  false,
		MonitorMode: "off",
	}
	entry := s.probeApp(context.Background(), host)
	if entry.Status != "off" {
		t.Errorf("status = %q, want off", entry.Status)
	}
	if n := atomic.LoadInt64(&hits); n != 0 {
		t.Errorf("probe made %d request(s) with monitoring off, want 0", n)
	}
}

// A custom probe must actually use the configured path and method.
func TestProbeAppUsesCustomPathAndMethod(t *testing.T) {
	gotPath := make(chan string, 4)
	gotMethod := make(chan string, 4)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath <- r.URL.Path
		gotMethod <- r.Method
		w.WriteHeader(http.StatusNoContent)
	}))
	defer srv.Close()

	s := &Server{appHealth: map[int64]appHealthEntry{}}
	host := models.ProxyHost{
		ID:                  1,
		Enabled:             true,
		Domains:             hostportOf(t, srv.URL),
		SSLEnabled:          false,
		MonitorMode:         "custom",
		MonitorPath:         "/healthz",
		MonitorMethod:       "HEAD",
		MonitorExpectStatus: 204,
	}
	entry := s.probeApp(context.Background(), host)
	if p := <-gotPath; p != "/healthz" {
		t.Errorf("probed path = %q, want /healthz", p)
	}
	if m := <-gotMethod; m != "HEAD" {
		t.Errorf("probed method = %q, want HEAD", m)
	}
	// 204 is neither 2xx-with-body nor an error; without the expect-status
	// override the default classifier calls it ok too, so assert the code
	// round-tripped and the verdict is ok.
	if entry.Status != "ok" || entry.Code != 204 {
		t.Errorf("entry = %+v, want ok/204", entry)
	}
}

// v2.36.0 (issue #59): the scenario from the report — a route that only
// accepts POST. Probing it with GET gets a 405 (from Caddy's matcher in
// production, from the upstream here), which the default classifier reports
// as "degraded": a verdict about the matcher, not the service. Probing with
// POST reaches the service and is "ok". Both verdicts must follow from the
// configured method alone, with no expect-status override.
func TestProbeAppReachesPostOnlyRouteWithPost(t *testing.T) {
	gotMethod := make(chan string, 4)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod <- r.Method
		if r.Method != http.MethodPost {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	for method, wantStatus := range map[string]string{"POST": "ok", "GET": "degraded"} {
		s := &Server{appHealth: map[int64]appHealthEntry{}}
		host := models.ProxyHost{
			ID: 1, Enabled: true, Domains: hostportOf(t, srv.URL), SSLEnabled: false,
			MonitorMode: "custom", MonitorPath: "/api/ping", MonitorMethod: method,
		}
		entry := s.probeApp(context.Background(), host)
		if m := <-gotMethod; m != method {
			t.Errorf("configured %s but the upstream saw %s", method, m)
		}
		if entry.Status != wantStatus {
			t.Errorf("method %s: status = %q (HTTP %d), want %q", method, entry.Status, entry.Code, wantStatus)
		}
	}
}

// An unexpected status under an explicit expectation is unambiguously down.
func TestProbeAppExpectStatusMismatchIsDown(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK) // 200, but the host demands 204
	}))
	defer srv.Close()

	s := &Server{appHealth: map[int64]appHealthEntry{}}
	host := models.ProxyHost{
		ID:                  1,
		Enabled:             true,
		Domains:             hostportOf(t, srv.URL),
		SSLEnabled:          false,
		MonitorMode:         "custom",
		MonitorExpectStatus: 204,
	}
	entry := s.probeApp(context.Background(), host)
	if entry.Status != "down" {
		t.Errorf("status = %q, want down (200 received, 204 expected)", entry.Status)
	}
	if entry.Code != 200 {
		t.Errorf("code = %d, want 200", entry.Code)
	}
}

// appProbeDue paces hosts that asked for a longer interval, and never delays
// hosts on the default.
func TestAppProbeDueRespectsCustomInterval(t *testing.T) {
	s := &Server{appHealth: map[int64]appHealthEntry{}}

	def := models.ProxyHost{ID: 1}
	if !s.appProbeDue(def) {
		t.Error("default-interval host should always be due")
	}

	slow := models.ProxyHost{ID: 2, MonitorMode: "custom", MonitorIntervalSec: 600}
	if !s.appProbeDue(slow) {
		t.Error("never-probed host should be due")
	}

	s.appHealth[2] = appHealthEntry{Status: "ok", CheckedAt: time.Now()}
	if s.appProbeDue(slow) {
		t.Error("host probed just now with a 600s interval should not be due")
	}

	s.appHealth[2] = appHealthEntry{Status: "ok", CheckedAt: time.Now().Add(-11 * time.Minute)}
	if !s.appProbeDue(slow) {
		t.Error("host last probed 11m ago with a 600s interval should be due")
	}
}