package symlinks import ( "os" "path/filepath" "strings" "testing" ) // testRequired returns a SharedSymlink list whose Targets live entirely // under tmpDir, so the tests never touch the operator's real home. func testRequired(tmpDir string) []SharedSymlink { return []SharedSymlink{ {Target: filepath.Join(tmpDir, "session-env-shared"), Name: "session-env"}, {Target: filepath.Join(tmpDir, "file-history-shared"), Name: "file-history"}, {Target: filepath.Join(tmpDir, "projects-shared"), Name: "projects"}, } } func TestEnsureForAccount_missingCreatesLinksAndTargets(t *testing.T) { tmp := t.TempDir() home := filepath.Join(tmp, "account1") req := testRequired(tmp) if err := EnsureForAccount(home, req); err != nil { t.Fatalf("EnsureForAccount: %v", err) } for _, sl := range req { linkPath := filepath.Join(home, sl.Name) info, err := os.Lstat(linkPath) if err != nil { t.Errorf("expected link at %s: %v", linkPath, err) continue } if info.Mode()&os.ModeSymlink == 0 { t.Errorf("%s exists but is not a symlink", linkPath) } got, err := os.Readlink(linkPath) if err != nil { t.Errorf("readlink %s: %v", linkPath, err) continue } if got != sl.Target { t.Errorf("link %s points to %q, want %q", linkPath, got, sl.Target) } // Target directory must exist too. if st, err := os.Stat(sl.Target); err != nil || !st.IsDir() { t.Errorf("target %s should be a directory, err=%v", sl.Target, err) } } } func TestEnsureForAccount_idempotent(t *testing.T) { tmp := t.TempDir() home := filepath.Join(tmp, "account1") req := testRequired(tmp) if err := EnsureForAccount(home, req); err != nil { t.Fatalf("first pass: %v", err) } if err := EnsureForAccount(home, req); err != nil { t.Fatalf("second pass should be a no-op, got: %v", err) } } func TestEnsureForAccount_divergentLinkReturnsError(t *testing.T) { tmp := t.TempDir() home := filepath.Join(tmp, "account1") req := testRequired(tmp) // Pre-create a wrong symlink for "projects". if err := os.MkdirAll(home, 0700); err != nil { t.Fatalf("mkdir home: %v", err) } wrongTarget := filepath.Join(tmp, "someone-elses-dir") if err := os.MkdirAll(wrongTarget, 0700); err != nil { t.Fatalf("mkdir wrong target: %v", err) } linkPath := filepath.Join(home, "projects") if err := os.Symlink(wrongTarget, linkPath); err != nil { t.Fatalf("seed wrong symlink: %v", err) } err := EnsureForAccount(home, req) if err == nil { t.Fatal("expected error for divergent link, got nil") } if !strings.Contains(err.Error(), "divergent") { t.Errorf("error should mention 'divergent': %v", err) } // The wrong symlink MUST be preserved (no auto-correction). got, err := os.Readlink(linkPath) if err != nil { t.Fatalf("readlink after error: %v", err) } if got != wrongTarget { t.Errorf("divergent link was mutated: now %q, want preserved %q", got, wrongTarget) } } func TestEnsureForAccount_regularFileInsteadOfLinkFails(t *testing.T) { tmp := t.TempDir() home := filepath.Join(tmp, "account1") req := testRequired(tmp) if err := os.MkdirAll(home, 0700); err != nil { t.Fatalf("mkdir home: %v", err) } // Create a regular file at the session-env path. bogus := filepath.Join(home, "session-env") if err := os.WriteFile(bogus, []byte("oops"), 0600); err != nil { t.Fatalf("seed regular file: %v", err) } err := EnsureForAccount(home, req) if err == nil { t.Fatal("expected error for regular-file-at-link-path, got nil") } if !strings.Contains(err.Error(), "not a symlink") { t.Errorf("error should mention 'not a symlink': %v", err) } } func TestEnsureForAccount_emptyHomeReturnsError(t *testing.T) { if err := EnsureForAccount("", nil); err == nil { t.Fatal("expected error for empty home, got nil") } } func TestValidateAll_multipleAccountsAllOK(t *testing.T) { tmp := t.TempDir() req := testRequired(tmp) homes := []string{ filepath.Join(tmp, "a"), filepath.Join(tmp, "b"), } if err := ValidateAll(homes, req); err != nil { t.Fatalf("ValidateAll: %v", err) } } func TestValidateAll_aggregatesErrors(t *testing.T) { tmp := t.TempDir() req := testRequired(tmp) homes := []string{ filepath.Join(tmp, "a"), filepath.Join(tmp, "b"), } // Pre-seed account `a` with a divergent link so ValidateAll must // surface that error while still processing account `b`. if err := os.MkdirAll(homes[0], 0700); err != nil { t.Fatalf("mkdir a: %v", err) } wrongTarget := filepath.Join(tmp, "bad") if err := os.MkdirAll(wrongTarget, 0700); err != nil { t.Fatalf("mkdir bad: %v", err) } if err := os.Symlink(wrongTarget, filepath.Join(homes[0], "projects")); err != nil { t.Fatalf("seed wrong link: %v", err) } err := ValidateAll(homes, req) if err == nil { t.Fatal("expected aggregated error, got nil") } if !strings.Contains(err.Error(), "divergent") { t.Errorf("should surface divergent: %v", err) } // Account `b` must have been configured successfully even though `a` // failed. Otherwise the operator cannot see the full state at once. for _, sl := range req { if _, err := os.Lstat(filepath.Join(homes[1], sl.Name)); err != nil { t.Errorf("account b link %s should have been created despite a's failure: %v", sl.Name, err) } } } func TestValidateAll_emptyListReturnsError(t *testing.T) { if err := ValidateAll(nil, nil); err == nil { t.Fatal("expected error for empty account list") } } // TestRequiredShared_defaultsAreReasonable pins the default SharedSymlink // list so an accidental edit that breaks production is caught. func TestRequiredShared_defaultsAreReasonable(t *testing.T) { want := map[string]string{ "session-env": "/home/ubuntu/.claude-session-env-shared", "file-history": "/home/ubuntu/.claude-file-history-shared", "projects": "/home/ubuntu/.claude-projects-shared", } if len(RequiredShared) != len(want) { t.Fatalf("RequiredShared has %d entries, want %d", len(RequiredShared), len(want)) } for _, sl := range RequiredShared { if got, ok := want[sl.Name]; !ok { t.Errorf("unexpected RequiredShared entry %q", sl.Name) } else if got != sl.Target { t.Errorf("RequiredShared %q target = %q, want %q", sl.Name, sl.Target, got) } } }