Files

765 lines
29 KiB
Go

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)
}
}