internal/server/expectations_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// v2.38.0: expectations are real requests, judged on status, Location and latency.
func TestEvaluateExpectation(t *testing.T) {
site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ok":
w.WriteHeader(200)
case "/redir":
w.Header().Set("Location", "https://example.test/x")
w.WriteHeader(308)
case "/slow":
time.Sleep(80 * time.Millisecond)
w.WriteHeader(200)
case "/post-only":
if r.Method != http.MethodPost {
w.WriteHeader(405)
return
}
w.WriteHeader(200)
default:
w.WriteHeader(404)
}
}))
defer site.Close()
host := hostportOf(t, site.URL)
for _, tc := range []struct {
name string
e models.HostExpectation
ok bool
err string
}{
{"any 2xx", models.HostExpectation{Scheme: "http", Path: "/ok"}, true, ""},
{"exact status mismatch", models.HostExpectation{Scheme: "http", Path: "/ok", ExpectStatus: 204}, false, "HTTP 200, expected 204"},
{"redirect with location prefix", models.HostExpectation{Scheme: "http", Path: "/redir", ExpectStatus: 308, ExpectLocation: "https://example.test/"}, true, ""},
{"redirect to the wrong place", models.HostExpectation{Scheme: "http", Path: "/redir", ExpectStatus: 308, ExpectLocation: "https://other/"}, false, "does not start with"},
{"too slow", models.HostExpectation{Scheme: "http", Path: "/slow", MaxLatencyMS: 10}, false, "limit 10 ms"},
{"method honoured", models.HostExpectation{Scheme: "http", Method: "POST", Path: "/post-only", ExpectStatus: 200}, true, ""},
{"method matters", models.HostExpectation{Scheme: "http", Method: "GET", Path: "/post-only"}, false, "HTTP 405"},
{"404 is a failure by default", models.HostExpectation{Scheme: "http", Path: "/missing"}, false, "HTTP 404"},
{"connection refused is a failure", models.HostExpectation{Scheme: "http", Path: "/"}, false, "connection refused"},
} {
h := host
if tc.name == "connection refused is a failure" {
h = "127.0.0.1:1"
}
r := evaluateExpectation(t.Context(), h, tc.e)
if r.OK != tc.ok || (tc.err != "" && !strings.Contains(r.Error, tc.err)) {
t.Errorf("%s: ok=%v err=%q (status %d), want ok=%v err~%q", tc.name, r.OK, r.Error, r.Status, tc.ok, tc.err)
}
if !strings.Contains(r.Summary(), tc.e.Normalized().Path) {
t.Errorf("%s: Summary %q should name the path", tc.name, r.Summary())
}
}
}
// A failed post-apply check must load the previous config back into Caddy,
// record a hold, and keep the results; report-only mode must do neither;
// a passing host must be left alone.
func TestVerifyAppliedConfigRollsBackAndHolds(t *testing.T) {
var mu sync.Mutex
var loads []map[string]any
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/load" {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
loads = append(loads, body)
mu.Unlock()
}
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
}))
defer admin.Close()
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }))
defer broken.Close()
healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }))
defer healthy.Close()
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}
host := &models.ProxyHost{
Domains: hostportOf(t, broken.URL), ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true, SSLEnabled: false,
Expectations: `[{"label":"home","method":"GET","scheme":"http","path":"/","expect_status":200}]`,
}
id, err := models.CreateProxyHost(conn, 1, 0, host)
if err != nil {
t.Fatal(err)
}
previous := map[string]any{"apps": map[string]any{"http": map[string]any{"servers": map[string]any{"srv0": map[string]any{"listen": []any{":443"}}}}}}
client := newCaddyClient(admin.URL, "", "")
err = s.verifyAppliedConfig(1, "Primary", client, previous)
if err == nil || !strings.Contains(err.Error(), "rolled back") {
t.Fatalf("expected a rolled-back error, got %v", err)
}
mu.Lock()
n := len(loads)
mu.Unlock()
if n != 1 || !reflect.DeepEqual(loads[0], previous) {
t.Fatalf("Caddy should have been sent exactly the previous config once, got %d load(s): %+v", n, loads)
}
hold := s.syncHoldFor(1)
if hold == nil || !hold.RolledBack || len(hold.Failed) != 1 || !strings.Contains(hold.Failed[0], "home") {
t.Fatalf("hold = %+v, want a rolled-back hold naming the failed check", hold)
}
if got := s.expectationResultsFor(id); len(got) != 1 || got[0].OK || got[0].Status != 503 {
t.Errorf("results for host = %+v, want one failed 503 result", got)
}
if holds := s.activeSyncHolds([]models.CaddyServer{{ID: 1, Name: "Primary"}, {ID: 2, Name: "Other"}}); len(holds) != 1 || holds[0].ServerID != 1 {
t.Errorf("activeSyncHolds = %+v, want only server 1", holds)
}
// Report-only: nothing is rolled back and no hold is recorded.
s.clearSyncHold(1)
if err := models.SetSetting(conn, settingExpectationsAutoRollback, "0"); err != nil {
t.Fatal(err)
}
mu.Lock()
loads = nil
mu.Unlock()
if err := s.verifyAppliedConfig(1, "Primary", client, previous); err != nil {
t.Fatalf("report-only mode must not return an error, got %v", err)
}
mu.Lock()
n = len(loads)
mu.Unlock()
if n != 0 || s.syncHoldFor(1) != nil {
t.Fatalf("report-only mode must not roll back (loads=%d) or hold (%+v)", n, s.syncHoldFor(1))
}
// A passing host: no rollback either way.
if err := models.SetSetting(conn, settingExpectationsAutoRollback, "1"); err != nil {
t.Fatal(err)
}
got, _ := models.GetProxyHost(conn, id)
got.Domains = hostportOf(t, healthy.URL)
if err := models.UpdateProxyHost(conn, got); err != nil {
t.Fatal(err)
}
if err := s.verifyAppliedConfig(1, "Primary", client, previous); err != nil {
t.Fatalf("passing checks must not error, got %v", err)
}
mu.Lock()
n = len(loads)
mu.Unlock()
if n != 0 || s.syncHoldFor(1) != nil {
t.Errorf("passing checks must not roll back or hold")
}
if got := s.expectationResultsFor(id); len(got) != 1 || !got[0].OK {
t.Errorf("results after a passing run = %+v", got)
}
}