Bug #1 (CRITIQUE) — A3 flip+ensure inconsistency - Before: EnsureForAccount failure after flip was WARN-only, SetActiveAccount still fired → daemon declared target active while shared symlinks were absent/divergent → transcripts silently duplicated, resume broken. - After: ensure failure triggers rollback flip to previous account home; if rollback succeeds → explicit error, ActiveAccount stays on previous. If rollback ALSO fails → sticky partialSwap flag + ErrPartialSwap; all further swaps refused until operator intervention (daemon restart). - New public IsPartialSwap() for watchdog / health-check integration. Bug #10 (MOYENNE) — requiredShared contract never exercised - All existing tests override a.sharedSymlinks with tmpdir-scoped lists, so symlinks.RequiredShared itself was never tested. A rename or drop would pass every test but silently break prod failover. - TestRequiredSharedIsCoherent asserts (no filesystem): 3 entries with the exact required names, absolute targets, and a single shared parent directory (invariant EnsureForAccount depends on). Tests: - go test ./... PASS - go test -race ./... PASS (no data race) - 2 new switcher tests: TestFlipEnsureFailureTriggersRollback, TestFlipEnsureAndRollbackFailure - 1 new symlinks test: TestRequiredSharedIsCoherent - 1 obsolete test replaced: TestFlipEnsureSymlinksFailureDoesNotAbortSwap (encoded the old buggy best-effort behaviour)
276 lines
8.7 KiB
Go
276 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRequiredSharedIsCoherent validates the contract of the package-level
|
|
// RequiredShared constant that the switcher and lifecycle manager consume
|
|
// in production. The rest of the suite exercises EnsureForAccount /
|
|
// ValidateAll with tmpdir-scoped override lists, so the actual prod
|
|
// constant (pointing under /home/ubuntu/.claude-*-shared) is never touched
|
|
// by those tests — a regression that shrinks or renames RequiredShared
|
|
// would pass every other test but silently break failover on real VMs
|
|
// (missing a link → writes to private state → transcripts duplicated).
|
|
//
|
|
// The test is filesystem-free: it only asserts the shape of the constant.
|
|
//
|
|
// 1. Exactly three entries, one per Name required by the A-failover design.
|
|
// 2. Every Target is absolute.
|
|
// 3. All three Targets share the same parent directory — there is no
|
|
// mode of operation where one shared dir lives elsewhere than the
|
|
// others. `filepath.Dir(target)` must be identical across entries.
|
|
//
|
|
// This encodes the "3 links under one shared root" invariant that
|
|
// EnsureForAccount relies on. Any future change to RequiredShared that
|
|
// breaks this invariant should force the author to update the switcher
|
|
// contract explicitly.
|
|
func TestRequiredSharedIsCoherent(t *testing.T) {
|
|
expectedNames := map[string]bool{
|
|
"session-env": false,
|
|
"file-history": false,
|
|
"projects": false,
|
|
}
|
|
if len(RequiredShared) != len(expectedNames) {
|
|
t.Fatalf("RequiredShared must contain exactly %d entries (session-env, file-history, projects); got %d: %+v",
|
|
len(expectedNames), len(RequiredShared), RequiredShared)
|
|
}
|
|
|
|
var sharedParent string
|
|
for i, sl := range RequiredShared {
|
|
if _, ok := expectedNames[sl.Name]; !ok {
|
|
t.Errorf("RequiredShared[%d]: unexpected Name %q (allowed: session-env / file-history / projects)", i, sl.Name)
|
|
continue
|
|
}
|
|
if expectedNames[sl.Name] {
|
|
t.Errorf("RequiredShared[%d]: duplicate Name %q", i, sl.Name)
|
|
}
|
|
expectedNames[sl.Name] = true
|
|
|
|
if sl.Target == "" {
|
|
t.Errorf("RequiredShared[%d] (%q): empty Target", i, sl.Name)
|
|
continue
|
|
}
|
|
if !filepath.IsAbs(sl.Target) {
|
|
t.Errorf("RequiredShared[%d] (%q): Target %q must be absolute", i, sl.Name, sl.Target)
|
|
continue
|
|
}
|
|
|
|
parent := filepath.Dir(sl.Target)
|
|
if i == 0 {
|
|
sharedParent = parent
|
|
continue
|
|
}
|
|
if parent != sharedParent {
|
|
t.Errorf("RequiredShared[%d] (%q): parent dir %q diverges from %q — all shared targets must live under the same root",
|
|
i, sl.Name, parent, sharedParent)
|
|
}
|
|
}
|
|
|
|
for name, seen := range expectedNames {
|
|
if !seen {
|
|
t.Errorf("RequiredShared is missing the required %q entry", name)
|
|
}
|
|
}
|
|
}
|