mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
fix(testenv): isolate config locks from the real user home
This commit is contained in:
1 parent
cc08d5552b
commit
9e68ee8f25
8 files changed
+236
-21
No files matched your search
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
|
||||
go test ./internal/tools/builtin/ -run TestGrep # one test
|
||||
```
|
||||
|
||||
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
|
||||
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
|
||||
registry, while explicitly isolated Reasonix homes retain their own locks.
|
||||
|
||||
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
|
||||
skips the isolation guard must install the inherited home with
|
||||
`testscope.InstallHome` before running tests.
|
||||
|
||||
The environment variable alone cannot activate a test scope. Production
|
||||
executables still use the OS account's home for shared config locks.
|
||||
|
||||
The guard removes its scratch home on normal completion. Scratch homes are
|
||||
created under the original home, so an interrupted run can still leave
|
||||
`.reasonix-test-home-*` directories; this is separate from persistent
|
||||
config-edit locks in the real `.reasonix/locks` directory.
|
||||
|
||||
### Code style
|
||||
|
||||
- `gofmt` is enforced by CI — format before committing
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/base/testscope"
|
||||
)
|
||||
|
||||
// TestingM is the subset of testing.M needed by RunWithIsolatedUserState.
|
||||
@@ -17,10 +19,9 @@ type savedEnvironment struct {
|
||||
set bool
|
||||
}
|
||||
|
||||
// IsolateUserState redirects default user-scoped Reasonix paths to a disposable
|
||||
// home and clears inherited explicit path overrides. Tests may still override
|
||||
// any of these variables for a focused scenario after this process-level guard
|
||||
// is installed.
|
||||
// IsolateUserState redirects user-scoped paths and test-only config edit locks
|
||||
// to a disposable home, clearing inherited explicit path overrides. Tests may
|
||||
// override these variables for a focused scenario after this guard is installed.
|
||||
func IsolateUserState() (func(), error) {
|
||||
originalHome, _ := os.UserHomeDir()
|
||||
home, err := os.MkdirTemp(originalHome, ".reasonix-test-home-*")
|
||||
@@ -32,13 +33,14 @@ func IsolateUserState() (func(), error) {
|
||||
}
|
||||
|
||||
set := map[string]string{
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_CACHE_HOME": filepath.Join(home, ".cache"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"AppData": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LocalAppData": filepath.Join(home, "AppData", "Local"),
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"REASONIX_TEST_HOME": home,
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_CACHE_HOME": filepath.Join(home, ".cache"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"AppData": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LocalAppData": filepath.Join(home, "AppData", "Local"),
|
||||
}
|
||||
unset := []string{
|
||||
"REASONIX_HOME",
|
||||
@@ -82,9 +84,11 @@ func IsolateUserState() (func(), error) {
|
||||
}
|
||||
}
|
||||
|
||||
restoreScope := testscope.InstallHome(home)
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
restoreScope()
|
||||
restore()
|
||||
_ = os.RemoveAll(home)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testscope"
|
||||
)
|
||||
|
||||
func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) {
|
||||
@@ -20,6 +22,7 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) {
|
||||
"REASONIX_HOME": filepath.Join(callerHome, "explicit-reasonix-home"),
|
||||
"REASONIX_STATE_HOME": filepath.Join(callerHome, "caller-state"),
|
||||
"REASONIX_CACHE_HOME": filepath.Join(callerHome, "caller-cache"),
|
||||
"REASONIX_TEST_HOME": filepath.Join(callerHome, "caller-test-home"),
|
||||
}
|
||||
for key, value := range callerEnvironment {
|
||||
t.Setenv(key, value)
|
||||
@@ -38,13 +41,14 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) {
|
||||
t.Fatalf("isolated home %q is not contained by caller home %q", isolateHome, callerHome)
|
||||
}
|
||||
wantIsolatedEnvironment := map[string]string{
|
||||
"HOME": isolateHome,
|
||||
"USERPROFILE": isolateHome,
|
||||
"XDG_CONFIG_HOME": filepath.Join(isolateHome, ".config"),
|
||||
"XDG_CACHE_HOME": filepath.Join(isolateHome, ".cache"),
|
||||
"XDG_STATE_HOME": filepath.Join(isolateHome, ".local", "state"),
|
||||
"AppData": filepath.Join(isolateHome, "AppData", "Roaming"),
|
||||
"LocalAppData": filepath.Join(isolateHome, "AppData", "Local"),
|
||||
"HOME": isolateHome,
|
||||
"REASONIX_TEST_HOME": isolateHome,
|
||||
"USERPROFILE": isolateHome,
|
||||
"XDG_CONFIG_HOME": filepath.Join(isolateHome, ".config"),
|
||||
"XDG_CACHE_HOME": filepath.Join(isolateHome, ".cache"),
|
||||
"XDG_STATE_HOME": filepath.Join(isolateHome, ".local", "state"),
|
||||
"AppData": filepath.Join(isolateHome, "AppData", "Roaming"),
|
||||
"LocalAppData": filepath.Join(isolateHome, "AppData", "Local"),
|
||||
}
|
||||
for key, want := range wantIsolatedEnvironment {
|
||||
if got := os.Getenv(key); got != want {
|
||||
@@ -67,3 +71,53 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) {
|
||||
t.Fatalf("isolated home still exists after cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsolateUserStateRestoresUnsetTestHomeAndNestedScopes(t *testing.T) {
|
||||
callerScope := testscope.Home()
|
||||
t.Setenv("REASONIX_TEST_HOME", "")
|
||||
if err := os.Unsetenv("REASONIX_TEST_HOME"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanup, err := IsolateUserState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
outer := os.Getenv("REASONIX_TEST_HOME")
|
||||
if got := testscope.Home(); got != outer {
|
||||
t.Fatalf("installed scope = %q, want %q", got, outer)
|
||||
}
|
||||
nestedCleanup, err := IsolateUserState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nestedCleanup)
|
||||
inner := os.Getenv("REASONIX_TEST_HOME")
|
||||
if inner == "" || inner == outer {
|
||||
t.Fatalf("nested test home = %q, outer = %q", inner, outer)
|
||||
}
|
||||
if got := testscope.Home(); got != inner {
|
||||
t.Fatalf("nested scope = %q, want %q", got, inner)
|
||||
}
|
||||
nestedCleanup()
|
||||
if got := os.Getenv("REASONIX_TEST_HOME"); got != outer {
|
||||
t.Fatalf("restored test home = %q, want %q", got, outer)
|
||||
}
|
||||
if got := testscope.Home(); got != outer {
|
||||
t.Fatalf("restored scope = %q, want %q", got, outer)
|
||||
}
|
||||
if _, err := os.Stat(inner); !os.IsNotExist(err) {
|
||||
t.Fatalf("nested test home remains after cleanup: %v", err)
|
||||
}
|
||||
cleanup()
|
||||
cleanup()
|
||||
if got := testscope.Home(); got != callerScope {
|
||||
t.Fatalf("scope after cleanup = %q, want %q", got, callerScope)
|
||||
}
|
||||
if _, ok := os.LookupEnv("REASONIX_TEST_HOME"); ok {
|
||||
t.Fatal("test home remained set after cleanup")
|
||||
}
|
||||
if _, err := os.Stat(outer); !os.IsNotExist(err) {
|
||||
t.Fatalf("test home remains after cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package testscope holds the user-state home explicitly installed by a test harness.
|
||||
package testscope
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var userHome atomic.Pointer[string]
|
||||
|
||||
// Home returns the installed user-state home, or empty outside a test scope.
|
||||
func Home() string {
|
||||
if home := userHome.Load(); home != nil {
|
||||
return *home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallHome installs a test harness's user-state home and returns its restore.
|
||||
// Restore nested scopes in reverse installation order.
|
||||
func InstallHome(home string) func() {
|
||||
previous := userHome.Swap(&home)
|
||||
return func() { userHome.Store(previous) }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package testscope
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInstalledHomesRestoreInReverseOrder(t *testing.T) {
|
||||
if got := Home(); got != "" {
|
||||
t.Fatalf("uninstalled home = %q", got)
|
||||
}
|
||||
t.Run("outer", func(t *testing.T) {
|
||||
t.Cleanup(InstallHome("outer"))
|
||||
if got := Home(); got != "outer" {
|
||||
t.Fatalf("installed home = %q, want outer", got)
|
||||
}
|
||||
t.Run("inner", func(t *testing.T) {
|
||||
t.Cleanup(InstallHome("inner"))
|
||||
if got := Home(); got != "inner" {
|
||||
t.Fatalf("nested home = %q, want inner", got)
|
||||
}
|
||||
})
|
||||
if got := Home(); got != "outer" {
|
||||
t.Fatalf("restored home = %q, want outer", got)
|
||||
}
|
||||
})
|
||||
if got := Home(); got != "" {
|
||||
t.Fatalf("home after restoration = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/base/testscope"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("REASONIX_CONFIG_LOCK_HELPER") == "1" {
|
||||
os.Exit(m.Run())
|
||||
restore := testscope.InstallHome(os.Getenv("REASONIX_TEST_HOME"))
|
||||
code := m.Run()
|
||||
restore()
|
||||
os.Exit(code)
|
||||
}
|
||||
// RunWithIsolatedUserState redirects every path-shaped location, but the OS
|
||||
// keyring has no environment variable in front of it, so legacy migration
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/base/testscope"
|
||||
)
|
||||
|
||||
// userEditMu serializes in-process read-modify-write cycles. The public lock
|
||||
@@ -339,9 +340,12 @@ func configEditLockRegistryDir() (string, error) {
|
||||
return "", fmt.Errorf("lock config edits: OS user identity unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(identity))
|
||||
// Per-user home from the account database, not $HOME or TMPDIR; a shared
|
||||
// /tmp path let another identity pre-create it. The owner check backs this up.
|
||||
// Production locks use the account database, not HOME or TMPDIR; the owner
|
||||
// check also rejects a registry another identity pre-created.
|
||||
home := strings.TrimSpace(current.HomeDir)
|
||||
if testHome := testscope.Home(); testHome != "" {
|
||||
home = testHome
|
||||
}
|
||||
if home == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "", fmt.Errorf("lock config edits: OS user home unavailable")
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/base/testscope"
|
||||
)
|
||||
|
||||
// TestLockUserConfigEditsSerializesRMW drives concurrent load-modify-save
|
||||
@@ -617,6 +618,9 @@ func TestAcquireConfigEditLockPathTightensOwnLockDir(t *testing.T) {
|
||||
// TestConfigEditLockRegistryDirIsUnderUserHome: the lock directory must not be a
|
||||
// shared, predictable /tmp path another identity could create first.
|
||||
func TestConfigEditLockRegistryDirIsUnderUserHome(t *testing.T) {
|
||||
restore := testscope.InstallHome("")
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("REASONIX_TEST_HOME", testenv.TempDir(t))
|
||||
current, err := osuser.Current()
|
||||
if err != nil || strings.TrimSpace(current.HomeDir) == "" {
|
||||
t.Skip("OS user home unavailable")
|
||||
@@ -662,6 +666,8 @@ func TestIsolatedHomeKeepsItsConfigEditLocksInsideIt(t *testing.T) {
|
||||
// Only what the isolated home holds moves: a file elsewhere (a project's
|
||||
// reasonix.toml) keeps its lock where every other launch will look for it.
|
||||
func TestIsolatedHomeLeavesOtherFilesLockedUnderTheAccountHome(t *testing.T) {
|
||||
restore := testscope.InstallHome("")
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
elsewhere := filepath.Join(testenv.TempDir(t), "reasonix.toml")
|
||||
lock, err := configFileEditLockPath(elsewhere)
|
||||
@@ -705,3 +711,82 @@ func TestPathWithin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsolatedTestUserStateContainsConfigEditLocks(t *testing.T) {
|
||||
cleanup, err := testenv.IsolateUserState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
home := os.Getenv("HOME")
|
||||
for _, path := range []string{UserConfigPath(), filepath.Join(testenv.TempDir(t), "reasonix.toml")} {
|
||||
lock, err := configFileEditLockPath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("config %q chooses lock %q with isolated HOME %q", path, lock, home)
|
||||
if !homeHolds(home, lock) {
|
||||
t.Errorf("config edit lock escaped isolated test home: %q", lock)
|
||||
continue
|
||||
}
|
||||
unlock, err := LockConfigFileEdits(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
if _, err := os.Stat(lock); err != nil {
|
||||
t.Fatalf("lock file was not created in isolated home: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsolatedTestUserStateConfigEditLocksInChildProcess(t *testing.T) {
|
||||
if os.Getenv("REASONIX_CONFIG_TEST_SCOPE_HELPER") == "1" {
|
||||
path := os.Getenv("REASONIX_CONFIG_TEST_SCOPE_PATH")
|
||||
if path == "" {
|
||||
path = UserConfigPath()
|
||||
}
|
||||
lock, err := configFileEditLockPath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !homeHolds(os.Getenv("REASONIX_TEST_HOME"), lock) {
|
||||
t.Fatalf("child process lock escaped isolated test home: %q", lock)
|
||||
}
|
||||
unlock, err := LockConfigFileEdits(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
t.Logf("child config %q uses isolated lock %q", path, lock)
|
||||
return
|
||||
}
|
||||
cleanup, err := testenv.IsolateUserState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "default"},
|
||||
{name: "project", path: filepath.Join(testenv.TempDir(t), "reasonix.toml")},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_CONFIG_LOCK_HELPER", "1")
|
||||
t.Setenv("REASONIX_CONFIG_TEST_SCOPE_HELPER", "1")
|
||||
t.Setenv("REASONIX_CONFIG_TEST_SCOPE_PATH", scenario.path)
|
||||
cmd := exec.Command(exe, "-test.run=^TestIsolatedTestUserStateConfigEditLocksInChildProcess$", "-test.v")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("child process: %v\n%s", err, output)
|
||||
} else {
|
||||
t.Logf("%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user