package tui import ( "context" "errors" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" ) type recordingModule struct { snapshot app.Snapshot intents []app.Intent } func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot } func (m *recordingModule) Dispatch(intent app.Intent) app.Result { m.intents = append(m.intents, intent) return app.Result{Snapshot: m.snapshot, Notice: "done"} } func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream { result := m.Dispatch(intent) updates := make(chan app.OperationUpdate, 1) status := app.OperationStatus{Summary: result.Notice} if result.Err != nil { updates <- app.FailedOperationUpdate(result.Snapshot, app.OperationStatus{Summary: "Operation failed"}, nil, "", result.Err) } else { updates <- app.SucceededOperationUpdate(result.Snapshot, status, nil) } close(updates) return app.OperationStream{Snapshot: m.snapshot, Updates: updates} } func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) { module := &recordingModule{snapshot: app.Snapshot{ ActiveProfile: "office", Profiles: []profile.Profile{{ Name: "office", Mode: profile.SingleMode, SingleURL: "https://alice:very-secret@proxy.example:8443", }}, }} model := NewModel(module) if view := model.View(); !strings.Contains(view, "Active Profile: office") { t.Fatalf("main view missing Active Profile:\n%s", view) } model = updateKey(model, "m") view := model.View() if !strings.Contains(view, "Proxy Profile management") || !strings.Contains(view, "alice:") { t.Fatalf("Profile page did not render selected Profile:\n%s", view) } if strings.Contains(view, "very-secret") { t.Fatalf("Profile page exposed proxy password:\n%s", view) } model = updateKey(model, "d") if !strings.Contains(model.View(), "Delete Proxy Profile") { t.Fatalf("delete key did not request confirmation:\n%s", model.View()) } model = updateKey(model, "y") if len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || !module.intents[0].Confirm { t.Fatalf("confirmed delete dispatched %#v", module.intents) } } func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) { module := &recordingModule{} model := NewModel(module) model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "socks5://localhost:1080") model = updateKey(model, "tab") model = updateRunes(model, "localhost, .example.com") model = updateKey(model, "ctrl+s") if len(module.intents) != 1 { t.Fatalf("create form dispatched %d intents", len(module.intents)) } intent := module.intents[0] if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" { t.Fatalf("create form dispatched %#v", intent) } if got := intent.Profile.Bypass; len(got) != 2 || got[0] != "localhost" || got[1] != ".example.com" { t.Fatalf("create form normalized Bypass = %#v", got) } } func TestEditingSingleURLExpandsCurrentValueWhenSwitchingToAdvanced(t *testing.T) { const oldURL = "http://old.example:8080" const newURL = "socks5h://new.example:1080" module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{ Name: "office", Mode: profile.SingleMode, SingleURL: oldURL, HTTPURL: oldURL, HTTPSURL: oldURL, AllURL: oldURL, }}}} model := NewModel(module) model = updateKey(model, "m") model = updateKey(model, "e") model = updateKey(model, "tab") for range oldURL { model = updateKey(model, "backspace") } model = updateRunes(model, newURL) model = updateKey(model, "ctrl+t") model = updateKey(model, "ctrl+s") if len(module.intents) != 1 { t.Fatalf("edit form dispatched %d intents", len(module.intents)) } got := module.intents[0].Profile if got.HTTPURL != newURL || got.HTTPSURL != newURL || got.AllURL != newURL { t.Fatalf("advanced URLs = %q, %q, %q; want current Single URL %q", got.HTTPURL, got.HTTPSURL, got.AllURL, newURL) } } func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) { module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{ Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad", }}}} model := NewModel(module) model = updateKey(model, "m") if view := model.View(); strings.Contains(view, "do-not-show") { t.Fatalf("malformed proxy URL exposed password:\n%s", view) } } func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) { model := NewModel(&recordingModule{}) view := model.View() if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: manage Proxy Profiles") { t.Fatalf("no-Profile main view is not actionable:\n%s", view) } } func TestProtocolProbeDetectsHTTPAndRewritesSingleURL(t *testing.T) { module := &recordingModule{} model := NewModel(module) var probed []string model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { probed = append(probed, address) return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "alice:secret@localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) if len(probed) != 1 || probed[0] != "localhost:8888" { t.Fatalf("Protocol Probe dialed %#v, want exactly localhost:8888 without userinfo", probed) } if got := model.form.profile.SingleURL; got != "http://alice:secret@localhost:8888" { t.Fatalf("Single URL after probe = %q, want detected scheme with preserved userinfo", got) } view := model.View() if !strings.Contains(view, "Protocol Probe: detected HTTP proxy at localhost:8888") { t.Fatalf("form view missing detection status:\n%s", view) } if strings.Contains(view, "secret") { t.Fatalf("form view exposed proxy password:\n%s", view) } } func pressCtrlD(model Model) (Model, tea.Cmd) { updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) return updated.(Model), cmd } func TestProtocolProbeFailureLeavesFieldUntouchedAndSavePossible(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{}, errors.New("connection refused") } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) if got := model.form.profile.SingleURL; got != "localhost:8888" { t.Fatalf("failed probe rewrote Single URL to %q", got) } if view := model.View(); !strings.Contains(view, "Protocol Probe: no proxy detected at localhost:8888: connection refused") { t.Fatalf("form view missing failure status:\n%s", view) } model = updateKey(model, "ctrl+s") if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { t.Fatalf("save after failed probe dispatched %#v", module.intents) } } func TestProtocolProbeUnrecognizedProtocolLeavesFieldUntouched(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) if got := model.form.profile.SingleURL; got != "localhost:8888" { t.Fatalf("unrecognized probe rewrote Single URL to %q", got) } if view := model.View(); !strings.Contains(view, "Protocol Probe: unrecognized protocol at localhost:8888") { t.Fatalf("form view missing unrecognized status:\n%s", view) } } func TestProtocolProbeIgnoresRepeatedCtrlDWhileInFlight(t *testing.T) { module := &recordingModule{} model := NewModel(module) calls := 0 model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, first := pressCtrlD(model) if first == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } model, second := pressCtrlD(model) if second != nil { t.Fatal("ctrl+d during an in-flight probe started a second one") } updated, _ := model.Update(first()) model = updated.(Model) if calls != 1 { t.Fatalf("probe ran %d times, want 1", calls) } if got := model.form.profile.SingleURL; got != "http://localhost:8888" { t.Fatalf("Single URL after probe = %q", got) } } func TestProtocolProbeLateResultIgnoredAfterEscLeavesForm(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } gen := model.form.probeGen model = updateKey(model, "esc") updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen}) model = updated.(Model) if model.page != profilesPage { t.Fatalf("page = %d, want profilesPage", model.page) } if strings.Contains(model.status, "detected HTTP proxy") { t.Fatalf("profiles page status mutated by late probe result: %s", model.status) } if model.form.profile.SingleURL != "" { t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL) } } func TestProtocolProbeLateResultIgnoredAfterSaveLeavesForm(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } gen := model.form.probeGen model = updateKey(model, "ctrl+s") if len(module.intents) != 1 { t.Fatalf("save dispatched %d intents, want 1", len(module.intents)) } updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen}) model = updated.(Model) if model.page != profilesPage { t.Fatalf("page = %d, want profilesPage", model.page) } if strings.Contains(model.status, "detected HTTP proxy") { t.Fatalf("profiles page status mutated by late probe result: %s", model.status) } if model.form.profile.SingleURL != "" { t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL) } } func TestSaveFormDoesNotTriggerProtocolProbe(t *testing.T) { module := &recordingModule{} model := NewModel(module) calls := 0 model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model = updateKey(model, "ctrl+s") if calls != 0 { t.Fatalf("ctrl+s triggered %d probe call(s), want 0", calls) } if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { t.Fatalf("ctrl+s dispatched %#v", module.intents) } } func TestProtocolProbeOnlyTriggersOnSingleURLField(t *testing.T) { module := &recordingModule{} model := NewModel(module) calls := 0 model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") // Field 0 is Name. model, cmd := pressCtrlD(model) if cmd != nil { t.Fatal("ctrl+d on Name field started a Protocol Probe") } // Move to Single URL and probe once so we can reach the Bypass field. model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd = pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d on Single URL field did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) // Move to Bypass (field 2) and press Ctrl+D again. model = updateKey(model, "tab") model, cmd = pressCtrlD(model) if cmd != nil { t.Fatal("ctrl+d on Bypass field started a Protocol Probe") } if calls != 1 { t.Fatalf("Protocol Probe ran %d times, want 1", calls) } } func TestProtocolProbeDetectsDualProtocolEndpointBuildsCompatibleAdvancedProfile(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true, SOCKS5: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:7890") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) got := model.form.profile if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://localhost:7890" || got.HTTPSURL != "http://localhost:7890" || got.AllURL != "socks5://localhost:7890" { t.Fatalf("dual-protocol Profile = %#v", got) } if view := model.View(); !strings.Contains(view, "Protocol Probe: detected HTTP and SOCKS5 proxy at localhost:7890") { t.Fatalf("form view missing dual-protocol detection status:\n%s", view) } } func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) { module := &recordingModule{} model := NewModel(module) model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { return probe.Capabilities{SOCKS5: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd == nil { t.Fatal("ctrl+d did not start a Protocol Probe") } updated, _ := model.Update(cmd()) model = updated.(Model) if got := model.form.profile.SingleURL; got != "socks5h://localhost:8888" { t.Fatalf("Single URL after SOCKS5 probe = %q, want socks5h://localhost:8888", got) } if view := model.View(); !strings.Contains(view, "Protocol Probe: detected SOCKS5 proxy at localhost:8888") { t.Fatalf("form view missing SOCKS5 detection status:\n%s", view) } } func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) { module := &recordingModule{} model := NewModel(module) calls := 0 model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil } model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "home") model = updateKey(model, "ctrl+t") model = updateKey(model, "tab") model = updateRunes(model, "localhost:8888") model, cmd := pressCtrlD(model) if cmd != nil { t.Fatal("ctrl+d in Advanced mode started a Protocol Probe") } if calls != 0 { t.Fatalf("Advanced mode probe ran %d times, want 0", calls) } if view := model.View(); !strings.Contains(view, "Protocol Probe: available in Single mode only") { t.Fatalf("form view missing Advanced mode hint:\n%s", view) } } func updateKey(model Model, key string) Model { msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} special := map[string]tea.KeyType{ "tab": tea.KeyTab, "shift+tab": tea.KeyShiftTab, "enter": tea.KeyEnter, "esc": tea.KeyEsc, "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, "home": tea.KeyHome, "end": tea.KeyEnd, "delete": tea.KeyDelete, "backspace": tea.KeyBackspace, "pgup": tea.KeyPgUp, "pgdown": tea.KeyPgDown, "ctrl+d": tea.KeyCtrlD, "ctrl+s": tea.KeyCtrlS, "ctrl+t": tea.KeyCtrlT, "ctrl+u": tea.KeyCtrlU, } if keyType, ok := special[key]; ok { msg = tea.KeyMsg{Type: keyType} } updated, _ := model.Update(msg) return updated.(Model) } func updateRunes(model Model, value string) Model { updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}) return updated.(Model) }