internal/server/layer4_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"context"
"database/sql"
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/X4Applegate/caddyui/internal/caddy"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/X4Applegate/caddyui/web"
"github.com/go-chi/chi/v5"
)
// withChiURLParam attaches a chi URL parameter to req's context, for testing
// chi-routed handlers (e.g. updateServer's {id}) directly without a router.
func withChiURLParam(req *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// newRenderingTestServer builds a *Server whose s.render(...) actually works
// against the real embedded templates, for handler tests that must inspect a
// rendered error message. Unlike server.New, this does not start any of the
// production background loops (health poller, auto-sync, etc.) — those would
// otherwise outlive every individual test for the life of the test binary.
func newRenderingTestServer(t *testing.T, conn *sql.DB) *Server {
t.Helper()
tplFS, err := fs.Sub(web.FS, "templates")
if err != nil {
t.Fatal(err)
}
tpl, err := parseTemplates(tplFS)
if err != nil {
t.Fatal(err)
}
return &Server{DB: conn, Templates: tpl, Version: "test"}
}
// v2.56.0 (issue #113): layer4 (github.com/mholt/caddy-l4) app support. A
// per-server Layer4Caddyfile field holds a pasted `layer4 { ... }` Caddyfile
// block; it is adapted through that server's own admin API and only the
// resulting apps.layer4 subtree is merged into the config CaddyUI pushes.
// --- applyLayer4App / extractAdaptedLayer4App (pure, no network) ---
func TestApplyLayer4AppAddsAndRemoves(t *testing.T) {
cfg := map[string]any{}
applyLayer4App(cfg, map[string]any{"servers": map[string]any{"srv0": map[string]any{"listen": []any{":8443"}}}})
apps, ok := cfg["apps"].(map[string]any)
if !ok {
t.Fatal("apps key missing after applying a non-empty layer4 app")
}
layer4, ok := apps["layer4"].(map[string]any)
if !ok {
t.Fatalf("apps.layer4 missing or wrong type: %#v", apps["layer4"])
}
if _, ok := layer4["servers"]; !ok {
t.Fatal("layer4 servers subtree not preserved")
}
// Clearing (nil, mirroring an empty Layer4Caddyfile field) must remove
// the subtree entirely, not just stop adding to it — a server that turns
// this off expects layer4 gone from the live config.
applyLayer4App(cfg, nil)
apps = cfg["apps"].(map[string]any)
if _, ok := apps["layer4"]; ok {
t.Fatal("apps.layer4 should have been removed when layer4App is empty")
}
// An empty (non-nil) map behaves the same as nil.
applyLayer4App(cfg, map[string]any{"servers": map[string]any{}})
applyLayer4App(cfg, map[string]any{})
if _, ok := cfg["apps"].(map[string]any)["layer4"]; ok {
t.Fatal("apps.layer4 should have been removed for an empty map too")
}
}
func TestExtractAdaptedLayer4AppOnlyTakesLayer4Key(t *testing.T) {
cfg := map[string]any{
"admin": map[string]any{"disabled": true},
"apps": map[string]any{
"layer4": map[string]any{"servers": map[string]any{"srv0": map[string]any{"listen": []any{":8443"}}}},
"http": map[string]any{"servers": map[string]any{"srv0": map[string]any{"listen": []any{":443"}}}},
},
}
got := extractAdaptedLayer4App(cfg)
if got == nil {
t.Fatal("expected a non-nil layer4 subtree")
}
if len(got) != 1 {
t.Fatalf("extracted layer4 app should hold only its own key, got %#v", got)
}
if _, ok := got["servers"]; !ok {
t.Fatalf("layer4 servers missing: %#v", got)
}
if got := extractAdaptedLayer4App(map[string]any{"apps": map[string]any{"http": map[string]any{}}}); got != nil {
t.Fatalf("expected nil when apps.layer4 is absent, got %#v", got)
}
if got := extractAdaptedLayer4App(map[string]any{}); got != nil {
t.Fatalf("expected nil for a config with no apps key at all, got %#v", got)
}
}
// --- buildLayer4App (talks to a fake admin API's /adapt) ---
func TestBuildLayer4AppSkipsAdaptForEmptyInput(t *testing.T) {
var calls int
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
defer admin.Close()
cl := caddy.New(admin.URL, "", "")
app, err := buildLayer4App(cl, "")
if err != nil {
t.Fatalf("empty input should not error, got: %v", err)
}
if app != nil {
t.Fatalf("expected nil app for empty input, got %#v", app)
}
app, err = buildLayer4App(cl, " \n\t ")
if err != nil || app != nil {
t.Fatalf("whitespace-only input should behave like empty, got app=%#v err=%v", app, err)
}
if calls != 0 {
t.Fatalf("empty/whitespace-only input must never call the admin API, got %d call(s)", calls)
}
}
// VERIFIED FACT #2 from issue #113's scoping comment: adapting a layer4 block
// produces a clean, self-contained apps.layer4 subtree. This test also mocks
// the response with an UNRELATED extra top-level key and an unrelated
// apps.http subtree alongside it, proving buildLayer4App discards everything
// except apps.layer4 — defense against a user pasting something unrelated.
func TestBuildLayer4AppExtractsOnlyLayer4KeyFromAdaptResponse(t *testing.T) {
const block = "layer4 {\n\t:8443 {\n\t\troute {\n\t\t\tproxy 127.0.0.1:9000\n\t\t}\n\t}\n}"
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/adapt" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
return
}
if ct := r.Header.Get("Content-Type"); ct != "text/caddyfile" {
http.Error(w, "unexpected content-type "+ct, http.StatusBadRequest)
return
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "layer4") {
http.Error(w, "expected layer4 block in request body, got "+string(body), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"result":{"apps":{"layer4":{"servers":{"srv0":{"listen":[":8443"],"routes":[{"handle":[{"handler":"proxy","upstreams":[{"dial":["127.0.0.1:9000"]}]}]}]}}},"http":{"servers":{"srv0":{"listen":[":443"]}}}},"admin":{"disabled":true}}}`)
}))
defer admin.Close()
app, err := buildLayer4App(caddy.New(admin.URL, "", ""), block)
if err != nil {
t.Fatalf("buildLayer4App failed: %v", err)
}
if len(app) != 1 {
t.Fatalf("expected exactly the layer4 'servers' key (apps.http and admin must be discarded), got %#v", app)
}
servers, ok := app["servers"].(map[string]any)
if !ok {
t.Fatalf("servers subtree missing or wrong type: %#v", app)
}
srv0, ok := servers["srv0"].(map[string]any)
if !ok {
t.Fatalf("srv0 missing: %#v", servers)
}
if listen, _ := srv0["listen"].([]any); len(listen) != 1 || listen[0] != ":8443" {
t.Fatalf("listen = %#v, want [:8443]", srv0["listen"])
}
}
// A Caddy build without caddy-l4 compiled in (or a syntax error) must surface
// as a clear, actionable error — not a crash or a silently-dropped config.
func TestBuildLayer4AppSurfacesAdaptFailureClearly(t *testing.T) {
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"adapting config using caddyfile adapter: parsing caddyfile tokens for 'layer4': unrecognized directive: layer4"}`)
}))
defer admin.Close()
app, err := buildLayer4App(caddy.New(admin.URL, "", ""), "layer4 {\n\t:8443 {\n\t}\n}")
if err == nil {
t.Fatal("expected an error when Caddy rejects the layer4 block")
}
if app != nil {
t.Fatalf("expected nil app on failure, got %#v", app)
}
if !strings.Contains(err.Error(), "unrecognized directive: layer4") {
t.Fatalf("error should surface Caddy's diagnostic text, got: %v", err)
}
}
// --- full sync pipeline against a fake Caddy admin API ---
// layer4FakeAdmin is a controllable fake Caddy admin API covering every
// endpoint syncCaddyInner's layer4 handling touches: GET /config (initial
// fetch), POST /adapt (layer4 Caddyfile adaptation), POST /load (validate),
// GET/POST /config/apps/layer4 (FetchPath/PutPath) and DELETE /apps/layer4.
// Everything else (routes, TLS, etc.) is answered with a generic 200 so
// these tests can isolate layer4 behavior specifically.
type layer4FakeAdmin struct {
mu sync.Mutex
baseConfig string
adaptStatus int
adaptBody string
fetchBody string // GET /config/apps/layer4 response body; "null" = unset
loadCalls int
putBody string
putCalls int
deleteCalls int
writes []string // every non-GET request: "METHOD /path body" (v2.59.3)
wrappersBody string // GET .../listener_wrappers response; "" = null (v2.61.0)
fetchLayer4Hit int
}
func newLayer4FakeAdmin(t *testing.T, baseConfig string) (*httptest.Server, *layer4FakeAdmin) {
t.Helper()
f := &layer4FakeAdmin{baseConfig: baseConfig, adaptStatus: http.StatusOK, fetchBody: "null"}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodGet {
raw, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(strings.NewReader(string(raw)))
f.writes = append(f.writes, r.Method+" "+r.URL.Path+" "+string(raw))
}
switch {
case r.Method == http.MethodGet && (r.URL.Path == "/config/" || r.URL.Path == "/config"):
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, f.baseConfig)
case r.Method == http.MethodPost && r.URL.Path == "/adapt":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(f.adaptStatus)
_, _ = io.WriteString(w, f.adaptBody)
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/load"):
f.loadCalls++
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/listener_wrappers"):
w.Header().Set("Content-Type", "application/json")
if f.wrappersBody == "" {
_, _ = io.WriteString(w, "null")
} else {
_, _ = io.WriteString(w, f.wrappersBody)
}
case r.Method == http.MethodGet && r.URL.Path == "/config/apps/layer4":
f.fetchLayer4Hit++
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, f.fetchBody)
case r.Method == http.MethodPost && r.URL.Path == "/config/apps/layer4":
body, _ := io.ReadAll(r.Body)
f.putBody = string(body)
f.putCalls++
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
case r.Method == http.MethodDelete && r.URL.Path == "/config/apps/layer4":
f.deleteCalls++
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}
}))
t.Cleanup(srv.Close)
return srv, f
}
const layer4TestBlock = "layer4 {\n\t:8443 {\n\t\troute {\n\t\t\tproxy 127.0.0.1:9000\n\t\t}\n\t}\n}"
const layer4TestAdaptResponse = `{"result":{"apps":{"layer4":{"servers":{"srv0":{"listen":[":8443"],"routes":[{"handle":[{"handler":"proxy","upstreams":[{"dial":["127.0.0.1:9000"]}]}]}]}}}}}}`
// The common case: a server that already has at least one normal proxy host
// ALSO has a layer4 block configured. Both must be pushed in the same sync.
func TestSyncCaddyAppliesLayer4AppAlongsideNormalRoutes(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
fake.adaptBody = layer4TestAdaptResponse
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
serverID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: admin.URL, Type: models.CaddyServerTypeManaged,
Layer4Caddyfile: layer4TestBlock,
})
if err != nil {
t.Fatal(err)
}
if _, err := models.CreateProxyHost(conn, serverID, 0, &models.ProxyHost{
Domains: "app.example.com", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true,
}); err != nil {
t.Fatal(err)
}
s := &Server{DB: conn, Caddy: newCaddyClient(admin.URL, "", "")}
if err := s.syncCaddy(serverID, false); err != nil {
t.Fatalf("sync failed: %v", err)
}
if fake.putCalls != 1 {
t.Fatalf("expected exactly one PUT to /config/apps/layer4, got %d", fake.putCalls)
}
var pushed map[string]any
if err := json.Unmarshal([]byte(fake.putBody), &pushed); err != nil {
t.Fatalf("pushed layer4 body is not JSON: %v (body=%s)", err, fake.putBody)
}
if _, ok := pushed["servers"]; !ok {
t.Fatalf("pushed layer4 config missing servers: %#v", pushed)
}
if fake.deleteCalls != 0 {
t.Fatalf("a configured layer4 app must not be deleted, got %d DELETE call(s)", fake.deleteCalls)
}
}
// Zero behavior change: a server with no Layer4Caddyfile must never call
// /adapt, must never PUT or DELETE apps/layer4 (nothing to clean up when
// Caddy already reports it unset), and the pushed config carries no
// apps.layer4 key at all.
func TestSyncCaddyEmptyLayer4FieldIsANoOp(t *testing.T) {
var adaptCalls int
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/config"):
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
case r.URL.Path == "/adapt":
adaptCalls++
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"result":{}}`)
default:
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}
}))
defer admin.Close()
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
serverID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: admin.URL, Type: models.CaddyServerTypeManaged,
})
if err != nil {
t.Fatal(err)
}
if _, err := models.CreateProxyHost(conn, serverID, 0, &models.ProxyHost{
Domains: "app.example.com", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true,
}); err != nil {
t.Fatal(err)
}
s := &Server{DB: conn, Caddy: newCaddyClient(admin.URL, "", "")}
if err := s.syncCaddy(serverID, false); err != nil {
t.Fatalf("sync failed: %v", err)
}
if adaptCalls != 0 {
t.Fatalf("an empty Layer4Caddyfile must never call /adapt, got %d call(s)", adaptCalls)
}
}
// A server dedicated to layer4 TCP/UDP routing — no proxies, redirects, raw
// routes, or certificates at all — must still sync instead of being refused
// by the "no entries in DB" empty-routes guard (issue #113's syncLayer4Only).
func TestSyncCaddyLayer4OnlyServerWithNoOtherRoutes(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"]}}}}}`)
fake.adaptBody = layer4TestAdaptResponse
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
serverID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Layer4Node", AdminURL: admin.URL, Type: models.CaddyServerTypeManaged,
Layer4Caddyfile: layer4TestBlock,
})
if err != nil {
t.Fatal(err)
}
s := &Server{DB: conn, Caddy: newCaddyClient(admin.URL, "", "")}
if err := s.syncCaddy(serverID, false); err != nil {
t.Fatalf("layer4-only sync should succeed, got: %v", err)
}
if fake.putCalls != 1 {
t.Fatalf("expected one PUT to /config/apps/layer4, got %d", fake.putCalls)
}
}
// A sync must abort with a clear error — not push a partial/broken config —
// when the stored layer4 block fails to adapt (e.g. a Caddy build without
// caddy-l4, or a block that was valid when saved but no longer is).
func TestSyncCaddyAbortsOnLayer4AdaptFailure(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
fake.adaptStatus = http.StatusBadRequest
fake.adaptBody = `{"error":"unrecognized directive: layer4"}`
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
serverID, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: admin.URL, Type: models.CaddyServerTypeManaged,
Layer4Caddyfile: layer4TestBlock,
})
if err != nil {
t.Fatal(err)
}
if _, err := models.CreateProxyHost(conn, serverID, 0, &models.ProxyHost{
Domains: "app.example.com", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true,
}); err != nil {
t.Fatal(err)
}
s := &Server{DB: conn, Caddy: newCaddyClient(admin.URL, "", "")}
err = s.syncCaddy(serverID, false)
if err == nil {
t.Fatal("expected the sync to fail when the layer4 block fails to adapt")
}
if !strings.Contains(err.Error(), "unrecognized directive: layer4") {
t.Fatalf("error should surface Caddy's diagnostic, got: %v", err)
}
if fake.loadCalls != 0 {
t.Fatalf("a layer4 adapt failure must abort before any /load validation call, got %d", fake.loadCalls)
}
if fake.putCalls != 0 || fake.deleteCalls != 0 {
t.Fatalf("a layer4 adapt failure must not write any subtree, got put=%d delete=%d", fake.putCalls, fake.deleteCalls)
}
}
// --- model round trip ---
func TestCaddyServerLayer4CaddyfileRoundTrips(t *testing.T) {
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
id, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: "http://10.8.0.3:2019", Type: models.CaddyServerTypeManaged,
Layer4Caddyfile: " " + layer4TestBlock + " \n",
})
if err != nil {
t.Fatal(err)
}
got, err := models.GetCaddyServer(conn, id)
if err != nil {
t.Fatal(err)
}
if got.Layer4Caddyfile != layer4TestBlock {
t.Fatalf("GetCaddyServer Layer4Caddyfile = %q, want trimmed %q", got.Layer4Caddyfile, layer4TestBlock)
}
list, err := models.ListCaddyServers(conn)
if err != nil {
t.Fatal(err)
}
var found bool
for _, sr := range list {
if sr.ID == id {
found = sr.Layer4Caddyfile == layer4TestBlock
}
}
if !found {
t.Fatalf("ListCaddyServers lost the layer4 Caddyfile: %+v", list)
}
got.Layer4Caddyfile = ""
if err := models.UpdateCaddyServer(conn, got); err != nil {
t.Fatal(err)
}
again, err := models.GetCaddyServer(conn, id)
if err != nil {
t.Fatal(err)
}
if again.Layer4Caddyfile != "" {
t.Errorf("after clearing, Layer4Caddyfile = %q, want empty", again.Layer4Caddyfile)
}
// A server created before this feature existed (migration backfill) must
// read back as an empty string, not NULL/error.
bare, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Bare", AdminURL: "http://10.8.0.4:2019", Type: models.CaddyServerTypeManaged})
if err != nil {
t.Fatal(err)
}
bareRow, err := models.GetCaddyServer(conn, bare)
if err != nil {
t.Fatal(err)
}
if bareRow.Layer4Caddyfile != "" {
t.Errorf("a server created without the field set must default to empty, got %q", bareRow.Layer4Caddyfile)
}
}
// --- save-time validation (createServer / updateServer handlers) ---
func postForm(t *testing.T, path string, values url.Values) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
func TestCreateServerRejectsBadLayer4CaddyfileAtSaveTime(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{}`)
fake.adaptStatus = http.StatusBadRequest
fake.adaptBody = `{"error":"unrecognized directive: layer4"}`
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
s := newRenderingTestServer(t, conn)
form := url.Values{
"name": {"Edge"},
"admin_url": {admin.URL},
"type": {"managed"},
"layer4_caddyfile": {layer4TestBlock},
}
rec := httptest.NewRecorder()
s.createServer(rec, postForm(t, "/servers", form))
if rec.Code != http.StatusOK {
t.Fatalf("expected the form to be re-rendered with an error (200), got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Layer4 Caddyfile rejected by Caddy") || !strings.Contains(rec.Body.String(), "unrecognized directive: layer4") {
t.Fatalf("response should explain the rejection, got: %s", rec.Body.String())
}
if n, err := models.CountCaddyServers(conn); err != nil || n != 0 {
t.Fatalf("a rejected layer4 block must not create the server, count=%d err=%v", n, err)
}
}
func TestCreateServerAcceptsValidLayer4Caddyfile(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{}`)
fake.adaptBody = layer4TestAdaptResponse
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
s := newRenderingTestServer(t, conn)
form := url.Values{
"name": {"Edge"},
"admin_url": {admin.URL},
"type": {"managed"},
"layer4_caddyfile": {layer4TestBlock},
}
rec := httptest.NewRecorder()
s.createServer(rec, postForm(t, "/servers", form))
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected a redirect (303) on success, got %d: %s", rec.Code, rec.Body.String())
}
list, err := models.ListCaddyServers(conn)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Layer4Caddyfile != layer4TestBlock {
t.Fatalf("expected one server with the saved layer4 block, got %+v", list)
}
}
func TestUpdateServerRejectsBadLayer4CaddyfileAtSaveTime(t *testing.T) {
admin, fake := newLayer4FakeAdmin(t, `{}`)
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
id, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Edge", AdminURL: admin.URL, Type: models.CaddyServerTypeManaged})
if err != nil {
t.Fatal(err)
}
s := newRenderingTestServer(t, conn)
fake.adaptStatus = http.StatusBadRequest
fake.adaptBody = `{"error":"unrecognized directive: layer4"}`
form := url.Values{
"name": {"Edge"},
"admin_url": {admin.URL},
"type": {"managed"},
"layer4_caddyfile": {layer4TestBlock},
}
req := postForm(t, "/servers/"+strconv.FormatInt(id, 10), form)
req = withChiURLParam(req, "id", strconv.FormatInt(id, 10))
rec := httptest.NewRecorder()
s.updateServer(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected the form to be re-rendered with an error (200), got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Layer4 Caddyfile rejected by Caddy") {
t.Fatalf("response should explain the rejection, got: %s", rec.Body.String())
}
saved, err := models.GetCaddyServer(conn, id)
if err != nil {
t.Fatal(err)
}
if saved.Layer4Caddyfile != "" {
t.Fatalf("a rejected layer4 block must not be persisted, got %q", saved.Layer4Caddyfile)
}
}
// --- fleet replication ("Also copy to") ---
// One target accepts the adapt and gets the layer4 Caddyfile copied +
// synced; another target's Caddy rejects it (module not compiled in) and
// must be skipped without affecting the first target or erroring out the
// whole operation.
func TestCrossDeployLayer4CaddyfileValidatesPerTargetIndependently(t *testing.T) {
goodAdmin, goodFake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
goodFake.adaptBody = layer4TestAdaptResponse
badAdmin, badFake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
badFake.adaptStatus = http.StatusBadRequest
badFake.adaptBody = `{"error":"unrecognized directive: layer4"}`
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
sourceID, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Source", AdminURL: "http://source:2019", Type: models.CaddyServerTypeManaged})
if err != nil {
t.Fatal(err)
}
goodID, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Good", AdminURL: goodAdmin.URL, Type: models.CaddyServerTypeManaged})
if err != nil {
t.Fatal(err)
}
badID, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Bad", AdminURL: badAdmin.URL, Type: models.CaddyServerTypeManaged})
if err != nil {
t.Fatal(err)
}
s := &Server{DB: conn}
s.crossDeployLayer4Caddyfile("tester", sourceID, layer4TestBlock, []int64{goodID, badID})
good, err := models.GetCaddyServer(conn, goodID)
if err != nil {
t.Fatal(err)
}
if good.Layer4Caddyfile != layer4TestBlock {
t.Fatalf("the accepting target should have the layer4 block copied, got %q", good.Layer4Caddyfile)
}
if goodFake.putCalls != 1 {
t.Fatalf("the accepting target should have been synced (one PUT), got %d", goodFake.putCalls)
}
bad, err := models.GetCaddyServer(conn, badID)
if err != nil {
t.Fatal(err)
}
if bad.Layer4Caddyfile != "" {
t.Fatalf("the rejecting target must not have the layer4 block saved, got %q", bad.Layer4Caddyfile)
}
}