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"}, + {"