| package handlers |
|
|
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| "testing" |
|
|
| "github.com/chromedp/cdproto/target" |
| "github.com/chromedp/chromedp" |
| "github.com/pinchtab/pinchtab/internal/bridge" |
| "github.com/pinchtab/pinchtab/internal/config" |
| ) |
|
|
| type mockBridge struct { |
| bridge.BridgeAPI |
| failTab bool |
| } |
|
|
| func (m *mockBridge) TabContext(tabID string) (context.Context, string, error) { |
| if m.failTab { |
| return nil, "", fmt.Errorf("tab not found") |
| } |
| |
| |
| ctx, _ := chromedp.NewContext(context.Background()) |
| return ctx, "tab1", nil |
| } |
|
|
| func (m *mockBridge) ListTargets() ([]*target.Info, error) { |
| return []*target.Info{{TargetID: "tab1", Type: "page"}}, nil |
| } |
|
|
| func (m *mockBridge) AvailableActions() []string { |
| return []string{bridge.ActionClick, bridge.ActionType} |
| } |
|
|
| func (m *mockBridge) ExecuteAction(ctx context.Context, kind string, req bridge.ActionRequest) (map[string]any, error) { |
| return map[string]any{"success": true}, nil |
| } |
|
|
| func (m *mockBridge) CreateTab(url string) (string, context.Context, context.CancelFunc, error) { |
| ctx, cancel := chromedp.NewContext(context.Background()) |
| return "tab_abc12345", ctx, cancel, nil |
| } |
|
|
| func (m *mockBridge) CloseTab(tabID string) error { |
| if tabID == "fail" { |
| return fmt.Errorf("close failed") |
| } |
| return nil |
| } |
|
|
| func (m *mockBridge) FocusTab(tabID string) error { |
| if tabID == "fail" { |
| return fmt.Errorf("tab not found") |
| } |
| return nil |
| } |
|
|
| func (m *mockBridge) EnsureChrome(cfg *config.RuntimeConfig) error { |
| |
| return nil |
| } |
|
|
| func (m *mockBridge) DeleteRefCache(tabID string) {} |
|
|
| func (m *mockBridge) TabLockInfo(tabID string) *bridge.LockInfo { return nil } |
|
|
| func (m *mockBridge) GetMemoryMetrics(tabID string) (*bridge.MemoryMetrics, error) { |
| return &bridge.MemoryMetrics{JSHeapUsedMB: 10}, nil |
| } |
|
|
| func (m *mockBridge) GetBrowserMemoryMetrics() (*bridge.MemoryMetrics, error) { |
| return &bridge.MemoryMetrics{JSHeapUsedMB: 50}, nil |
| } |
|
|
| func (m *mockBridge) GetAggregatedMemoryMetrics() (*bridge.MemoryMetrics, error) { |
| return &bridge.MemoryMetrics{JSHeapUsedMB: 50, Nodes: 500}, nil |
| } |
|
|
| func (m *mockBridge) GetCrashLogs() []string { |
| return nil |
| } |
|
|
| func (m *mockBridge) Execute(ctx context.Context, tabID string, task func(ctx context.Context) error) error { |
| return task(ctx) |
| } |
|
|
| func TestHandlers(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
| mux := http.NewServeMux() |
| h.RegisterRoutes(mux, nil) |
|
|
| req := httptest.NewRequest("GET", "/help", nil) |
| w := httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != 200 { |
| t.Fatalf("expected 200 from /help, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "endpoints") { |
| t.Fatalf("expected /help response to include endpoints") |
| } |
|
|
| req = httptest.NewRequest("GET", "/openapi.json", nil) |
| w = httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != 200 { |
| t.Fatalf("expected 200 from /openapi.json, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "openapi") { |
| t.Fatalf("expected /openapi.json response to include openapi") |
| } |
|
|
| req = httptest.NewRequest("GET", "/metrics", nil) |
| w = httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != 200 { |
| t.Fatalf("expected 200 from /metrics, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "metrics") { |
| t.Fatalf("expected /metrics response to include metrics") |
| } |
| } |
|
|
| func TestHelpIncludesSecurityStatus(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
|
|
| req := httptest.NewRequest("GET", "/help", nil) |
| w := httptest.NewRecorder() |
| h.HandleHelp(w, req) |
|
|
| if w.Code != 200 { |
| t.Fatalf("expected 200 from /help, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "\"security\"") { |
| t.Fatalf("expected /help response to include security status") |
| } |
| if !strings.Contains(w.Body.String(), "security.allowEvaluate") { |
| t.Fatalf("expected /help response to include locked setting names") |
| } |
| } |
|
|
| func TestOpenAPIIncludesSensitiveEndpointStatus(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{AllowDownload: true}, nil, nil, nil) |
|
|
| req := httptest.NewRequest("GET", "/openapi.json", nil) |
| w := httptest.NewRecorder() |
| h.HandleOpenAPI(w, req) |
|
|
| if w.Code != 200 { |
| t.Fatalf("expected 200 from /openapi.json, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "\"x-pinchtab-security\"") { |
| t.Fatalf("expected /openapi.json response to include security metadata") |
| } |
| if !strings.Contains(w.Body.String(), "\"x-pinchtab-enabled\":true") { |
| t.Fatalf("expected /openapi.json response to mark enabled sensitive endpoints") |
| } |
| } |
|
|
| func TestHandleNavigate(t *testing.T) { |
| cfg := &config.RuntimeConfig{} |
| m := &mockBridge{} |
| h := New(m, cfg, nil, nil, nil) |
|
|
| |
| body := `{"url": "https://pinchtab.com"}` |
| req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleNavigate(w, req) |
| |
| |
| if w.Code != 200 && w.Code != 500 { |
| t.Errorf("unexpected status %d: %s", w.Code, w.Body.String()) |
| } |
|
|
| |
| req = httptest.NewRequest("GET", "/nav?url=https%3A%2F%2Fpinchtab.com", nil) |
| w = httptest.NewRecorder() |
| h.HandleNavigate(w, req) |
| if w.Code != 200 && w.Code != 500 { |
| t.Errorf("unexpected status for GET navigate %d: %s", w.Code, w.Body.String()) |
| } |
|
|
| |
| req = httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{}`))) |
| w = httptest.NewRecorder() |
| h.HandleNavigate(w, req) |
| if w.Code != 400 { |
| t.Errorf("expected 400 for missing url, got %d", w.Code) |
| } |
| } |
|
|
| func TestHandleTab(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
|
|
| |
| body := `{"action": "new", "url": "about:blank"}` |
| req := httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 200 && w.Code != 500 { |
| t.Errorf("unexpected status %d", w.Code) |
| } |
|
|
| |
| body = `{"action": "close", "tabId": "tab1"}` |
| req = httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w = httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 200 { |
| t.Errorf("expected 200, got %d", w.Code) |
| } |
| } |
|
|
| func TestHandleTabFocus(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
|
|
| t.Run("focus success", func(t *testing.T) { |
| body := `{"action": "focus", "tabId": "tab1"}` |
| req := httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 200 { |
| t.Errorf("expected 200, got %d", w.Code) |
| } |
| var resp map[string]any |
| if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { |
| t.Fatalf("decode: %v", err) |
| } |
| if resp["focused"] != true { |
| t.Error("expected focused=true") |
| } |
| if resp["tabId"] != "tab1" { |
| t.Errorf("expected tabId=tab1, got %v", resp["tabId"]) |
| } |
| }) |
|
|
| t.Run("focus missing tabId", func(t *testing.T) { |
| body := `{"action": "focus"}` |
| req := httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 400 { |
| t.Errorf("expected 400, got %d", w.Code) |
| } |
| }) |
|
|
| t.Run("focus not found", func(t *testing.T) { |
| body := `{"action": "focus", "tabId": "fail"}` |
| req := httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 404 { |
| t.Errorf("expected 404, got %d", w.Code) |
| } |
| }) |
|
|
| t.Run("invalid action", func(t *testing.T) { |
| body := `{"action": "invalid"}` |
| req := httptest.NewRequest("POST", "/tab", bytes.NewReader([]byte(body))) |
| w := httptest.NewRecorder() |
| h.HandleTab(w, req) |
| if w.Code != 400 { |
| t.Errorf("expected 400, got %d", w.Code) |
| } |
| }) |
| } |
|
|
| func TestRoutesRegistration(t *testing.T) { |
| b := &mockBridge{} |
| cfg := &config.RuntimeConfig{} |
| h := New(b, cfg, nil, nil, nil) |
|
|
| mux := http.NewServeMux() |
| h.RegisterRoutes(mux, func() {}) |
|
|
| tests := []struct { |
| method string |
| path string |
| code int |
| }{ |
| {"GET", "/health", 200}, |
| {"GET", "/tabs", 200}, |
| {"GET", "/welcome", 200}, |
| {"POST", "/navigate", 400}, |
| } |
|
|
| for _, tt := range tests { |
| req := httptest.NewRequest(tt.method, tt.path, nil) |
| w := httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != tt.code { |
| t.Errorf("%s %s expected %d, got %d", tt.method, tt.path, tt.code, w.Code) |
| } |
| } |
| } |
|
|
| func TestEvaluateRouteLockedByDefault(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
| mux := http.NewServeMux() |
| h.RegisterRoutes(mux, nil) |
|
|
| req := httptest.NewRequest("POST", "/evaluate", bytes.NewReader([]byte(`{"expression":"1+1"}`))) |
| w := httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != 403 { |
| t.Fatalf("expected 403 when evaluate is disabled, got %d", w.Code) |
| } |
| if !strings.Contains(w.Body.String(), "security.allowEvaluate") { |
| t.Fatalf("expected evaluate lock response to include the setting name, got %s", w.Body.String()) |
| } |
| } |
|
|
| func TestEvaluateRouteRegisteredWhenEnabled(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{AllowEvaluate: true}, nil, nil, nil) |
| mux := http.NewServeMux() |
| h.RegisterRoutes(mux, nil) |
|
|
| req := httptest.NewRequest("POST", "/evaluate", bytes.NewReader([]byte(`not json`))) |
| w := httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
| if w.Code != 400 { |
| t.Fatalf("expected evaluate route to be active, got %d", w.Code) |
| } |
| } |
|
|
| func TestSensitiveTabRouteLockedByDefault(t *testing.T) { |
| h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) |
| mux := http.NewServeMux() |
| h.RegisterRoutes(mux, nil) |
|
|
| req := httptest.NewRequest("POST", "/tabs/tab1/evaluate", bytes.NewReader([]byte(`{"expression":"1+1"}`))) |
| w := httptest.NewRecorder() |
| mux.ServeHTTP(w, req) |
|
|
| if w.Code != 403 { |
| t.Fatalf("expected 403 when tab evaluate is disabled, got %d", w.Code) |
| } |
|
|
| var payload map[string]any |
| if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { |
| t.Fatalf("failed to parse response: %v", err) |
| } |
| if payload["code"] != "evaluate_disabled" { |
| t.Fatalf("expected evaluate_disabled code, got %v", payload["code"]) |
| } |
| } |
|
|