- Ctrl+D in the Single URL field runs a handshake probe against the typed endpoint. It detects HTTP and/or SOCKS5 without forwarding traffic or contacting external hosts. - HTTP-capable endpoints map to http://; SOCKS5-only endpoints map to socks5h://. Userinfo is preserved as typed. - Bare host:port input is accepted and defaults to http:// at save. - Update README privacy boundary and add .scratch/protocol-probe/spec.md.
472 lines
15 KiB
Go
472 lines
15 KiB
Go
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 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, "p")
|
|
view := model.View()
|
|
if !strings.Contains(view, "Proxy Profiles") || !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, "a")
|
|
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
|
|
t.Fatalf("activate key dispatched %#v", module.intents)
|
|
}
|
|
model = updateKey(model, "d")
|
|
if !strings.Contains(model.View(), "Delete Profile") {
|
|
t.Fatalf("delete key did not request confirmation:\n%s", model.View())
|
|
}
|
|
model = updateKey(model, "y")
|
|
if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm {
|
|
t.Fatalf("confirmed delete dispatched %#v", module.intents)
|
|
}
|
|
}
|
|
|
|
func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
|
|
module := &recordingModule{}
|
|
model := NewModel(module)
|
|
model = updateKey(model, "p")
|
|
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) != 1 || got[0] != "localhost, .example.com" {
|
|
t.Fatalf("create form 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, "p")
|
|
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, "p")
|
|
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, "p: 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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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, "p")
|
|
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 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, "p")
|
|
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, "p")
|
|
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)}
|
|
switch key {
|
|
case "tab":
|
|
msg = tea.KeyMsg{Type: tea.KeyTab}
|
|
case "ctrl+s":
|
|
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
|
|
case "ctrl+t":
|
|
msg = tea.KeyMsg{Type: tea.KeyCtrlT}
|
|
case "backspace":
|
|
msg = tea.KeyMsg{Type: tea.KeyBackspace}
|
|
}
|
|
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)
|
|
}
|