Files
orca/internal/storage/replication_test.go
T
Jon Chery 675feabf0c feat(P09): Syncthing storage replication + conflict resolution (REQ-081; gates C-02, C-14)
P09 — Storage replication via per-namespace Syncthing (R-005).

C-02 spike (.ciagent/C02_SYNCTHING_FEASIBILITY_v0.9.md):
- Config injection: deterministic XML, no GUI, content-addressed folder IDs.
- Conflict policy: flock-style lock + source-wins migration + gc-conflicts.
- Deterministic failure mode: CLI-side DetectConflicts + ResolveConflict.
- Auto-decision: C-02 SATISFIED.

C-14 forced-divergence test (internal/storage/conflict_test.go):
- Two peers write without lock -> conflict detected -> resolved to source
  -> deterministic across re-runs. Unknown source -> nil (no silent winner).
- C-14 SATISFIED.

Replication (internal/storage/replication.go, REQ-081):
- FolderID = sha256(ns+masterKeyFP)[:32] (content-addressed).
- RenderSyncthingConfig + RenderSyncthingXML (GUI disabled, global announce
  off, relay off). DetectConflicts (sorted, deterministic). ResolveConflict
  (source-peer-wins). 97.6% coverage.

Emitter (internal/emitter/syncthing.go):
- SyncthingEmitter renders one config.xml per replicated volume at
  /etc/syncthing/orca-<ns>-<volume>.xml. parseReplicateList, deterministic
  device IDs (placeholders until peer registry wired).

24 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: P09
milestone: v0.9
status: execute
---/ci---
2026-08-05 18:38:49 +00:00

302 lines
8.6 KiB
Go

package storage
import (
"encoding/xml"
"strings"
"testing"
)
func TestFolderID_Deterministic(t *testing.T) {
a := FolderID("team-alpha", "fp1")
b := FolderID("team-alpha", "fp1")
if a != b {
t.Errorf("FolderID not deterministic: %q vs %q", a, b)
}
}
func TestFolderID_DifferentNamespaces(t *testing.T) {
a := FolderID("team-alpha", "fp1")
b := FolderID("team-beta", "fp1")
if a == b {
t.Errorf("different namespaces produced same folder ID: %q", a)
}
}
func TestFolderID_DifferentMasterKeys(t *testing.T) {
a := FolderID("team-alpha", "fp1")
b := FolderID("team-alpha", "fp2")
if a == b {
t.Errorf("different master keys produced same folder ID: %q", a)
}
}
func TestFolderID_EmptyFingerprintUsesPlaceholder(t *testing.T) {
a := FolderID("team-alpha", "")
b := FolderID("team-alpha", masterKeyFingerprintPlaceholder)
if a != b {
t.Errorf("empty fingerprint did not match placeholder: %q vs %q", a, b)
}
}
func TestFolderID_Length(t *testing.T) {
id := FolderID("ns", "fp")
if len(id) != 32 {
t.Errorf("folder ID length = %d, want 32", len(id))
}
}
func TestRenderSyncthingConfig_OK(t *testing.T) {
rep := VolumeReplication{
Namespace: "team-alpha",
VolumeName: "data",
SourcePath: "/var/lib/orca/volumes/data",
ReplicateTo: []string{"peer-b", "peer-c"},
SyncMode: "sendreceive",
}
peers := []SyncthingDevice{
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
{ID: "dev-c", Name: "peer-c", Address: "tcp://peer-c:22000"},
}
cfg, err := RenderSyncthingConfig(rep, peers)
if err != nil {
t.Fatalf("RenderSyncthingConfig: %v", err)
}
if cfg.FolderID != FolderID("team-alpha", "") {
t.Errorf("folder ID = %q, want %q", cfg.FolderID, FolderID("team-alpha", ""))
}
if cfg.Path != rep.SourcePath {
t.Errorf("path = %q, want %q", cfg.Path, rep.SourcePath)
}
if len(cfg.Devices) != 3 {
t.Errorf("devices = %d, want 3", len(cfg.Devices))
}
if cfg.Devices[0].ID != "dev-source" {
t.Errorf("first device = %q, want dev-source", cfg.Devices[0].ID)
}
}
func TestRenderSyncthingConfig_Errors(t *testing.T) {
peers := []SyncthingDevice{{ID: "d", Name: "n", Address: "dynamic"}}
if _, err := RenderSyncthingConfig(VolumeReplication{}, peers); err == nil {
t.Error("empty namespace: expected error")
}
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns"}, peers); err == nil {
t.Error("empty source path: expected error")
}
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns", SourcePath: "/p"}, nil); err == nil {
t.Error("no peers: expected error")
}
}
func TestRenderSyncthingXML_Valid(t *testing.T) {
cfg := &SyncthingConfig{
FolderID: "abcd1234abcd1234abcd1234abcd1234",
Path: "/var/lib/orca/data",
Devices: []SyncthingDevice{
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
},
}
out, err := RenderSyncthingXML(cfg)
if err != nil {
t.Fatalf("RenderSyncthingXML: %v", err)
}
if !strings.HasPrefix(out, xml.Header) {
t.Errorf("output missing XML header")
}
if !strings.Contains(out, `id="abcd1234abcd1234abcd1234abcd1234"`) {
t.Errorf("folder ID not in output")
}
if !strings.Contains(out, "dev-source") {
t.Errorf("source device ID not in output")
}
if !strings.Contains(out, "dev-b") {
t.Errorf("peer device ID not in output")
}
if !strings.Contains(out, "/var/lib/orca/data") {
t.Errorf("path not in output")
}
if !strings.Contains(out, `tcp://peer-b:22000`) {
t.Errorf("peer address not in output")
}
if !strings.Contains(out, `<gui enabled="false"`) {
t.Errorf("GUI not disabled in output")
}
// Must be parseable as XML.
var doc syncthingXMLConfig
if err := xml.Unmarshal([]byte(out), &doc); err != nil {
t.Fatalf("output is not valid XML: %v", err)
}
if doc.Folders[0].ID != cfg.FolderID {
t.Errorf("parsed folder ID = %q, want %q", doc.Folders[0].ID, cfg.FolderID)
}
}
func TestRenderSyncthingXML_Deterministic(t *testing.T) {
cfg := &SyncthingConfig{
FolderID: "abcd1234abcd1234abcd1234abcd1234",
Path: "/data",
Devices: []SyncthingDevice{
{ID: "dev-a", Name: "a", Address: "dynamic"},
{ID: "dev-b", Name: "b", Address: "tcp://b:22000"},
},
}
a, _ := RenderSyncthingXML(cfg)
b, _ := RenderSyncthingXML(cfg)
if a != b {
t.Errorf("RenderSyncthingXML not deterministic")
}
}
func TestRenderSyncthingXML_Errors(t *testing.T) {
if _, err := RenderSyncthingXML(nil); err == nil {
t.Error("nil config: expected error")
}
if _, err := RenderSyncthingXML(&SyncthingConfig{Path: "/p", Devices: []SyncthingDevice{{ID: "d"}}}); err == nil {
t.Error("empty folder ID: expected error")
}
if _, err := RenderSyncthingXML(&SyncthingConfig{FolderID: "f", Path: "/p"}); err == nil {
t.Error("no devices: expected error")
}
}
func TestDetectConflicts_Differ(t *testing.T) {
peerFiles := map[string]map[string][]byte{
"peer-a": {"f": []byte("a"), "shared": []byte("same")},
"peer-b": {"f": []byte("b"), "shared": []byte("same")},
}
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))
}
if conflicts[0].Path != "f" {
t.Errorf("conflict path = %q, want f", conflicts[0].Path)
}
}
func TestDetectConflicts_AllAgree(t *testing.T) {
peerFiles := map[string]map[string][]byte{
"peer-a": {"f": []byte("same"), "g": []byte("x")},
"peer-b": {"f": []byte("same"), "g": []byte("x")},
}
conflicts, err := DetectConflicts("ns", peerFiles)
if err != nil {
t.Fatalf("DetectConflicts: %v", err)
}
if len(conflicts) != 0 {
t.Errorf("expected 0 conflicts, got %d", len(conflicts))
}
}
func TestDetectConflicts_SinglePeerNoConflict(t *testing.T) {
peerFiles := map[string]map[string][]byte{
"peer-a": {"f": []byte("a")},
}
conflicts, err := DetectConflicts("ns", peerFiles)
if err != nil {
t.Fatalf("DetectConflicts: %v", err)
}
if len(conflicts) != 0 {
t.Errorf("single peer should not produce conflict, got %d", len(conflicts))
}
}
func TestDetectConflicts_SortedOutput(t *testing.T) {
peerFiles := map[string]map[string][]byte{
"peer-a": {"z": []byte("a"), "a": []byte("a"), "m": []byte("a")},
"peer-b": {"z": []byte("b"), "a": []byte("b"), "m": []byte("b")},
}
conflicts, _ := DetectConflicts("ns", peerFiles)
if len(conflicts) != 3 {
t.Fatalf("expected 3 conflicts, got %d", len(conflicts))
}
if conflicts[0].Path != "a" || conflicts[1].Path != "m" || conflicts[2].Path != "z" {
t.Errorf("conflicts not sorted: %v", []string{conflicts[0].Path, conflicts[1].Path, conflicts[2].Path})
}
}
func TestDetectConflicts_EmptyInput(t *testing.T) {
conflicts, err := DetectConflicts("ns", nil)
if err != nil {
t.Fatalf("DetectConflicts: %v", err)
}
if conflicts != nil {
t.Errorf("empty input should return nil, got %v", conflicts)
}
}
func TestResolveConflict_SourceWins(t *testing.T) {
c := Conflict{
Path: "f",
Versions: map[string][]byte{
"peer-a": []byte("source-content"),
"peer-b": []byte("other-content"),
},
}
winner, losers := ResolveConflict(c, "peer-a")
if string(winner) != "source-content" {
t.Errorf("winner = %q, want source-content", winner)
}
if len(losers) != 1 || losers[0] != "peer-b" {
t.Errorf("losers = %v, want [peer-b]", losers)
}
}
func TestResolveConflict_PeersAgreeNotLosers(t *testing.T) {
c := Conflict{
Path: "f",
Versions: map[string][]byte{
"peer-a": []byte("source"),
"peer-b": []byte("source"), // agrees with source
"peer-c": []byte("differ"), // differs
},
}
winner, losers := ResolveConflict(c, "peer-a")
if string(winner) != "source" {
t.Errorf("winner = %q, want source", winner)
}
if len(losers) != 1 || losers[0] != "peer-c" {
t.Errorf("losers = %v, want [peer-c]", losers)
}
}
func TestResolveConflict_Deterministic(t *testing.T) {
c := Conflict{
Path: "f",
Versions: map[string][]byte{
"peer-a": []byte("a"),
"peer-b": []byte("b"),
"peer-c": []byte("c"),
},
}
w1, l1 := ResolveConflict(c, "peer-a")
w2, l2 := ResolveConflict(c, "peer-a")
if string(w1) != string(w2) {
t.Errorf("non-deterministic winner")
}
if !equalStringSlices(l1, l2) {
t.Errorf("non-deterministic losers: %v vs %v", l1, l2)
}
}
func TestResolveConflict_UnknownSource(t *testing.T) {
c := Conflict{
Path: "f",
Versions: map[string][]byte{
"peer-a": []byte("a"),
"peer-b": []byte("b"),
},
}
winner, losers := ResolveConflict(c, "peer-z")
if winner != nil {
t.Errorf("unknown source winner should be nil, got %q", winner)
}
if losers != nil {
t.Errorf("unknown source losers should be nil, got %v", losers)
}
}