diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go
index d34158409..3a3d41599 100644
--- a/pkg/miniapp/miniapp.go
+++ b/pkg/miniapp/miniapp.go
@@ -9,10 +9,12 @@ import (
"encoding/json"
"fmt"
"io"
+ "net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -159,10 +161,21 @@ func (n *StateNotifier) Notify() {
}
}
-// DevTargetSetter allows tools to control the dev proxy target.
-type DevTargetSetter interface {
- SetDevTarget(target string) error
+// DevTarget represents a registered dev server target.
+type DevTarget struct {
+ ID string `json:"id"`
+ Name string `json:"name"` // display name (e.g. "frontend")
+ Target string `json:"target"` // URL (e.g. "http://localhost:3000")
+}
+
+// DevTargetManager allows tools to register, activate, and deactivate dev proxy targets.
+type DevTargetManager interface {
+ RegisterDevTarget(name, target string) (id string, err error)
+ UnregisterDevTarget(id string) error
+ ActivateDevTarget(id string) error
+ DeactivateDevTarget() error
GetDevTarget() string
+ ListDevTargets() []DevTarget
}
// Handler serves the Mini App HTML and API endpoints.
@@ -172,48 +185,130 @@ type Handler struct {
botToken string
notifier *StateNotifier
- devMu sync.RWMutex
- devTarget *url.URL
- devProxy *httputil.ReverseProxy
+ devMu sync.RWMutex
+ devTarget *url.URL
+ devProxy *httputil.ReverseProxy
+ devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
+ devNextID int
+ devActiveID string
}
// NewHandler creates a new Mini App handler.
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler {
return &Handler{
- provider: provider,
- sender: sender,
- botToken: botToken,
- notifier: notifier,
+ provider: provider,
+ sender: sender,
+ botToken: botToken,
+ notifier: notifier,
+ devTargets: make(map[string]*DevTarget),
}
}
-// SetDevTarget sets the reverse proxy target URL. Only localhost targets are allowed.
-// Pass an empty string to disable the proxy.
-func (h *Handler) SetDevTarget(target string) error {
+// validateLocalhostURL parses and validates that a URL targets localhost.
+func validateLocalhostURL(target string) (*url.URL, error) {
+ u, err := url.Parse(target)
+ if err != nil {
+ return nil, fmt.Errorf("invalid URL: %w", err)
+ }
+ host := u.Hostname()
+ if host != "localhost" && host != "127.0.0.1" && host != "::1" {
+ return nil, fmt.Errorf("only localhost targets are allowed, got %q", host)
+ }
+ return u, nil
+}
+
+// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
+func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
+ if _, err := validateLocalhostURL(target); err != nil {
+ return "", err
+ }
+
h.devMu.Lock()
defer h.devMu.Unlock()
- if target == "" {
+ h.devNextID++
+ id := strconv.Itoa(h.devNextID)
+
+ h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target}
+ if h.notifier != nil {
+ h.notifier.Notify()
+ }
+ return id, nil
+}
+
+// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
+func (h *Handler) UnregisterDevTarget(id string) error {
+ h.devMu.Lock()
+ defer h.devMu.Unlock()
+
+ if _, ok := h.devTargets[id]; !ok {
+ return fmt.Errorf("target %q not found", id)
+ }
+ delete(h.devTargets, id)
+
+ if h.devActiveID == id {
+ h.devActiveID = ""
h.devTarget = nil
h.devProxy = nil
- if h.notifier != nil {
- h.notifier.Notify()
- }
- return nil
+ }
+ if h.notifier != nil {
+ h.notifier.Notify()
+ }
+ return nil
+}
+
+// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
+func (h *Handler) ActivateDevTarget(id string) error {
+ h.devMu.Lock()
+ defer h.devMu.Unlock()
+
+ dt, ok := h.devTargets[id]
+ if !ok {
+ return fmt.Errorf("target %q not found", id)
}
- u, err := url.Parse(target)
+ u, err := url.Parse(dt.Target)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
- host := u.Hostname()
- if host != "localhost" && host != "127.0.0.1" && host != "::1" {
- return fmt.Errorf("only localhost targets are allowed, got %q", host)
+ // Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems
+ // where localhost resolves to [::1] but the dev server only listens on IPv4.
+ if u.Hostname() == "localhost" {
+ u.Host = net.JoinHostPort("127.0.0.1", u.Port())
+ }
+
+ proxy := httputil.NewSingleHostReverseProxy(u)
+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadGateway)
+ fmt.Fprintf(w, `
+
Cannot connect
%s
Target: %s
`,
+ escapeHTMLString(err.Error()), escapeHTMLString(dt.Target))
}
h.devTarget = u
- h.devProxy = httputil.NewSingleHostReverseProxy(u)
+ h.devProxy = proxy
+ h.devActiveID = id
+ if h.notifier != nil {
+ h.notifier.Notify()
+ }
+ return nil
+}
+
+// DeactivateDevTarget disables the reverse proxy without removing registrations.
+func (h *Handler) DeactivateDevTarget() error {
+ h.devMu.Lock()
+ defer h.devMu.Unlock()
+
+ h.devActiveID = ""
+ h.devTarget = nil
+ h.devProxy = nil
if h.notifier != nil {
h.notifier.Notify()
}
@@ -230,6 +325,29 @@ func (h *Handler) GetDevTarget() string {
return h.devTarget.String()
}
+// ListDevTargets returns all registered dev targets.
+func (h *Handler) ListDevTargets() []DevTarget {
+ h.devMu.RLock()
+ defer h.devMu.RUnlock()
+
+ targets := make([]DevTarget, 0, len(h.devTargets))
+ for _, dt := range h.devTargets {
+ targets = append(targets, *dt)
+ }
+ // Sort by ID for stable order
+ sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
+ return targets
+}
+
+// escapeHTMLString escapes HTML special characters in a string.
+func escapeHTMLString(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, "\"", """)
+ return s
+}
+
// RegisterRoutes registers Mini App routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/miniapp", h.serveIndex)
@@ -345,11 +463,7 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
- target := h.GetDevTarget()
- writeJSON(w, map[string]any{
- "active": target != "",
- "target": target,
- })
+ writeJSON(w, h.devStatus())
case http.MethodPost:
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
@@ -357,21 +471,33 @@ func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
- Target string `json:"target"`
+ Action string `json:"action"`
+ ID string `json:"id"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
- if err := h.SetDevTarget(req.Target); err != nil {
- writeJSON(w, map[string]any{"error": err.Error()})
+ switch req.Action {
+ case "activate":
+ if req.ID == "" {
+ writeJSON(w, map[string]any{"error": "id is required"})
+ return
+ }
+ if err := h.ActivateDevTarget(req.ID); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ case "deactivate":
+ if err := h.DeactivateDevTarget(); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ default:
+ writeJSON(w, map[string]any{"error": "unknown action"})
return
}
- target := h.GetDevTarget()
- writeJSON(w, map[string]any{
- "active": target != "",
- "target": target,
- })
+ writeJSON(w, h.devStatus())
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
@@ -462,10 +588,26 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) devStatus() map[string]any {
- target := h.GetDevTarget()
+ h.devMu.RLock()
+ defer h.devMu.RUnlock()
+
+ active := h.devTarget != nil
+ target := ""
+ if h.devTarget != nil {
+ target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite
+ }
+
+ targets := make([]DevTarget, 0, len(h.devTargets))
+ for _, dt := range h.devTargets {
+ targets = append(targets, *dt)
+ }
+ sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID })
+
return map[string]any{
- "active": target != "",
- "target": target,
+ "active": active,
+ "active_id": h.devActiveID,
+ "target": target,
+ "targets": targets,
}
}
diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go
index c2d277bf7..4108a647b 100644
--- a/pkg/miniapp/miniapp_test.go
+++ b/pkg/miniapp/miniapp_test.go
@@ -461,7 +461,7 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
// ── Dev proxy tests ──
-func TestDevProxy_SetAndGet(t *testing.T) {
+func TestDevProxy_RegisterAndActivate(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
// Initially empty
@@ -469,42 +469,122 @@ func TestDevProxy_SetAndGet(t *testing.T) {
t.Errorf("expected empty target, got %q", got)
}
- // Set a valid target
- if err := h.SetDevTarget("http://localhost:3000"); err != nil {
- t.Fatalf("SetDevTarget failed: %v", err)
+ // Register a target
+ id, err := h.RegisterDevTarget("frontend", "http://localhost:3000")
+ if err != nil {
+ t.Fatalf("RegisterDevTarget failed: %v", err)
}
- if got := h.GetDevTarget(); got != "http://localhost:3000" {
- t.Errorf("expected http://localhost:3000, got %q", got)
+ if id == "" {
+ t.Fatal("expected non-empty id")
}
- // Clear target
- if err := h.SetDevTarget(""); err != nil {
- t.Fatalf("SetDevTarget(\"\") failed: %v", err)
+ // Still inactive until activated
+ if got := h.GetDevTarget(); got != "" {
+ t.Errorf("expected empty target before activation, got %q", got)
+ }
+
+ // Activate
+ if err := h.ActivateDevTarget(id); err != nil {
+ t.Fatalf("ActivateDevTarget failed: %v", err)
+ }
+ if got := h.GetDevTarget(); got == "" {
+ t.Error("expected non-empty target after activation")
+ }
+
+ // Deactivate
+ if err := h.DeactivateDevTarget(); err != nil {
+ t.Fatalf("DeactivateDevTarget failed: %v", err)
}
if got := h.GetDevTarget(); got != "" {
- t.Errorf("expected empty target after clear, got %q", got)
+ t.Errorf("expected empty target after deactivation, got %q", got)
+ }
+}
+
+func TestDevProxy_UnregisterActive(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.ActivateDevTarget(id)
+
+ if err := h.UnregisterDevTarget(id); err != nil {
+ t.Fatalf("UnregisterDevTarget failed: %v", err)
+ }
+ if got := h.GetDevTarget(); got != "" {
+ t.Errorf("expected empty target after unregister of active, got %q", got)
+ }
+ if targets := h.ListDevTargets(); len(targets) != 0 {
+ t.Errorf("expected 0 targets, got %d", len(targets))
+ }
+}
+
+func TestDevProxy_UnregisterNotFound(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ if err := h.UnregisterDevTarget("999"); err == nil {
+ t.Error("expected error for non-existent target")
+ }
+}
+
+func TestDevProxy_ActivateNotFound(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ if err := h.ActivateDevTarget("999"); err == nil {
+ t.Error("expected error for non-existent target")
}
}
func TestDevProxy_LocalhostOnly(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
- // External host should be rejected
- if err := h.SetDevTarget("http://example.com:3000"); err == nil {
+ // External host should be rejected at registration
+ if _, err := h.RegisterDevTarget("ext", "http://example.com:3000"); err == nil {
t.Error("expected error for external host, got nil")
}
// 127.0.0.1 should be allowed
- if err := h.SetDevTarget("http://127.0.0.1:8080"); err != nil {
+ if _, err := h.RegisterDevTarget("local4", "http://127.0.0.1:8080"); err != nil {
t.Errorf("expected 127.0.0.1 to be allowed, got %v", err)
}
// ::1 should be allowed
- if err := h.SetDevTarget("http://[::1]:9000"); err != nil {
+ if _, err := h.RegisterDevTarget("local6", "http://[::1]:9000"); err != nil {
t.Errorf("expected [::1] to be allowed, got %v", err)
}
}
+func TestDevProxy_IPv4Rewrite(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
+ h.ActivateDevTarget(id)
+
+ // After activation, the internal target should use 127.0.0.1 instead of localhost
+ got := h.GetDevTarget()
+ if strings.Contains(got, "localhost") {
+ t.Errorf("expected localhost to be rewritten to 127.0.0.1, got %q", got)
+ }
+ if !strings.Contains(got, "127.0.0.1") {
+ t.Errorf("expected 127.0.0.1 in target, got %q", got)
+ }
+}
+
+func TestDevProxy_ListDevTargets(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ h.RegisterDevTarget("api", "http://localhost:8080")
+ h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ targets := h.ListDevTargets()
+ if len(targets) != 2 {
+ t.Fatalf("expected 2 targets, got %d", len(targets))
+ }
+ // Should be sorted by ID
+ if targets[0].Name != "api" {
+ t.Errorf("expected first target name=api, got %q", targets[0].Name)
+ }
+ if targets[1].Name != "frontend" {
+ t.Errorf("expected second target name=frontend, got %q", targets[1].Name)
+ }
+}
+
func TestDevProxy_ReverseProxy(t *testing.T) {
// Create a backend server
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -517,9 +597,13 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
- // Set target to the test backend
- if err := h.SetDevTarget(backend.URL); err != nil {
- t.Fatalf("SetDevTarget failed: %v", err)
+ // Register and activate the test backend
+ id, err := h.RegisterDevTarget("backend", backend.URL)
+ if err != nil {
+ t.Fatalf("RegisterDevTarget failed: %v", err)
+ }
+ if err := h.ActivateDevTarget(id); err != nil {
+ t.Fatalf("ActivateDevTarget failed: %v", err)
}
// Request through the proxy
@@ -535,6 +619,28 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
}
}
+func TestDevProxy_ErrorHandler(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Register a target that points to a non-existent server
+ id, _ := h.RegisterDevTarget("dead", "http://127.0.0.1:19999")
+ h.ActivateDevTarget(id)
+
+ req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadGateway {
+ t.Errorf("expected 502, got %d", w.Code)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "Cannot connect") {
+ t.Errorf("expected error page with 'Cannot connect', got %q", body)
+ }
+}
+
func TestDevProxy_503WhenNotConfigured(t *testing.T) {
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
@@ -559,7 +665,7 @@ func TestDevProxy_APIEndpoint(t *testing.T) {
initData := testInitData()
- // GET — initially inactive
+ // GET — initially inactive with empty targets
resp, err := http.Get(ts.URL + "/miniapp/api/dev?initData=" + url.QueryEscape(initData))
if err != nil {
t.Fatalf("GET failed: %v", err)
@@ -570,9 +676,19 @@ func TestDevProxy_APIEndpoint(t *testing.T) {
if result["active"] != false {
t.Errorf("expected active=false, got %v", result["active"])
}
+ targets, ok := result["targets"].([]any)
+ if !ok {
+ t.Fatalf("expected targets array, got %T", result["targets"])
+ }
+ if len(targets) != 0 {
+ t.Errorf("expected 0 targets, got %d", len(targets))
+ }
- // POST — set target
- body := strings.NewReader(`{"target":"http://localhost:4000"}`)
+ // Register a target via the manager
+ id, _ := h.RegisterDevTarget("frontend", "http://localhost:4000")
+
+ // POST — activate target
+ body := strings.NewReader(`{"action":"activate","id":"` + id + `"}`)
resp2, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(initData), "application/json", body)
if err != nil {
t.Fatalf("POST failed: %v", err)
@@ -581,11 +697,1120 @@ func TestDevProxy_APIEndpoint(t *testing.T) {
var result2 map[string]any
json.NewDecoder(resp2.Body).Decode(&result2)
if result2["active"] != true {
- t.Errorf("expected active=true after set, got %v", result2["active"])
+ t.Errorf("expected active=true after activate, got %v", result2["active"])
}
if result2["target"] != "http://localhost:4000" {
t.Errorf("expected target=http://localhost:4000, got %v", result2["target"])
}
+ if result2["active_id"] != id {
+ t.Errorf("expected active_id=%s, got %v", id, result2["active_id"])
+ }
+
+ // POST — deactivate
+ body2 := strings.NewReader(`{"action":"deactivate"}`)
+ resp3, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(initData), "application/json", body2)
+ if err != nil {
+ t.Fatalf("POST deactivate failed: %v", err)
+ }
+ defer resp3.Body.Close()
+ var result3 map[string]any
+ json.NewDecoder(resp3.Body).Decode(&result3)
+ if result3["active"] != false {
+ t.Errorf("expected active=false after deactivate, got %v", result3["active"])
+ }
+}
+
+// ── Registration edge cases ──
+
+func TestDevProxy_RegisterUniqueIDs(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("a", "http://localhost:3000")
+ id2, _ := h.RegisterDevTarget("b", "http://localhost:3001")
+ id3, _ := h.RegisterDevTarget("c", "http://localhost:3002")
+
+ if id1 == id2 || id2 == id3 || id1 == id3 {
+ t.Errorf("IDs must be unique: got %q, %q, %q", id1, id2, id3)
+ }
+}
+
+func TestDevProxy_RegisterInvalidURL(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ if _, err := h.RegisterDevTarget("bad", "://not-a-url"); err == nil {
+ t.Error("expected error for malformed URL")
+ }
+}
+
+func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ cases := []struct {
+ name string
+ target string
+ ok bool
+ }{
+ {"localhost with port", "http://localhost:3000", true},
+ {"localhost no port", "http://localhost", true},
+ {"127.0.0.1 with port", "http://127.0.0.1:8080", true},
+ {"127.0.0.1 no port", "http://127.0.0.1", true},
+ {"::1 with port", "http://[::1]:9000", true},
+ {"::1 no port", "http://[::1]", true},
+ {"external host", "http://evil.com:3000", false},
+ {"ip addr", "http://192.168.1.1:3000", false},
+ {"0.0.0.0", "http://0.0.0.0:3000", false},
+ {"https localhost", "https://localhost:3000", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := h.RegisterDevTarget(tc.name, tc.target)
+ if tc.ok && err != nil {
+ t.Errorf("expected success, got %v", err)
+ }
+ if !tc.ok && err == nil {
+ t.Error("expected error, got nil")
+ }
+ })
+ }
+}
+
+// ── Activation switching ──
+
+func TestDevProxy_SwitchActiveTarget(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ // Activate first
+ h.ActivateDevTarget(id1)
+ got := h.GetDevTarget()
+ if !strings.Contains(got, "8080") {
+ t.Errorf("expected 8080 in target, got %q", got)
+ }
+
+ // Switch to second — should replace without error
+ if err := h.ActivateDevTarget(id2); err != nil {
+ t.Fatalf("switch activate failed: %v", err)
+ }
+ got = h.GetDevTarget()
+ if !strings.Contains(got, "3000") {
+ t.Errorf("expected 3000 in target after switch, got %q", got)
+ }
+}
+
+func TestDevProxy_ReactivateSameTarget(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.ActivateDevTarget(id)
+
+ // Re-activating the same target should succeed
+ if err := h.ActivateDevTarget(id); err != nil {
+ t.Fatalf("re-activate failed: %v", err)
+ }
+ if got := h.GetDevTarget(); got == "" {
+ t.Error("target should still be active after re-activate")
+ }
+}
+
+func TestDevProxy_ActivateAfterDeactivate(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.ActivateDevTarget(id)
+ h.DeactivateDevTarget()
+
+ // Should be able to re-activate
+ if err := h.ActivateDevTarget(id); err != nil {
+ t.Fatalf("activate after deactivate failed: %v", err)
+ }
+ if got := h.GetDevTarget(); got == "" {
+ t.Error("expected non-empty target after re-activate")
+ }
+}
+
+// ── Unregister edge cases ──
+
+func TestDevProxy_UnregisterInactiveTarget(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
+ h.ActivateDevTarget(id1) // api is active
+
+ // Unregister the INACTIVE one — proxy should remain pointing at api
+ if err := h.UnregisterDevTarget(id2); err != nil {
+ t.Fatalf("unregister inactive failed: %v", err)
+ }
+ if got := h.GetDevTarget(); got == "" {
+ t.Error("proxy should still be active after unregistering inactive target")
+ }
+ if targets := h.ListDevTargets(); len(targets) != 1 {
+ t.Errorf("expected 1 target remaining, got %d", len(targets))
+ }
+}
+
+func TestDevProxy_UnregisterTwice(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.UnregisterDevTarget(id)
+
+ if err := h.UnregisterDevTarget(id); err == nil {
+ t.Error("expected error for double unregister")
+ }
+}
+
+// ── IPv4 rewrite edge cases ──
+
+func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://127.0.0.1:8080")
+ h.ActivateDevTarget(id)
+
+ got := h.GetDevTarget()
+ if !strings.Contains(got, "127.0.0.1:8080") {
+ t.Errorf("expected 127.0.0.1:8080 unchanged, got %q", got)
+ }
+}
+
+func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://[::1]:9000")
+ h.ActivateDevTarget(id)
+
+ got := h.GetDevTarget()
+ // [::1] should not be rewritten to 127.0.0.1
+ if strings.Contains(got, "127.0.0.1") {
+ t.Errorf("expected [::1] NOT to be rewritten, got %q", got)
+ }
+}
+
+func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("api", "http://localhost")
+ h.ActivateDevTarget(id)
+
+ got := h.GetDevTarget()
+ if strings.Contains(got, "localhost") {
+ t.Errorf("expected localhost to be rewritten, got %q", got)
+ }
+ if !strings.Contains(got, "127.0.0.1") {
+ t.Errorf("expected 127.0.0.1 in rewritten target, got %q", got)
+ }
+}
+
+// ── devStatus ──
+
+func TestDevProxy_DevStatusEmpty(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ status := h.devStatus()
+ if status["active"] != false {
+ t.Errorf("expected active=false, got %v", status["active"])
+ }
+ if status["active_id"] != "" {
+ t.Errorf("expected empty active_id, got %v", status["active_id"])
+ }
+ if status["target"] != "" {
+ t.Errorf("expected empty target, got %v", status["target"])
+ }
+ targets := status["targets"].([]DevTarget)
+ if len(targets) != 0 {
+ t.Errorf("expected 0 targets, got %d", len(targets))
+ }
+}
+
+func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ h.RegisterDevTarget("api", "http://localhost:8080")
+ h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ status := h.devStatus()
+ if status["active"] != false {
+ t.Errorf("expected active=false, got %v", status["active"])
+ }
+ targets := status["targets"].([]DevTarget)
+ if len(targets) != 2 {
+ t.Errorf("expected 2 targets, got %d", len(targets))
+ }
+}
+
+func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
+ h.ActivateDevTarget(id)
+
+ status := h.devStatus()
+ // devStatus should return the ORIGINAL URL (localhost), not the rewritten 127.0.0.1
+ if status["target"] != "http://localhost:3000" {
+ t.Errorf("expected original URL http://localhost:3000, got %v", status["target"])
+ }
+}
+
+func TestDevProxy_DevStatusActiveID(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ h.ActivateDevTarget(id2)
+ status := h.devStatus()
+
+ if status["active_id"] != id2 {
+ t.Errorf("expected active_id=%s, got %v", id2, status["active_id"])
+ }
+
+ // Switch
+ h.ActivateDevTarget(id1)
+ status = h.devStatus()
+ if status["active_id"] != id1 {
+ t.Errorf("expected active_id=%s after switch, got %v", id1, status["active_id"])
+ }
+
+ // Deactivate
+ h.DeactivateDevTarget()
+ status = h.devStatus()
+ if status["active_id"] != "" {
+ t.Errorf("expected empty active_id after deactivate, got %v", status["active_id"])
+ }
+}
+
+// ── ListDevTargets ──
+
+func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ targets := h.ListDevTargets()
+ if len(targets) != 0 {
+ t.Errorf("expected 0, got %d", len(targets))
+ }
+}
+
+func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ // Register in reverse order of expected sort
+ h.RegisterDevTarget("c", "http://localhost:3003")
+ h.RegisterDevTarget("b", "http://localhost:3002")
+ h.RegisterDevTarget("a", "http://localhost:3001")
+
+ targets := h.ListDevTargets()
+ for i := 1; i < len(targets); i++ {
+ if targets[i].ID < targets[i-1].ID {
+ t.Errorf("targets not sorted by ID: %s < %s", targets[i].ID, targets[i-1].ID)
+ }
+ }
+}
+
+func TestDevProxy_ListDevTargetsAfterUnregister(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("a", "http://localhost:3001")
+ h.RegisterDevTarget("b", "http://localhost:3002")
+
+ h.UnregisterDevTarget(id1)
+ targets := h.ListDevTargets()
+ if len(targets) != 1 {
+ t.Fatalf("expected 1 target, got %d", len(targets))
+ }
+ if targets[0].Name != "b" {
+ t.Errorf("expected remaining target name=b, got %q", targets[0].Name)
+ }
+}
+
+// ── Proxy path stripping ──
+
+func TestDevProxy_PathStripping(t *testing.T) {
+ var capturedPaths []string
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedPaths = append(capturedPaths, r.URL.Path+"?"+r.URL.RawQuery)
+ w.WriteHeader(200)
+ }))
+ defer backend.Close()
+
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ id, _ := h.RegisterDevTarget("back", backend.URL)
+ h.ActivateDevTarget(id)
+
+ cases := []struct {
+ path string
+ expected string
+ }{
+ {"/miniapp/dev/", "/?"},
+ {"/miniapp/dev/hello", "/hello?"},
+ {"/miniapp/dev/path/deep", "/path/deep?"},
+ {"/miniapp/dev/search?q=test", "/search?q=test"},
+ }
+ for _, tc := range cases {
+ capturedPaths = nil
+ req := httptest.NewRequest("GET", tc.path, nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if len(capturedPaths) != 1 || capturedPaths[0] != tc.expected {
+ t.Errorf("path %q: expected %q, got %v", tc.path, tc.expected, capturedPaths)
+ }
+ }
+}
+
+func TestDevProxy_RootPathStripRedirect(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ id, _ := h.RegisterDevTarget("back", "http://127.0.0.1:19999")
+ h.ActivateDevTarget(id)
+
+ // /miniapp/dev (no trailing slash) triggers Go's ServeMux redirect to /miniapp/dev/
+ req := httptest.NewRequest("GET", "/miniapp/dev", nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if w.Code != http.StatusMovedPermanently && w.Code != http.StatusTemporaryRedirect {
+ t.Errorf("expected redirect for /miniapp/dev, got %d", w.Code)
+ }
+}
+
+// ── ErrorHandler details ──
+
+func TestDevProxy_ErrorHandlerHTMLContent(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ id, _ := h.RegisterDevTarget("dead", "http://127.0.0.1:19999")
+ h.ActivateDevTarget(id)
+
+ req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+
+ // Check Content-Type
+ ct := w.Header().Get("Content-Type")
+ if !strings.Contains(ct, "text/html") {
+ t.Errorf("expected text/html content-type, got %q", ct)
+ }
+
+ body := w.Body.String()
+ // Target URL should be displayed
+ if !strings.Contains(body, "127.0.0.1:19999") {
+ t.Errorf("expected target URL in error page, got %q", body)
+ }
+ // Should be valid HTML
+ if !strings.Contains(body, "") {
+ t.Errorf("expected HTML doctype in error page")
+ }
+}
+
+// ── Notifier integration ──
+
+func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) {
+ notifier := NewStateNotifier()
+ ch := notifier.Subscribe()
+ defer notifier.Unsubscribe(ch)
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+
+ h.RegisterDevTarget("api", "http://localhost:8080")
+
+ select {
+ case <-ch:
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected notification on register")
+ }
+}
+
+func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
+ notifier := NewStateNotifier()
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+
+ ch := notifier.Subscribe()
+ defer notifier.Unsubscribe(ch)
+
+ h.UnregisterDevTarget(id)
+
+ select {
+ case <-ch:
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected notification on unregister")
+ }
+}
+
+func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
+ notifier := NewStateNotifier()
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+
+ ch := notifier.Subscribe()
+ defer notifier.Unsubscribe(ch)
+
+ h.ActivateDevTarget(id)
+
+ select {
+ case <-ch:
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected notification on activate")
+ }
+}
+
+func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
+ notifier := NewStateNotifier()
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+ id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.ActivateDevTarget(id)
+
+ ch := notifier.Subscribe()
+ defer notifier.Unsubscribe(ch)
+
+ h.DeactivateDevTarget()
+
+ select {
+ case <-ch:
+ case <-time.After(100 * time.Millisecond):
+ t.Error("expected notification on deactivate")
+ }
+}
+
+// ── API endpoint edge cases ──
+
+func TestDevAPI_InvalidJSON(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ body := strings.NewReader(`{invalid json`)
+ resp, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()),
+ "application/json", body)
+ if err != nil {
+ t.Fatalf("POST failed: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for invalid JSON, got %d", resp.StatusCode)
+ }
+}
+
+func TestDevAPI_UnknownAction(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ body := strings.NewReader(`{"action":"magic"}`)
+ resp, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()),
+ "application/json", body)
+ if err != nil {
+ t.Fatalf("POST failed: %v", err)
+ }
+ defer resp.Body.Close()
+ var result map[string]any
+ json.NewDecoder(resp.Body).Decode(&result)
+ if result["error"] == nil {
+ t.Error("expected error field for unknown action")
+ }
+}
+
+func TestDevAPI_ActivateMissingID(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ body := strings.NewReader(`{"action":"activate"}`)
+ resp, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()),
+ "application/json", body)
+ if err != nil {
+ t.Fatalf("POST failed: %v", err)
+ }
+ defer resp.Body.Close()
+ var result map[string]any
+ json.NewDecoder(resp.Body).Decode(&result)
+ if result["error"] == nil {
+ t.Error("expected error for activate without id")
+ }
+}
+
+func TestDevAPI_ActivateNonExistentID(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ body := strings.NewReader(`{"action":"activate","id":"999"}`)
+ resp, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()),
+ "application/json", body)
+ if err != nil {
+ t.Fatalf("POST failed: %v", err)
+ }
+ defer resp.Body.Close()
+ var result map[string]any
+ json.NewDecoder(resp.Body).Decode(&result)
+ if result["error"] == nil {
+ t.Error("expected error for non-existent ID")
+ }
+}
+
+func TestDevAPI_MethodNotAllowed(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ req, _ := http.NewRequest("DELETE", ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()), nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("DELETE failed: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Errorf("expected 405, got %d", resp.StatusCode)
+ }
+}
+
+func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
+ notifier := NewStateNotifier()
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ // Register two targets
+ id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ h.RegisterDevTarget("frontend", "http://localhost:3000")
+ h.ActivateDevTarget(id1)
+
+ resp, err := http.Get(ts.URL + "/miniapp/api/dev?initData=" + url.QueryEscape(testInitData()))
+ if err != nil {
+ t.Fatalf("GET failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var result map[string]any
+ json.NewDecoder(resp.Body).Decode(&result)
+
+ targets, ok := result["targets"].([]any)
+ if !ok {
+ t.Fatalf("expected targets array, got %T", result["targets"])
+ }
+ if len(targets) != 2 {
+ t.Errorf("expected 2 targets, got %d", len(targets))
+ }
+ if result["active_id"] != id1 {
+ t.Errorf("expected active_id=%s, got %v", id1, result["active_id"])
+ }
+}
+
+func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ body := strings.NewReader(`{"action":"deactivate"}`)
+ resp, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(testInitData()),
+ "application/json", body)
+ if err != nil {
+ t.Fatalf("POST failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var result map[string]any
+ json.NewDecoder(resp.Body).Decode(&result)
+ // Should succeed even if already inactive
+ if result["error"] != nil {
+ t.Errorf("expected no error for deactivate when inactive, got %v", result["error"])
+ }
+ if result["active"] != false {
+ t.Errorf("expected active=false, got %v", result["active"])
+ }
+}
+
+// ── Concurrency ──
+
+func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ const n = 50
+ done := make(chan struct{}, n)
+
+ for i := 0; i < n; i++ {
+ go func(i int) {
+ defer func() { done <- struct{}{} }()
+ name := fmt.Sprintf("target-%d", i)
+ target := fmt.Sprintf("http://localhost:%d", 3000+i)
+ id, err := h.RegisterDevTarget(name, target)
+ if err != nil {
+ return
+ }
+ h.ActivateDevTarget(id)
+ h.GetDevTarget()
+ h.ListDevTargets()
+ }(i)
+ }
+
+ for i := 0; i < n; i++ {
+ <-done
+ }
+
+ targets := h.ListDevTargets()
+ if len(targets) != n {
+ t.Errorf("expected %d targets after concurrent registration, got %d", n, len(targets))
+ }
+}
+
+func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) {
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
+
+ id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
+ id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ const n = 100
+ done := make(chan struct{}, n)
+
+ for i := 0; i < n; i++ {
+ go func(i int) {
+ defer func() { done <- struct{}{} }()
+ if i%3 == 0 {
+ h.ActivateDevTarget(id1)
+ } else if i%3 == 1 {
+ h.ActivateDevTarget(id2)
+ } else {
+ h.DeactivateDevTarget()
+ }
+ }(i)
+ }
+
+ for i := 0; i < n; i++ {
+ <-done
+ }
+
+ // Should not panic — result is indeterminate but state should be consistent
+ h.GetDevTarget()
+ h.ListDevTargets()
+}
+
+// ── SSE dev event with targets ──
+
+func TestSSE_DevEventContainsTargets(t *testing.T) {
+ notifier := NewStateNotifier()
+ h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ h.RegisterDevTarget("frontend", "http://localhost:3000")
+
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/miniapp/api/events?initData=" + url.QueryEscape(testInitData()))
+ if err != nil {
+ t.Fatalf("GET failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ scanner := bufio.NewScanner(resp.Body)
+ deadline := time.After(2 * time.Second)
+
+ var devData string
+ for {
+ scanDone := make(chan bool, 1)
+ go func() { scanDone <- scanner.Scan() }()
+ select {
+ case ok := <-scanDone:
+ if !ok {
+ t.Fatal("scanner ended early")
+ }
+ case <-deadline:
+ t.Fatal("timed out waiting for dev event")
+ }
+ line := scanner.Text()
+ if strings.HasPrefix(line, "event: dev") {
+ // Read the next data line
+ scanDone2 := make(chan bool, 1)
+ go func() { scanDone2 <- scanner.Scan() }()
+ select {
+ case <-scanDone2:
+ case <-deadline:
+ t.Fatal("timed out waiting for data line")
+ }
+ devData = strings.TrimPrefix(scanner.Text(), "data: ")
+ break
+ }
+ if devData != "" {
+ break
+ }
+ }
+
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(devData), &parsed); err != nil {
+ t.Fatalf("failed to parse dev event data: %v", err)
+ }
+ targets, ok := parsed["targets"].([]any)
+ if !ok {
+ t.Fatalf("expected targets array in SSE dev event, got %T", parsed["targets"])
+ }
+ if len(targets) != 1 {
+ t.Errorf("expected 1 target in SSE dev event, got %d", len(targets))
+ }
+}
+
+// ── validateLocalhostURL ──
+
+func TestValidateLocalhostURL(t *testing.T) {
+ cases := []struct {
+ target string
+ ok bool
+ }{
+ {"http://localhost:3000", true},
+ {"http://127.0.0.1:8080", true},
+ {"http://[::1]:9000", true},
+ {"http://example.com", false},
+ {"http://10.0.0.1:3000", false},
+ {"://bad", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.target, func(t *testing.T) {
+ _, err := validateLocalhostURL(tc.target)
+ if tc.ok && err != nil {
+ t.Errorf("expected ok, got %v", err)
+ }
+ if !tc.ok && err == nil {
+ t.Error("expected error, got nil")
+ }
+ })
+ }
+}
+
+// ── escapeHTMLString ──
+
+func TestEscapeHTMLString(t *testing.T) {
+ cases := []struct {
+ in, out string
+ }{
+ {"hello", "hello"},
+ {"