package storage import ( "bytes" "testing" ) // TestForcedDivergence_ConflictDetectedAndResolved is the gate C-14 // forced-divergence integration test. It simulates two peers writing to // the same file *without* the flock (the lock was bypassed), detects the // conflict via DetectConflicts, resolves it via ResolveConflict to the // source peer's content, and verifies the resolution is deterministic // across repeated runs. // // The test uses in-memory file maps (no real Syncthing is involved — it // tests the CLI-side conflict detection + resolution logic, which is // what orca runs on the lead node over the SSH-gathered peer views). func TestForcedDivergence_ConflictDetectedAndResolved(t *testing.T) { // Two peers, same file, different content — no lock held. peerA := []byte("source-writes-this") peerB := []byte("peer-b-writes-that") peerFiles := map[string]map[string][]byte{ "peer-a": {"data/db.sqlite": peerA}, "peer-b": {"data/db.sqlite": peerB}, } const sourcePeer = "peer-a" const ns = "divergence-ns" // Detect. conflicts, err := DetectConflicts(ns, peerFiles) if err != nil { t.Fatalf("DetectConflicts: %v", err) } if len(conflicts) != 1 { t.Fatalf("expected 1 conflict, got %d", len(conflicts)) } c := conflicts[0] if c.Path != "data/db.sqlite" { t.Errorf("conflict path = %q, want %q", c.Path, "data/db.sqlite") } if len(c.Versions) != 2 { t.Errorf("conflict has %d versions, want 2", len(c.Versions)) } // Resolve — source peer (the one holding the lock) wins. winner, losers := ResolveConflict(c, sourcePeer) if !bytes.Equal(winner, peerA) { t.Errorf("winning content = %q, want %q (source peer)", winner, peerA) } if len(losers) != 1 || losers[0] != "peer-b" { t.Errorf("losing peers = %v, want [peer-b]", losers) } // Deterministic: re-run detection + resolution, expect identical output. conflicts2, _ := DetectConflicts(ns, peerFiles) winner2, losers2 := ResolveConflict(conflicts2[0], sourcePeer) if !bytes.Equal(winner2, winner) { t.Errorf("non-deterministic winner: %q vs %q", winner2, winner) } if !equalStringSlices(losers2, losers) { t.Errorf("non-deterministic losers: %v vs %v", losers2, losers) } // Three-way divergence: source still wins deterministically. peerFiles3 := map[string]map[string][]byte{ "peer-a": {"data/db.sqlite": peerA}, "peer-b": {"data/db.sqlite": peerB}, "peer-c": {"data/db.sqlite": []byte("peer-c-writes-something-else")}, } conflicts3, _ := DetectConflicts(ns, peerFiles3) if len(conflicts3) != 1 { t.Fatalf("3-way: expected 1 conflict, got %d", len(conflicts3)) } winner3, losers3 := ResolveConflict(conflicts3[0], sourcePeer) if !bytes.Equal(winner3, peerA) { t.Errorf("3-way winner = %q, want %q", winner3, peerA) } if len(losers3) != 2 { t.Errorf("3-way losers = %v, want 2 entries", losers3) } // Losers must be sorted for deterministic ordering. if !isSorted(losers3) { t.Errorf("losers not sorted: %v", losers3) } } // TestForcedDivergence_UnknownSource_Unresolved verifies the // deterministic failure mode: when the source peer is unknown (the lock // holder is not in the versions map), ResolveConflict returns (nil, nil) // so the CLI can flag the conflict for manual resolution. No silent // winner is picked. func TestForcedDivergence_UnknownSource_Unresolved(t *testing.T) { peerFiles := map[string]map[string][]byte{ "peer-a": {"f": []byte("a")}, "peer-b": {"f": []byte("b")}, } conflicts, _ := DetectConflicts("ns", peerFiles) if len(conflicts) != 1 { t.Fatalf("expected 1 conflict, got %d", len(conflicts)) } // Source peer is "peer-z" — not in the versions map. winner, losers := ResolveConflict(conflicts[0], "peer-z") if winner != nil { t.Errorf("unknown source: winner = %v, want nil", winner) } if losers != nil { t.Errorf("unknown source: losers = %v, want nil", losers) } } // TestForcedDivergence_NoLockNoConflict simulates the normal path: two // peers hold the same content for the same file (one wrote under the // lock, the other synced read-only). DetectConflicts reports no // conflict. func TestForcedDivergence_NoLockNoConflict(t *testing.T) { content := []byte("same-content") peerFiles := map[string]map[string][]byte{ "peer-a": {"data/f": content}, "peer-b": {"data/f": content}, } conflicts, err := DetectConflicts("ns", peerFiles) if err != nil { t.Fatalf("DetectConflicts: %v", err) } if len(conflicts) != 0 { t.Errorf("expected 0 conflicts, got %d: %+v", len(conflicts), conflicts) } } // equalStringSlices reports whether two string slices are equal in order. func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // isSorted reports whether the string slice is in ascending order. func isSorted(s []string) bool { for i := 1; i < len(s); i++ { if s[i-1] > s[i] { return false } } return true }