Files
proxy/internal/tui/protocol_probe_field_action_test.go
T

371 lines
14 KiB
Go

package tui
import (
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
)
func openProtocolProbeEditor(t *testing.T, module *recordingModule, detect func(context.Context, string) (probe.Capabilities, error), raw string) Model {
t.Helper()
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model.detect = detect
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "office")
model = updateKey(model, "tab")
return updateRunes(model, raw)
}
func startProtocolProbe(t *testing.T, model Model) (Model, tea.Cmd) {
t.Helper()
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
model = updated.(Model)
if command == nil {
t.Fatal("Ctrl+D did not emit an explicit Protocol Probe command")
}
return model, command
}
func finishProtocolProbe(t *testing.T, model Model, command tea.Cmd) Model {
t.Helper()
updated, _ := model.Update(command())
return updated.(Model)
}
func TestCompleteModelProtocolProbeIsVisibleExplicitAndFieldAttached(t *testing.T) {
calls := 0
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}, "proxy.example:8080")
view := model.View()
assertUsableFrame(t, view, 80, 24, "Single proxy URL", "Protocol Probe [Ctrl+D]: available", "Ctrl+D: probe")
if calls != 0 {
t.Fatalf("opening, focusing, or editing the form ran %d hidden probes", calls)
}
model, command := startProtocolProbe(t, model)
view = model.View()
assertUsableFrame(t, view, 80, 24, "Protocol Probe: checking proxy.example:8080", "Protocol Probe in progress")
if strings.Contains(view, "Status: Protocol Probe") {
t.Fatalf("probe progress rendered as unrelated global status:\n%s", view)
}
field := strings.Index(view, "Single proxy URL")
progress := strings.Index(view, "Protocol Probe: checking")
if field < 0 || progress < field || strings.Count(view[field:progress], "\n") > 1 {
t.Fatalf("probe progress was not attached directly to the initiating field:\n%s", view)
}
if calls != 0 {
t.Fatalf("accepting the shortcut ran work synchronously; calls=%d", calls)
}
model = finishProtocolProbe(t, model, command)
view = model.View()
assertUsableFrame(t, view, 80, 24, "Protocol Probe: detected HTTP proxy at proxy.example:8080")
if strings.Contains(view, "Status: Protocol Probe") {
t.Fatalf("probe result rendered as unrelated global status:\n%s", view)
}
if calls != 1 {
t.Fatalf("explicit probe ran %d times, want 1", calls)
}
model = updateKey(model, "ctrl+s")
if calls != 1 {
t.Fatalf("saving ran a hidden probe; calls=%d", calls)
}
}
func TestCompleteModelProtocolProbeNeverRunsWithoutExplicitInvocation(t *testing.T) {
calls := 0
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}, "proxy.example:8080")
model = updateKey(model, "tab")
model = updateKey(model, "shift+tab")
model = resize(model, 120, 36)
model = updateKey(model, "ctrl+s")
if calls != 0 {
t.Fatalf("form entry, focus changes, resize, or save ran %d hidden probes", calls)
}
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("ordinary save dispatched %#v", module.intents)
}
}
func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) {
tests := []struct {
name string
caps probe.Capabilities
want profile.Profile
text string
}{
{
name: "HTTP only", caps: probe.Capabilities{HTTP: true},
want: profile.Profile{Mode: profile.SingleMode, SingleURL: "http://proxy.example:7890", HTTPURL: "http://proxy.example:7890", HTTPSURL: "http://proxy.example:7890", AllURL: "http://proxy.example:7890"},
text: "detected HTTP proxy",
},
{
name: "SOCKS5 only", caps: probe.Capabilities{SOCKS5: true},
want: profile.Profile{Mode: profile.SingleMode, SingleURL: "socks5h://proxy.example:7890", HTTPURL: "socks5h://proxy.example:7890", HTTPSURL: "socks5h://proxy.example:7890", AllURL: "socks5h://proxy.example:7890"},
text: "detected SOCKS5 proxy",
},
{
name: "HTTP and SOCKS5", caps: probe.Capabilities{HTTP: true, SOCKS5: true},
want: profile.Profile{Mode: profile.AdvancedMode, SingleURL: "proxy.example:7890", HTTPURL: "http://proxy.example:7890", HTTPSURL: "http://proxy.example:7890", AllURL: "socks5://proxy.example:7890"},
text: "detected HTTP and SOCKS5 proxy",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) {
return test.caps, nil
}, "proxy.example:7890")
model, command := startProtocolProbe(t, model)
model = finishProtocolProbe(t, model, command)
view := model.View()
assertUsableFrame(t, view, 80, 24, test.text, "proxy.example:7890")
if strings.Contains(view, "Status: Protocol Probe") {
t.Fatalf("capability result detached into global status:\n%s", view)
}
attachedField := "Single proxy URL"
if test.caps.HTTP && test.caps.SOCKS5 {
attachedField = "HTTP URL"
}
field := strings.Index(view, attachedField)
result := strings.Index(view, "Protocol Probe: detected")
if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 {
t.Fatalf("capability result was not attached to its populated URL field:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("save after probe dispatched %#v", module.intents)
}
got := module.intents[0].Profile
if got.Mode != test.want.Mode || got.SingleURL != test.want.SingleURL || got.HTTPURL != test.want.HTTPURL || got.HTTPSURL != test.want.HTTPSURL || got.AllURL != test.want.AllURL {
t.Fatalf("saved capability representation = %#v, want %#v", got, test.want)
}
})
}
}
func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) {
tests := []struct {
name string
caps probe.Capabilities
err error
text string
}{
{name: "failure", err: errors.New("connection refused"), text: "Protocol Probe failed for proxy.example:8080"},
{name: "unrecognized", text: "Protocol Probe: unrecognized protocol at proxy.example:8080"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) {
return test.caps, test.err
}, "proxy.example:8080")
model, command := startProtocolProbe(t, model)
model = finishProtocolProbe(t, model, command)
assertUsableFrame(t, model.View(), 80, 24, test.text)
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "http://")
if strings.Contains(model.View(), "Protocol Probe:") || strings.Contains(model.View(), "Protocol Probe failed") {
t.Fatalf("editing the URL left stale probe feedback:\n%s", model.View())
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://proxy.example:8080" {
t.Fatalf("manual value was not savable after %s: %#v", test.name, module.intents)
}
})
}
}
func TestCompleteModelProtocolProbeSuppressesDuplicateActiveInvocation(t *testing.T) {
calls := 0
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}, "proxy.example:8080")
model, command := startProtocolProbe(t, model)
updated, duplicate := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
model = updated.(Model)
if duplicate != nil {
t.Fatal("repeated Ctrl+D emitted duplicate work while the same probe was active")
}
model = finishProtocolProbe(t, model, command)
if calls != 1 {
t.Fatalf("detector called %d times, want 1", calls)
}
}
func TestCompleteModelProtocolProbeInvalidatesEveryStaleResultPath(t *testing.T) {
tests := []struct {
name string
change func(Model) Model
check func(*testing.T, Model, *recordingModule)
}{
{
name: "relevant URL edit",
change: func(model Model) Model { return updateRunes(model, "0") },
check: func(t *testing.T, model Model, _ *recordingModule) {
if !strings.Contains(model.View(), "proxy.example:80800") {
t.Fatalf("URL edit was not preserved:\n%s", model.View())
}
},
},
{
name: "mode change",
change: func(model Model) Model { return updateKey(model, "ctrl+t") },
check: func(t *testing.T, model Model, _ *recordingModule) {
if !strings.Contains(model.View(), "advanced mode") {
t.Fatalf("mode change was not preserved:\n%s", model.View())
}
},
},
{
name: "rejected save",
change: func(model Model) Model {
model = updateKey(model, "shift+tab")
model = editorKey(model, tea.KeyCtrlU)
return updateKey(model, "ctrl+s")
},
check: func(t *testing.T, model Model, module *recordingModule) {
if len(module.intents) != 0 || !strings.Contains(model.View(), "Profile name is required") {
t.Fatalf("rejected save changed persistence or left the form: intents=%#v\n%s", module.intents, model.View())
}
},
},
{
name: "save",
change: func(model Model) Model { return updateKey(model, "ctrl+s") },
check: func(t *testing.T, model Model, module *recordingModule) {
if !strings.Contains(model.View(), "Proxy Profile management") || len(module.intents) != 1 {
t.Fatalf("save did not leave the form exactly once: intents=%#v\n%s", module.intents, model.View())
}
},
},
{
name: "cancel",
change: func(model Model) Model { return updateKey(model, "esc") },
check: func(t *testing.T, model Model, module *recordingModule) {
if !strings.Contains(model.View(), "Proxy Profile management") || len(module.intents) != 0 {
t.Fatalf("cancel changed persistence: intents=%#v\n%s", module.intents, model.View())
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}, "proxy.example:8080")
model, command := startProtocolProbe(t, model)
model = test.change(model)
model = finishProtocolProbe(t, model, command)
test.check(t, model, module)
view := model.View()
if strings.Contains(view, "detected HTTP proxy") || strings.Contains(view, "http://proxy.example:8080") {
t.Fatalf("stale result survived %s:\n%s", test.name, view)
}
})
}
}
func TestCompleteModelProtocolProbeRejectsCancelledFormResultAfterAnotherProbeStarts(t *testing.T) {
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}, "old.example:8080")
model, oldCommand := startProtocolProbe(t, model)
model = updateKey(model, "esc")
model = updateKey(model, "n")
model = updateRunes(model, "replacement")
model = updateKey(model, "tab")
model = updateRunes(model, "new.example:8080")
model, newCommand := startProtocolProbe(t, model)
model = finishProtocolProbe(t, model, oldCommand)
view := model.View()
assertUsableFrame(t, view, 80, 24, "new.example:8080", "Protocol Probe: checking new.example:8080")
if strings.Contains(view, "old.example") || strings.Contains(view, "detected HTTP proxy") {
t.Fatalf("cancelled form result completed a newer form's probe:\n%s", view)
}
model = finishProtocolProbe(t, model, newCommand)
assertUsableFrame(t, model.View(), 80, 24, "Protocol Probe: detected HTTP proxy at new.example:8080")
}
func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t *testing.T) {
calls := 0
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{SOCKS5: true}, nil
}, "proxy.example:1080")
model, command := startProtocolProbe(t, model)
for _, size := range []struct{ width, height int }{{60, 16}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "Protocol Probe: checking proxy.example:1080")
if strings.Contains(view, "Status: Protocol Probe") {
t.Fatalf("resize detached probe progress at %dx%d:\n%s", size.width, size.height, view)
}
}
if calls != 0 {
t.Fatalf("resize ran %d hidden probe calls", calls)
}
model = finishProtocolProbe(t, model, command)
assertUsableFrame(t, model.View(), 80, 24, "detected SOCKS5 proxy at proxy.example:1080")
if calls != 1 {
t.Fatalf("probe ran %d times across resize, want 1", calls)
}
}
func TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) {
const (
inputSecret = "input-secret"
errorSecret = "error-secret"
)
var dialed []string
model := openProtocolProbeEditor(t, &recordingModule{}, func(_ context.Context, address string) (probe.Capabilities, error) {
dialed = append(dialed, address)
return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999")
}, "alice:"+inputSecret+"@proxy.example:8080")
model, command := startProtocolProbe(t, model)
progress := model.View()
if strings.Contains(progress, inputSecret) {
t.Fatalf("probe progress exposed credentials:\n%s", progress)
}
for _, line := range strings.Split(progress, "\n") {
if strings.Contains(line, "Protocol Probe") && strings.Contains(line, "alice") {
t.Fatalf("probe-generated progress exposed userinfo:\n%s", progress)
}
}
model = finishProtocolProbe(t, model, command)
view := model.View()
if len(dialed) != 1 || dialed[0] != "proxy.example:8080" {
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
}
if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") {
t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view)
}
assertUsableFrame(t, view, 80, 24, "Protocol Probe failed for proxy.example:8080")
}