445 lines
17 KiB
Go
445 lines
17 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 TestCompleteModelProtocolProbeCompletionPreservesAnotherEditedFieldsFocusCursorAndViewport(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 = updateKey(model, "shift+tab")
|
|
model = editorKey(model, tea.KeyHome)
|
|
model = updateRunes(model, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontally-")
|
|
model = editorKey(model, tea.KeyLeft)
|
|
before := model.View()
|
|
assertUsableFrame(t, before, 80, 24, "> Name [focused]", "Protocol Probe: checking proxy.example:8080")
|
|
|
|
model = finishProtocolProbe(t, model, command)
|
|
after := model.View()
|
|
assertUsableFrame(t, after, 80, 24, "> Name [focused]", "Protocol Probe: detected HTTP proxy at proxy.example:8080")
|
|
if fieldLine(before, "> Name [focused]") != fieldLine(after, "> Name [focused]") {
|
|
t.Fatalf("non-mode-changing probe completion reset the edited field viewport or cursor:\nbefore:\n%s\nafter:\n%s", before, after)
|
|
}
|
|
|
|
model = updateRunes(model, "X")
|
|
model = updateKey(model, "ctrl+s")
|
|
if len(module.intents) != 1 {
|
|
t.Fatalf("save after asynchronous completion dispatched %#v", module.intents)
|
|
}
|
|
if got, want := module.intents[0].Profile.Name, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontallyX-office"; got != want {
|
|
t.Fatalf("focus/cursor was not preserved: saved name = %q, want %q", got, want)
|
|
}
|
|
if got := module.intents[0].Profile.SingleURL; got != "http://proxy.example:8080" {
|
|
t.Fatalf("probe did not update its affected URL input: %q", got)
|
|
}
|
|
}
|
|
|
|
func fieldLine(frame, label string) string {
|
|
for _, line := range strings.Split(frame, "\n") {
|
|
if strings.Contains(line, label) {
|
|
return line
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 = "Single proxy URL probe [read-only]"
|
|
}
|
|
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 initiating URL context:\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 TestCompleteModelProtocolProbeDualResultRetainsSingleSourceAndLinksGeneratedFields(t *testing.T) {
|
|
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
|
|
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
|
|
}, "proxy.example:7890")
|
|
model, command := startProtocolProbe(t, model)
|
|
model = finishProtocolProbe(t, model, command)
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 80, 24,
|
|
"Create Proxy Profile — advanced mode",
|
|
"Single proxy URL probe [read-only]: proxy.example:7890",
|
|
"Protocol Probe: detected HTTP and SOCKS5 proxy at proxy.example:7890",
|
|
"Generated Advanced URL fields from this Single proxy URL probe:",
|
|
"HTTP URL", "HTTPS URL", "ALL/SOCKS URL")
|
|
if strings.Index(view, "Single proxy URL probe [read-only]") > strings.Index(view, "HTTP URL") {
|
|
t.Fatalf("initiating Single URL context was not visibly linked before generated fields:\n%s", view)
|
|
}
|
|
if strings.Contains(fieldLine(view, "HTTP URL"), "Protocol Probe") {
|
|
t.Fatalf("dual result was silently relabelled as an HTTP-field probe:\n%s", view)
|
|
}
|
|
}
|
|
|
|
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 TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) {
|
|
const (
|
|
inputUser = "alice-private"
|
|
inputSecret = "input-secret"
|
|
errorUser = "mallory-private"
|
|
errorSecret = "error-secret"
|
|
)
|
|
var dialed []string
|
|
module := &recordingModule{}
|
|
model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) {
|
|
dialed = append(dialed, address)
|
|
return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999")
|
|
}, inputUser+":"+inputSecret+"@proxy.example:8080")
|
|
frames := []string{model.View()}
|
|
model, command := startProtocolProbe(t, model)
|
|
frames = append(frames, model.View())
|
|
model = finishProtocolProbe(t, model, command)
|
|
frames = append(frames, model.View())
|
|
model = resize(model, 60, 16)
|
|
frames = append(frames, model.View())
|
|
model = resize(model, 80, 24)
|
|
frames = append(frames, model.View())
|
|
|
|
if len(dialed) != 1 || dialed[0] != "proxy.example:8080" {
|
|
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
|
|
}
|
|
for i, frame := range frames {
|
|
for _, literal := range []string{inputUser, inputSecret, errorUser, errorSecret} {
|
|
if strings.Contains(frame, literal) {
|
|
t.Fatalf("form frame %d exposed userinfo literal %q:\n%s", i, literal, frame)
|
|
}
|
|
}
|
|
}
|
|
assertUsableFrame(t, frames[len(frames)-1], 80, 24, "Protocol Probe failed for proxy.example:8080")
|
|
|
|
model = updateKey(model, "ctrl+s")
|
|
if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://"+inputUser+":"+inputSecret+"@proxy.example:8080" {
|
|
t.Fatalf("userinfo masking weakened saved Profile semantics: %#v", module.intents)
|
|
}
|
|
}
|