pi-agent: Implement final TUI hardening

This commit is contained in:
proxyctl coordinator
2026-07-18 01:07:54 +08:00
parent 954e7d977a
commit f15ffc2301
10 changed files with 598 additions and 39 deletions
+370
View File
@@ -0,0 +1,370 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
// These tests deliberately drive the complete production Model. They verify
// user-visible rendering and accepted input, not page/component internals.
func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, width, height int) Model
want []string
}{
{
name: "startup",
setup: func(t *testing.T, width, height int) Model {
snapshot := hardeningSnapshot()
for i := range snapshot.Targets {
snapshot.Targets[i].DetectionPhase = app.TargetChecking
}
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
},
want: []string{"Operations Console", "Checking", "Target availability"},
},
{
name: "refresh",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
if command == nil {
t.Fatal("refresh did not start")
}
return model
},
want: []string{"Operations Console", "Checking Target availability and state"},
},
{
name: "Target action",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil {
t.Fatal("Target action did not start")
}
return model
},
want: []string{"Applying", "Starting Target mutation"},
},
{
name: "Active Profile switching",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "p")
},
want: []string{"Switch Active Profile", "Search:", "remote"},
},
{
name: "management",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "m")
},
want: []string{"Proxy Profile management", "Selected Profile", "n: create"},
},
{
name: "editor",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model = updateKey(model, "m")
return updateKey(model, "e")
},
want: []string{"Edit Proxy Profile", "[focused]", "Ctrl+S: save"},
},
{
name: "Protocol Probe",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model.detect = func(_ context.Context, _ string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "e")
model = updateKey(model, "tab")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
model = updated.(Model)
if command == nil {
t.Fatal("Protocol Probe did not start")
}
return model
},
want: []string{"Protocol Probe", "checking proxy.example:8080", "in progress"},
},
{
name: "Help",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "?")
},
want: []string{"Contextual Help", "q/Esc: close Help"},
},
{
name: "Drift Resolution",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model = updateKey(model, "down")
return updateKey(model, " ")
},
want: []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 36}, {80, 24}} {
model := test.setup(t, size.width, size.height)
assertCellBoundedFrame(t, model.View(), size.width, size.height, test.want...)
}
model := test.setup(t, 80, 24)
model = resize(model, 59, 15)
assertCellBoundedFrame(t, model.View(), 59, 15, "Terminal too small", "59×15")
model = resize(model, 80, 24)
assertCellBoundedFrame(t, model.View(), 80, 24, test.want...)
})
}
}
func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T) {
views := make(map[themeMode]string)
for _, mode := range []themeMode{themeModeDark, themeModeLight, themeModeNoColor} {
module := &targetModule{
snapshot: hardeningSnapshot(),
result: app.Result{Snapshot: hardeningSnapshot(), Err: errors.New("runner failed https://alice:theme-secret@proxy.example:8080")},
}
model := resizedModel(t, module, mode, 80, 24)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
model = updateKey(model, "tab")
progressFrame := model.View()
updated, _ = model.Update(command())
model = updated.(Model)
model = updateKey(model, "e")
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "mutations disabled", "Current error:", "REDACTED", "Action: Enable"} {
if !strings.Contains(views[mode], semantic) {
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
}
}
}
plain := views[themeModeNoColor]
for _, symbol := range []string{"● Enabled", "○ Disabled", "! Drifted", "▶ Target details [focused]"} {
if !strings.Contains(plain, symbol) {
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain)
}
}
for _, r := range plain {
if (r >= '\ue000' && r <= '\uf8ff') || (r >= '\U000f0000' && r <= '\U000ffffd') || (r >= '\U00100000' && r <= '\U0010fffd') {
t.Fatalf("rendering depends on a private-use/Nerd Font glyph %U", r)
}
}
for _, mode := range []themeMode{themeModeDark, themeModeLight} {
if got := ansiPattern.ReplaceAllString(views[mode], ""); got != plain {
t.Fatalf("theme %d changed semantic layout\ncolored stripped:\n%s\nNO_COLOR:\n%s", mode, got, plain)
}
}
}
func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
longName := strings.Repeat("東京界", 50) + " TARGET-END"
longProfile := strings.Repeat("支店", 70) + " PROFILE-END"
snapshot := hardeningSnapshot()
snapshot.ActiveProfile = longProfile
snapshot.Profiles[0].Name = longProfile
snapshot.Targets[0].Description.Name = longName
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END"
snapshot.Targets[0].Description.Details = strings.Repeat("詳細 line ", 100) + " DETAILS-END"
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
model = updateKey(model, "tab")
for range 120 {
model = updateKey(model, "pgdown")
}
assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END")
model = updateKey(model, "m")
for range 60 {
model = updateKey(model, "pgdown")
}
assertCellBoundedFrame(t, model.View(), 80, 24, "PROFILE-END")
model = updateKey(model, "d")
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {59, 15}, {80, 24}} {
model = resize(model, size.width, size.height)
if size.width < minimumWidth {
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
continue
}
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Delete Proxy Profile", "PROFILE-END", "> Cancel")
}
}
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
snapshot := hardeningSnapshot()
snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END"
updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updates <- app.RunningOperationUpdate(snapshot,
app.OperationStatus{Summary: "Writing Shell configuration", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
model, command = applyOperationCommand(t, model, command)
model = updateKey(model, "down")
model = updateKey(model, "tab")
for range 120 {
model = updateKey(model, "pgdown")
}
model = updateKey(model, "?")
model = updateKey(model, "end")
updated, quit := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
model = updated.(Model)
if quit != nil {
t.Fatal("exit was not deferred during the in-flight mutation")
}
for _, size := range []struct{ width, height int }{{80, 24}, {59, 15}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
if size.width < minimumWidth {
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
continue
}
assertCellBoundedFrame(t, model.View(), size.width, size.height,
"Global exit safety", "q/Esc: close Help", "Exit pending until safe completion")
}
model = updateKey(model, "q")
assertCellBoundedFrame(t, model.View(), 80, 24,
"> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END",
"Applying", "Exit requested", "new work disabled")
updates <- app.SucceededOperationUpdate(snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(updates)
model, quit = applyOperationCommand(t, model, command)
if quit == nil {
t.Fatal("safe completion did not retain deferred exit intent")
}
if _, ok := quit().(tea.QuitMsg); !ok {
t.Fatal("safe completion did not emit Bubble Tea quit")
}
}
func TestCompleteModelRedactsAdversarialSecretsFromEveryRenderedIngress(t *testing.T) {
const secret = "ticket11 secret"
credential := "https://alice:" + secret + "@proxy.example:8080"
hostile := "\x1b]2;hostile-title\x07\x1b[8m" + credential + "\r"
snapshot := hardeningSnapshot()
snapshot.ActiveProfile = hostile
snapshot.Profiles[0] = profile.Profile{Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential}}
snapshot.Targets[0].ID = credential
snapshot.Targets[0].Description = target.Description{
Name: credential, Summary: credential, Details: credential, Scope: credential,
Permissions: credential, Ownership: credential, RestartRisk: credential,
}
snapshot.Targets[0].Evidence = hostile
snapshot.Targets[0].AvailabilityEvidence = hostile
snapshot.Targets[0].PrimaryAction.DisabledReason = hostile
updates := make(chan app.OperationUpdate, 3)
confirmation := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmation}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "?")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "q")
model = updateKey(model, "m")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "e")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "q")
model = updateKey(model, "q")
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
updates <- app.RunningOperationUpdate(snapshot,
app.OperationStatus{Summary: "notice " + credential, TargetID: credential},
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressRunning, Evidence: credential, Err: errors.New("runner output " + credential)}})
model, command = applyOperationCommand(t, model, command)
assertSecretAbsent(t, model.View(), secret)
updates <- app.FailedOperationUpdate(snapshot,
app.OperationStatus{Summary: "error " + credential, TargetID: credential},
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressFailed, Evidence: credential, Err: errors.New("progress output " + credential)}},
credential, errors.New("runner output "+credential))
close(updates)
model, _ = applyOperationCommand(t, model, command)
model = updateKey(model, "e")
assertSecretAbsent(t, model.View(), secret)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
confirmation <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
Title: "confirm " + credential, Summary: credential, Details: []string{credential},
ConfirmLabel: "continue " + credential, CancelLabel: "cancel " + credential,
})
model, _ = applyOperationCommand(t, model, command)
assertSecretAbsent(t, model.View(), secret)
}
func hardeningSnapshot() app.Snapshot {
return app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{
{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"localhost", ".example.com"}},
{Name: "remote", Mode: profile.SingleMode, SingleURL: "socks5h://remote.example:1080"},
},
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Disabled, Evidence: "fragment absent", PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.Drifted, Evidence: "managed values differ", PrimaryAction: app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}, DriftResolutionActions: []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}},
},
}
}
func assertCellBoundedFrame(t *testing.T, view string, width, height int, semantics ...string) {
t.Helper()
plain := ansiPattern.ReplaceAllString(view, "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
if len(lines) > height {
t.Fatalf("frame has %d lines, terminal height is %d:\n%s", len(lines), height, view)
}
for _, line := range lines {
if got := lipgloss.Width(line); got > width {
t.Fatalf("frame line is %d cells, terminal width is %d: %q", got, width, line)
}
}
for _, semantic := range semantics {
if !strings.Contains(plain, semantic) {
t.Fatalf("frame missing %q:\n%s", semantic, view)
}
}
}
func assertSecretAbsent(t *testing.T, frame, secret string) {
t.Helper()
if strings.Contains(frame, secret) {
t.Fatalf("rendered frame exposed secret %q:\n%s", secret, frame)
}
if strings.ContainsAny(frame, "\x1b\x07\r") {
t.Fatalf("rendered frame retained hostile terminal control bytes: %q", frame)
}
}