/ 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  }