/ GUNRPG.Tests / RunReplayEngineTests.cs
RunReplayEngineTests.cs
  1  using GUNRPG.Core.Operators;
  2  using GUNRPG.Security;
  3  
  4  namespace GUNRPG.Tests;
  5  
  6  public sealed class RunReplayEngineTests
  7  {
  8      private static readonly DateTimeOffset ReferenceNow = new(2026, 03, 15, 04, 00, 00, TimeSpan.Zero);
  9  
 10      [Fact]
 11      public void ReplayRun_ProducesSignedValidation()
 12      {
 13          var runId = Guid.NewGuid();
 14          var playerId = Guid.NewGuid();
 15          var serverIdentity = CreateServerIdentity(out var authorityRoot);
 16          var verifier = new SignatureVerifier(authorityRoot);
 17          var engine = new RunReplayEngine();
 18  
 19          var result = engine.ValidateAndSignRun(runId, playerId, CreateCompletedRunEvents(), serverIdentity);
 20  
 21          Assert.Equal(runId, result.RunId);
 22          Assert.Equal(playerId, result.PlayerId);
 23          Assert.Equal(serverIdentity.Certificate.ServerId, result.ServerId);
 24          Assert.True(result.FinalStateHash.SequenceEqual(result.Attestation.Validation.FinalStateHash));
 25          Assert.Equal(runId, result.Attestation.Validation.RunId);
 26          Assert.Equal(playerId, result.Attestation.Validation.PlayerId);
 27          Assert.Equal(result.ServerId, result.Attestation.Validation.ServerId);
 28          Assert.True(verifier.Verify(result.Attestation, ReferenceNow));
 29      }
 30  
 31      [Fact]
 32      public void ReplayRun_VerificationFails_WhenSignatureTampered()
 33      {
 34          var runId = Guid.NewGuid();
 35          var playerId = Guid.NewGuid();
 36          var serverIdentity = CreateServerIdentity(out var authorityRoot);
 37          var verifier = new SignatureVerifier(authorityRoot);
 38          var engine = new RunReplayEngine();
 39  
 40          var result = engine.ValidateAndSignRun(runId, playerId, CreateCompletedRunEvents(), serverIdentity);
 41          var tamperedSignature = result.Attestation.Validation.Signature;
 42          tamperedSignature[0] ^= 0xFF;
 43  
 44          var tamperedAttestation = new SignedRunValidation(
 45              new RunValidationSignature(
 46                  result.RunId,
 47                  result.PlayerId,
 48                  result.FinalStateHash,
 49                  result.Attestation.Validation.ServerId,
 50                  tamperedSignature),
 51              result.Attestation.Certificate);
 52  
 53          Assert.False(verifier.Verify(tamperedAttestation, ReferenceNow));
 54      }
 55  
 56      [Fact]
 57      public void ReplayRun_HashIsDeterministic()
 58      {
 59          var engine = new RunReplayEngine();
 60          var events = CreateCompletedRunEvents();
 61  
 62          var hash1 = engine.ValidateRunOnly(events);
 63          var hash2 = engine.ValidateRunOnly(events);
 64  
 65          Assert.True(hash1.SequenceEqual(hash2));
 66      }
 67  
 68      [Fact]
 69      public void ValidateRunOnly_ThrowsWhenChainTamperedAtTail()
 70      {
 71          var engine = new RunReplayEngine();
 72          var events = CreateCompletedRunEvents().ToList();
 73  
 74          // Replace the last event with one that has a broken previous-hash so replay stops short
 75          var last = (InfilEndedEvent)events[^1];
 76          var (wasSuccessful, endReason) = last.GetPayload();
 77          var tampered = new InfilEndedEvent(
 78              last.OperatorId,
 79              last.SequenceNumber,
 80              wasSuccessful,
 81              endReason,
 82              new string('0', last.PreviousHash.Length), // all-zero previous hash breaks chain
 83              last.Timestamp);
 84          events[^1] = tampered;
 85  
 86          Assert.Throws<InvalidOperationException>(() => engine.ValidateRunOnly(events));
 87      }
 88  
 89      private static IReadOnlyList<OperatorEvent> CreateCompletedRunEvents()
 90      {
 91          var operatorId = OperatorId.NewId();
 92          var created = new OperatorCreatedEvent(operatorId, "Replay Tester", ReferenceNow.AddMinutes(-10));
 93          var loadout = new LoadoutChangedEvent(operatorId, 1, "Rifle", created.Hash, ReferenceNow.AddMinutes(-9));
 94          var perk = new PerkUnlockedEvent(operatorId, 2, "Scavenger", loadout.Hash, ReferenceNow.AddMinutes(-8));
 95          var infil = new InfilStartedEvent(
 96              operatorId,
 97              3,
 98              Guid.NewGuid(),
 99              "Rifle|Medkit",
100              ReferenceNow.AddMinutes(-7),
101              perk.Hash,
102              ReferenceNow.AddMinutes(-7));
103          var combatStart = new CombatSessionStartedEvent(operatorId, 4, Guid.NewGuid(), infil.Hash, ReferenceNow.AddMinutes(-6));
104          var xp = new XpGainedEvent(operatorId, 5, 150, "MissionComplete", combatStart.Hash, ReferenceNow.AddMinutes(-5));
105          var victory = new CombatVictoryEvent(operatorId, 6, xp.Hash, ReferenceNow.AddMinutes(-4));
106          var exfil = new InfilEndedEvent(operatorId, 7, true, "EXFIL", victory.Hash, ReferenceNow.AddMinutes(-3));
107  
108          return [created, loadout, perk, infil, combatStart, xp, victory, exfil];
109      }
110  
111      private static ServerIdentity CreateServerIdentity(out AuthorityRoot authorityRoot)
112      {
113          var rootPrivateKey = CertificateIssuer.GeneratePrivateKey();
114          var certificateIssuer = new CertificateIssuer(rootPrivateKey);
115          authorityRoot = new AuthorityRoot(certificateIssuer.RootPublicKey);
116  
117          var serverPrivateKey = ServerIdentity.GeneratePrivateKey();
118          var certificate = certificateIssuer.IssueServerCertificate(
119              Guid.NewGuid(),
120              ServerIdentity.GetPublicKey(serverPrivateKey),
121              ReferenceNow.AddMinutes(-5),
122              ReferenceNow.AddMinutes(30));
123  
124          return new ServerIdentity(certificate, serverPrivateKey);
125      }
126  }