/ GUNRPG.Tests / LedgerSyncTests.cs
LedgerSyncTests.cs
1 using System.Collections.Immutable; 2 using System.Security.Cryptography; 3 using GUNRPG.Core.Operators; 4 using GUNRPG.Gossip; 5 using GUNRPG.Ledger; 6 using GUNRPG.Security; 7 8 namespace GUNRPG.Tests; 9 10 public sealed class LedgerSyncTests 11 { 12 private static readonly DateTimeOffset ReferenceNow = new(2026, 03, 15, 04, 00, 00, TimeSpan.Zero); 13 14 [Fact] 15 public void LedgerSync_AppendsMissingEntries() 16 { 17 var serverIdentity = CreateServerIdentity(); 18 var engine = new RunReplayEngine(); 19 20 var ledgerA = new RunLedger(); 21 var ledgerB = new RunLedger(); 22 23 var result1 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 24 var result2 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 25 var result3 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 26 27 ledgerA.Append(result1); 28 ledgerA.Append(result2); 29 ledgerA.Append(result3); 30 31 var syncEngine = new LedgerSyncEngine(ledgerB); 32 var peerHead = ledgerA.GetHead(); 33 34 Assert.True(syncEngine.NeedsSync(peerHead)); 35 36 var request = syncEngine.BuildSyncRequest(peerHead); 37 Assert.Equal(0L, request.FromIndex); 38 39 var entries = ledgerA.GetEntriesFrom(request.FromIndex, LedgerSyncEngine.MaxSyncBatchSize); 40 var response = new LedgerSyncResponse(entries); 41 42 var applied = syncEngine.ApplyResponse(response); 43 44 Assert.True(applied); 45 Assert.Equal(ledgerA.Entries.Count, ledgerB.Entries.Count); 46 Assert.True(ledgerA.GetHead().EntryHash.SequenceEqual(ledgerB.GetHead().EntryHash)); 47 Assert.True(ledgerB.Verify()); 48 } 49 50 [Fact] 51 public void LedgerSync_RejectsInvalidEntry() 52 { 53 var serverIdentity = CreateServerIdentity(); 54 var engine = new RunReplayEngine(); 55 56 var ledgerA = new RunLedger(); 57 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 58 var entry = ledgerA.Append(result); 59 60 var tamperedHash = ImmutableArray.Create(new byte[SHA256.HashSizeInBytes]); 61 var tamperedEntry = entry with { EntryHash = tamperedHash }; 62 63 var ledgerB = new RunLedger(); 64 var syncEngine = new LedgerSyncEngine(ledgerB); 65 66 var response = new LedgerSyncResponse([tamperedEntry]); 67 var applied = syncEngine.ApplyResponse(response); 68 69 Assert.False(applied); 70 Assert.Empty(ledgerB.Entries); 71 } 72 73 [Fact] 74 public void LedgerSync_NeedsSync_ReturnsFalseWhenUpToDate() 75 { 76 var serverIdentity = CreateServerIdentity(); 77 var engine = new RunReplayEngine(); 78 79 var ledgerA = new RunLedger(); 80 var ledgerB = new RunLedger(); 81 82 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 83 ledgerA.Append(result); 84 ledgerB.Append(result); 85 86 var syncEngine = new LedgerSyncEngine(ledgerB); 87 Assert.False(syncEngine.NeedsSync(ledgerA.GetHead())); 88 } 89 90 [Fact] 91 public void LedgerSync_GetHead_ReturnsMinusOneForEmptyLedger() 92 { 93 var ledger = new RunLedger(); 94 var head = ledger.GetHead(); 95 96 Assert.Equal(-1L, head.Index); 97 Assert.True(head.EntryHash.SequenceEqual(new byte[SHA256.HashSizeInBytes])); 98 } 99 100 [Fact] 101 public void LedgerSync_GetEntriesFrom_ReturnsCorrectRange() 102 { 103 var serverIdentity = CreateServerIdentity(); 104 var engine = new RunReplayEngine(); 105 var ledger = new RunLedger(); 106 107 var result1 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 108 var result2 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 109 var result3 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 110 111 ledger.Append(result1); 112 ledger.Append(result2); 113 ledger.Append(result3); 114 115 var entries = ledger.GetEntriesFrom(1); 116 Assert.Equal(2, entries.Count); 117 Assert.Equal(1L, entries[0].Index); 118 Assert.Equal(2L, entries[1].Index); 119 } 120 121 [Fact] 122 public void LedgerSync_TryAppendEntry_RejectsWrongIndex() 123 { 124 var serverIdentity = CreateServerIdentity(); 125 var engine = new RunReplayEngine(); 126 127 var ledgerA = new RunLedger(); 128 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 129 var entry = ledgerA.Append(result); 130 131 // Try appending an entry at index 0 twice — second attempt must fail due to wrong index 132 var ledgerB = new RunLedger(); 133 Assert.True(ledgerB.TryAppendEntry(entry)); 134 Assert.False(ledgerB.TryAppendEntry(entry)); 135 } 136 137 [Fact] 138 public void LedgerSync_ConvergesAfterExchange() 139 { 140 var serverIdentity = CreateServerIdentity(); 141 var engine = new RunReplayEngine(); 142 143 var ledgerA = new RunLedger(); 144 var ledgerB = new RunLedger(); 145 146 var result1 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 147 var result2 = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 148 149 ledgerA.Append(result1); 150 ledgerA.Append(result2); 151 152 // Sync B from A 153 var syncEngineB = new LedgerSyncEngine(ledgerB); 154 var headA = ledgerA.GetHead(); 155 Assert.True(syncEngineB.NeedsSync(headA)); 156 157 var requestB = syncEngineB.BuildSyncRequest(headA); 158 var entriesForB = ledgerA.GetEntriesFrom(requestB.FromIndex, LedgerSyncEngine.MaxSyncBatchSize); 159 Assert.True(syncEngineB.ApplyResponse(new LedgerSyncResponse(entriesForB))); 160 161 // A should not need sync from B (B has no additional data) 162 var syncEngineA = new LedgerSyncEngine(ledgerA); 163 var headB = ledgerB.GetHead(); 164 Assert.False(syncEngineA.NeedsSync(headB)); 165 166 // Both heads must now match 167 Assert.True(syncEngineA.IsSameHead(headB)); 168 Assert.True(syncEngineB.IsSameHead(ledgerA.GetHead())); 169 } 170 171 [Fact] 172 public void LedgerSync_DetectsFork() 173 { 174 var serverIdentity = CreateServerIdentity(); 175 var engine = new RunReplayEngine(); 176 177 var ledgerA = new RunLedger(); 178 var ledgerB = new RunLedger(); 179 180 // Both ledgers append different runs at the same index 181 var resultA = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 182 var resultB = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 183 184 ledgerA.Append(resultA); 185 ledgerB.Append(resultB); 186 187 var syncEngine = new LedgerSyncEngine(ledgerB); 188 var peerHead = ledgerA.GetHead(); 189 190 // Same index, different hash — fork detected; sync must be rejected 191 Assert.False(syncEngine.NeedsSync(peerHead)); 192 Assert.False(syncEngine.IsSameHead(peerHead)); 193 } 194 195 [Fact] 196 public void ForkResolution_SelectsLongestChain() 197 { 198 var serverIdentity = CreateServerIdentity(); 199 var engine = new RunReplayEngine(); 200 var ledgerA = new RunLedger(); 201 var ledgerB = new RunLedger(); 202 203 for (var i = 0; i < 8; i++) 204 { 205 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 206 var timestamp = ReferenceNow.AddMinutes(i); 207 208 var entryA = ledgerA.Append(result, timestamp); 209 var entryB = ledgerB.Append(result, timestamp); 210 211 Assert.True(entryA.EntryHash.SequenceEqual(entryB.EntryHash)); 212 } 213 214 var sharedPrefixHashes = ledgerA.Entries 215 .Select(entry => entry.EntryHash) 216 .ToArray(); 217 218 for (var i = 0; i < 2; i++) 219 { 220 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 221 ledgerA.Append(result, ReferenceNow.AddMinutes(8 + i)); 222 } 223 224 for (var i = 0; i < 5; i++) 225 { 226 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 227 ledgerB.Append(result, ReferenceNow.AddHours(1).AddMinutes(i)); 228 } 229 230 Assert.Equal(8L, ledgerA.MerkleSkipIndex.FindDivergenceIndex(ledgerB.MerkleSkipIndex)); 231 232 var syncEngineA = new LedgerSyncEngine(ledgerA); 233 var syncEngineB = new LedgerSyncEngine(ledgerB); 234 235 Assert.True(syncEngineA.ResolveFork(ledgerB.Entries)); 236 Assert.False(syncEngineB.ResolveFork(ledgerA.Entries)); 237 Assert.Equal(ledgerA.Entries.Count, ledgerB.Entries.Count); 238 for (var i = 0; i < sharedPrefixHashes.Length; i++) 239 { 240 Assert.True(sharedPrefixHashes[i].SequenceEqual(ledgerA.Entries[i].EntryHash)); 241 } 242 243 Assert.True(syncEngineA.IsSameHead(ledgerB.GetHead())); 244 Assert.Equal(-1L, ledgerA.MerkleSkipIndex.FindDivergenceIndex(ledgerB.MerkleSkipIndex)); 245 Assert.True(ledgerA.Verify()); 246 Assert.True(ledgerB.Verify()); 247 } 248 249 [Fact] 250 public void ForkResolution_RejectsInvalidPeerChain() 251 { 252 var serverIdentity = CreateServerIdentity(); 253 var engine = new RunReplayEngine(); 254 var localLedger = new RunLedger(); 255 var peerLedger = new RunLedger(); 256 257 for (var i = 0; i < 8; i++) 258 { 259 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 260 var timestamp = ReferenceNow.AddMinutes(i); 261 262 localLedger.Append(result, timestamp); 263 peerLedger.Append(result, timestamp); 264 } 265 266 for (var i = 0; i < 3; i++) 267 { 268 var result = engine.ValidateAndSignRun(Guid.NewGuid(), Guid.NewGuid(), CreateCompletedRunEvents(), serverIdentity); 269 peerLedger.Append(result, ReferenceNow.AddHours(1).AddMinutes(i)); 270 } 271 272 var originalHead = localLedger.GetHead(); 273 var invalidPeerEntries = new List<RunLedgerEntry>(peerLedger.Entries); 274 invalidPeerEntries[^1] = invalidPeerEntries[^1] with 275 { 276 EntryHash = ImmutableArray.Create(new byte[SHA256.HashSizeInBytes]) 277 }; 278 279 var syncEngine = new LedgerSyncEngine(localLedger); 280 281 Assert.False(syncEngine.ResolveFork(invalidPeerEntries)); 282 Assert.True(syncEngine.IsSameHead(originalHead)); 283 Assert.Equal(8, localLedger.Entries.Count); 284 } 285 286 private static IReadOnlyList<OperatorEvent> CreateCompletedRunEvents() 287 { 288 var operatorId = OperatorId.NewId(); 289 var created = new OperatorCreatedEvent(operatorId, "Sync Tester", ReferenceNow.AddMinutes(-10)); 290 var loadout = new LoadoutChangedEvent(operatorId, 1, "Rifle", created.Hash, ReferenceNow.AddMinutes(-9)); 291 var perk = new PerkUnlockedEvent(operatorId, 2, "Scavenger", loadout.Hash, ReferenceNow.AddMinutes(-8)); 292 var infil = new InfilStartedEvent( 293 operatorId, 294 3, 295 Guid.NewGuid(), 296 "Rifle|Medkit", 297 ReferenceNow.AddMinutes(-7), 298 perk.Hash, 299 ReferenceNow.AddMinutes(-7)); 300 var combatStart = new CombatSessionStartedEvent(operatorId, 4, Guid.NewGuid(), infil.Hash, ReferenceNow.AddMinutes(-6)); 301 var xp = new XpGainedEvent(operatorId, 5, 150, "MissionComplete", combatStart.Hash, ReferenceNow.AddMinutes(-5)); 302 var victory = new CombatVictoryEvent(operatorId, 6, xp.Hash, ReferenceNow.AddMinutes(-4)); 303 var exfil = new InfilEndedEvent(operatorId, 7, true, "EXFIL", victory.Hash, ReferenceNow.AddMinutes(-3)); 304 305 return [created, loadout, perk, infil, combatStart, xp, victory, exfil]; 306 } 307 308 private static ServerIdentity CreateServerIdentity() 309 { 310 var rootPrivateKey = CertificateIssuer.GeneratePrivateKey(); 311 var certificateIssuer = new CertificateIssuer(rootPrivateKey); 312 313 var serverPrivateKey = ServerIdentity.GeneratePrivateKey(); 314 var certificate = certificateIssuer.IssueServerCertificate( 315 Guid.NewGuid(), 316 ServerIdentity.GetPublicKey(serverPrivateKey), 317 ReferenceNow.AddMinutes(-5), 318 ReferenceNow.AddMinutes(30)); 319 320 return new ServerIdentity(certificate, serverPrivateKey); 321 } 322 }