package gnome_test import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" "time" "proxyctl/internal/gnome" "proxyctl/internal/profile" "proxyctl/internal/target" "proxyctl/internal/target/targettest" ) type fakeRunner struct { values map[string]string schemas []string calls []gnome.Command fail func(gnome.Command) error } func newRunner() *fakeRunner { return &fakeRunner{schemas: []string{"org.gnome.system.proxy", "org.gnome.system.proxy.http", "org.gnome.system.proxy.https", "org.gnome.system.proxy.socks"}, values: map[string]string{ "org.gnome.system.proxy mode": "'none'", "org.gnome.system.proxy autoconfig-url": "''", "org.gnome.system.proxy ignore-hosts": "@as []", "org.gnome.system.proxy.http host": "''", "org.gnome.system.proxy.http port": "0", "org.gnome.system.proxy.http use-authentication": "false", "org.gnome.system.proxy.http authentication-user": "''", "org.gnome.system.proxy.http authentication-password": "''", "org.gnome.system.proxy.https host": "''", "org.gnome.system.proxy.https port": "0", "org.gnome.system.proxy.socks host": "''", "org.gnome.system.proxy.socks port": "0", }} } func (r *fakeRunner) Run(_ context.Context, c gnome.Command) (string, error) { r.calls = append(r.calls, c) if r.fail != nil { if err := r.fail(c); err != nil { return "", err } } if reflect.DeepEqual(c.Args, []string{"list-schemas"}) { return strings.Join(r.schemas, "\n") + "\n", nil } if len(c.Args) == 3 && c.Args[0] == "get" { key := c.Args[1] + " " + c.Args[2] value, ok := r.values[key] if !ok { return "", fmt.Errorf("missing fake value %s", key) } return value + "\n", nil } if len(c.Args) == 3 && c.Args[0] == "writable" { return "true\n", nil } if len(c.Args) == 4 && c.Args[0] == "set" { r.values[c.Args[1]+" "+c.Args[2]] = c.Args[3] return "", nil } return "", fmt.Errorf("unexpected fake command: %v", c.Args) } type memoryState struct { data []byte saveErr, deleteErr error failSaveOnce bool } func (s *memoryState) Load() ([]byte, error) { if s.data == nil { return nil, os.ErrNotExist } return append([]byte(nil), s.data...), nil } func (s *memoryState) Save(data []byte) error { if s.failSaveOnce { s.failSaveOnce = false return os.ErrPermission } if s.saveErr != nil { return s.saveErr } s.data = append([]byte(nil), data...) return nil } func (s *memoryState) Delete() error { if s.deleteErr != nil { return s.deleteErr } s.data = nil return nil } func (s *memoryState) Lock() (io.Closer, error) { return nopCloser{}, nil } type nopCloser struct{} func (nopCloser) Close() error { return nil } func adapter(r *fakeRunner, state *memoryState) *gnome.Adapter { return gnome.New(gnome.Configuration{CommandName: "gsettings", UID: 1000, GID: 1001, HomeDir: "/home/alice", ConfigDir: "/home/alice/.config", DataDir: "/home/alice/.local/share", CacheDir: "/home/alice/.cache", RuntimeDir: "/run/user/1000", StateFile: "/state/gnome.json"}, r, state) } func active() *profile.Profile { return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", HTTPSURL: "http://secure.example:8443", AllURL: "socks5://socks.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}} } func TestDetectClassifiesMalformedRecoveryMetadataAsFailureWithoutRepeatingSettingsProbe(t *testing.T) { runner := newRunner() a := adapter(runner, &memoryState{data: []byte("{malformed")}) detection := a.Detect(context.Background(), active()) if detection.Err == nil || detection.Inspection.State != target.Drifted { t.Fatalf("detection = %#v, want failed Drifted inspection", detection) } gets := 0 for _, call := range runner.calls { if len(call.Args) > 0 && call.Args[0] == "get" { gets++ } } if gets != 12 { t.Fatalf("Detect issued %d gsettings get probes, want one 12-setting inspection", gets) } } func TestAuthenticationPasswordSetFailureSuppressesRunnerErrorAndProfileArgument(t *testing.T) { const profilePassword = "GNOME-PROFILE-PASSWORD-07" r, state := newRunner(), &memoryState{} r.fail = func(c gnome.Command) error { if len(c.Args) == 4 && c.Args[0] == "set" && c.Args[2] == "authentication-password" { return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission) } return nil } p := *active() p.HTTPURL = "http://alice:" + profilePassword + "@proxy.example:8080" result := adapter(r, state).SetDesired(context.Background(), true, &p) if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) { t.Fatalf("set error classification = %#v", result) } if strings.Contains(result.Err.Error(), profilePassword) || strings.Contains(result.Err.Error(), "runner echoed") { t.Fatalf("set exposed runner error or password argument: %v", result.Err) } if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") { t.Fatalf("set lacks fixed safe message: %v", result.Err) } } func TestAuthenticationPasswordRestoreFailureSuppressesRunnerErrorAndSavedArgument(t *testing.T) { const savedPassword = "GNOME-SAVED-ORIGINAL-PASSWORD-07" r, state := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy.http authentication-password"] = "'" + savedPassword + "'" a := adapter(r, state) if result := a.SetDesired(context.Background(), true, active()); result.Err != nil { t.Fatalf("enable = %#v", result) } r.fail = func(c gnome.Command) error { if reflect.DeepEqual(c.Args[:min(len(c.Args), 3)], []string{"set", "org.gnome.system.proxy.http", "authentication-password"}) { return fmt.Errorf("runner echoed argument %s: %w", c.Args[3], os.ErrPermission) } return nil } result := a.SetDesired(context.Background(), false, nil) if result.Err == nil || !errors.Is(result.Err, os.ErrPermission) { t.Fatalf("restore error classification = %#v", result) } if strings.Contains(result.Err.Error(), savedPassword) || strings.Contains(result.Err.Error(), "runner echoed") { t.Fatalf("restore exposed runner error or password argument: %v", result.Err) } if !strings.Contains(result.Err.Error(), "sensitive argument and runner error suppressed") { t.Fatalf("restore lacks fixed safe message: %v", result.Err) } } func TestIncompleteRecoveryMetadataRejectsMutationAndPreservesGNOMESettings(t *testing.T) { cases := map[string]string{ "empty object": `{}`, "missing original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `"}`, "incomplete original": `{"version":1,"lastFingerprint":"` + strings.Repeat("a", 64) + `","original":{"mode":"none"}}`, "invalid fingerprint": `{"version":1,"lastFingerprint":"short","original":{}}`, } for name, metadata := range cases { t.Run(name, func(t *testing.T) { r, state := newRunner(), &memoryState{data: []byte(metadata)} before := make(map[string]string, len(r.values)) for key, value := range r.values { before[key] = value } a := adapter(r, state) if inspection := a.Inspect(context.Background(), nil); inspection.State != target.Drifted { t.Fatalf("inspection = %#v", inspection) } callStart := len(r.calls) result := a.SetDesired(context.Background(), false, nil) if result.Err == nil || result.Inspection.State != target.Drifted { t.Fatalf("unsafe disable = %#v", result) } if !reflect.DeepEqual(r.values, before) || string(state.data) != metadata { t.Fatalf("configuration mutated: values=%#v metadata=%q", r.values, state.data) } for _, call := range r.calls[callStart:] { if len(call.Args) > 0 && call.Args[0] == "set" { t.Fatalf("unsafe metadata caused set: %#v", call) } } }) } } func TestDiagnosticsSuppressSensitiveGSettingsOutputAndRunnerErrors(t *testing.T) { const secret = "GNOME-DIAGNOSTIC-SECRET-7f3a" for _, test := range []struct { name string value string fail bool }{ {name: "malformed value", value: "unterminated-" + secret}, {name: "runner error", value: secret, fail: true}, } { t.Run(test.name, func(t *testing.T) { r := newRunner() key := "org.gnome.system.proxy.http authentication-password" r.values[key] = test.value if test.fail { r.fail = func(c gnome.Command) error { if reflect.DeepEqual(c.Args, []string{"get", "org.gnome.system.proxy.http", "authentication-password"}) { return errors.New("runner exposed " + secret) } return nil } } a := adapter(r, &memoryState{}) checks := a.DiagnosticChecks(context.Background(), active()) for _, check := range checks { if strings.Contains(check.Evidence, secret) { t.Fatalf("diagnostic evidence exposed password: %#v", checks) } } result := a.SetDesired(context.Background(), true, active()) operationError := "" if result.Err != nil { operationError = result.Err.Error() } for _, evidence := range []string{a.Availability(context.Background()).Evidence, a.Inspect(context.Background(), active()).Evidence, operationError} { if strings.Contains(evidence, secret) { t.Fatalf("Target evidence or loggable operation error exposed password: %q", evidence) } } }) } } func TestDiagnosticChecksCoverCommandSchemasSessionAndAuthorityWithoutMutation(t *testing.T) { r, state := newRunner(), &memoryState{} a := adapter(r, state) checks := a.DiagnosticChecks(context.Background(), active()) for _, name := range []string{"required command", "required schemas", "desktop session", "write authority"} { found := false for _, check := range checks { found = found || check.Name == name } if !found { t.Errorf("diagnostics missing %q: %#v", name, checks) } } for _, call := range r.calls { if len(call.Args) > 0 && call.Args[0] == "set" { t.Fatalf("diagnostics mutated GNOME settings: %#v", call) } } if state.data != nil { t.Fatalf("diagnostics wrote GNOME recovery state: %q", state.data) } } func TestGNOMEAdapterRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { r, state := newRunner(), &memoryState{} a := adapter(r, state) return targettest.Fixture{ Target: a, Active: active(), Secret: "secret", MakeExternal: func(t *testing.T) { r.values["org.gnome.system.proxy mode"] = "'manual'" }, MakeDrifted: func(t *testing.T) { r.values["org.gnome.system.proxy.http port"] = "9999" }, PermissionFailure: func(t *testing.T) target.OperationResult { return adapter(newRunner(), &memoryState{saveErr: os.ErrPermission}).SetDesired(context.Background(), true, active()) }, PartialFailure: func(t *testing.T) target.OperationResult { failedRunner := newRunner() failedRunner.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "set" { return os.ErrPermission } return nil } return adapter(failedRunner, &memoryState{}).SetDesired(context.Background(), true, active()) }, RedactionFailure: func(t *testing.T) target.OperationResult { p := *active() p.HTTPURL = "http://alice:secret@proxy.example:8080" failedRunner := newRunner() failedRunner.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "set" { return errors.New("write password secret failed") } return nil } return adapter(failedRunner, &memoryState{}).SetDesired(context.Background(), true, &p) }, RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) { retryRunner, retryState := newRunner(), &memoryState{failSaveOnce: true} retryRunner.values["org.gnome.system.proxy mode"] = "'auto'" retryAdapter := adapter(retryRunner, retryState) first := retryAdapter.SetDesired(context.Background(), true, active()) retry := retryAdapter.SetDesired(context.Background(), true, active()) disabled := retryAdapter.SetDesired(context.Background(), false, nil) return first, retry, disabled }, } }) } func TestAvailabilityRequiresCommandSchemasAndUsableSession(t *testing.T) { t.Run("available", func(t *testing.T) { got := adapter(newRunner(), &memoryState{}).Availability(context.Background()) if !got.Available || !strings.Contains(got.Evidence, "schema") { t.Fatalf("%#v", got) } }) t.Run("missing schema", func(t *testing.T) { r := newRunner() r.schemas = r.schemas[:3] got := adapter(r, &memoryState{}).Availability(context.Background()) if got.Available || !strings.Contains(got.Evidence, "org.gnome.system.proxy.socks") { t.Fatalf("%#v", got) } }) t.Run("missing command", func(t *testing.T) { r := newRunner() r.fail = func(c gnome.Command) error { return os.ErrNotExist } got := adapter(r, &memoryState{}).Availability(context.Background()) if got.Available || !strings.Contains(got.Evidence, "gsettings") { t.Fatalf("%#v", got) } }) t.Run("session", func(t *testing.T) { r := newRunner() r.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "get" { return errors.New("Cannot autolaunch D-Bus without X11 $DISPLAY") } return nil } got := adapter(r, &memoryState{}).Availability(context.Background()) if got.Available || !strings.Contains(got.Evidence, "D-Bus") { t.Fatalf("%#v", got) } }) } func TestDescriptionStatesDesktopScopeWithoutUniversalCoverage(t *testing.T) { d := adapter(newRunner(), &memoryState{}).Description() if !strings.Contains(d.Details, "GNOME system network settings") || !strings.Contains(d.Details, "not every graphical application") { t.Fatalf("%#v", d) } } func TestGVariantSingleAndDoubleQuotedValuesRestoreCanonically(t *testing.T) { r, s := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy autoconfig-url"] = `"https://config.example/don't\\path"` r.values["org.gnome.system.proxy ignore-hosts"] = `[ "don't.example", 'back\\slash' ]` a := adapter(r, s) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatalf("enable: %#v", got) } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil { t.Fatalf("disable: %#v", got) } if got := r.values["org.gnome.system.proxy autoconfig-url"]; got != `"https://config.example/don't\\path"` { t.Fatalf("canonical autoconfig URL = %s", got) } if got := r.values["org.gnome.system.proxy ignore-hosts"]; got != `["don't.example", 'back\\slash']` { t.Fatalf("canonical ignore hosts = %s", got) } } func TestEnableSnapshotsAppliesInspectsAndRestoresExternalSettings(t *testing.T) { r, s := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy autoconfig-url"] = "'https://config.example/proxy.pac'" r.values["org.gnome.system.proxy ignore-hosts"] = "['old.local']" a := adapter(r, s) if got := a.Inspect(context.Background(), active()); got.State != target.External || !strings.Contains(got.Evidence, "automatic") { t.Fatalf("initial %#v", got) } if got := a.SetDesired(context.Background(), true, active()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable %#v", got) } want := map[string]string{ "org.gnome.system.proxy mode": "'manual'", "org.gnome.system.proxy autoconfig-url": "''", "org.gnome.system.proxy ignore-hosts": "['localhost', '*.example.com', '10.0.0.0/8', '2001:db8::1']", "org.gnome.system.proxy.http host": "'proxy.example'", "org.gnome.system.proxy.http port": "8080", "org.gnome.system.proxy.https host": "'secure.example'", "org.gnome.system.proxy.https port": "8443", "org.gnome.system.proxy.socks host": "'socks.example'", "org.gnome.system.proxy.socks port": "1080", } for key, value := range want { if r.values[key] != value { t.Errorf("%s=%q want %q", key, r.values[key], value) } } if len(s.data) == 0 { t.Fatal("original/recovery state not persisted") } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("disable %#v", got) } if r.values["org.gnome.system.proxy mode"] != "'auto'" || r.values["org.gnome.system.proxy autoconfig-url"] != "'https://config.example/proxy.pac'" || r.values["org.gnome.system.proxy ignore-hosts"] != "['old.local']" || s.data != nil { t.Fatalf("original not restored: mode=%s url=%s bypass=%s state=%q", r.values["org.gnome.system.proxy mode"], r.values["org.gnome.system.proxy autoconfig-url"], r.values["org.gnome.system.proxy ignore-hosts"], s.data) } } func TestInspectionDistinguishesFourStatesUsingPositiveSettingsEvidence(t *testing.T) { r, s := newRunner(), &memoryState{} a := adapter(r, s) if got := a.Inspect(context.Background(), active()); got.State != target.Disabled || !strings.Contains(got.Evidence, "mode is none") { t.Fatalf("disabled %#v", got) } r.values["org.gnome.system.proxy mode"] = "'manual'" if got := a.Inspect(context.Background(), active()); got.State != target.External || !strings.Contains(got.Evidence, "manual") { t.Fatalf("external %#v", got) } if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } if got := a.Inspect(context.Background(), active()); got.State != target.Enabled { t.Fatalf("enabled %#v", got) } r.values["org.gnome.system.proxy.http port"] = "9999" if got := a.Inspect(context.Background(), active()); got.State != target.Drifted || !strings.Contains(got.Evidence, "managed") { t.Fatalf("drifted %#v", got) } } func TestSingleSOCKSProxyUsesGNOMESOCKSForAllProtocols(t *testing.T) { r, s := newRunner(), &memoryState{} p := &profile.Profile{Name: "simple", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080", Bypass: []string{"localhost"}} got := adapter(r, s).SetDesired(context.Background(), true, p) if got.Err != nil || got.Inspection.State != target.Enabled || r.values["org.gnome.system.proxy.socks host"] != "'proxy.example'" || r.values["org.gnome.system.proxy.http host"] != "''" { t.Fatalf("single proxy result=%#v values=%#v", got, r.values) } } func TestDualProtocolEndpointConfiguresHTTPAndSOCKSAtSameAddress(t *testing.T) { r, s := newRunner(), &memoryState{} p := &profile.Profile{ Name: "dual", Mode: profile.AdvancedMode, HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890", } got := adapter(r, s).SetDesired(context.Background(), true, p) if got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("dual-protocol result = %#v", got) } for _, key := range []string{"org.gnome.system.proxy.http host", "org.gnome.system.proxy.https host", "org.gnome.system.proxy.socks host"} { if r.values[key] != "'localhost'" { t.Errorf("%s = %q", key, r.values[key]) } } for _, key := range []string{"org.gnome.system.proxy.http port", "org.gnome.system.proxy.https port", "org.gnome.system.proxy.socks port"} { if r.values[key] != "7890" { t.Errorf("%s = %q", key, r.values[key]) } } } func TestAdvancedHTTPProxyCredentialsArePreserved(t *testing.T) { r, s := newRunner(), &memoryState{} p := *active() p.HTTPURL = "http://alice:secret@proxy.example:8080" got := adapter(r, s).SetDesired(context.Background(), true, &p) if got.Err != nil || got.Inspection.State != target.Enabled || r.values["org.gnome.system.proxy.http use-authentication"] != "true" || r.values["org.gnome.system.proxy.http authentication-user"] != "'alice'" || r.values["org.gnome.system.proxy.http authentication-password"] != "'secret'" { t.Fatalf("authenticated HTTP result=%#v values=%#v", got, r.values) } } func TestValidationRejectsUnrepresentableProtocolsAndBypassBeforeMutation(t *testing.T) { cases := []struct { name string mutate func(*profile.Profile) message string }{ {"HTTPS proxy transport", func(p *profile.Profile) { p.HTTPSURL = "https://secure.example:8443" }, "HTTPS proxy URL"}, {"remote DNS SOCKS", func(p *profile.Profile) { p.AllURL = "socks5h://socks.example:1080" }, "socks5h"}, {"HTTP wrong transport", func(p *profile.Profile) { p.HTTPURL = "socks5://proxy.example:8080" }, "HTTP proxy URL"}, {"single HTTP cannot cover ALL", func(p *profile.Profile) { p.Mode = profile.SingleMode; p.SingleURL = "http://proxy.example:8080" }, "Single proxy URL"}, {"HTTPS credentials", func(p *profile.Profile) { p.HTTPSURL = "http://alice:secret@secure.example:8443" }, "credentials"}, {"wildcard bypass", func(p *profile.Profile) { p.Bypass = []string{"*.example.com"} }, "Bypass"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r, s := newRunner(), &memoryState{} p := *active() tc.mutate(&p) before := len(r.calls) got := adapter(r, s).SetDesired(context.Background(), true, &p) if got.Err == nil || !strings.Contains(got.Err.Error(), tc.message) || s.data != nil { t.Fatalf("result %#v state=%q", got, s.data) } for _, c := range r.calls[before:] { if len(c.Args) > 0 && c.Args[0] == "set" { t.Fatalf("mutated before rejection: %#v", c) } } }) } } func TestCommandContractCarriesInvokingUserIdentityAndSessionEnvironment(t *testing.T) { r := newRunner() a := adapter(r, &memoryState{}) _ = a.Inspect(context.Background(), active()) if len(r.calls) == 0 { t.Fatal("no commands") } for _, c := range r.calls { if c.Name != "gsettings" || c.UID != 1000 || c.GID != 1001 || c.Env["HOME"] != "/home/alice" || c.Env["XDG_CONFIG_HOME"] != "/home/alice/.config" || c.Env["XDG_DATA_HOME"] != "/home/alice/.local/share" || c.Env["XDG_CACHE_HOME"] != "/home/alice/.cache" || c.Env["XDG_RUNTIME_DIR"] != "/run/user/1000" || c.Env["DBUS_SESSION_BUS_ADDRESS"] != "unix:path=/run/user/1000/bus" { t.Fatalf("command identity %#v", c) } } } func TestMidOperationSetFailuresExposePartialStateAndRemainRecoverable(t *testing.T) { t.Run("enable", func(t *testing.T) { r, s := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'" setCount := 0 r.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "set" { setCount++ if setCount == 4 { return os.ErrPermission } } return nil } a := adapter(r, s) got := a.SetDesired(context.Background(), true, active()) if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Drifted { t.Fatalf("partial enable = %#v", got) } if setCount != 4 || r.values["org.gnome.system.proxy autoconfig-url"] != "''" || r.values["org.gnome.system.proxy ignore-hosts"] == "[]" || r.values["org.gnome.system.proxy.http host"] != "''" || s.data == nil { t.Fatalf("failure was not genuinely partial: sets=%d values=%#v metadata=%q", setCount, r.values, s.data) } r.fail = nil if got := a.SetDesired(context.Background(), true, active()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("retry enable = %#v", got) } if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("restore after retry = %#v", got) } }) t.Run("restore", func(t *testing.T) { r, s := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'" a := adapter(r, s) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } setCount := 0 r.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "set" { setCount++ if setCount == 5 { return errors.New("session disappeared") } } return nil } got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || !strings.Contains(got.Err.Error(), "session disappeared") || got.Inspection.State != target.Drifted || setCount != 5 || s.data == nil { t.Fatalf("partial restore = %#v sets=%d metadata=%q", got, setCount, s.data) } r.fail = nil // A partial restore loses ownership evidence and must not overwrite the // mixed settings on retry. before := len(r.calls) got = a.SetDesired(context.Background(), false, nil) if got.Err == nil || !strings.Contains(got.Err.Error(), "ownership") { t.Fatalf("unsafe retry = %#v", got) } for _, c := range r.calls[before:] { if len(c.Args) > 0 && c.Args[0] == "set" { t.Fatalf("retry mutated partial external state: %#v", c) } } }) } func TestRestoreMetadataDeleteFailureCanBeSafelyRetried(t *testing.T) { r, s := newRunner(), &memoryState{} r.values["org.gnome.system.proxy mode"] = "'auto'" r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'" a := adapter(r, s) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } s.deleteErr = os.ErrPermission first := a.SetDesired(context.Background(), false, nil) if first.Err == nil || !errors.Is(first.Err, os.ErrPermission) || r.values["org.gnome.system.proxy mode"] != "'auto'" || s.data == nil { t.Fatalf("first disable = %#v values=%#v metadata=%q", first, r.values, s.data) } s.deleteErr = nil before := len(r.calls) retry := a.SetDesired(context.Background(), false, nil) if retry.Err != nil || retry.Inspection.State != target.External || s.data != nil { t.Fatalf("retry disable = %#v metadata=%q", retry, s.data) } for _, c := range r.calls[before:] { if len(c.Args) > 0 && c.Args[0] == "set" { t.Fatalf("cleanup retry rewrote restored settings: %#v", c) } } } func TestFileStateLockSerializesGNOMEOperationsAcrossProcesses(t *testing.T) { if path := os.Getenv("PROXYCTL_GNOME_LOCK_HELPER_PATH"); path != "" { r := newRunner() state := gnome.NewFileState(path, -1, -1, filepath.Dir(filepath.Dir(path))) a := gnome.New(gnome.Configuration{UID: os.Getuid(), GID: os.Getgid(), HomeDir: t.TempDir(), StateFile: path}, r, state) got := a.SetDesired(context.Background(), true, active()) if got.Err != nil { t.Fatal(got.Err) } return } path := filepath.Join(t.TempDir(), "proxyctl", "gnome.json") state := gnome.NewFileState(path, -1, -1, filepath.Dir(filepath.Dir(path))) lock, err := state.Lock() if err != nil { t.Fatal(err) } command := exec.Command(os.Args[0], "-test.run=^TestFileStateLockSerializesGNOMEOperationsAcrossProcesses$") command.Env = append(os.Environ(), "PROXYCTL_GNOME_LOCK_HELPER_PATH="+path) done := make(chan error, 1) go func() { done <- command.Run() }() select { case err := <-done: t.Fatalf("child operation did not wait for lock: %v", err) case <-time.After(100 * time.Millisecond): } if err := lock.Close(); err != nil { t.Fatal(err) } select { case err := <-done: if err != nil { t.Fatalf("child operation after unlock: %v", err) } case <-time.After(5 * time.Second): _ = command.Process.Kill() t.Fatal("child operation remained blocked after unlock") } } func TestFailuresAreImmediateTruthfulAndPreserveOwnership(t *testing.T) { t.Run("permission before mutation", func(t *testing.T) { r, s := newRunner(), &memoryState{} r.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "set" { return os.ErrPermission } return nil } got := adapter(r, s).SetDesired(context.Background(), true, active()) if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Drifted { t.Fatalf("%#v", got) } }) t.Run("recovery must precede mutation", func(t *testing.T) { r, s := newRunner(), &memoryState{saveErr: os.ErrPermission} got := adapter(r, s).SetDesired(context.Background(), true, active()) if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Disabled { t.Fatalf("%#v", got) } for _, c := range r.calls { if len(c.Args) > 0 && c.Args[0] == "set" { t.Fatalf("mutation occurred: %#v", c) } } }) t.Run("drift refuses restore", func(t *testing.T) { r, s := newRunner(), &memoryState{} a := adapter(r, s) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } r.values["org.gnome.system.proxy.http host"] = "'external-change'" got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || !strings.Contains(got.Err.Error(), "ownership") || got.Inspection.State != target.Drifted || s.data == nil || r.values["org.gnome.system.proxy.http host"] != "'external-change'" { t.Fatalf("%#v", got) } }) t.Run("command failure", func(t *testing.T) { r, s := newRunner(), &memoryState{} a := adapter(r, s) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } r.fail = func(c gnome.Command) error { if len(c.Args) > 0 && c.Args[0] == "get" { return errors.New("session bus unavailable") } return nil } got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || !strings.Contains(got.Err.Error(), "session bus") || got.Inspection.State != target.Drifted { t.Fatalf("%#v", got) } }) } func TestFakeCommandNeverTouchesDesktop(t *testing.T) { r := newRunner() a := adapter(r, &memoryState{}) if got := a.SetDesired(context.Background(), true, active()); got.Err != nil { t.Fatal(got.Err) } if len(r.calls) == 0 { t.Fatal("expected fake commands") } } func TestOSStateStoreWritesOwnerOnlyAtomically(t *testing.T) { path := filepath.Join(t.TempDir(), "state", "gnome.json") store := gnome.NewFileState(path, -1, -1, filepath.Dir(filepath.Dir(path))) if err := store.Save([]byte("secret")); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("mode=%o", info.Mode().Perm()) } data, err := store.Load() if err != nil || string(data) != "secret" { t.Fatalf("load=%q err=%v", data, err) } if err := store.Delete(); err != nil { t.Fatal(err) } }