Files
proxy/internal/tui/profile_management_page_test.go
T

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)
}
}