internal/server/preflight_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// fakeCaddyAdmin answers the calls a preview validation and a sync make. A
// validate/load whose body mentions "/missing/" is rejected the way Caddy
// rejects an unreadable certificate file.
func fakeCaddyAdmin(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var loads []string
srv := 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")
_, _ = w.Write([]byte(`{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`))
case r.Method == http.MethodPost && r.URL.Path == "/load":
body, _ := io.ReadAll(r.Body)
mu.Lock()
loads = append(loads, r.URL.RawQuery)
mu.Unlock()
if strings.Contains(string(body), "/missing/") {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":"loading config: loading new config: loading http app module: provision http: getting tls app: loading tls app module: provision tls: loading certificates: open /missing/fullchain.pem: no such file or directory"}`))
return
}
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
default:
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
}
}))
t.Cleanup(srv.Close)
return srv, &loads
}
func newPreflightTestServer(t *testing.T) (*Server, int64) {
t.Helper()
admin, _ := fakeCaddyAdmin(t)
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)
}
return &Server{DB: conn, Caddy: newCaddyClient(admin.URL, "", "")}, serverID
}
// v2.42.1 (issue #74): a file-path certificate Caddy cannot open is refused
// before it is saved, with the container-path explanation; a readable one
// passes; a host referencing the bad certificate is refused too.
func TestPreviewValidationRefusesUnreadableCertificateFile(t *testing.T) {
s, serverID := newPreflightTestServer(t)
bad := &models.Certificate{Name: "letsencrypt-on-host", Domains: "app.example.test", Source: models.CertSourcePath, CertPath: "/missing/fullchain.pem", KeyPath: "/missing/privkey.pem"}
msg := s.previewCertificateValidate(serverID, bad)
if !strings.Contains(msg, "Caddy cannot open /missing/fullchain.pem") || !strings.Contains(msg, "inside the Caddy container") || !strings.Contains(msg, "letsencrypt-on-host") {
t.Fatalf("message = %q", msg)
}
good := &models.Certificate{Name: "ok", Domains: "app.example.test", Source: models.CertSourcePath, CertPath: "/certs/fullchain.pem", KeyPath: "/certs/privkey.pem"}
if msg := s.previewCertificateValidate(serverID, good); msg != "" {
t.Fatalf("readable certificate should pass, got %q", msg)
}
// Once a bad certificate exists (say, from before this release), a host
// attached to it is refused with the same explanation.
badID, err := models.CreateCertificate(s.DB, serverID, 0, bad)
if err != nil {
t.Fatal(err)
}
host := &models.ProxyHost{Domains: "app.example.test", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true, SSLForced: true, CertificateID: badID}
if msg := s.previewProxyHostValidate(serverID, host); !strings.Contains(msg, "Caddy cannot open /missing/fullchain.pem") {
t.Fatalf("host with unreadable certificate should be refused, got %q", msg)
}
redirect := &models.RedirectionHost{Domains: "old.example.test", ForwardScheme: "https", ForwardDomain: "new.example.test", ForwardHTTPCode: 301, Enabled: true, SSLEnabled: true, CertificateID: badID}
if msg := s.previewRedirectValidate(serverID, redirect); !strings.Contains(msg, "Caddy cannot open") {
t.Fatalf("redirect with unreadable certificate should be refused, got %q", msg)
}
// A generic rejection keeps Caddy's words but says the change was not saved.
if got := friendlyCaddyRejection("Caddy rejected the proposed config: something else", nil); !strings.HasPrefix(got, "Caddy rejected this change, so it was not saved: something else") {
t.Errorf("generic message = %q", got)
}
}
// A sync Caddy rejects is recorded for the banner and cleared by the next
// successful sync; a held server is left to the hold banner.
func TestSyncCaddyRecordsFailuresForTheBanner(t *testing.T) {
s, serverID := newPreflightTestServer(t)
bad := models.Certificate{Name: "bad", Domains: "app.example.test", Source: models.CertSourcePath, CertPath: "/missing/fullchain.pem", KeyPath: "/missing/privkey.pem"}
badID, err := models.CreateCertificate(s.DB, serverID, 0, &bad)
if err != nil {
t.Fatal(err)
}
host := models.ProxyHost{Domains: "app.example.test", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true, SSLForced: true, CertificateID: badID}
if _, err := models.CreateProxyHost(s.DB, serverID, 0, &host); err != nil {
t.Fatal(err)
}
if err := s.syncCaddy(serverID, true); err == nil {
t.Fatal("sync should fail while the certificate file is unreadable")
}
rec := s.syncErrorFor(serverID)
if rec == nil || !strings.Contains(rec.Error, "no such file or directory") || rec.ServerName != "Primary" {
t.Fatalf("recorded sync error = %+v", rec)
}
servers, _ := models.ListCaddyServers(s.DB)
if banners := s.activeSyncErrors(servers); len(banners) != 1 || banners[0].ServerID != serverID {
t.Fatalf("activeSyncErrors = %+v", banners)
}
// Fix the certificate → the next sync succeeds and clears the record.
bad.ID = badID
bad.CertPath, bad.KeyPath = "/certs/fullchain.pem", "/certs/privkey.pem"
if err := models.UpdateCertificate(s.DB, &bad); err != nil {
t.Fatal(err)
}
if err := s.syncCaddy(serverID, true); err != nil {
t.Fatalf("sync should succeed now: %v", err)
}
if s.syncErrorFor(serverID) != nil {
t.Fatal("a successful sync must clear the recorded error")
}
// A held server reports through the hold banner only.
s.setSyncHold(syncHold{ServerID: serverID, ServerName: "Primary", Detail: "held"})
_ = s.syncCaddy(serverID, true)
if s.syncErrorFor(serverID) != nil {
t.Fatal("no sync-error record while a hold is set")
}
s.clearSyncHold(serverID)
s.setSyncError(serverID, "Primary", io.ErrUnexpectedEOF)
raw, _ := models.GetSetting(s.DB, syncErrorKey(serverID))
var stored syncError
if json.Unmarshal([]byte(raw), &stored) != nil || stored.Error != io.ErrUnexpectedEOF.Error() {
t.Fatalf("stored = %q", raw)
}
s.clearSyncError(serverID)
if s.syncErrorFor(serverID) != nil {
t.Fatal("clearSyncError should remove the record")
}
}
// The banner's "back" redirect never follows a protocol-relative referer.
func TestRedirectBackStaysLocal(t *testing.T) {
for referer, want := range map[string]string{
"http://caddyui.local/certificates": "/certificates",
"http://caddyui.local//evil.example/x": "/",
"": "/",
"http://caddyui.local/proxy-hosts?x=1": "/proxy-hosts",
} {
req := httptest.NewRequest(http.MethodPost, "/servers/1/sync-error/clear", nil)
if referer != "" {
req.Header.Set("Referer", referer)
}
rec := httptest.NewRecorder()
redirectBack(rec, req)
if got := rec.Header().Get("Location"); got != want {
t.Errorf("referer %q → %q, want %q", referer, got, want)
}
}
if e := (models.HostExpectation{Path: "//evil.example/x"}).Normalized(); e.Path != "/evil.example/x" {
t.Errorf("expectation path = %q, want a single leading slash", e.Path)
}
}