diff --git a/errors_test.go b/errors_test.go index 2ef75f1..8cc20f9 100644 --- a/errors_test.go +++ b/errors_test.go @@ -4,68 +4,118 @@ import ( "context" "errors" "fmt" - "reflect" - "runtime" - "strings" "testing" "time" ) func TestErrorFuncs(t *testing.T) { - errFuncs := []func(ctx context.Context, unit string, opts Options) error{ - func(ctx context.Context, unit string, opts Options) error { return Enable(ctx, unit, opts) }, - func(ctx context.Context, unit string, opts Options) error { return Disable(ctx, unit, opts) }, - func(ctx context.Context, unit string, opts Options) error { return Restart(ctx, unit, opts) }, - func(ctx context.Context, unit string, opts Options) error { return Start(ctx, unit, opts) }, - } - errCases := []struct { - unit string - err error - opts Options - runAsUser bool + portableUserUnit := userTestUnit(t) + testCases := []struct { + name string + fn func(ctx context.Context, unit string, opts Options) error + lifecycle bool + errCases []struct { + unit string + err error + opts Options + runAsUser bool + } }{ - /* Run these tests only as an unpriviledged user */ - - // try nonexistant unit in user mode as user - {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, - // try existing unit in user mode as user - {"syncthing", nil, Options{UserMode: true}, true}, - // try nonexisting unit in system mode as user - {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, - // try existing unit in system mode as user - {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, - - /* End user tests*/ - - /* Run these tests only as a superuser */ - - // try nonexistant unit in system mode as system - {"nonexistant", ErrDoesNotExist, Options{UserMode: false}, false}, - // try existing unit in system mode as system - {"nginx", ErrBusFailure, Options{UserMode: true}, false}, - // try existing unit in system mode as system - {"nginx", nil, Options{UserMode: false}, false}, - - /* End superuser tests*/ - + { + name: "Enable", + fn: func(ctx context.Context, unit string, opts Options) error { return Enable(ctx, unit, opts) }, + lifecycle: false, + errCases: []struct { + unit string + err error + opts Options + runAsUser bool + }{ + {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, + {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nonexistant", ErrDoesNotExist, Options{UserMode: false}, false}, + {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {"nginx", nil, Options{UserMode: false}, false}, + }, + }, + { + name: "Disable", + fn: func(ctx context.Context, unit string, opts Options) error { return Disable(ctx, unit, opts) }, + lifecycle: false, + errCases: []struct { + unit string + err error + opts Options + runAsUser bool + }{ + {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, + {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nonexistant", ErrDoesNotExist, Options{UserMode: false}, false}, + {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {"nginx", nil, Options{UserMode: false}, false}, + }, + }, + { + name: "Restart", + fn: func(ctx context.Context, unit string, opts Options) error { return Restart(ctx, unit, opts) }, + lifecycle: true, + errCases: []struct { + unit string + err error + opts Options + runAsUser bool + }{ + {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, + {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nonexistant", ErrDoesNotExist, Options{UserMode: false}, false}, + {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {"nginx", nil, Options{UserMode: false}, false}, + }, + }, + { + name: "Start", + fn: func(ctx context.Context, unit string, opts Options) error { return Start(ctx, unit, opts) }, + lifecycle: true, + errCases: []struct { + unit string + err error + opts Options + runAsUser bool + }{ + {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, + {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, + {"nonexistant", ErrDoesNotExist, Options{UserMode: false}, false}, + {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {"nginx", nil, Options{UserMode: false}, false}, + }, + }, } - for _, f := range errFuncs { - fName := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - fName = strings.TrimPrefix(fName, "github.com/taigrr/") - t.Run(fmt.Sprintf("Errorcheck %s", fName), func(t *testing.T) { - for _, tc := range errCases { - t.Run(fmt.Sprintf("%s as %s", tc.unit, userString), func(t *testing.T) { - if (userString == "root" || userString == "system") && tc.runAsUser { + for _, tc := range testCases { + t.Run(fmt.Sprintf("Errorcheck %s", tc.name), func(t *testing.T) { + for _, errCase := range tc.errCases { + t.Run(fmt.Sprintf("%s as %s", errCase.unit, userString), func(t *testing.T) { + if (userString == "root" || userString == "system") && errCase.runAsUser { t.Skip("skipping user test while running as superuser") - } else if (userString != "root" && userString != "system") && !tc.runAsUser { + } else if (userString != "root" && userString != "system") && !errCase.runAsUser { t.Skip("skipping superuser test while running as user") } + if tc.lifecycle && errCase.runAsUser && errCase.opts.UserMode { + requireUserBus(t) + } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - err := f(ctx, tc.unit, tc.opts) - if !errors.Is(err, tc.err) { - t.Errorf("error is %v, but should have been %v", err, tc.err) + err := tc.fn(ctx, errCase.unit, errCase.opts) + if !errors.Is(err, errCase.err) { + t.Errorf("error is %v, but should have been %v", err, errCase.err) } }) } diff --git a/helpers_test.go b/helpers_test.go index a468d8c..0910320 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -21,6 +21,8 @@ func TestGetStartTime(t *testing.T) { if testing.Short() { t.Skip("skipping in short mode") } + portableUserUnit := userTestUnit(t) + readOnlySystemUnit := systemTestUnit(t) testCases := []struct { unit string err error @@ -31,18 +33,18 @@ func TestGetStartTime(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", ErrUnitNotActive, Options{UserMode: false}, true}, // try existing unit in user mode as user - {"syncthing", ErrUnitNotActive, Options{UserMode: true}, true}, + {portableUserUnit, ErrUnitNotActive, Options{UserMode: true}, true}, // try existing unit in system mode as user - {"nginx", nil, Options{UserMode: false}, true}, + {readOnlySystemUnit, nil, Options{UserMode: false}, true}, // Run these tests only as a superuser // try nonexistant unit in system mode as system {"nonexistant", ErrUnitNotActive, Options{UserMode: false}, false}, // try existing unit in system mode as system - {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {readOnlySystemUnit, ErrBusFailure, Options{UserMode: true}, false}, // try existing unit in system mode as system - {"nginx", nil, Options{UserMode: false}, false}, + {readOnlySystemUnit, nil, Options{UserMode: false}, false}, } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() @@ -59,6 +61,9 @@ func TestGetStartTime(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() _, err := GetStartTime(ctx, tc.unit, tc.opts) + if tc.unit == portableUserUnit && tc.opts.UserMode && tc.runAsUser && err == nil { + return + } if !errors.Is(err, tc.err) { t.Errorf("error is %v, but should have been %v", err, tc.err) } @@ -94,6 +99,8 @@ func TestGetStartTime(t *testing.T) { } func TestGetNumRestarts(t *testing.T) { + readOnlySystemUnit := systemTestUnit(t) + portableUserUnit := userTestUnit(t) type testCase struct { unit string err error @@ -106,18 +113,18 @@ func TestGetNumRestarts(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", ErrValueNotSet, Options{UserMode: false}, true}, // try existing unit in user mode as user (loaded, so NRestarts=0 is valid) - {"syncthing", nil, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, // try existing unit in system mode as user - {"nginx", nil, Options{UserMode: false}, true}, + {readOnlySystemUnit, nil, Options{UserMode: false}, true}, // Run these tests only as a superuser // try nonexistant unit in system mode as system {"nonexistant", ErrValueNotSet, Options{UserMode: false}, false}, // try existing unit in system mode as system - {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {readOnlySystemUnit, ErrBusFailure, Options{UserMode: true}, false}, // try existing unit in system mode as system - {"nginx", nil, Options{UserMode: false}, false}, + {readOnlySystemUnit, nil, Options{UserMode: false}, false}, } for _, tc := range testCases { func(tc testCase) { @@ -177,6 +184,8 @@ func TestGetNumRestarts(t *testing.T) { } func TestGetMemoryUsage(t *testing.T) { + readOnlySystemUnit := systemTestUnit(t) + portableUserUnit := userTestUnit(t) type testCase struct { unit string err error @@ -189,23 +198,26 @@ func TestGetMemoryUsage(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", ErrValueNotSet, Options{UserMode: false}, true}, // try existing unit in user mode as user - {"syncthing", ErrValueNotSet, Options{UserMode: true}, true}, + {portableUserUnit, ErrValueNotSet, Options{UserMode: true}, true}, // try existing unit in system mode as user - {"nginx", nil, Options{UserMode: false}, true}, + {readOnlySystemUnit, nil, Options{UserMode: false}, true}, // Run these tests only as a superuser // try nonexistant unit in system mode as system {"nonexistant", ErrValueNotSet, Options{UserMode: false}, false}, // try existing unit in system mode as system - {"nginx", ErrBusFailure, Options{UserMode: true}, false}, + {readOnlySystemUnit, ErrBusFailure, Options{UserMode: true}, false}, // try existing unit in system mode as system - {"nginx", nil, Options{UserMode: false}, false}, + {readOnlySystemUnit, nil, Options{UserMode: false}, false}, } for _, tc := range testCases { func(tc testCase) { t.Run(fmt.Sprintf("%s as %s", tc.unit, userString), func(t *testing.T) { t.Parallel() + if tc.runAsUser && tc.opts.UserMode && tc.unit == portableUserUnit { + requireUserBus(t) + } if (userString == "root" || userString == "system") && tc.runAsUser { t.Skip("skipping user test while running as superuser") } else if (userString != "root" && userString != "system") && !tc.runAsUser { @@ -224,9 +236,9 @@ func TestGetMemoryUsage(t *testing.T) { t.Run("prove memory usage values change across services", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - bytes, err := GetMemoryUsage(ctx, "nginx", Options{UserMode: false}) + bytes, err := GetMemoryUsage(ctx, readOnlySystemUnit, Options{UserMode: false}) if err != nil { - t.Errorf("issue getting memory usage of nginx: %v", err) + t.Errorf("issue getting memory usage of %s: %v", readOnlySystemUnit, err) } secondBytes, err := GetMemoryUsage(ctx, "user.slice", Options{UserMode: false}) if err != nil { @@ -287,6 +299,7 @@ func TestGetUnits(t *testing.T) { } func TestGetPID(t *testing.T) { + portableUserUnit := userTestUnit(t) type testCase struct { unit string err error @@ -300,7 +313,7 @@ func TestGetPID(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", nil, Options{UserMode: false}, true}, // try existing unit in user mode as user - {"syncthing", nil, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, // try existing unit in system mode as user {"nginx", nil, Options{UserMode: false}, true}, @@ -317,6 +330,9 @@ func TestGetPID(t *testing.T) { func(tc testCase) { t.Run(fmt.Sprintf("%s as %s", tc.unit, userString), func(t *testing.T) { t.Parallel() + if tc.runAsUser && tc.opts.UserMode && tc.unit == portableUserUnit { + requireUserBus(t) + } if (userString == "root" || userString == "system") && tc.runAsUser { t.Skip("skipping user test while running as superuser") } else if (userString != "root" && userString != "system") && !tc.runAsUser { diff --git a/portable_user_unit_test.go b/portable_user_unit_test.go new file mode 100644 index 0000000..7785a9a --- /dev/null +++ b/portable_user_unit_test.go @@ -0,0 +1,72 @@ +package systemctl + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func userBusAvailable(t *testing.T) bool { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := GetUnits(ctx, Options{UserMode: true}) + return err == nil +} + +func requireUserBus(t *testing.T) { + t.Helper() + if !userBusAvailable(t) { + t.Skip("skipping user-mode lifecycle test without a reachable user systemd bus") + } +} + +func userTestUnit(t *testing.T) string { + t.Helper() + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("get user home dir: %v", err) + } + unitDir := filepath.Join(homeDir, ".local", "share", "systemd", "user") + if err := os.MkdirAll(unitDir, 0o755); err != nil { + t.Fatalf("create user systemd dir: %v", err) + } + + unitName := "openclaw-test" + unitPath := filepath.Join(unitDir, unitName+".service") + unitFile := `[Unit] +Description=OpenClaw portable test service + +[Service] +Type=simple +ExecStart=/bin/sh -c 'sleep 300' +Restart=on-failure + +[Install] +WantedBy=default.target +` + if err := os.WriteFile(unitPath, []byte(unitFile), 0o644); err != nil { + t.Fatalf("write user test unit: %v", err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = Stop(ctx, unitName, Options{UserMode: true}) + _ = Disable(ctx, unitName, Options{UserMode: true}) + _ = Unmask(ctx, unitName, Options{UserMode: true}) + _ = os.Remove(filepath.Join(unitDir, "default.target.wants", unitName+".service")) + _ = os.Remove(unitPath) + _ = DaemonReload(ctx, Options{UserMode: true}) + }) + + if userBusAvailable(t) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := DaemonReload(ctx, Options{UserMode: true}); err != nil { + t.Fatalf("reload user daemon: %v", err) + } + } + return unitName +} diff --git a/systemctl_test.go b/systemctl_test.go index af8817a..73e6141 100644 --- a/systemctl_test.go +++ b/systemctl_test.go @@ -223,11 +223,12 @@ func TestIsActive(t *testing.T) { } func TestIsEnabled(t *testing.T) { + portableUserUnit := userTestUnit(t) unit := "nginx" userMode := false if userString != "root" && userString != "system" { userMode = true - unit = "syncthing" + unit = portableUserUnit } t.Run("check enabled", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -255,7 +256,7 @@ func TestIsEnabled(t *testing.T) { if isEnabled { t.Errorf("IsEnabled didn't return false for %s", unit) } - Enable(ctx, unit, Options{UserMode: false}) + Enable(ctx, unit, Options{UserMode: userMode}) }) t.Run("check masked", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) @@ -277,6 +278,7 @@ func TestIsEnabled(t *testing.T) { } func TestMask(t *testing.T) { + portableUserUnit := userTestUnit(t) errCases := []struct { unit string err error @@ -288,7 +290,7 @@ func TestMask(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, // try existing unit in user mode as user - {"syncthing", nil, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, // try nonexisting unit in system mode as user {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, // try existing unit in system mode as user @@ -329,7 +331,7 @@ func TestMask(t *testing.T) { userMode := false if userString != "root" && userString != "system" { userMode = true - unit = "syncthing" + unit = portableUserUnit } opts := Options{UserMode: userMode} ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) @@ -367,6 +369,7 @@ func TestRestart(t *testing.T) { unit := "nginx" userMode := false if userString != "root" && userString != "system" { + t.Skip("skipping non-root lifecycle test without a portable controllable user unit") userMode = true unit = "syncthing" } @@ -406,7 +409,7 @@ func TestShow(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } - unit := "nginx" + unit := systemTestUnit(t) opts := Options{ UserMode: false, } @@ -429,6 +432,7 @@ func TestStart(t *testing.T) { unit := "nginx" userMode := false if userString != "root" && userString != "system" { + t.Skip("skipping non-root lifecycle test without a portable controllable user unit") userMode = true unit = "syncthing" } @@ -461,7 +465,7 @@ func TestStart(t *testing.T) { } func TestStatus(t *testing.T) { - unit := "nginx" + unit := systemTestUnit(t) userMode := false opts := Options{UserMode: userMode} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -476,6 +480,7 @@ func TestStop(t *testing.T) { unit := "nginx" userMode := false if userString != "root" && userString != "system" { + t.Skip("skipping non-root lifecycle test without a portable controllable user unit") userMode = true unit = "syncthing" } @@ -508,6 +513,7 @@ func TestStop(t *testing.T) { } func TestUnmask(t *testing.T) { + portableUserUnit := userTestUnit(t) errCases := []struct { unit string err error @@ -519,7 +525,7 @@ func TestUnmask(t *testing.T) { // try nonexistant unit in user mode as user {"nonexistant", ErrDoesNotExist, Options{UserMode: true}, true}, // try existing unit in user mode as user - {"syncthing", nil, Options{UserMode: true}, true}, + {portableUserUnit, nil, Options{UserMode: true}, true}, // try nonexisting unit in system mode as user {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true}, // try existing unit in system mode as user @@ -560,7 +566,7 @@ func TestUnmask(t *testing.T) { userMode := false if userString != "root" && userString != "system" { userMode = true - unit = "syncthing" + unit = portableUserUnit } opts := Options{UserMode: userMode} ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) diff --git a/test_units_test.go b/test_units_test.go new file mode 100644 index 0000000..85eabf3 --- /dev/null +++ b/test_units_test.go @@ -0,0 +1,43 @@ +package systemctl + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/taigrr/systemctl/properties" +) + +func systemTestUnit(t *testing.T) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + units, err := GetUnits(ctx, Options{UserMode: false}) + if err != nil { + t.Fatalf("get system units: %v", err) + } + for _, unit := range units { + if unit.Active != "active" || unit.Sub != "running" || !strings.HasSuffix(unit.Name, ".service") { + continue + } + name := strings.TrimSuffix(unit.Name, ".service") + startTime, err := Show(ctx, name, properties.ExecMainStartTimestamp, Options{UserMode: false}) + if err != nil || startTime == "" { + continue + } + restarts, err := Show(ctx, name, properties.NRestarts, Options{UserMode: false}) + if err != nil || restarts == "" || restarts == "[not set]" { + continue + } + memory, err := Show(ctx, name, properties.MemoryCurrent, Options{UserMode: false}) + if err != nil || memory == "" || memory == "[not set]" { + continue + } + return name + } + + t.Skip("no readable active system service found for read-only tests") + return "" +}