feat: add persistent proxy profile management

This commit is contained in:
proxyctl coordinator
2026-07-17 02:08:51 +08:00
parent 73b027b85b
commit f9469cf6a2
13 changed files with 1390 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"fmt"
"io"
"os"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/tui"
)
func main() {
if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "proxyctl:", err)
os.Exit(1)
}
}
func run(args []string, input io.Reader, output io.Writer) error {
if len(args) != 0 {
return fmt.Errorf("this build supports no command-line arguments")
}
path, err := profile.ConfigPath()
if err != nil {
return err
}
application, err := app.New(profile.NewFileStore(path))
if err != nil {
return err
}
program := tea.NewProgram(tui.NewModel(application), tea.WithInput(input), tea.WithOutput(output))
_, err = program.Run()
return err
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
var output bytes.Buffer
if err := run(nil, strings.NewReader("q"), &output); err != nil {
t.Fatalf("run proxyctl: %v", err)
}
if !strings.Contains(output.String(), "No Active Profile") {
t.Fatalf("default TUI output missing no-Profile state: %q", output.String())
}
}
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
writeConfig(t, filepath.Join(xdg, "proxyctl", "profiles.json"), `{
"profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080","httpUrl":"http://proxy.example:8080","httpsUrl":"http://proxy.example:8080","allUrl":"http://proxy.example:8080"}],
"activeProfile": "office"
}`)
var output bytes.Buffer
if err := run(nil, strings.NewReader("q"), &output); err != nil {
t.Fatalf("run proxyctl: %v", err)
}
if !strings.Contains(output.String(), "Active Profile: office") {
t.Fatalf("default TUI output missing Active Profile: %q", output.String())
}
}
func writeConfig(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
+26
View File
@@ -0,0 +1,26 @@
module proxyctl
go 1.24.0
require github.com/charmbracelet/bubbletea v1.3.10
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+43
View File
@@ -0,0 +1,43 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"errors"
"fmt"
"proxyctl/internal/profile"
)
type IntentKind int
const (
CreateProfile IntentKind = iota + 1
UpdateProfile
DeleteProfile
ActivateProfile
)
type Intent struct {
Kind IntentKind
Name string
Profile profile.Profile
Confirm bool
}
type Snapshot struct {
Profiles []profile.Profile
ActiveProfile string
}
type Result struct {
Snapshot Snapshot
Notice string
Err error
}
type Module interface {
Snapshot() Snapshot
Dispatch(Intent) Result
}
type Application struct {
store profile.Store
state profile.State
}
func New(store profile.Store) (*Application, error) {
state, err := store.Load()
if err != nil {
return nil, err
}
return &Application{store: store, state: state}, nil
}
func (a *Application) Snapshot() Snapshot {
profiles := append([]profile.Profile(nil), a.state.Profiles...)
return Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile}
}
func (a *Application) Dispatch(intent Intent) Result {
var notice string
var err error
switch intent.Kind {
case CreateProfile:
notice, err = a.create(intent.Profile)
case ActivateProfile:
notice, err = a.activate(intent.Name)
case UpdateProfile:
notice, err = a.update(intent.Name, intent.Profile)
case DeleteProfile:
notice, err = a.delete(intent.Name, intent.Confirm)
default:
err = errors.New("unsupported Profile action")
}
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err}
}
func (a *Application) create(input profile.Profile) (string, error) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return "", err
}
if a.index(p.Name) >= 0 {
return "", fmt.Errorf("a Profile named %q already exists", p.Name)
}
next := cloneState(a.state)
next.Profiles = append(next.Profiles, p)
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Created Profile %q", p.Name), nil
}
func (a *Application) activate(name string) (string, error) {
if a.index(name) < 0 {
return "", fmt.Errorf("Profile %q does not exist", name)
}
next := cloneState(a.state)
next.ActiveProfile = name
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Activated Profile %q", name), nil
}
func (a *Application) update(originalName string, input profile.Profile) (string, error) {
index := a.index(originalName)
if index < 0 {
return "", fmt.Errorf("Profile %q does not exist", originalName)
}
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return "", err
}
if other := a.index(p.Name); other >= 0 && other != index {
return "", fmt.Errorf("a Profile named %q already exists", p.Name)
}
next := cloneState(a.state)
next.Profiles[index] = p
if next.ActiveProfile == originalName {
next.ActiveProfile = p.Name
}
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Updated Profile %q", p.Name), nil
}
func (a *Application) delete(name string, confirm bool) (string, error) {
index := a.index(name)
if index < 0 {
return "", fmt.Errorf("Profile %q does not exist", name)
}
if len(a.state.Profiles) == 1 && !confirm {
return "", errors.New("deleting the final Profile requires confirmation")
}
next := cloneState(a.state)
next.Profiles = append(next.Profiles[:index], next.Profiles[index+1:]...)
if next.ActiveProfile == name {
next.ActiveProfile = ""
}
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Deleted Profile %q", name), nil
}
func (a *Application) index(name string) int {
for i := range a.state.Profiles {
if a.state.Profiles[i].Name == name {
return i
}
}
return -1
}
func cloneState(state profile.State) profile.State {
state.Profiles = append([]profile.Profile(nil), state.Profiles...)
return state
}
+69
View File
@@ -0,0 +1,69 @@
package app_test
import (
"path/filepath"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestUserCanCreateAndActivatePersistentSingleProxyProfile(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
application, err := app.New(profile.NewFileStore(configFile))
if err != nil {
t.Fatalf("start application: %v", err)
}
result := application.Dispatch(app.Intent{
Kind: app.CreateProfile,
Profile: profile.Profile{
Name: "office",
Mode: profile.SingleMode,
SingleURL: "http://proxy.example:8080",
Bypass: []string{"LOCALHOST", ".Example.com", "localhost"},
},
})
if result.Err != nil {
t.Fatalf("create Profile: %v", result.Err)
}
result = application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"})
if result.Err != nil {
t.Fatalf("activate Profile: %v", result.Err)
}
if result.Snapshot.ActiveProfile != "office" {
t.Fatalf("Active Profile = %q, want office", result.Snapshot.ActiveProfile)
}
got := result.Snapshot.Profiles[0]
if got.SingleURL != "http://proxy.example:8080" {
t.Fatalf("single proxy value = %q", got.SingleURL)
}
if want := []string{"localhost", ".example.com"}; !equalStrings(got.Bypass, want) {
t.Fatalf("normalized Bypass = %#v, want %#v", got.Bypass, want)
}
if got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" {
t.Fatalf("Single proxy was not expanded: %#v", got)
}
restarted, err := app.New(profile.NewFileStore(configFile))
if err != nil {
t.Fatalf("restart application: %v", err)
}
persisted := restarted.Snapshot()
if persisted.ActiveProfile != "office" || len(persisted.Profiles) != 1 {
t.Fatalf("persisted snapshot = %#v", persisted)
}
}
func equalStrings(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
+75
View File
@@ -0,0 +1,75 @@
package app_test
import (
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestUserCanOpenSingleProfileInAdvancedModeWithoutLosingItsValue(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "home", Mode: profile.SingleMode, SingleURL: "socks5h://127.0.0.1:1080",
}})
if created.Err != nil {
t.Fatal(created.Err)
}
edited := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "home", Profile: profile.Profile{
Name: "home", Mode: profile.AdvancedMode, SingleURL: "socks5h://127.0.0.1:1080",
}})
if edited.Err != nil {
t.Fatalf("open advanced editing: %v", edited.Err)
}
got := edited.Snapshot.Profiles[0]
if got.HTTPURL != got.SingleURL || got.HTTPSURL != got.SingleURL || got.AllURL != got.SingleURL {
t.Fatalf("advanced values = %q, %q, %q; want preserved %q", got.HTTPURL, got.HTTPSURL, got.AllURL, got.SingleURL)
}
}
func TestUserCanViewEditActivateAndDeleteProfiles(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
for _, p := range []profile.Profile{
{Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:8080"},
{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", HTTPSURL: "https://proxy.example:4443", AllURL: "socks5://proxy.example:1080"},
} {
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: p}); result.Err != nil {
t.Fatalf("create %s: %v", p.Name, result.Err)
}
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:9000"}}); result.Err == nil || !strings.Contains(result.Err.Error(), "already exists") {
t.Fatalf("duplicate name error = %v", result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "office", Profile: profile.Profile{
Name: "work", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", AllURL: "socks5h://proxy.example:1080",
}})
if updated.Err != nil {
t.Fatal(updated.Err)
}
if updated.Snapshot.ActiveProfile != "work" {
t.Fatalf("renamed Active Profile = %q", updated.Snapshot.ActiveProfile)
}
deleted := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "home", Confirm: true})
if deleted.Err != nil || len(deleted.Snapshot.Profiles) != 1 {
t.Fatalf("delete Profile result = %#v", deleted)
}
if unconfirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work"}); unconfirmed.Err == nil {
t.Fatal("deleting the final Profile did not require confirmation")
}
confirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work", Confirm: true})
if confirmed.Err != nil || confirmed.Snapshot.ActiveProfile != "" || len(confirmed.Snapshot.Profiles) != 0 {
t.Fatalf("delete final Profile result = %#v", confirmed)
}
}
+82
View File
@@ -0,0 +1,82 @@
package app_test
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestProfileConfigurationUsesIsolatedXDGPathOwnerOnlyAndAtomicReplacement(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
path, err := profile.ConfigPath()
if err != nil {
t.Fatalf("resolve XDG configuration: %v", err)
}
wantPath := filepath.Join(xdg, "proxyctl", "profiles.json")
if path != wantPath {
t.Fatalf("configuration path = %q, want %q", path, wantPath)
}
application, err := app.New(profile.NewFileStore(path))
if err != nil {
t.Fatal(err)
}
created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "private", Mode: profile.SingleMode, SingleURL: "http://alice:secret@proxy.example:8080",
}})
if created.Err != nil {
t.Fatal(created.Err)
}
assertMode(t, filepath.Dir(path), 0o700)
assertMode(t, path, 0o600)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "\n \"profiles\"") {
t.Fatalf("Profile configuration is not human-readable JSON:\n%s", data)
}
oldFile, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer oldFile.Close()
updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "private", Profile: profile.Profile{
Name: "renamed", Mode: profile.SingleMode, SingleURL: "http://alice:new-secret@proxy.example:8080",
}})
if updated.Err != nil {
t.Fatal(updated.Err)
}
oldData, err := io.ReadAll(oldFile)
if err != nil {
t.Fatal(err)
}
newData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(oldData), `"name": "private"`) || !strings.Contains(string(newData), `"name": "renamed"`) {
t.Fatalf("configuration was not atomically replaced; old=%s new=%s", oldData, newData)
}
matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".profiles-*"))
if err != nil || len(matches) != 0 {
t.Fatalf("temporary Profile files left behind: %v (%v)", matches, err)
}
}
func assertMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("mode for %s = %#o, want %#o", path, got, want)
}
}
+72
View File
@@ -0,0 +1,72 @@
package app_test
import (
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestProfileValidationAcceptsSupportedURLsAndNormalizesEveryBypassKind(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "mixed",
Mode: profile.AdvancedMode,
HTTPURL: "http://proxy.example:80",
HTTPSURL: "https://user:secret@proxy.example:443",
AllURL: "socks5h://[2001:db8::5]:1080",
Bypass: []string{
"LOCALHOST, Example.COM.",
".Internal.Example",
"192.0.2.1",
"2001:0db8::1",
"10.2.3.4/8",
"2001:db8:abcd::5/48",
"localhost",
},
}})
if result.Err != nil {
t.Fatalf("create validated Profile: %v", result.Err)
}
want := []string{"localhost", "example.com", ".internal.example", "192.0.2.1", "2001:db8::1", "10.0.0.0/8", "2001:db8:abcd::/48"}
if got := result.Snapshot.Profiles[0].Bypass; !equalStrings(got, want) {
t.Fatalf("normalized Bypass = %#v, want %#v", got, want)
}
}
func TestProfileValidationRejectsMalformedURLsAndBypassWithActionableErrors(t *testing.T) {
tests := []struct {
name string
profile profile.Profile
want string
}{
{"unsupported scheme", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "ftp://user:secret@proxy.example:21"}, "http, https, socks5, or socks5h"},
{"missing host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://:8080"}, "host"},
{"missing port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example"}, "port"},
{"invalid port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:70000"}, "between 1 and 65535"},
{"path", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080/path"}, "path"},
{"invalid IPv4 host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://999.1.2.3:8080"}, "invalid host"},
{"invalid bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"*"}}, "hostname, domain suffix, IP address, or CIDR"},
{"invalid IPv4 bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"999.1.2.3"}}, "hostname, domain suffix, IP address, or CIDR"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: tt.profile})
if result.Err == nil || !strings.Contains(result.Err.Error(), tt.want) {
t.Fatalf("error = %v, want actionable text containing %q", result.Err, tt.want)
}
if strings.Contains(result.Err.Error(), "secret") {
t.Fatalf("validation error exposed proxy password: %v", result.Err)
}
})
}
}
+220
View File
@@ -0,0 +1,220 @@
package profile
import (
"errors"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"
)
type Mode string
const (
SingleMode Mode = "single"
AdvancedMode Mode = "advanced"
)
type Profile struct {
Name string `json:"name"`
Mode Mode `json:"mode"`
SingleURL string `json:"singleUrl,omitempty"`
HTTPURL string `json:"httpUrl,omitempty"`
HTTPSURL string `json:"httpsUrl,omitempty"`
AllURL string `json:"allUrl,omitempty"`
Bypass []string `json:"bypass,omitempty"`
}
type State struct {
Profiles []Profile `json:"profiles"`
ActiveProfile string `json:"activeProfile,omitempty"`
}
type Store interface {
Load() (State, error)
Save(State) error
}
func ValidateAndNormalize(input Profile) (Profile, error) {
p := input
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
return Profile{}, errors.New("Profile name is required")
}
if p.Mode == "" {
p.Mode = SingleMode
}
switch p.Mode {
case SingleMode:
value, err := validateURL("Single proxy URL", p.SingleURL)
if err != nil {
return Profile{}, err
}
p.SingleURL = value
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
case AdvancedMode:
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" {
value, err := validateURL("Single proxy URL", p.SingleURL)
if err != nil {
return Profile{}, err
}
p.SingleURL = value
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
}
var err error
if p.HTTPURL, err = validateOptionalURL("HTTP proxy URL", p.HTTPURL); err != nil {
return Profile{}, err
}
if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil {
return Profile{}, err
}
if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil {
return Profile{}, err
}
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" {
return Profile{}, errors.New("at least one advanced proxy URL is required")
}
default:
return Profile{}, errors.New("proxy mode must be single or advanced")
}
bypass, err := normalizeBypass(p.Bypass)
if err != nil {
return Profile{}, err
}
p.Bypass = bypass
return p, nil
}
func validateOptionalURL(field, value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", nil
}
return validateURL(field, value)
}
func validateURL(field, value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("%s is required", field)
}
u, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("%s is malformed", field)
}
switch strings.ToLower(u.Scheme) {
case "http", "https", "socks5", "socks5h":
default:
return "", fmt.Errorf("%s must use http, https, socks5, or socks5h", field)
}
if u.Hostname() == "" {
return "", fmt.Errorf("%s must include a host", field)
}
if u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("%s must not include a path, query, or fragment", field)
}
port := u.Port()
if port == "" {
return "", fmt.Errorf("%s must include a port", field)
}
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", fmt.Errorf("%s port must be between 1 and 65535", field)
}
if net.ParseIP(u.Hostname()) == nil && (looksLikeIPv4(u.Hostname()) || !validHostname(u.Hostname())) {
return "", fmt.Errorf("%s contains an invalid host", field)
}
u.Scheme = strings.ToLower(u.Scheme)
return u.String(), nil
}
func normalizeBypass(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]bool)
for _, raw := range values {
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
value, err := normalizeBypassEntry(item)
if err != nil {
return nil, err
}
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result, nil
}
func normalizeBypassEntry(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", errors.New("Bypass entries cannot be empty")
}
if _, network, err := net.ParseCIDR(value); err == nil {
return network.String(), nil
}
if ip := net.ParseIP(value); ip != nil {
return ip.String(), nil
}
suffix := strings.HasPrefix(value, ".")
host := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(value, "."), "."))
if looksLikeIPv4(host) || !validHostname(host) {
return "", errors.New("Bypass entry must be a hostname, domain suffix, IP address, or CIDR range")
}
if suffix {
return "." + host, nil
}
return host, nil
}
var credentialPattern = regexp.MustCompile(`(?i)(https?|socks5h?)://([^/@\s:]+):([^@\s]*)@`)
func ObscureURL(value string) string {
u, err := url.Parse(value)
if err != nil || u.User == nil {
return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`)
}
if _, present := u.User.Password(); !present {
return value
}
u.User = url.UserPassword(u.User.Username(), "REDACTED")
return u.String()
}
func looksLikeIPv4(host string) bool {
parts := strings.Split(host, ".")
if len(parts) != 4 {
return false
}
for _, part := range parts {
if part == "" {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
func validHostname(host string) bool {
if len(host) == 0 || len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return false
}
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, r := range label {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-') {
return false
}
}
}
return true
}
+91
View File
@@ -0,0 +1,91 @@
package profile
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
type FileStore struct {
path string
}
func NewFileStore(path string) *FileStore {
return &FileStore{path: path}
}
func ConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("locate user configuration directory: %w", err)
}
return filepath.Join(configDir, "proxyctl", "profiles.json"), nil
}
func (s *FileStore) Load() (State, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, fs.ErrNotExist) {
return State{}, nil
}
if err != nil {
return State{}, fmt.Errorf("read Profile configuration: %w", err)
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, fmt.Errorf("parse Profile configuration: %w", err)
}
return state, nil
}
func (s *FileStore) Save(state State) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create Profile configuration directory: %w", err)
}
if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("secure Profile configuration directory: %w", err)
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode Profile configuration: %w", err)
}
data = append(data, '\n')
temp, err := os.CreateTemp(filepath.Dir(s.path), ".profiles-*")
if err != nil {
return fmt.Errorf("create temporary Profile configuration: %w", err)
}
tempName := temp.Name()
defer os.Remove(tempName)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return fmt.Errorf("secure temporary Profile configuration: %w", err)
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return fmt.Errorf("write temporary Profile configuration: %w", err)
}
if err := temp.Sync(); err != nil {
temp.Close()
return fmt.Errorf("sync temporary Profile configuration: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary Profile configuration: %w", err)
}
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("replace Profile configuration: %w", err)
}
if err := os.Chmod(s.path, 0o600); err != nil {
return fmt.Errorf("secure Profile configuration: %w", err)
}
dir, err := os.Open(filepath.Dir(s.path))
if err == nil {
err = dir.Sync()
_ = dir.Close()
}
if err != nil {
return fmt.Errorf("sync Profile configuration directory: %w", err)
}
return nil
}
+347
View File
@@ -0,0 +1,347 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
type page int
const (
mainPage page = iota
profilesPage
formPage
)
type formState struct {
originalName string
profile profile.Profile
field int
}
type Model struct {
application app.Module
snapshot app.Snapshot
page page
selected int
form formState
confirm bool
status string
}
func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot()}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
key, ok := message.(tea.KeyMsg)
if !ok {
return m, nil
}
switch m.page {
case mainPage:
return m.updateMain(key)
case profilesPage:
return m.updateProfiles(key)
case formPage:
return m.updateForm(key)
default:
return m, nil
}
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "p":
m.page = profilesPage
m.status = ""
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm {
switch key.String() {
case "y":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true})
}
m.confirm = false
case "n", "esc", "q":
m.confirm = false
}
return m, nil
}
switch key.String() {
case "q", "esc":
m.page = mainPage
m.status = ""
case "up", "k":
if m.selected > 0 {
m.selected--
}
case "down", "j":
if m.selected+1 < len(m.snapshot.Profiles) {
m.selected++
}
case "n":
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
m.page = formPage
m.status = ""
case "e":
if selected, ok := m.selectedProfile(); ok {
m.form = formState{originalName: selected.Name, profile: selected}
m.page = formPage
m.status = ""
}
case "a", "enter":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
}
case "d":
if _, ok := m.selectedProfile(); ok {
m.confirm = true
}
}
return m, nil
}
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "esc":
m.page = profilesPage
m.status = ""
return m, nil
case "ctrl+s":
kind := app.CreateProfile
if m.form.originalName != "" {
kind = app.UpdateProfile
}
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
return m, nil
}
m.status = result.Notice
m.page = profilesPage
m.selectName(intent.Profile.Name)
return m, nil
case "ctrl+t":
if m.form.profile.Mode == profile.SingleMode {
m.form.profile.Mode = profile.AdvancedMode
if m.form.profile.HTTPURL == "" && m.form.profile.HTTPSURL == "" && m.form.profile.AllURL == "" {
m.form.profile.HTTPURL = m.form.profile.SingleURL
m.form.profile.HTTPSURL = m.form.profile.SingleURL
m.form.profile.AllURL = m.form.profile.SingleURL
}
} else {
m.form.profile.Mode = profile.SingleMode
}
m.form.field = 0
return m, nil
case "tab", "down":
m.form.field = (m.form.field + 1) % m.form.fieldCount()
return m, nil
case "shift+tab", "up":
m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount()
return m, nil
case "backspace":
value := []rune(m.form.value())
if len(value) > 0 {
m.form.setValue(string(value[:len(value)-1]))
}
return m, nil
}
if key.Type == tea.KeyRunes {
m.form.setValue(m.form.value() + string(key.Runes))
}
return m, nil
}
func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected--
}
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
} else {
m.status = result.Notice
}
}
func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
return profile.Profile{}, false
}
return m.snapshot.Profiles[m.selected], true
}
func (m *Model) selectName(name string) {
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == name {
m.selected = i
return
}
}
}
func (m Model) View() string {
switch m.page {
case profilesPage:
return m.profilesView()
case formPage:
return m.formView()
default:
return m.mainView()
}
}
func (m Model) mainView() string {
var b strings.Builder
b.WriteString("proxyctl — Target panel\n\n")
if m.snapshot.ActiveProfile == "" {
b.WriteString("No Active Profile\n")
b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n")
} else {
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
}
b.WriteString("\nNo Targets are available in this build.\n")
b.WriteString("\np: manage Proxy Profiles q/Esc: quit\n")
return b.String()
}
func (m Model) profilesView() string {
var b strings.Builder
b.WriteString("Proxy Profiles\n\n")
if len(m.snapshot.Profiles) == 0 {
b.WriteString("No Proxy Profiles. Press n to create one.\n")
}
for i, item := range m.snapshot.Profiles {
cursor, active := " ", " "
if i == m.selected {
cursor = "> "
}
if item.Name == m.snapshot.ActiveProfile {
active = "*"
}
fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name)
}
if selected, ok := m.selectedProfile(); ok {
b.WriteString("\nSelected Profile\n")
fmt.Fprintf(&b, "Mode: %s\n", selected.Mode)
if selected.Mode == profile.SingleMode {
fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL))
} else {
fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n",
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
}
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
}
if m.confirm {
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
}
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (m Model) formView() string {
var b strings.Builder
action := "Create"
if m.form.originalName != "" {
action = "Edit"
}
fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode)
labels := m.form.labels()
for i, label := range labels {
cursor := " "
if i == m.form.field {
cursor = "> "
}
value := m.form.valueAt(i)
if label != "Name" && label != "Bypass" {
value = profile.ObscureURL(value)
}
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
}
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n")
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (f formState) labels() []string {
if f.profile.Mode == profile.AdvancedMode {
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
}
return []string{"Name", "Single proxy URL", "Bypass"}
}
func (f formState) fieldCount() int { return len(f.labels()) }
func (f formState) value() string { return f.valueAt(f.field) }
func (f formState) valueAt(index int) string {
if index == 0 {
return f.profile.Name
}
if f.profile.Mode == profile.AdvancedMode {
switch index {
case 1:
return f.profile.HTTPURL
case 2:
return f.profile.HTTPSURL
case 3:
return f.profile.AllURL
default:
return strings.Join(f.profile.Bypass, ", ")
}
}
if index == 1 {
return f.profile.SingleURL
}
return strings.Join(f.profile.Bypass, ", ")
}
func (f *formState) setValue(value string) {
if f.field == 0 {
f.profile.Name = value
return
}
if f.profile.Mode == profile.AdvancedMode {
switch f.field {
case 1:
f.profile.HTTPURL = value
case 2:
f.profile.HTTPSURL = value
case 3:
f.profile.AllURL = value
default:
f.profile.Bypass = []string{value}
}
return
}
if f.field == 1 {
f.profile.SingleURL = value
} else {
f.profile.Bypass = []string{value}
}
}
+118
View File
@@ -0,0 +1,118 @@
package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
type recordingModule struct {
snapshot app.Snapshot
intents []app.Intent
}
func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot, Notice: "done"}
}
func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode,
SingleURL: "https://alice:very-secret@proxy.example:8443",
}},
}}
model := NewModel(module)
if view := model.View(); !strings.Contains(view, "Active Profile: office") {
t.Fatalf("main view missing Active Profile:\n%s", view)
}
model = updateKey(model, "p")
view := model.View()
if !strings.Contains(view, "Proxy Profiles") || !strings.Contains(view, "alice:") {
t.Fatalf("Profile page did not render selected Profile:\n%s", view)
}
if strings.Contains(view, "very-secret") {
t.Fatalf("Profile page exposed proxy password:\n%s", view)
}
model = updateKey(model, "a")
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
t.Fatalf("activate key dispatched %#v", module.intents)
}
model = updateKey(model, "d")
if !strings.Contains(model.View(), "Delete Profile") {
t.Fatalf("delete key did not request confirmation:\n%s", model.View())
}
model = updateKey(model, "y")
if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm {
t.Fatalf("confirmed delete dispatched %#v", module.intents)
}
}
func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model = updateKey(model, "p")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "socks5://localhost:1080")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost, .example.com")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("create form dispatched %d intents", len(module.intents))
}
intent := module.intents[0]
if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" {
t.Fatalf("create form dispatched %#v", intent)
}
if got := intent.Profile.Bypass; len(got) != 1 || got[0] != "localhost, .example.com" {
t.Fatalf("create form Bypass = %#v", got)
}
}
func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad",
}}}}
model := NewModel(module)
model = updateKey(model, "p")
if view := model.View(); strings.Contains(view, "do-not-show") {
t.Fatalf("malformed proxy URL exposed password:\n%s", view)
}
}
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
model := NewModel(&recordingModule{})
view := model.View()
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "p: manage Proxy Profiles") {
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
}
}
func updateKey(model Model, key string) Model {
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
switch key {
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
}
updated, _ := model.Update(msg)
return updated.(Model)
}
func updateRunes(model Model, value string) Model {
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
return updated.(Model)
}