2269 lines
67 KiB
Go
2269 lines
67 KiB
Go
package miniapp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
gitpkg "github.com/sipeed/picoclaw/pkg/git"
|
|
"github.com/sipeed/picoclaw/pkg/orch"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
"github.com/sipeed/picoclaw/pkg/stats"
|
|
)
|
|
|
|
// buildInitData constructs a valid initData string from params and a bot token.
|
|
func buildInitData(params map[string]string, botToken string) string {
|
|
// Build data-check-string
|
|
pairs := make([]string, 0, len(params))
|
|
for k, v := range params {
|
|
pairs = append(pairs, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
sort.Strings(pairs)
|
|
dataCheckString := strings.Join(pairs, "\n")
|
|
|
|
// Compute secret key
|
|
secretKeyMac := hmac.New(sha256.New, []byte("WebAppData"))
|
|
secretKeyMac.Write([]byte(botToken))
|
|
secretKey := secretKeyMac.Sum(nil)
|
|
|
|
// Compute hash
|
|
hashMac := hmac.New(sha256.New, secretKey)
|
|
hashMac.Write([]byte(dataCheckString))
|
|
hash := hex.EncodeToString(hashMac.Sum(nil))
|
|
|
|
// Build query string
|
|
values := url.Values{}
|
|
for k, v := range params {
|
|
values.Set(k, v)
|
|
}
|
|
values.Set("hash", hash)
|
|
return values.Encode()
|
|
}
|
|
|
|
func TestValidateInitData(t *testing.T) {
|
|
botToken := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
|
|
|
t.Run("valid initData", func(t *testing.T) {
|
|
params := map[string]string{
|
|
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
|
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
|
"auth_date": freshAuthDate(),
|
|
}
|
|
initData := buildInitData(params, botToken)
|
|
if !ValidateInitData(initData, botToken) {
|
|
t.Error("ValidateInitData() returned false for valid data")
|
|
}
|
|
})
|
|
|
|
t.Run("tampered data", func(t *testing.T) {
|
|
params := map[string]string{
|
|
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
|
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
|
"auth_date": freshAuthDate(),
|
|
}
|
|
initData := buildInitData(params, botToken)
|
|
// Tamper with the data
|
|
initData = strings.Replace(initData, "Vlad", "Evil", 1)
|
|
if ValidateInitData(initData, botToken) {
|
|
t.Error("ValidateInitData() returned true for tampered data")
|
|
}
|
|
})
|
|
|
|
t.Run("wrong bot token", func(t *testing.T) {
|
|
params := map[string]string{
|
|
"auth_date": freshAuthDate(),
|
|
}
|
|
initData := buildInitData(params, botToken)
|
|
if ValidateInitData(initData, "wrong-token") {
|
|
t.Error("ValidateInitData() returned true for wrong bot token")
|
|
}
|
|
})
|
|
|
|
t.Run("expired auth_date", func(t *testing.T) {
|
|
params := map[string]string{
|
|
"auth_date": "1234567890",
|
|
}
|
|
initData := buildInitData(params, botToken)
|
|
if ValidateInitData(initData, botToken) {
|
|
t.Error("ValidateInitData() returned true for expired auth_date")
|
|
}
|
|
})
|
|
|
|
t.Run("missing hash", func(t *testing.T) {
|
|
now := freshAuthDate()
|
|
if ValidateInitData("auth_date="+now, botToken) {
|
|
t.Error("ValidateInitData() returned true for missing hash")
|
|
}
|
|
})
|
|
|
|
t.Run("empty initData", func(t *testing.T) {
|
|
if ValidateInitData("", botToken) {
|
|
t.Error("ValidateInitData() returned true for empty initData")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid query string", func(t *testing.T) {
|
|
if ValidateInitData("%%%invalid", botToken) {
|
|
t.Error("ValidateInitData() returned true for invalid query string")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── StateNotifier tests ──
|
|
|
|
func TestStateNotifier_FanOut(t *testing.T) {
|
|
n := NewStateNotifier()
|
|
ch1 := n.Subscribe()
|
|
ch2 := n.Subscribe()
|
|
defer n.Unsubscribe(ch1)
|
|
defer n.Unsubscribe(ch2)
|
|
|
|
n.Notify()
|
|
|
|
select {
|
|
case <-ch1:
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("ch1 did not receive notification")
|
|
}
|
|
select {
|
|
case <-ch2:
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("ch2 did not receive notification")
|
|
}
|
|
}
|
|
|
|
func TestStateNotifier_Coalesce(t *testing.T) {
|
|
n := NewStateNotifier()
|
|
ch := n.Subscribe()
|
|
defer n.Unsubscribe(ch)
|
|
|
|
// Multiple rapid notifications should coalesce into one
|
|
n.Notify()
|
|
n.Notify()
|
|
n.Notify()
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("ch did not receive notification")
|
|
}
|
|
|
|
// Channel should be empty now (coalesced)
|
|
select {
|
|
case <-ch:
|
|
t.Error("expected no second notification (should coalesce)")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// ── SSE endpoint tests ──
|
|
|
|
type mockDataProvider struct{}
|
|
|
|
func (m *mockDataProvider) ListSkills() []skills.SkillInfo {
|
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
|
}
|
|
|
|
func (m *mockDataProvider) GetPlanInfo() PlanInfo {
|
|
return PlanInfo{HasPlan: false, Status: "none"}
|
|
}
|
|
|
|
func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
|
return []SessionInfo{}
|
|
}
|
|
|
|
func (m *mockDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
|
|
|
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
|
|
return GitInfo{Name: name}
|
|
}
|
|
|
|
func (m *mockDataProvider) GetContextInfo() ContextInfo {
|
|
return ContextInfo{Workspace: "/mock/workspace"}
|
|
}
|
|
|
|
func (m *mockDataProvider) GetSystemPrompt() string {
|
|
return "mock system prompt"
|
|
}
|
|
|
|
type mockSender struct{}
|
|
|
|
func (m *mockSender) SendCommand(senderID, chatID, command string) {}
|
|
|
|
const testBotToken = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
|
|
|
func freshAuthDate() string {
|
|
return strconv.FormatInt(time.Now().Unix(), 10)
|
|
}
|
|
|
|
func testInitData() string {
|
|
return buildInitData(map[string]string{
|
|
"user": `{"id":279058397,"first_name":"Test"}`,
|
|
"auth_date": freshAuthDate(),
|
|
}, testBotToken)
|
|
}
|
|
|
|
func TestMiniApp_IndexTemplateInjectsOrchFlag(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "window.ORCH_ENABLED = false;") {
|
|
t.Fatalf("expected ORCH_ENABLED=false in rendered template")
|
|
}
|
|
|
|
h.SetOrchBroadcaster(orch.NewBroadcaster())
|
|
w2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(w2, httptest.NewRequest("GET", "/miniapp", nil))
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with broadcaster, got %d", w2.Code)
|
|
}
|
|
if !strings.Contains(w2.Body.String(), "window.ORCH_ENABLED = true;") {
|
|
t.Fatalf("expected ORCH_ENABLED=true in rendered template")
|
|
}
|
|
}
|
|
|
|
func TestMiniApp_StaticFileServerServesAssets(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{path: "/miniapp/map-preview.html", want: "Orchestration Room"},
|
|
{path: "/miniapp/dist/map.js", want: "MAP_POSITIONS"},
|
|
{path: "/miniapp/dist/app.js", want: "renderLogs"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for %s, got %d", tc.path, w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), tc.want) {
|
|
t.Fatalf("expected %q in %s", tc.want, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestSSE_AuthRequired(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/api/events", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSSE_Headers(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
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()
|
|
|
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Errorf("expected Content-Type text/event-stream, got %q", ct)
|
|
}
|
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
|
t.Errorf("expected Cache-Control no-cache, got %q", cc)
|
|
}
|
|
}
|
|
|
|
func TestSSE_InitialEvents(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
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)
|
|
events := make(map[string]bool)
|
|
deadline := time.After(2 * time.Second)
|
|
|
|
for len(events) < 4 {
|
|
done := make(chan bool, 1)
|
|
go func() {
|
|
done <- scanner.Scan()
|
|
}()
|
|
select {
|
|
case ok := <-done:
|
|
if !ok {
|
|
t.Fatalf("scanner ended early: %v", scanner.Err())
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for events, got: %v", events)
|
|
}
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "event: ") {
|
|
events[strings.TrimPrefix(line, "event: ")] = true
|
|
}
|
|
}
|
|
|
|
for _, name := range []string{"plan", "session", "skills", "dev"} {
|
|
if !events[name] {
|
|
t.Errorf("missing initial event %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateNotifier_UnsubscribeStopsDelivery(t *testing.T) {
|
|
n := NewStateNotifier()
|
|
ch := n.Subscribe()
|
|
|
|
n.Unsubscribe(ch)
|
|
n.Notify()
|
|
|
|
select {
|
|
case <-ch:
|
|
t.Error("received notification after Unsubscribe")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestStateNotifier_SubscribeCycleNoLeak(t *testing.T) {
|
|
n := NewStateNotifier()
|
|
|
|
for i := 0; i < 100; i++ {
|
|
ch := n.Subscribe()
|
|
n.Unsubscribe(ch)
|
|
}
|
|
|
|
n.mu.Lock()
|
|
count := len(n.subs)
|
|
n.mu.Unlock()
|
|
|
|
if count != 0 {
|
|
t.Errorf("expected 0 subscribers after cycle, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
// Baseline goroutine count
|
|
runtime.GC()
|
|
time.Sleep(50 * time.Millisecond)
|
|
baseGoroutines := runtime.NumGoroutine()
|
|
|
|
// Open and close several SSE connections
|
|
for i := 0; i < 5; i++ {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
req, _ := http.NewRequestWithContext(ctx, "GET",
|
|
ts.URL+"/miniapp/api/events?initData="+url.QueryEscape(testInitData()), nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request %d failed: %v", i, err)
|
|
}
|
|
// Read at least one event line to confirm the handler is running
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Scan()
|
|
// Disconnect
|
|
cancel()
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// Wait for goroutines to wind down
|
|
time.Sleep(200 * time.Millisecond)
|
|
runtime.GC()
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
finalGoroutines := runtime.NumGoroutine()
|
|
leaked := finalGoroutines - baseGoroutines
|
|
if leaked > 2 { // small tolerance for runtime jitter
|
|
t.Errorf("possible goroutine leak: baseline=%d, final=%d, leaked=%d",
|
|
baseGoroutines, finalGoroutines, leaked)
|
|
}
|
|
|
|
// Verify all subscribers were cleaned up
|
|
notifier.mu.Lock()
|
|
subCount := len(notifier.subs)
|
|
notifier.mu.Unlock()
|
|
if subCount != 0 {
|
|
t.Errorf("expected 0 subscribers after disconnect, got %d", subCount)
|
|
}
|
|
}
|
|
|
|
func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
provider := &mutatingDataProvider{}
|
|
h := NewHandler(provider, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
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)
|
|
// Drain initial 6 events (plan, session, skills, dev, context, prompt)
|
|
drainEvents(t, scanner, 6, 2*time.Second)
|
|
|
|
// Mutate state and notify — diff dedup should detect the change and send a new event
|
|
provider.mutated.Store(true)
|
|
notifier.Notify()
|
|
|
|
events := drainEvents(t, scanner, 1, 2*time.Second)
|
|
if !events["plan"] {
|
|
t.Errorf("expected plan event after mutation, got: %v", events)
|
|
}
|
|
}
|
|
|
|
func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
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)
|
|
// Drain initial events (plan, session, skills, dev, context, prompt)
|
|
drainEvents(t, scanner, 6, 2*time.Second)
|
|
|
|
// Notify with unchanged data — should produce zero new event lines
|
|
notifier.Notify()
|
|
|
|
// Give the handler time to process
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Try to read — nothing should arrive since data hasn't changed
|
|
gotExtra := make(chan string, 1)
|
|
go func() {
|
|
if scanner.Scan() {
|
|
gotExtra <- scanner.Text()
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case line := <-gotExtra:
|
|
// Only fail if it's an actual event (not an empty keepalive)
|
|
if strings.HasPrefix(line, "event:") {
|
|
t.Errorf("received duplicate event despite unchanged data: %s", line)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
// Expected: no duplicate sent
|
|
}
|
|
}
|
|
|
|
// ── helpers ──
|
|
|
|
// mutatingDataProvider returns different PlanInfo after mutate is set.
|
|
type mutatingDataProvider struct {
|
|
mutated atomic.Bool
|
|
}
|
|
|
|
func (m *mutatingDataProvider) ListSkills() []skills.SkillInfo {
|
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
|
}
|
|
|
|
func (m *mutatingDataProvider) GetPlanInfo() PlanInfo {
|
|
if m.mutated.Load() {
|
|
return PlanInfo{HasPlan: true, Status: "executing", CurrentPhase: 1, TotalPhases: 2}
|
|
}
|
|
return PlanInfo{HasPlan: false, Status: "none"}
|
|
}
|
|
func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
|
|
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
|
return []SessionInfo{}
|
|
}
|
|
|
|
func (m *mutatingDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
|
|
|
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
|
return nil
|
|
}
|
|
|
|
func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
|
return GitInfo{Name: name}
|
|
}
|
|
|
|
func (m *mutatingDataProvider) GetContextInfo() ContextInfo {
|
|
return ContextInfo{Workspace: "/mock/workspace"}
|
|
}
|
|
|
|
func (m *mutatingDataProvider) GetSystemPrompt() string {
|
|
return "mock system prompt"
|
|
}
|
|
|
|
// ── Dev proxy tests ──
|
|
|
|
func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
|
|
// Initially empty
|
|
if got := h.GetDevTarget(); got != "" {
|
|
t.Errorf("expected empty target, got %q", got)
|
|
}
|
|
|
|
// Register a target
|
|
id, err := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
|
if err != nil {
|
|
t.Fatalf("RegisterDevTarget failed: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Fatal("expected non-empty id")
|
|
}
|
|
|
|
// 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 deactivation, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_UnregisterActive(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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(), 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.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.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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
fmt.Fprintf(w, "path=%s", r.URL.Path)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// 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
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/hello", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
if got := w.Body.String(); got != "path=/hello" {
|
|
t.Errorf("expected path=/hello, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_ErrorHandler(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
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(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_APIEndpoint(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
initData := testInitData()
|
|
|
|
// 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)
|
|
}
|
|
defer resp.Body.Close()
|
|
var result map[string]any
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
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))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
defer resp2.Body.Close()
|
|
var result2 map[string]any
|
|
json.NewDecoder(resp2.Body).Decode(&result2)
|
|
if result2["active"] != true {
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
|
|
// 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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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, "<!DOCTYPE html>") {
|
|
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, nil, "")
|
|
|
|
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, nil, "")
|
|
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, nil, "")
|
|
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, nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
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, nil, "")
|
|
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(), nil, "")
|
|
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(), nil, "")
|
|
|
|
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(), nil, "")
|
|
|
|
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, nil, "")
|
|
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"},
|
|
{"<script>", "<script>"},
|
|
{`"quoted"`, ""quoted""},
|
|
{"a & b", "a & b"},
|
|
{"<>&\"", "<>&""},
|
|
}
|
|
for _, tc := range cases {
|
|
got := escapeHTMLString(tc.in)
|
|
if got != tc.out {
|
|
t.Errorf("escapeHTMLString(%q) = %q, want %q", tc.in, got, tc.out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Handler implements DevTargetManager ──
|
|
|
|
func TestHandler_ImplementsDevTargetManager(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
var _ DevTargetManager = h // compile-time check
|
|
}
|
|
|
|
// ── Nil notifier safety ──
|
|
|
|
func TestDevProxy_NilNotifier(t *testing.T) {
|
|
// Handler should not panic when notifier is nil
|
|
h := &Handler{
|
|
provider: &mockDataProvider{},
|
|
sender: &mockSender{},
|
|
botToken: testBotToken,
|
|
devTargets: make(map[string]*DevTarget),
|
|
}
|
|
|
|
id, err := h.RegisterDevTarget("api", "http://localhost:8080")
|
|
if err != nil {
|
|
t.Fatalf("RegisterDevTarget with nil notifier: %v", err)
|
|
}
|
|
if err := h.ActivateDevTarget(id); err != nil {
|
|
t.Fatalf("ActivateDevTarget with nil notifier: %v", err)
|
|
}
|
|
h.DeactivateDevTarget()
|
|
h.UnregisterDevTarget(id)
|
|
}
|
|
|
|
// ── E2E full lifecycle ──
|
|
|
|
func TestDevProxy_FullLifecycle(t *testing.T) {
|
|
// Complete user journey: Register → Activate → Proxy works → Switch target →
|
|
// Deactivate → Re-activate → Unregister active → Proxy gone
|
|
backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "backend1")
|
|
}))
|
|
defer backend1.Close()
|
|
backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "backend2")
|
|
}))
|
|
defer backend2.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// Step 1: Register two targets
|
|
id1, err := h.RegisterDevTarget("svc1", backend1.URL)
|
|
if err != nil {
|
|
t.Fatalf("register svc1: %v", err)
|
|
}
|
|
id2, err := h.RegisterDevTarget("svc2", backend2.URL)
|
|
if err != nil {
|
|
t.Fatalf("register svc2: %v", err)
|
|
}
|
|
if targets := h.ListDevTargets(); len(targets) != 2 {
|
|
t.Fatalf("expected 2 targets, got %d", len(targets))
|
|
}
|
|
|
|
// Step 2: Proxy should return 503 (nothing active yet)
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 before activation, got %d", w.Code)
|
|
}
|
|
|
|
// Step 3: Activate first target, proxy should reach backend1
|
|
if err := h.ActivateDevTarget(id1); err != nil {
|
|
t.Fatalf("activate id1: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp/dev/", nil))
|
|
if w.Code != 200 || w.Body.String() != "backend1" {
|
|
t.Fatalf("expected backend1, got %d %q", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Step 4: Switch to second target
|
|
if err := h.ActivateDevTarget(id2); err != nil {
|
|
t.Fatalf("activate id2: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp/dev/", nil))
|
|
if w.Code != 200 || w.Body.String() != "backend2" {
|
|
t.Fatalf("expected backend2, got %d %q", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Step 5: Deactivate
|
|
h.DeactivateDevTarget()
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp/dev/", nil))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 after deactivate, got %d", w.Code)
|
|
}
|
|
|
|
// Step 6: Re-activate second target (should work)
|
|
if err := h.ActivateDevTarget(id2); err != nil {
|
|
t.Fatalf("re-activate id2: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp/dev/", nil))
|
|
if w.Body.String() != "backend2" {
|
|
t.Fatalf("expected backend2 after re-activate, got %q", w.Body.String())
|
|
}
|
|
|
|
// Step 7: Unregister the ACTIVE target — proxy should go down
|
|
if err := h.UnregisterDevTarget(id2); err != nil {
|
|
t.Fatalf("unregister id2: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp/dev/", nil))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 after unregister active, got %d", w.Code)
|
|
}
|
|
|
|
// Step 8: Only svc1 remains
|
|
targets := h.ListDevTargets()
|
|
if len(targets) != 1 || targets[0].ID != id1 {
|
|
t.Fatalf("expected only svc1 remaining, got %+v", targets)
|
|
}
|
|
|
|
// Step 9: Unregister last
|
|
h.UnregisterDevTarget(id1)
|
|
if targets := h.ListDevTargets(); len(targets) != 0 {
|
|
t.Fatalf("expected 0 targets, got %d", len(targets))
|
|
}
|
|
}
|
|
|
|
// ── SSE dev event on activate/deactivate ──
|
|
|
|
func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
|
notifier := NewStateNotifier()
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
id, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
|
|
|
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)
|
|
// Drain initial 6 events (plan, session, skills, dev, context, prompt)
|
|
drainEvents(t, scanner, 6, 2*time.Second)
|
|
|
|
// Activate — should trigger a dev event with active=true
|
|
h.ActivateDevTarget(id)
|
|
events := drainEvents(t, scanner, 1, 2*time.Second)
|
|
if !events["dev"] {
|
|
t.Errorf("expected dev event after activate, got: %v", events)
|
|
}
|
|
|
|
// Deactivate — should trigger another dev event
|
|
h.DeactivateDevTarget()
|
|
events = drainEvents(t, scanner, 1, 2*time.Second)
|
|
if !events["dev"] {
|
|
t.Errorf("expected dev event after deactivate, got: %v", events)
|
|
}
|
|
}
|
|
|
|
// ── API auth on /miniapp/api/dev ──
|
|
|
|
func TestDevAPI_AuthRequired(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// GET without initData
|
|
req := httptest.NewRequest("GET", "/miniapp/api/dev", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("GET without auth: expected 401, got %d", w.Code)
|
|
}
|
|
|
|
// POST without initData
|
|
req = httptest.NewRequest("POST", "/miniapp/api/dev", strings.NewReader(`{"action":"deactivate"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w = httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("POST without auth: expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── Proxy endpoint has no auth (by design, for iframe) ──
|
|
|
|
func TestDevProxy_NoAuthRequired(t *testing.T) {
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "ok")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
id, _ := h.RegisterDevTarget("back", backend.URL)
|
|
h.ActivateDevTarget(id)
|
|
|
|
// No initData — should still work (no auth on proxy endpoint)
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Errorf("expected 200 without auth on proxy, got %d", w.Code)
|
|
}
|
|
if w.Body.String() != "ok" {
|
|
t.Errorf("expected 'ok', got %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// ── API response JSON structure ──
|
|
|
|
func TestDevAPI_ResponseTargetFields(t *testing.T) {
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
id, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
|
h.ActivateDevTarget(id)
|
|
|
|
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 struct {
|
|
Active bool `json:"active"`
|
|
ActiveID string `json:"active_id"`
|
|
Target string `json:"target"`
|
|
Targets []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Target string `json:"target"`
|
|
} `json:"targets"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
|
|
if !result.Active {
|
|
t.Error("expected active=true")
|
|
}
|
|
if result.ActiveID != id {
|
|
t.Errorf("expected active_id=%s, got %s", id, result.ActiveID)
|
|
}
|
|
if result.Target != "http://localhost:3000" {
|
|
t.Errorf("expected target=http://localhost:3000, got %s", result.Target)
|
|
}
|
|
if len(result.Targets) != 1 {
|
|
t.Fatalf("expected 1 target, got %d", len(result.Targets))
|
|
}
|
|
tgt := result.Targets[0]
|
|
if tgt.ID != id {
|
|
t.Errorf("target.id: expected %s, got %s", id, tgt.ID)
|
|
}
|
|
if tgt.Name != "frontend" {
|
|
t.Errorf("target.name: expected frontend, got %s", tgt.Name)
|
|
}
|
|
if tgt.Target != "http://localhost:3000" {
|
|
t.Errorf("target.target: expected http://localhost:3000, got %s", tgt.Target)
|
|
}
|
|
}
|
|
|
|
// ── Proxy forwards Host header correctly ──
|
|
|
|
func TestDevProxy_HostHeaderForwarded(t *testing.T) {
|
|
var capturedHost string
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedHost = r.Host
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
id, _ := h.RegisterDevTarget("back", backend.URL)
|
|
h.ActivateDevTarget(id)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
// ReverseProxy sets Host to backend's host by default
|
|
if capturedHost == "" {
|
|
t.Error("expected Host header to be forwarded to backend")
|
|
}
|
|
}
|
|
|
|
// ── injectDevProxyScript tests ──
|
|
|
|
func TestInjectDevProxyScript_HeadTag(t *testing.T) {
|
|
html := []byte(`<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hi</h1></body></html>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
if !strings.Contains(s, `<script data-dev-proxy>`) {
|
|
t.Error("expected script to be injected")
|
|
}
|
|
// Script should appear before </head>
|
|
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
|
|
headIdx := strings.Index(s, `</head>`)
|
|
if scriptIdx >= headIdx {
|
|
t.Errorf("script should be before </head>: scriptIdx=%d, headIdx=%d", scriptIdx, headIdx)
|
|
}
|
|
// Original content should be preserved
|
|
if !strings.Contains(s, `<title>Test</title>`) {
|
|
t.Error("original <title> content should be preserved")
|
|
}
|
|
if !strings.Contains(s, `<h1>Hi</h1>`) {
|
|
t.Error("original <body> content should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestInjectDevProxyScript_NoHead(t *testing.T) {
|
|
html := []byte(`<!DOCTYPE html><html><body><p>Hello</p></body></html>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
if !strings.Contains(s, `<script data-dev-proxy>`) {
|
|
t.Error("expected script to be injected")
|
|
}
|
|
// Script should appear right after <body>
|
|
bodyIdx := strings.Index(s, `<body>`)
|
|
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
|
|
if scriptIdx != bodyIdx+len(`<body>`) {
|
|
t.Errorf("script should be immediately after <body>: bodyIdx=%d, scriptIdx=%d", bodyIdx, scriptIdx)
|
|
}
|
|
}
|
|
|
|
func TestInjectDevProxyScript_Minimal(t *testing.T) {
|
|
html := []byte(`<div>just a div</div>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
if !strings.Contains(s, `<script data-dev-proxy>`) {
|
|
t.Error("expected script to be injected")
|
|
}
|
|
// Script should be at the beginning
|
|
if !strings.HasPrefix(s, `<script data-dev-proxy>`) {
|
|
t.Error("script should be prepended when no head/body tags exist")
|
|
}
|
|
if !strings.Contains(s, `<div>just a div</div>`) {
|
|
t.Error("original content should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestInjectDevProxyScript_BodyWithAttributes(t *testing.T) {
|
|
html := []byte(`<html><body class="dark" id="main"><p>Content</p></body></html>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
// Script should be after the full <body ...> opening tag
|
|
bodyCloseIdx := strings.Index(s, `id="main">`) + len(`id="main">`)
|
|
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
|
|
if scriptIdx != bodyCloseIdx {
|
|
t.Errorf("script should follow <body> closing '>': bodyClose=%d, scriptIdx=%d", bodyCloseIdx, scriptIdx)
|
|
}
|
|
}
|
|
|
|
func TestInjectDevProxyScript_CaseInsensitive(t *testing.T) {
|
|
html := []byte(`<HTML><HEAD><TITLE>Upper</TITLE></HEAD><BODY>test</BODY></HTML>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
if !strings.Contains(s, `<script data-dev-proxy>`) {
|
|
t.Error("expected script injection with uppercase tags")
|
|
}
|
|
// Should still inject before </HEAD>
|
|
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
|
|
headIdx := strings.Index(s, `</HEAD>`)
|
|
if scriptIdx >= headIdx {
|
|
t.Errorf("script should be before </HEAD>")
|
|
}
|
|
}
|
|
|
|
func TestInjectDevProxyScript_ScriptContent(t *testing.T) {
|
|
html := []byte(`<html><head></head><body></body></html>`)
|
|
result := injectDevProxyScript(html)
|
|
s := string(result)
|
|
// Verify the script rewrites fetch and XHR
|
|
if !strings.Contains(s, `/miniapp/dev`) {
|
|
t.Error("script should contain /miniapp/dev prefix")
|
|
}
|
|
if !strings.Contains(s, `window.fetch`) {
|
|
t.Error("script should patch window.fetch")
|
|
}
|
|
if !strings.Contains(s, `XMLHttpRequest.prototype.open`) {
|
|
t.Error("script should patch XMLHttpRequest.prototype.open")
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_ResponseRewriting(t *testing.T) {
|
|
// Backend returns HTML with text/html content type
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, `<!DOCTYPE html><html><head><title>App</title></head><body><h1>Hello</h1></body></html>`)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
id, _ := h.RegisterDevTarget("app", backend.URL)
|
|
h.ActivateDevTarget(id)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `<script data-dev-proxy>`) {
|
|
t.Error("expected dev proxy script to be injected into HTML response")
|
|
}
|
|
if !strings.Contains(body, `<title>App</title>`) {
|
|
t.Error("original HTML content should be preserved")
|
|
}
|
|
// Script should be before </head>
|
|
scriptIdx := strings.Index(body, `<script data-dev-proxy>`)
|
|
headIdx := strings.Index(body, `</head>`)
|
|
if scriptIdx >= headIdx {
|
|
t.Errorf("script should be injected before </head>")
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_ResponseRewriting_NonHTML(t *testing.T) {
|
|
// Backend returns JSON — should NOT be modified
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"status":"ok"}`)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
id, _ := h.RegisterDevTarget("api", backend.URL)
|
|
h.ActivateDevTarget(id)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/api/status", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, `<script`) {
|
|
t.Error("script should NOT be injected into non-HTML response")
|
|
}
|
|
if body != `{"status":"ok"}` {
|
|
t.Errorf("JSON response should be unchanged, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestDevProxy_ResponseRewriting_ContentLength(t *testing.T) {
|
|
originalHTML := `<!DOCTYPE html><html><head></head><body>Test</body></html>`
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprint(w, originalHTML)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
id, _ := h.RegisterDevTarget("app", backend.URL)
|
|
h.ActivateDevTarget(id)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
body := w.Body.String()
|
|
clHeader := w.Header().Get("Content-Length")
|
|
if clHeader != "" {
|
|
cl, _ := strconv.Atoi(clHeader)
|
|
if cl != len(body) {
|
|
t.Errorf("Content-Length mismatch: header=%d, actual=%d", cl, len(body))
|
|
}
|
|
}
|
|
}
|
|
|
|
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
|
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
|
t.Helper()
|
|
events := make(map[string]bool)
|
|
deadline := time.After(timeout)
|
|
for len(events) < want {
|
|
done := make(chan bool, 1)
|
|
go func() { done <- scanner.Scan() }()
|
|
select {
|
|
case ok := <-done:
|
|
if !ok {
|
|
return events
|
|
}
|
|
case <-deadline:
|
|
return events
|
|
}
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "event: ") {
|
|
events[strings.TrimPrefix(line, "event: ")] = true
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
func initMiniAppGitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
repo := t.TempDir()
|
|
|
|
runGit := func(dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %s: %s: %v", strings.Join(args, " "), strings.TrimSpace(string(out)), err)
|
|
}
|
|
}
|
|
|
|
runGit(repo, "init")
|
|
runGit(repo, "config", "user.email", "test@test.com")
|
|
runGit(repo, "config", "user.name", "Test")
|
|
|
|
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Test\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
runGit(repo, "add", "-A")
|
|
runGit(repo, "commit", "-m", "initial")
|
|
|
|
return repo
|
|
}
|
|
|
|
func TestAPIWorktrees_List(t *testing.T) {
|
|
repo := initMiniAppGitRepo(t)
|
|
wtPath := filepath.Join(repo, ".worktrees", "api-list")
|
|
if _, err := gitpkg.CreateWorktree(repo, wtPath, "plan/api-list"); err != nil {
|
|
t.Fatalf("CreateWorktree: %v", err)
|
|
}
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest("GET", "/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()), nil)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var items []struct {
|
|
Name string `json:"name"`
|
|
Branch string `json:"branch"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
|
|
t.Fatalf("json.Unmarshal: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 worktree, got %d", len(items))
|
|
}
|
|
if items[0].Name != "api-list" {
|
|
t.Errorf("Name = %q, want %q", items[0].Name, "api-list")
|
|
}
|
|
if items[0].Branch != "plan/api-list" {
|
|
t.Errorf("Branch = %q, want %q", items[0].Branch, "plan/api-list")
|
|
}
|
|
}
|
|
|
|
func TestAPIWorktrees_MergeAndDispose(t *testing.T) {
|
|
repo := initMiniAppGitRepo(t)
|
|
|
|
mergePath := filepath.Join(repo, ".worktrees", "api-merge")
|
|
if _, err := gitpkg.CreateWorktree(repo, mergePath, "plan/api-merge"); err != nil {
|
|
t.Fatalf("CreateWorktree merge: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mergePath, "merged.txt"), []byte("from worktree"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile merge: %v", err)
|
|
}
|
|
if err := gitpkg.AutoCommit(mergePath, "add merged.txt"); err != nil {
|
|
t.Fatalf("AutoCommit merge: %v", err)
|
|
}
|
|
|
|
disposePath := filepath.Join(repo, ".worktrees", "api-dispose")
|
|
if _, err := gitpkg.CreateWorktree(repo, disposePath, "plan/api-dispose"); err != nil {
|
|
t.Fatalf("CreateWorktree dispose: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(disposePath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile dispose: %v", err)
|
|
}
|
|
|
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
mergeReq := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
|
strings.NewReader(`{"action":"merge","name":"api-merge"}`),
|
|
)
|
|
mergeReq.Header.Set("Content-Type", "application/json")
|
|
mergeW := httptest.NewRecorder()
|
|
mux.ServeHTTP(mergeW, mergeReq)
|
|
if mergeW.Code != http.StatusOK {
|
|
t.Fatalf("merge expected 200, got %d: %s", mergeW.Code, mergeW.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(repo, "merged.txt")); os.IsNotExist(err) {
|
|
t.Fatal("merged.txt should exist after merge")
|
|
}
|
|
|
|
disposeReq := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
|
strings.NewReader(`{"action":"dispose","name":"api-dispose"}`),
|
|
)
|
|
disposeReq.Header.Set("Content-Type", "application/json")
|
|
disposeW := httptest.NewRecorder()
|
|
mux.ServeHTTP(disposeW, disposeReq)
|
|
if disposeW.Code != http.StatusConflict {
|
|
t.Fatalf("dispose without force expected 409, got %d: %s", disposeW.Code, disposeW.Body.String())
|
|
}
|
|
|
|
disposeForceReq := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
|
strings.NewReader(`{"action":"dispose","name":"api-dispose","force":true}`),
|
|
)
|
|
disposeForceReq.Header.Set("Content-Type", "application/json")
|
|
disposeForceW := httptest.NewRecorder()
|
|
mux.ServeHTTP(disposeForceW, disposeForceReq)
|
|
if disposeForceW.Code != http.StatusOK {
|
|
t.Fatalf("dispose with force expected 200, got %d: %s", disposeForceW.Code, disposeForceW.Body.String())
|
|
}
|
|
if _, err := os.Stat(disposePath); !os.IsNotExist(err) {
|
|
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
|
}
|
|
}
|