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