claude-failover/internal/switcher/account_switcher_test.go
Ubuntu 8eaf0bbd35 feat(switcher): ensure shared symlinks on target home after flip (A3)
Wire symlinks.EnsureForAccount into executeSwitch, called immediately
after the ~/.claude flip. Guarantees the three shared-state links
(session-env, file-history, projects) exist on the target account home
even for freshly-provisioned accounts, preventing silent transcript
duplication and undo-history divergence on first resume.

Best-effort: errors are logged as WARN but never abort the swap. If we
returned here the daemon would be left inconsistent (symlink flipped,
SetActiveAccount never called). Operator sees the warning in logs and
resolves divergent links manually.

Tests:
- TestFlipReconcilesSharedSymlinksOnTargetHome: empty target home gets
  all three links pointing at canonical targets after the flip.
- TestFlipEnsureSymlinksFailureDoesNotAbortSwap: a planted divergent
  link triggers the symlinks-package error; the swap completes anyway
  and the active account is updated.

Hermetic: added AccountSwitcher.sharedSymlinks override so tests scope
the reconcile inside t.TempDir() and never touch
/home/ubuntu/.claude-*-shared. Existing tests migrated to this pattern
and hardcoded /tmp/claude-*-xxxx paths replaced with tmpdirs.

Phase 1 / Chantier A — task A3.
2026-04-16 19:34:03 +00:00

352 lines
12 KiB
Go

package switcher
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/quota"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
"forge.secuaas.ovh/olivier/claude-failover/internal/symlinks"
)
// tmpShared returns a SharedSymlink list whose targets live entirely under
// tmpDir, so switcher tests never touch /home/ubuntu/.claude-*-shared.
func tmpShared(tmpDir string) []symlinks.SharedSymlink {
return []symlinks.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"},
}
}
// mockTmux for switcher tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
killCalls []string
createCalls []string
sendKeyCalls []string
}
func newMockTmux() *mockTmux {
return &mockTmux{
sessions: make(map[string]bool),
paneOutput: make(map[string]string),
}
}
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
func (m *mockTmux) CreateSession(name, _ string) error {
m.sessions[name] = true
m.createCalls = append(m.createCalls, name)
return nil
}
func (m *mockTmux) KillSession(name string) error {
delete(m.sessions, name)
m.killCalls = append(m.killCalls, name)
return nil
}
func (m *mockTmux) SendKeys(session, keys string) error {
m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys)
return nil
}
func (m *mockTmux) SendEnter(session string) error {
m.sendKeyCalls = append(m.sendKeyCalls, session+":<ENTER>")
return nil
}
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestFindTargetAccount returns the first account that differs from current.
func TestFindTargetAccount(t *testing.T) {
tc := newMockTmux()
s := state.New("")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Priority: 1},
{Name: "compte2", Priority: 2},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
target := a.findTargetAccount("compte1")
if target == nil || target.Name != "compte2" {
t.Errorf("expected compte2, got %v", target)
}
}
// TestFindTargetAccountSingleAccount returns nil when only one account exists.
func TestFindTargetAccountSingleAccount(t *testing.T) {
tc := newMockTmux()
s := state.New("")
cfg := &config.Config{
Accounts: []config.AccountConfig{{Name: "solo"}},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
if got := a.findTargetAccount("solo"); got != nil {
t.Errorf("expected nil for single account, got %v", got)
}
}
// TestExtractResumeUUID parses UUID from pane output.
func TestExtractResumeUUID(t *testing.T) {
input := "$ claude --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890 --model sonnet"
got := extractResumeUUID(input)
want := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
if got != want {
t.Errorf("expected %q, got %q", want, got)
}
}
// TestExtractResumeUUIDMissing returns empty string when no UUID present.
func TestExtractResumeUUIDMissing(t *testing.T) {
if got := extractResumeUUID("no uuid here"); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
// TestTimeUntilReset parses minute and hour formats correctly.
func TestTimeUntilReset(t *testing.T) {
cases := []struct {
input string
want time.Duration
}{
{"in 45 minutes", 45 * time.Minute},
{"in 2 hours", 2 * time.Hour},
{"in 1 hour", 1 * time.Hour},
{"", 2 * time.Hour},
{"8pm", 2 * time.Hour}, // fallback for unrecognised formats
}
for _, c := range cases {
if got := timeUntilReset(c.input); got != c.want {
t.Errorf("timeUntilReset(%q) = %v, want %v", c.input, got, c.want)
}
}
}
// TestKillAndRecreatePoolSessions verifies that executeSwitch restarts sessions.
func TestKillAndRecreatePoolSessions(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.sessions["dedicated-1"] = true
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: t.TempDir()},
{Name: "compte2", Home: t.TempDir()},
},
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 2, Max: 2},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
// CRITICAL: isolate symlink manipulation in a tmpdir so the test never
// touches the real ~/.claude (regression: a reboot used to leave Claude
// Code unusable because the test had repointed ~/.claude to /tmp/...).
a.homeDir = t.TempDir()
// Scope shared-symlink targets to a tmpdir so the post-flip ensure
// pass does not write inside /home/ubuntu/.claude-*-shared.
a.sharedSymlinks = tmpShared(t.TempDir())
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// Active account must have changed.
if got := s.ActiveAccount(); got != "compte2" {
t.Errorf("expected active account compte2, got %q", got)
}
// All old sessions must have been killed.
for _, name := range []string{"ccl-auto-0", "ccl-auto-1", "dedicated-1"} {
found := false
for _, k := range tc.killCalls {
if k == name {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be killed", name)
}
}
// Min pool sessions must be recreated.
recreated := map[string]bool{}
for _, c := range tc.createCalls {
recreated[c] = true
}
if !recreated["ccl-auto-0"] || !recreated["ccl-auto-1"] {
t.Errorf("expected autonomous sessions recreated; createCalls=%v", tc.createCalls)
}
}
// TestDedicatedRelaunchAfterSwap verifies that a dedicated session is
// automatically restarted with `claude --resume <uuid>` on the target
// account's home after a swap, so interactive user work is preserved.
func TestDedicatedRelaunchAfterSwap(t *testing.T) {
tc := newMockTmux()
tc.sessions["dedicated-1"] = true
// Pane shows the full resume command — saveDedicatedUUIDs will extract it.
tc.paneOutput["dedicated-1"] = "claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --dangerously-skip-permissions"
s := state.New("")
s.SetActiveAccount("compte1")
home1 := filepath.Join(t.TempDir(), "claude-1-xxxx")
home2 := filepath.Join(t.TempDir(), "claude-2-xxxx")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: home1},
{Name: "compte2", Home: home2},
},
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
a.homeDir = t.TempDir()
a.sharedSymlinks = tmpShared(t.TempDir())
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// The relaunch must send a resume command on the dedicated session,
// pointing CLAUDE_CONFIG_DIR at the target account's home.
var relaunch string
for _, k := range tc.sendKeyCalls {
if strings.HasPrefix(k, "dedicated-1:") && strings.Contains(k, "--resume") {
relaunch = k
break
}
}
if relaunch == "" {
t.Fatalf("expected dedicated-1 relaunch send-keys; got %v", tc.sendKeyCalls)
}
if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR="+home2) {
t.Errorf("relaunch should set CLAUDE_CONFIG_DIR to target home; got %q", relaunch)
}
if !strings.Contains(relaunch, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") {
t.Errorf("relaunch should include captured UUID; got %q", relaunch)
}
}
// TestFlipReconcilesSharedSymlinksOnTargetHome verifies A3: after the main
// ~/.claude flip, the switcher reconciles the three shared-state symlinks
// (session-env / file-history / projects) on the TARGET account home.
// Scenario: the target home has NO links yet — a freshly-provisioned account
// that has never been flipped into. Post-switch, all three links must exist
// inside the target home and point at the canonical shared targets.
func TestFlipReconcilesSharedSymlinksOnTargetHome(t *testing.T) {
tc := newMockTmux()
s := state.New("")
s.SetActiveAccount("compte1")
// Target home starts empty: EnsureForAccount will mkdir + create links.
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
{Name: "compte2", Home: targetHome},
},
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
a.homeDir = t.TempDir()
shared := tmpShared(t.TempDir())
a.sharedSymlinks = shared
// Pre-assert: no link exists in targetHome.
for _, sl := range shared {
if _, err := os.Lstat(filepath.Join(targetHome, sl.Name)); !os.IsNotExist(err) {
t.Fatalf("pre-condition: %q should not exist yet (err=%v)", sl.Name, err)
}
}
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// Post-assert: every required link exists and points at the canonical
// target under the tmpdir-scoped shared root.
for _, sl := range shared {
linkPath := filepath.Join(targetHome, sl.Name)
info, err := os.Lstat(linkPath)
if err != nil {
t.Errorf("expected link at %s after flip: %v", linkPath, err)
continue
}
if info.Mode()&os.ModeSymlink == 0 {
t.Errorf("%s exists but is not a symlink", linkPath)
continue
}
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)
}
}
}
// TestFlipEnsureSymlinksFailureDoesNotAbortSwap verifies A3 best-effort:
// if EnsureForAccount returns an error (here: a divergent pre-existing link
// that the symlinks package refuses to auto-correct), the flip and the swap
// MUST still complete. The shared symlink reconcile is post-flip cleanup,
// not a gate on the failover itself — aborting here would leave the daemon
// in an inconsistent state (symlink flipped but active account not updated).
func TestFlipEnsureSymlinksFailureDoesNotAbortSwap(t *testing.T) {
tc := newMockTmux()
s := state.New("")
s.SetActiveAccount("compte1")
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
if err := os.MkdirAll(targetHome, 0700); err != nil {
t.Fatalf("mkdir target home: %v", err)
}
// Plant a divergent link at <targetHome>/session-env. The symlinks
// package refuses to auto-correct this (data-loss safeguard) and will
// return an error — which the switcher must swallow with a WARN log.
bogus := filepath.Join(t.TempDir(), "somewhere-else")
if err := os.MkdirAll(bogus, 0700); err != nil {
t.Fatalf("mkdir bogus: %v", err)
}
if err := os.Symlink(bogus, filepath.Join(targetHome, "session-env")); err != nil {
t.Fatalf("plant divergent link: %v", err)
}
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
{Name: "compte2", Home: targetHome},
},
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
a.homeDir = t.TempDir()
a.sharedSymlinks = tmpShared(t.TempDir())
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// The swap must have completed despite the divergent-link error.
if got := s.ActiveAccount(); got != "compte2" {
t.Errorf("swap should complete even when ensure fails; active=%q want compte2", got)
}
}