Ctrl K
ComunidadGuías…

internal/server/caddyfile_snippets_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 (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"testing"

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

// Issue #119: CADDYFILE_PATH snippets were auto-loaded for Advanced routes and
// the Caddyfile paste importer, but not for a proxy host's Advanced config, so
// `import my_snippet` there was rejected with "File to import not found".

var (
	snippetImportRe = regexp.MustCompile(`(?m)^\s*import\s+(\S+)`)
	snippetDefRe    = regexp.MustCompile(`(?m)^\(([^)\s]+)\)\s*\{`)
)

// snippetAwareAdapter stands in for Caddy's /adapt endpoint: like the real
// adapter it rejects an `import <name>` whose snippet definition is not part
// of the submitted Caddyfile, and otherwise returns one adapted route. It
// records every Caddyfile it was asked to adapt.
func snippetAwareAdapter(t *testing.T) (*caddy.Client, func() []string) {
	t.Helper()
	var mu sync.Mutex
	var bodies []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/adapt" {
			http.NotFound(w, r)
			return
		}
		raw, _ := io.ReadAll(r.Body)
		body := string(raw)
		mu.Lock()
		bodies = append(bodies, body)
		mu.Unlock()

		defined := map[string]bool{}
		for _, m := range snippetDefRe.FindAllStringSubmatch(body, -1) {
			defined[m[1]] = true
		}
		for _, m := range snippetImportRe.FindAllStringSubmatch(body, -1) {
			if !defined[m[1]] {
				w.WriteHeader(http.StatusBadRequest)
				_ = json.NewEncoder(w).Encode(map[string]string{
					"error": "adapting config using caddyfile adapter: File to import not found: " + m[1],
				})
				return
			}
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"result": map[string]any{"apps": map[string]any{"http": map[string]any{"servers": map[string]any{
				"srv0": map[string]any{
					"listen": []any{":443"},
					"routes": []any{map[string]any{"handle": []any{map[string]any{"handler": "headers"}}}},
				},
			}}}},
		})
	}))
	t.Cleanup(srv.Close)
	return caddy.New(srv.URL, "", ""), func() []string {
		mu.Lock()
		defer mu.Unlock()
		return append([]string(nil), bodies...)
	}
}

func writeTestCaddyfile(t *testing.T, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), "Caddyfile")
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	return path
}

const mountedCaddyfile = `{
	email admin@example.com
}

(my_snippet) {
	header X-From-Snippet "yes"
}

(other) {
	encode gzip
}

example.com {
	respond "hi"
}
`

func TestProxyAdvancedConfigImportsSnippetFromCaddyfilePath(t *testing.T) {
	cl, bodies := snippetAwareAdapter(t)
	s := &Server{CaddyfilePath: writeTestCaddyfile(t, mountedCaddyfile)}

	if _, _, err := s.adaptProxyAdvancedWithClient(cl, models.ProxyHost{AdvancedConfig: "import my_snippet"}); err != nil {
		t.Fatalf("Advanced config importing a CADDYFILE_PATH snippet was rejected: %v", err)
	}

	sent := bodies()
	if len(sent) != 1 {
		t.Fatalf("adapt called %d times, want 1", len(sent))
	}
	body := sent[0]
	def, site := strings.Index(body, "(my_snippet) {"), strings.Index(body, "localhost {")
	if def < 0 || site < 0 || def > site {
		t.Fatalf("snippet definition must precede the synthetic site block:\n%s", body)
	}
	// Only snippets are borrowed from the mounted file — never its global
	// options or site blocks, which would clash with / duplicate live config.
	for _, leaked := range []string{"email admin@example.com", "example.com {", `respond "hi"`} {
		if strings.Contains(body, leaked) {
			t.Fatalf("non-snippet content %q leaked from CADDYFILE_PATH into the adapt request:\n%s", leaked, body)
		}
	}
}

func TestProxyAdvancedConfigImportFailsClearlyWithoutASnippetFile(t *testing.T) {
	for name, path := range map[string]string{
		"unset":      "",
		"missing":    filepath.Join(t.TempDir(), "does-not-exist"),
		"no snippet": writeTestCaddyfile(t, "example.com {\n\trespond \"hi\"\n}\n"),
	} {
		t.Run(name, func(t *testing.T) {
			cl, _ := snippetAwareAdapter(t)
			s := &Server{CaddyfilePath: path}
			_, _, err := s.adaptProxyAdvancedWithClient(cl, models.ProxyHost{AdvancedConfig: "import my_snippet"})
			if err == nil || !strings.Contains(err.Error(), "File to import not found: my_snippet") {
				t.Fatalf("err = %v, want Caddy's File-to-import-not-found error passed through", err)
			}
		})
	}
}

func TestProxyAdvancedConfigWithoutImportIsUnaffectedBySnippets(t *testing.T) {
	cl, _ := snippetAwareAdapter(t)
	s := &Server{CaddyfilePath: writeTestCaddyfile(t, mountedCaddyfile)}
	if _, _, err := s.adaptProxyAdvancedWithClient(cl, models.ProxyHost{AdvancedConfig: "header X-Plain yes"}); err != nil {
		t.Fatalf("plain Advanced config broke when snippets are present: %v", err)
	}
}

func TestWithAutoLoadedSnippetsSkipsSnippetsTheSourceRedefines(t *testing.T) {
	s := &Server{CaddyfilePath: writeTestCaddyfile(t, mountedCaddyfile)}
	src := "(my_snippet) {\n\theader X-Mine \"1\"\n}\n\nsite.example {\n\timport my_snippet\n\timport other\n}\n"
	got := s.withAutoLoadedSnippets(src)

	if n := strings.Count(got, "(my_snippet) {"); n != 1 {
		t.Fatalf("(my_snippet) defined %d times, want exactly 1 (Caddy rejects duplicates):\n%s", n, got)
	}
	if !strings.Contains(got, "X-Mine") || strings.Contains(got, "X-From-Snippet") {
		t.Fatalf("the source's own definition must win over the mounted file's:\n%s", got)
	}
	if !strings.Contains(got, "(other) {") {
		t.Fatalf("snippet the source did not define should have been loaded:\n%s", got)
	}
}

func TestWithAutoLoadedSnippetsLeavesSourceAloneWhenThereIsNothingToLoad(t *testing.T) {
	const src = "localhost {\n\theader X-A 1\n}\n"
	for name, s := range map[string]*Server{
		"unset path":  {},
		"missing":     {CaddyfilePath: filepath.Join(t.TempDir(), "nope")},
		"no snippets": {CaddyfilePath: writeTestCaddyfile(t, "example.com {\n\trespond \"hi\"\n}\n")},
	} {
		if got := s.withAutoLoadedSnippets(src); got != src {
			t.Fatalf("%s: source was modified: %q", name, got)
		}
	}
}

// The paths that already loaded snippets before #119 now share the helper;
// pin that they still do.
func TestAdvancedRouteCaddyfileStillLoadsSnippets(t *testing.T) {
	cl, bodies := snippetAwareAdapter(t)
	s := &Server{CaddyfilePath: writeTestCaddyfile(t, mountedCaddyfile)}

	if _, _, err := s.adaptRawRouteCaddyfile(cl, "site.example {\n\timport my_snippet\n}\n"); err != nil {
		t.Fatalf("Advanced route importing a CADDYFILE_PATH snippet was rejected: %v", err)
	}
	if sent := bodies(); len(sent) != 1 || !strings.Contains(sent[0], "(my_snippet) {") {
		t.Fatalf("snippet definition missing from the adapt request: %#v", sent)
	}
}