Files
proxy/internal/tui/render_hardening_test.go
T

371 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}