package app_test import ( "errors" "io" "os" "path/filepath" "strings" "sync" "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) } } type committedErrorStore struct { state profile.State loadCalls int } func (s *committedErrorStore) Load() (profile.State, error) { s.loadCalls++ return s.state, nil } func (s *committedErrorStore) Mutate(change func(profile.State) (profile.State, error)) (profile.State, error) { next, err := change(s.state) if err != nil { return s.state, err } s.state = next return next, &profile.CommitError{Err: errors.New("injected directory sync failure")} } func TestApplicationReloadsStateAfterCommittedDurabilityError(t *testing.T) { store := &committedErrorStore{} application, err := app.New(store) if err != nil { t.Fatal(err) } result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{ Name: "committed", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", }}) var committed *profile.CommitError if !errors.As(result.Err, &committed) { t.Fatalf("mutation error = %v, want committed durability error", result.Err) } if store.loadCalls != 3 { t.Fatalf("Load calls = %d, want startup, pre-mutation, and post-commit reloads", store.loadCalls) } if len(result.Snapshot.Profiles) != 1 || result.Snapshot.Profiles[0].Name != "committed" { t.Fatalf("Application snapshot after committed error = %#v", result.Snapshot) } } func TestConcurrentApplicationsMutateLatestStateWithoutLostUpdates(t *testing.T) { path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json") first, err := app.New(profile.NewFileStore(path)) if err != nil { t.Fatal(err) } second, err := app.New(profile.NewFileStore(path)) if err != nil { t.Fatal(err) } start := make(chan struct{}) results := make(chan app.Result, 2) var workers sync.WaitGroup for application, name := range map[*app.Application]string{first: "home", second: "office"} { workers.Add(1) go func() { defer workers.Done() <-start results <- application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{ Name: name, Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", }}) }() } close(start) workers.Wait() close(results) for result := range results { if result.Err != nil { t.Fatalf("concurrent mutation: %v", result.Err) } } reloaded, err := app.New(profile.NewFileStore(path)) if err != nil { t.Fatal(err) } if got := len(reloaded.Snapshot().Profiles); got != 2 { t.Fatalf("persisted Profiles = %d, want both concurrent updates", got) } assertMode(t, path+".lock", 0o600) } func TestLoadSecuresExistingSecretBearingConfiguration(t *testing.T) { path := filepath.Join(t.TempDir(), "proxyctl", "profiles.json") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } contents := `{"profiles":[{"name":"private","mode":"single","singleUrl":"http://alice:secret@proxy.example:8080"}]}` if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatal(err) } if _, err := app.New(profile.NewFileStore(path)); err != nil { t.Fatalf("load existing configuration: %v", err) } assertMode(t, path, 0o600) if _, err := os.Lstat(path + ".lock"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("view-only Load unexpectedly created a lock: %v", 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) } }