Files
CaddyProxyManager/backend/internal/caddy/api_test.go
Pacerino 7ff0949067 [Backend] Add Caddy provider abstraction with Caddyfile and API modes
Introduce a Provider interface selectable via CPM_CADDY_MODE:
- CaddyfileProvider renders per-host snippets (template whitespace fixed)
- APIProvider manages Caddy's JSON config via the admin API
Add configurable reload strategy (systemd/exec/api/none) and a
configurable listen address for the bootstrapped server in API mode.
Includes unit tests for the JSON builder, provider factory and the
API provider (httptest).
2026-06-14 02:34:54 +02:00

165 lines
4.4 KiB
Go

package caddy
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
// fakeCaddy is a minimal in-memory stand-in for the Caddy admin API that
// mimics the quirks the real API exhibits (200 + "null" body for absent paths).
type fakeCaddy struct {
serverExists bool
routes map[string]bool // route id -> exists
calls []string // method+path log for assertions
}
func newFakeCaddy() *fakeCaddy {
return &fakeCaddy{routes: map[string]bool{}}
}
func (f *fakeCaddy) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
path := r.URL.Path
switch {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/apps/http/servers/"):
// Caddy returns 200 + "null" when the server is absent.
if f.serverExists {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"listen":[":8080"],"routes":[]}`))
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("null"))
}
case r.Method == http.MethodGet && strings.HasPrefix(path, "/id/"):
id := strings.TrimPrefix(path, "/id/")
w.WriteHeader(http.StatusOK)
if f.routes[id] {
w.Write([]byte(`{"@id":"` + id + `"}`))
} else {
w.Write([]byte("null"))
}
case r.Method == http.MethodPatch && path == "/config/apps/http":
f.serverExists = true
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && strings.HasSuffix(path, "/routes"):
// New route appended; mark generic existence is handled by id GET.
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/id/"):
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/id/"):
id := strings.TrimPrefix(path, "/id/")
delete(f.routes, id)
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusBadRequest)
}
})
}
func newTestProvider(url string) *APIProvider {
return &APIProvider{
baseURL: strings.TrimRight(url, "/"),
serverName: "srv0",
listen: []string{":8080"},
client: &http.Client{Timeout: 2 * time.Second},
}
}
func sampleHost() database.Host {
h := database.Host{
Domains: "example.com",
Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}},
}
h.ID = 1
return h
}
func TestAPIProviderBootstrapsServerOnFirstWrite(t *testing.T) {
fake := newFakeCaddy()
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.WriteHost(sampleHost()); err != nil {
t.Fatalf("WriteHost: %v", err)
}
if !fake.serverExists {
t.Fatal("expected server to be bootstrapped via PATCH")
}
assertCalled(t, fake, "PATCH /config/apps/http")
assertCalled(t, fake, "POST /config/apps/http/servers/srv0/routes")
}
func TestAPIProviderUpdatesExistingRoute(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
fake.routes["cpm_host_1"] = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.WriteHost(sampleHost()); err != nil {
t.Fatalf("WriteHost: %v", err)
}
// Existing route -> PATCH /id/, not a POST.
assertCalled(t, fake, "PATCH /id/cpm_host_1")
for _, c := range fake.calls {
if strings.HasPrefix(c, "POST ") {
t.Errorf("did not expect POST on update, got %q", c)
}
}
}
func TestAPIProviderRemoveHost(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
fake.routes["cpm_host_1"] = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.RemoveHost(1); err != nil {
t.Fatalf("RemoveHost: %v", err)
}
assertCalled(t, fake, "DELETE /id/cpm_host_1")
if fake.routes["cpm_host_1"] {
t.Error("route should have been deleted")
}
}
func TestAPIProviderRemoveMissingHostIsNoop(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.RemoveHost(99); err != nil {
t.Fatalf("RemoveHost on missing should be noop, got %v", err)
}
for _, c := range fake.calls {
if strings.HasPrefix(c, "DELETE ") {
t.Errorf("expected no DELETE for missing host, got %q", c)
}
}
}
func assertCalled(t *testing.T, f *fakeCaddy, want string) {
t.Helper()
for _, c := range f.calls {
if c == want {
return
}
}
t.Errorf("expected call %q, calls were: %v", want, f.calls)
}