172 lines
6.1 KiB
Go
172 lines
6.1 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"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 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)
|
|
model = updated.(Model)
|
|
// In-memory test modules publish terminal operation updates synchronously.
|
|
// Drain only updates already available; real in-flight operation tests remain
|
|
// under their explicit channel control.
|
|
for model.operation.active && isProfileMutation(model.operation.intent.Kind) {
|
|
select {
|
|
case update, open := <-model.operation.updates:
|
|
updated, _ = model.Update(operationProgress{updates: model.operation.updates, update: update, closed: !open})
|
|
model = updated.(Model)
|
|
default:
|
|
return model
|
|
}
|
|
}
|
|
return model
|
|
}
|
|
|
|
func updateRunes(model Model, value string) Model {
|
|
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
|
|
return updated.(Model)
|
|
}
|