577 lines
24 KiB
Go
577 lines
24 KiB
Go
package tui
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"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{"Profile:", "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{"Profile:", "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()
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
model = updateKey(model, "e")
|
||
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
|
||
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "q: Stop", "Current error:", "REDACTED", "Space: 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{"○ 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].Evidence = strings.Repeat("証拠 and wrapped evidence ", 120) + " EVIDENCE-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, "EVIDENCE-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 TestCompleteModelLongFieldsAndDialogsRemainReachableAcrossResizeAtNonterminalOffsets(t *testing.T) {
|
||
t.Run("long URL field and Bypass preview", func(t *testing.T) {
|
||
snapshot := hardeningSnapshot()
|
||
snapshot.Profiles[0].SingleURL = "http://start.example:8080/" + strings.Repeat("a", 90) + " URL-MIDDLE " + strings.Repeat("z", 90) + "URL-END"
|
||
bypass := make([]string, 40)
|
||
for i := range bypass {
|
||
bypass[i] = fmt.Sprintf("host-%02d.example", i+1)
|
||
}
|
||
snapshot.Profiles[0].Bypass = bypass
|
||
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
|
||
model = updateKey(model, "m")
|
||
model = updateKey(model, "e")
|
||
model = updateKey(model, "tab")
|
||
for range 115 {
|
||
model = updateKey(model, "left")
|
||
}
|
||
assertCellBoundedFrame(t, model.View(), 80, 24, "URL-MIDDLE")
|
||
model = resize(model, 120, 36)
|
||
assertCellBoundedFrame(t, model.View(), 120, 36, "URL-MIDDLE")
|
||
model = resize(model, 80, 24)
|
||
|
||
model = updateKey(model, "tab")
|
||
model = updateKey(model, "home")
|
||
model = updateKey(model, "pgdown")
|
||
model = updateKey(model, "pgdown")
|
||
view := model.View()
|
||
assertCellBoundedFrame(t, view, 80, 24, "host-10.example")
|
||
if strings.Contains(view, "host-40.example") {
|
||
t.Fatalf("Bypass preview unexpectedly jumped to its terminal offset:\n%s", view)
|
||
}
|
||
model = resize(model, 120, 36)
|
||
assertCellBoundedFrame(t, model.View(), 120, 36, "host-10.example")
|
||
})
|
||
|
||
t.Run("expanded error", func(t *testing.T) {
|
||
snapshot := hardeningSnapshot()
|
||
lines := make([]string, 60)
|
||
for i := range lines {
|
||
lines[i] = fmt.Sprintf("ERROR-LINE-%02d", i+1)
|
||
}
|
||
module := &targetModule{snapshot: snapshot, result: app.Result{Snapshot: snapshot, Err: errors.New(strings.Join(lines, "\n"))}}
|
||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||
model = updated.(Model)
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
model = updateKey(model, "e")
|
||
model = updateKey(model, "pgdown")
|
||
model = updateKey(model, "pgdown")
|
||
view := model.View()
|
||
assertCellBoundedFrame(t, view, 80, 24, "ERROR-LINE-05")
|
||
if strings.Contains(view, "ERROR-LINE-60") {
|
||
t.Fatalf("expanded error unexpectedly jumped to its terminal offset:\n%s", view)
|
||
}
|
||
model = resize(model, 120, 36)
|
||
assertCellBoundedFrame(t, model.View(), 120, 36, "ERROR-LINE-10")
|
||
})
|
||
|
||
t.Run("arbitrary confirmation details and labels", func(t *testing.T) {
|
||
snapshot := hardeningSnapshot()
|
||
updates := make(chan app.OperationUpdate, 1)
|
||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
|
||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||
updated, command := model.Update(keyMessage("r"))
|
||
model = updated.(Model)
|
||
details := make([]string, 45)
|
||
for i := range details {
|
||
details[i] = fmt.Sprintf("CONFIRM-DETAIL-%02d", i+1)
|
||
}
|
||
updates <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||
Title: "Long confirmation", Summary: "Review every detail", Details: details,
|
||
CancelLabel: "CANCEL-START " + strings.Repeat("cancel ", 50) + "CANCEL-MIDDLE " + strings.Repeat("later ", 50) + "CANCEL-END",
|
||
ConfirmLabel: "CONFIRM-START " + strings.Repeat("confirm ", 50) + "CONFIRM-MIDDLE " + strings.Repeat("later ", 50) + "CONFIRM-END",
|
||
})
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
model = updateKey(model, "pgdown")
|
||
model = updateKey(model, "pgdown")
|
||
view := model.View()
|
||
assertCellBoundedFrame(t, view, 80, 24, "CONFIRM-DETAIL-10")
|
||
if strings.Contains(view, "CONFIRM-DETAIL-45") {
|
||
t.Fatalf("confirmation unexpectedly jumped to its terminal offset:\n%s", view)
|
||
}
|
||
model = resize(model, 120, 36)
|
||
assertCellBoundedFrame(t, model.View(), 120, 36, "CONFIRM-DETAIL-10")
|
||
model = resize(model, 80, 24)
|
||
cancelMiddleAtNonterminal, confirmMiddleAtNonterminal := false, false
|
||
for range 20 {
|
||
model = updateKey(model, "pgdown")
|
||
view = model.View()
|
||
if strings.Contains(view, "CANCEL-MIDDLE") && !strings.Contains(view, "CANCEL-END") {
|
||
cancelMiddleAtNonterminal = true
|
||
}
|
||
if strings.Contains(view, "CONFIRM-MIDDLE") && !strings.Contains(view, "CONFIRM-END") {
|
||
confirmMiddleAtNonterminal = true
|
||
}
|
||
}
|
||
if !cancelMiddleAtNonterminal || !confirmMiddleAtNonterminal {
|
||
t.Fatalf("long confirmation labels were not reachable before their terminal offsets: cancel=%t confirm=%t\n%s", cancelMiddleAtNonterminal, confirmMiddleAtNonterminal, model.View())
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
|
||
snapshot := hardeningSnapshot()
|
||
snapshot.Targets[1].Evidence = strings.Repeat("APT scrollable evidence. ", 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]", "VIEWPORT-END",
|
||
"Applying", "Exit requested", "changes 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 TestCompleteModelStripsThemeAllowlistedANSIAndTerminalControlsBeforeSemanticStyling(t *testing.T) {
|
||
const plain = "INJECTED"
|
||
attack := "\x1b[38;5;75m" + plain + "\x1b[0m\x1b[1;38;5;160m\x1b]8;;https://attacker.invalid\x07\x9b\x00\r"
|
||
for _, mode := range []themeMode{themeModeDark, themeModeLight, themeModeNoColor} {
|
||
t.Run(fmt.Sprintf("theme-%d", mode), func(t *testing.T) {
|
||
got := completeModelInjectionFrames(t, mode, attack)
|
||
want := completeModelInjectionFrames(t, mode, plain)
|
||
if got != want {
|
||
t.Fatalf("untrusted presentation text affected rendered styling\n--- hostile ---\n%q\n--- clean ---\n%q", got, want)
|
||
}
|
||
if strings.ContainsAny(got, "\x00\x07\r\x9b") || strings.Contains(got, "attacker.invalid") {
|
||
t.Fatalf("terminal control payload survived complete-Model rendering: %q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func completeModelInjectionFrames(t *testing.T, mode themeMode, value string) string {
|
||
t.Helper()
|
||
snapshot := hardeningSnapshot()
|
||
snapshot.ActiveProfile = value
|
||
snapshot.Profiles[0] = profile.Profile{Name: value, Mode: profile.SingleMode, SingleURL: value, Bypass: []string{value}}
|
||
snapshot.Targets[0].Description = target.Description{
|
||
Name: value, Summary: value, Details: value, Scope: value,
|
||
Permissions: value, Ownership: value, RestartRisk: value,
|
||
}
|
||
snapshot.Targets[0].Evidence = value
|
||
snapshot.Targets[0].AvailabilityEvidence = value
|
||
snapshot.Targets[0].PrimaryAction.DisabledReason = value
|
||
|
||
updates := make(chan app.OperationUpdate, 3)
|
||
confirmations := make(chan app.OperationUpdate, 1)
|
||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmations}}
|
||
model := resizedModel(t, module, mode, 120, 36)
|
||
frames := []string{model.View()}
|
||
|
||
model = updateKey(model, "?")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "q")
|
||
model = updateKey(model, "p")
|
||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
|
||
model = updated.(Model)
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "esc")
|
||
model = updateKey(model, "m")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "e")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "q")
|
||
model = updateKey(model, "q")
|
||
|
||
updated, command := model.Update(keyMessage("r"))
|
||
model = updated.(Model)
|
||
updates <- app.RunningOperationUpdate(snapshot,
|
||
app.OperationStatus{Summary: value, TargetID: "shell"},
|
||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning, Evidence: value, Err: errors.New(value)}})
|
||
model, command = applyOperationCommand(t, model, command)
|
||
frames = append(frames, model.View())
|
||
updates <- app.FailedOperationUpdate(snapshot,
|
||
app.OperationStatus{Summary: value, TargetID: "shell"},
|
||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressFailed, Evidence: value, Err: errors.New(value)}},
|
||
"shell", errors.New(value))
|
||
close(updates)
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
model = updateKey(model, "e")
|
||
frames = append(frames, model.View())
|
||
|
||
updated, command = model.Update(keyMessage("r"))
|
||
model = updated.(Model)
|
||
confirmations <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||
Title: value, Summary: value, Details: []string{value}, ConfirmLabel: value, CancelLabel: value,
|
||
})
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
frames = append(frames, model.View())
|
||
return strings.Join(frames, "\n--- frame ---\n")
|
||
}
|
||
|
||
func TestCompleteModelRedactsMalformedSecretMatrixFromEveryRenderedIngress(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
value string
|
||
secrets []string
|
||
}{
|
||
{"valid URL", "https://alice:valid-secret@proxy.example:8080", []string{"valid-secret"}},
|
||
{"unsupported scheme CRLF spaces and slash", "odd+scheme://alice:line one/part\r\ntwo@proxy.example:8080", []string{"line one/part\r\ntwo"}},
|
||
{"bare malformed userinfo", "alice:bare secret/part@proxy.example:8080", []string{"bare secret/part"}},
|
||
{"multiple at signs", "http://alice:sec@ret@proxy.example:8080", []string{"sec@ret"}},
|
||
{"multiple URLs", "first http://alice:first-secret@one.example:1 then invalid://bob:second secret@two.example:2", []string{"first-secret", "second secret"}},
|
||
}
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
frames := completeModelSecretFrames(t, test.value)
|
||
for _, secret := range test.secrets {
|
||
assertSecretAbsent(t, frames, secret)
|
||
}
|
||
for _, surrounding := range []string{"before diagnostic", "after diagnostic"} {
|
||
if !strings.Contains(frames, surrounding) {
|
||
t.Fatalf("redaction swallowed surrounding diagnostics %q:\n%s", surrounding, frames)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func completeModelSecretFrames(t *testing.T, credential string) string {
|
||
t.Helper()
|
||
hostile := "before diagnostic \x1b]2;hostile-title\x07\x1b[8m" + credential + "\r after diagnostic"
|
||
snapshot := hardeningSnapshot()
|
||
snapshot.ActiveProfile = hostile
|
||
snapshot.Profiles[0] = profile.Profile{Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{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)
|
||
frames := []string{model.View()}
|
||
|
||
model = updateKey(model, "?")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "q")
|
||
model = updateKey(model, "p")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "esc")
|
||
model = updateKey(model, "m")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "e")
|
||
frames = append(frames, model.View())
|
||
model = updateKey(model, "q")
|
||
model = updateKey(model, "q")
|
||
|
||
updated, command := model.Update(keyMessage("r"))
|
||
model = updated.(Model)
|
||
updates <- app.RunningOperationUpdate(snapshot,
|
||
app.OperationStatus{Summary: hostile, TargetID: "shell"},
|
||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning, Evidence: hostile, Err: errors.New(hostile)}})
|
||
model, command = applyOperationCommand(t, model, command)
|
||
frames = append(frames, model.View())
|
||
updates <- app.FailedOperationUpdate(snapshot,
|
||
app.OperationStatus{Summary: hostile, TargetID: "shell"},
|
||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressFailed, Evidence: hostile, Err: errors.New(hostile)}},
|
||
"shell", errors.New(hostile))
|
||
close(updates)
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
model = updateKey(model, "e")
|
||
frames = append(frames, model.View())
|
||
|
||
updated, command = model.Update(keyMessage("r"))
|
||
model = updated.(Model)
|
||
confirmation <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||
Title: hostile, Summary: hostile, Details: []string{hostile}, ConfirmLabel: hostile, CancelLabel: hostile,
|
||
})
|
||
model, _ = applyOperationCommand(t, model, command)
|
||
frames = append(frames, model.View())
|
||
return strings.Join(frames, "\n--- frame ---\n")
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|