Files
proxy/internal/tui/profile_editor_test.go
T

294 lines
11 KiB
Go

package tui
import (
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func editorKey(model Model, key tea.KeyType) Model {
updated, _ := model.Update(tea.KeyMsg{Type: key})
return updated.(Model)
}
func openNewProfileEditor(t *testing.T, module *profileManagementModule) Model {
t.Helper()
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "m")
return updateKey(model, "n")
}
func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "ofice")
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = updateRunes(model, "f")
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "x")
model = editorKey(model, tea.KeyBackspace)
model = editorKey(model, tea.KeyEnd)
model = editorKey(model, tea.KeyBackspace)
model = updateRunes(model, "e")
model = updateKey(model, "tab")
model = updateRunes(model, "http://proxy.example:808x0") // multi-rune KeyMsg is Bubble Tea's paste boundary
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyDelete)
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents, want 1; frame:\n%s", len(module.intents), model.View())
}
got := module.intents[0].Profile
if got.Name != "office" || got.SingleURL != "http://proxy.example:8080" {
t.Fatalf("edited Profile = %#v", got)
}
}
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "credential-never-rendered"
const raw = "https://operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443"
model = updateRunes(model, raw)
end := model.View()
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") {
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end)
}
model = editorKey(model, tea.KeyHome)
start := model.View()
assertUsableFrame(t, start, 80, 24, "https://operator:")
if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
t.Fatalf("home did not scroll to the safe beginning of the input:\n%s", start)
}
}
func TestCompleteModelProfileEditorPreservesValuesAcrossSingleAdvancedTransitions(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "tab")
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "http://")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
}
got := module.intents[0].Profile
if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" {
t.Fatalf("transition discarded values: %#v", got)
}
}
func TestCompleteModelProfileEditorShowsCanonicalFieldValidationAndLiveBypassAnalysis(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example")
view := model.View()
assertUsableFrame(t, view, 80, 24, "Single proxy URL must include a port")
model = editorKey(model, tea.KeyEnd)
model = updateRunes(model, ":8080")
if view := model.View(); strings.Contains(view, "must include a port") {
t.Fatalf("correcting the URL left a stale field error:\n%s", view)
}
model = updateKey(model, "tab")
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, bad entry, 10.0.0.7/24")
view = model.View()
assertUsableFrame(t, view, 80, 24,
"Normalized Bypass preview", "localhost", ".example.com", "10.0.0.0/24", "bad entry", "invalid")
if strings.Count(view, "• localhost") != 1 {
t.Fatalf("Bypass preview did not deduplicate localhost:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 0 {
t.Fatalf("invalid Bypass dispatched %#v", module.intents)
}
model = editorKey(model, tea.KeyCtrlU)
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, 10.0.0.7/24")
if view := model.View(); strings.Contains(view, "bad entry") || strings.Contains(view, "invalid") {
t.Fatalf("correcting Bypass left stale item validation:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("valid save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
}
want := []string{"localhost", ".example.com", "10.0.0.0/24"}
got := module.intents[0].Profile.Bypass
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("saved Bypass = %#v, want %#v", got, want)
}
}
func TestCompleteModelProfileEditorScrollsLongBypassPreviewAt80Columns(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "tab")
items := make([]string, 0, 25)
for i := range 24 {
items = append(items, fmt.Sprintf("host-%02d.example", i))
}
items = append(items, "late invalid entry")
model = updateRunes(model, strings.Join(items, ","))
if strings.Contains(model.View(), "! item 25") {
t.Fatal("fixture did not exceed the 80x24 preview viewport")
}
for range 6 {
model = updateKey(model, "pgdown")
}
model = resize(model, 80, 24)
assertUsableFrame(t, model.View(), 80, 24,
"> Bypass [focused]", "! item 25", "late invalid entry", "invalid", "PgUp/PgDn: scroll form")
model = updateKey(model, "pgup")
view := model.View()
assertUsableFrame(t, view, 80, 24, "> Bypass [focused]", "host-19.example", "PgUp/PgDn: scroll form")
if strings.Contains(view, "! item 25") {
t.Fatalf("PgUp did not move the persistent Bypass preview viewport:\n%s", view)
}
}
func TestCompleteModelProfileEditorObscuresCredentialsInScrolledBypassValidation(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "tab")
items := make([]string, 0, 25)
for i := range 24 {
items = append(items, fmt.Sprintf("host-%02d.example", i))
}
const secret = "late-bypass-secret"
items = append(items, "http://operator:"+secret+"@proxy.invalid")
model = updateRunes(model, strings.Join(items, ","))
for range 6 {
model = updateKey(model, "pgdown")
}
for _, size := range []struct{ width, height int }{{80, 24}, {60, 16}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "> Bypass [focused]", "REDACTED@proxy.invalid")
if strings.Contains(view, secret) {
t.Fatalf("scrolled Bypass validation exposed credentials at %dx%d:\n%s", size.width, size.height, view)
}
}
}
func TestCompleteModelProfileEditorObscuresSchemeLessCredentialsDuringEntryValidationAndScroll(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "scheme-less-secret"
const raw = "operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-scroll.example:notaport"
model = updateRunes(model, raw)
for _, step := range []func(Model) Model{
func(value Model) Model { return value },
func(value Model) Model { return editorKey(value, tea.KeyHome) },
func(value Model) Model { return editorKey(value, tea.KeyEnd) },
func(value Model) Model { return updateKey(value, "ctrl+s") },
func(value Model) Model { return resize(value, 60, 16) },
func(value Model) Model { return resize(value, 80, 24) },
} {
model = step(model)
if view := model.View(); strings.Contains(view, secret) {
t.Fatalf("scheme-less credential rendered during entry, validation, or scroll:\n%s", view)
}
}
if len(module.intents) != 0 {
t.Fatalf("invalid credential-bearing URL dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorShowsNameConflictsInlineAndBlocksContradictorySave(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}}}}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "other.example:8080")
assertUsableFrame(t, model.View(), 80, 24, "Name", "a Profile named \"office\" already exists")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 0 {
t.Fatalf("conflicting name dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorSaveSelectsSavedProfileAndCancelDoesNotPersist(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "existing", Mode: profile.SingleMode, SingleURL: "http://existing.example:8080",
}}}}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "discarded")
model = updateKey(model, "tab")
model = updateRunes(model, "http://discarded.example:8080")
model = updateKey(model, "esc")
if len(module.intents) != 0 || len(module.snapshot.Profiles) != 1 {
t.Fatalf("cancel mutated persistence: intents=%#v snapshot=%#v", module.intents, module.snapshot)
}
model = updateKey(model, "n")
model = updateRunes(model, "saved")
model = updateKey(model, "tab")
model = updateRunes(model, "saved.example:8080")
model = updateKey(model, "ctrl+s")
assertUsableFrame(t, model.View(), 80, 24, "> saved", "Proxy: http://saved.example:8080")
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("save dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorResizeAndErrorsNeverRenderAdversarialCredentials(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "adversarial-secret"
model = updateRunes(model, "http://alice:"+secret+"@proxy.example:notaport")
model = updateKey(model, "tab")
model = updateRunes(model, "http://bob:"+secret+"@bypass.invalid")
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {60, 16}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "Ctrl+S: save", "Esc: cancel")
if strings.Contains(view, secret) {
t.Fatalf("secret rendered after resize to %dx%d:\n%s", size.width, size.height, view)
}
}
}