186 lines
5.4 KiB
Go
186 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|