248 lines
10 KiB
Go
248 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
// profileManagementModule is an in-memory Application seam used to drive the
|
|
// same complete Model as production while making CRUD results observable.
|
|
type profileManagementModule struct {
|
|
snapshot app.Snapshot
|
|
intents []app.Intent
|
|
reverseUpdate bool
|
|
}
|
|
|
|
func (m *profileManagementModule) Snapshot() app.Snapshot { return m.snapshot }
|
|
|
|
func (m *profileManagementModule) Dispatch(intent app.Intent) app.Result {
|
|
m.intents = append(m.intents, intent)
|
|
switch intent.Kind {
|
|
case app.CreateProfile:
|
|
m.snapshot.Profiles = append(m.snapshot.Profiles, intent.Profile)
|
|
case app.UpdateProfile:
|
|
for i := range m.snapshot.Profiles {
|
|
if m.snapshot.Profiles[i].Name == intent.Name {
|
|
m.snapshot.Profiles[i] = intent.Profile
|
|
break
|
|
}
|
|
}
|
|
if m.snapshot.ActiveProfile == intent.Name {
|
|
m.snapshot.ActiveProfile = intent.Profile.Name
|
|
}
|
|
if m.reverseUpdate {
|
|
slices.Reverse(m.snapshot.Profiles)
|
|
}
|
|
case app.DeleteProfile:
|
|
for i := range m.snapshot.Profiles {
|
|
if m.snapshot.Profiles[i].Name == intent.Name {
|
|
m.snapshot.Profiles = append(m.snapshot.Profiles[:i], m.snapshot.Profiles[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if m.snapshot.ActiveProfile == intent.Name {
|
|
m.snapshot.ActiveProfile = ""
|
|
}
|
|
}
|
|
return app.Result{Snapshot: m.snapshot, Notice: "done"}
|
|
}
|
|
|
|
func (m *profileManagementModule) StartOperation(intent app.Intent) app.OperationStream {
|
|
result := m.Dispatch(intent)
|
|
updates := make(chan app.OperationUpdate, 1)
|
|
updates <- app.SucceededOperationUpdate(result.Snapshot, app.OperationStatus{Summary: result.Notice}, nil)
|
|
close(updates)
|
|
return app.OperationStream{Snapshot: result.Snapshot, Updates: updates}
|
|
}
|
|
|
|
func TestCompleteModelProfileManagementScrollsAndReviewsObscuredDetailsAcrossResize(t *testing.T) {
|
|
profiles := make([]profile.Profile, 30)
|
|
for i := range profiles {
|
|
profiles[i] = profile.Profile{
|
|
Name: fmt.Sprintf("site-%02d", i),
|
|
Mode: profile.SingleMode,
|
|
SingleURL: fmt.Sprintf("https://operator:secret-%02d@proxy-%02d.example:8443", i, i),
|
|
Bypass: []string{fmt.Sprintf("internal-%02d.example", i), "localhost"},
|
|
}
|
|
}
|
|
module := &profileManagementModule{snapshot: app.Snapshot{ActiveProfile: "site-00", Profiles: profiles}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
for range 24 {
|
|
model = updateKey(model, "down")
|
|
}
|
|
|
|
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,
|
|
"> site-24", "Mode: single", "https://operator:REDACTED@proxy-24.example:8443", "internal-24.example")
|
|
if strings.Contains(view, "secret-24") {
|
|
t.Fatalf("Profile details exposed credentials after resize to %dx%d:\n%s", size.width, size.height, view)
|
|
}
|
|
if size.width == 60 && size.height == 16 {
|
|
model = updateKey(model, "pgdown")
|
|
assertUsableFrame(t, model.View(), 60, 16, "localhost")
|
|
model = updateKey(model, "pgup")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelProfileManagementHasCreateAndEditEntryPointsButNoActivationAction(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
|
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
|
}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 80, 24, "n: create", "e: edit", "d: delete", "q/Esc: back")
|
|
if strings.Contains(view, "activate") {
|
|
t.Fatalf("management page mixed in Active Profile switching:\n%s", view)
|
|
}
|
|
|
|
model = updateKey(model, "n")
|
|
if view := model.View(); !strings.Contains(view, "Create Proxy Profile") {
|
|
t.Fatalf("create entry point did not open the existing editor:\n%s", view)
|
|
}
|
|
model = updateKey(model, "esc")
|
|
model = updateKey(model, "e")
|
|
if view := model.View(); !strings.Contains(view, "Edit Proxy Profile") || !strings.Contains(view, "office") {
|
|
t.Fatalf("edit entry point did not open the existing editor:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelProfileDeletionDialogIsSafeByDefaultAndSupportsExplicitSelection(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
|
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
|
}, {
|
|
Name: "remote", Mode: profile.SingleMode, SingleURL: "http://remote.example:8080",
|
|
}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "d")
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 80, 24, "Delete Proxy Profile “office”?", "> Cancel", "Delete", "Enter: choose")
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
model = updated.(Model)
|
|
if command != nil || len(module.intents) != 0 || strings.Contains(model.View(), "Delete Proxy Profile “office”?") {
|
|
t.Fatalf("safe-default Enter did not cancel deletion: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
|
|
model = updateKey(model, "d")
|
|
model = updateKey(model, "q")
|
|
if len(module.intents) != 0 || strings.Contains(model.View(), "Delete Proxy Profile “office”?") {
|
|
t.Fatalf("q did not cancel deletion without mutation: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
|
|
model = updateKey(model, "d")
|
|
model = updateKey(model, "right")
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
model = updated.(Model)
|
|
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
|
|
t.Fatalf("focused Delete did not dispatch the confirmed mutation: intents=%#v", module.intents)
|
|
}
|
|
assertUsableFrame(t, model.View(), 80, 24, "> remote", "Proxy: http://remote.example:8080")
|
|
}
|
|
|
|
func TestCompleteModelFinalProfileDeletionExplainsDriftAndRequiresExplicitConfirmation(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{ActiveProfile: "office", Profiles: []profile.Profile{{
|
|
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
|
}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "d")
|
|
model = resize(model, 60, 16)
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 60, 16,
|
|
"final Proxy Profile", "managed configuration remains", "become Drifted", "> Cancel")
|
|
|
|
model = updateKey(model, "y")
|
|
if len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
|
|
t.Fatalf("explicit confirmation dispatched %#v", module.intents)
|
|
}
|
|
model = resize(model, 80, 24)
|
|
view = model.View()
|
|
assertUsableFrame(t, view, 80, 24, "No Proxy Profiles saved.", "Press n to create one.")
|
|
if strings.Contains(view, "No Active Profile") {
|
|
t.Fatalf("management empty state conflated saved Profiles with Active Profile state:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelProfileSelectionUsesIdentityAcrossResizeAndReorderedCRUDResult(t *testing.T) {
|
|
profiles := make([]profile.Profile, 22)
|
|
for i := range profiles {
|
|
profiles[i] = profile.Profile{Name: fmt.Sprintf("profile-%02d", i), Mode: profile.SingleMode, SingleURL: fmt.Sprintf("http://proxy-%02d.example:8080", i)}
|
|
}
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: profiles}, reverseUpdate: true}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
for range 17 {
|
|
model = updateKey(model, "down")
|
|
}
|
|
model = resize(model, 120, 36)
|
|
model = resize(model, 60, 16)
|
|
model = updateKey(model, "e")
|
|
model = updateKey(model, "ctrl+s")
|
|
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 60, 16, "> profile-17", "Proxy: http://proxy-17.example:8080")
|
|
if len(module.intents) != 1 || module.intents[0].Kind != app.UpdateProfile || module.intents[0].Name != "profile-17" {
|
|
t.Fatalf("edit dispatched %#v", module.intents)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelProfileManagementDoesNotConflateSavedAndActiveProfileState(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
|
Name: "available", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
|
}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 80, 24, "> available", "Selected Profile")
|
|
if strings.Contains(view, "No Active Profile") || strings.Contains(view, "No Proxy Profiles") {
|
|
t.Fatalf("management page misreported a non-active saved Profile as empty:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}}
|
|
for _, back := range []tea.KeyMsg{{Type: tea.KeyEsc}, {Type: tea.KeyRunes, Runes: []rune{'q'}}} {
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
updated, command := model.Update(back)
|
|
model = updated.(Model)
|
|
if command != nil || !strings.Contains(model.View(), "Operations Console") {
|
|
t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View())
|
|
}
|
|
}
|
|
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
if command == nil {
|
|
t.Fatal("Ctrl+C on Profile management did not follow the global exit contract")
|
|
}
|
|
if _, ok := command().(tea.QuitMsg); !ok {
|
|
t.Fatal("Ctrl+C on Profile management did not emit tea.Quit")
|
|
}
|
|
_ = updated
|
|
|
|
checking := &profileManagementModule{snapshot: app.Snapshot{
|
|
Profiles: []profile.Profile{{Name: "office"}},
|
|
Targets: []app.TargetSnapshot{{ID: "shell", DetectionPhase: app.TargetChecking}},
|
|
}}
|
|
busy := resizedModel(t, checking, themeModeNoColor, 80, 24)
|
|
busy = updateKey(busy, "m")
|
|
if view := busy.View(); !strings.Contains(view, "Profile management disabled: wait for the current mutation to finish") {
|
|
t.Fatalf("disabled Profile management action did not expose its reason:\n%s", view)
|
|
}
|
|
}
|