#nullable enable using System; using System.IO; using System.Reflection; using System.Text; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using NUnit.Framework; namespace GameCult.Networking.Tests { public class NetworkingTests { private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development(); private static readonly ClientSecurityOptions DevelopmentClientSecurity = ClientSecurityOptions.Development(); [Test] public void EncryptDecrypt_Roundtrip() { var plaintext = Encoding.UTF8.GetBytes("test"); var nonce = Secret.NewNonce; var encrypted = Secret.EncryptBytes(plaintext, nonce, DevelopmentClientSecurity); var decrypted = Secret.DecryptBytes(encrypted, nonce, DevelopmentClientSecurity); Assert.That(plaintext, Is.EqualTo(decrypted)); } [Test] public void SessionToken_Validates_AndRejectsTampering() { var userId = Guid.NewGuid(); var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), DevelopmentServerSecurity); Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out var expiresAt), Is.True); Assert.That(parsedUserId, Is.EqualTo(userId)); Assert.That(expiresAt, Is.GreaterThan(DateTimeOffset.UtcNow)); var tamperedToken = $"{token}tampered"; Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False); } [Test] public void SessionToken_Validates_SessionVersion() { var userId = Guid.NewGuid(); var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), 42, DevelopmentServerSecurity); Assert.That( Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out _, out var sessionVersion), Is.True); Assert.That(parsedUserId, Is.EqualTo(userId)); Assert.That(sessionVersion, Is.EqualTo(42)); } [Test] public void SessionToken_RejectsExpiredTokens() { var token = Secret.CreateSessionToken(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(-1), DevelopmentServerSecurity); Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out _, out _), Is.False); } [Test] public void SecurityOptions_FromEnvironment_Rejects_MissingSecrets() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); Assert.That( () => ServerSecurityOptions.FromEnvironment(), Throws.TypeOf().With.Message.Contains("Server security configuration is not configured")); } [Test] public void SecurityOptions_FromEnvironment_Rejects_PartialConfiguration() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, "connection-key"), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); Assert.That( () => ServerSecurityOptions.FromEnvironment(), Throws.TypeOf() .With.Message.Contains(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable)); } [Test] public void ServerSecurityOptions_FromEnvironment_Can_OptInto_DevelopmentDefaults() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); var options = ServerSecurityOptions.FromEnvironment(allowDevelopmentDefaults: true); Assert.That(options.IsDevelopment, Is.True); Assert.That(options.ConnectionKey, Is.Not.Empty); } [Test] public void MessageSerialization_RoundTrips_KnownMessageUnion() { var client = new Client(DevelopmentClientSecurity); var message = new LoginMessage { Nonce = Secret.NewNonce, Auth = Encoding.UTF8.GetBytes("auth"), Password = Encoding.UTF8.GetBytes("password") }; var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var payload = (byte[])serialize.Invoke(null, [message])!; var roundTrip = (LoginMessage)deserialize.Invoke(null, [payload])!; Assert.That(roundTrip.Auth, Is.EqualTo(message.Auth)); Assert.That(roundTrip.Password, Is.EqualTo(message.Password)); Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce)); } [Test] public void MessageSerialization_RoundTrips_SchemaCatalogMessages() { var client = new Client(DevelopmentClientSecurity); var message = new SchemaCatalogResponseMessage { MessageId = "catalog-1", Schemas = [ new SchemaDescriptorMessage { SchemaId = "https://example.test/contracts/example.schema.json", Kind = "shared_contract", SchemaVersion = "example.contract.v0", Title = "Example Contract", WireContracts = ["cultnet.schema.v0", "gamecult.networking.v0"], ContentHash = "deadbeef", SchemaJson = "{\"type\":\"object\"}" } ] }; var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var payload = (byte[])serialize.Invoke(null, [message])!; var roundTrip = (SchemaCatalogResponseMessage)deserialize.Invoke(null, [payload])!; Assert.That(roundTrip.MessageId, Is.EqualTo("catalog-1")); Assert.That(roundTrip.Schemas, Has.Length.EqualTo(1)); Assert.That(roundTrip.Schemas[0].SchemaId, Is.EqualTo("https://example.test/contracts/example.schema.json")); Assert.That(roundTrip.Schemas[0].WireContracts, Is.EqualTo(["cultnet.schema.v0", "gamecult.networking.v0"])); Assert.That(roundTrip.Schemas[0].SchemaJson, Is.EqualTo("{\"type\":\"object\"}")); } [Test] public void MessageSerialization_Rejects_InvalidPayload() { var client = new Client(DevelopmentClientSecurity); var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); Assert.That( () => deserialize.Invoke(null, [new byte[] { 0xC1 }]), Throws.TypeOf() .With.InnerException.InstanceOf()); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_RawSnapshotResponse() { var message = new CultNetSnapshotResponseRawMessage { MessageId = "snapshot-1", Documents = [ new CultNetRawDocumentRecord { SchemaId = "sha256:ghostlight-agent-state", RecordKey = "world/main", StoredAt = "2026-05-06T12:34:56.0000000+00:00", PayloadEncoding = "messagepack", Payload = [0x91, 0xA3, 0x66, 0x6F, 0x6F], SourceRuntimeId = "voidbot", SourceAgentId = "void", SourceRole = "herald", Tags = ["swarm", "dream"] } ] }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("snapshot-1")); Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); Assert.That(roundTrip.Documents[0].SchemaId, Is.EqualTo("sha256:ghostlight-agent-state")); Assert.That(roundTrip.Documents[0].PayloadEncoding, Is.EqualTo("messagepack")); Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(message.Documents[0].Payload)); Assert.That(roundTrip.Documents[0].Tags, Is.EqualTo(["swarm", "dream"])); } [Test] public async Task CultNetDocumentRegistry_RawSnapshotReplication_PreservesPayloadBytes() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var registry = new CultNetDocumentRegistry() .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var sourceEntry = new PlayerData { PlayerId = Guid.NewGuid(), Email = "cult@example.test", PasswordHash = "not-a-real-hash", Username = "CultGhost" }; var handle = await sourceCache.AddAsync(sourceEntry); var expectedPayload = SerializePlayerDataPayload(sourceEntry); var request = registry.CreateSnapshotRequest( "request-1", schemaIds: [sourceCache.Registry.GetRequired().SchemaId], recordKeys: [handle.Key.Value]); var response = registry.CreateRawSnapshotResponse(sourceCache, "snapshot-1", request); var serializedResponse = CultNetSchemaMessageSerialization.Serialize(response); var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(serializedResponse); Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(expectedPayload)); await registry.ApplyRawSnapshotResponseAsync(targetCache, roundTrip); var replicated = targetCache.GetByIndex("PlayerId", sourceEntry.PlayerId.ToString("D")); Assert.That(replicated, Is.Not.Null); Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); } [Test] public void CultNetSchemaRegistry_BuiltInCatalog_AdvertisesRawLane_AndSharedGhostlightContract() { var response = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse( new CultNetSchemaCatalogRequestMessage { MessageId = "catalog-raw", IncludeSchemaJson = true }); var rawPut = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.DocumentPutRaw); var rawSnapshot = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.SnapshotResponseRaw); var ghostlight = Array.Find(response.Schemas, schema => schema.SchemaVersion == "ghostlight.agent_state.v0"); Assert.That(rawPut, Is.Not.Null); Assert.That(rawPut!.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); Assert.That(rawPut.SchemaJson, Does.Contain("cultnet.document_put_raw.v0")); Assert.That(rawPut.ContentHash, Has.Length.EqualTo(64)); Assert.That(rawSnapshot, Is.Not.Null); Assert.That(rawSnapshot!.SchemaJson, Does.Contain("cultnet.snapshot_response_raw.v0")); Assert.That(ghostlight, Is.Not.Null); Assert.That(ghostlight!.DocumentType, Is.EqualTo("ghostlight.agent-state")); Assert.That(ghostlight.Kind, Is.EqualTo("document_payload")); } [Test] public void Server_Separates_Connection_And_Auth_RateLimits() { var cache = new CultCache(); var server = new Server(cache, DevelopmentServerSecurity); var serverType = typeof(Server); var connectionMethod = serverType.GetMethod("CheckConnectionRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; var authMethod = serverType.GetMethod("CheckAuthRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; for (var index = 0; index < 5; index++) { Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); } Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); for (var index = 0; index < 5; index++) { _ = authMethod.Invoke(server, new object[] { "127.0.0.2" }); } Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.False); Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.True); } [Test] public void Client_Reconnect_Backoff_Is_Bounded_And_Grows() { var method = typeof(Client).GetMethod("GetReconnectDelayForAttempt", BindingFlags.Static | BindingFlags.NonPublic)!; var first = (TimeSpan)method.Invoke(null, new object[] { 1 })!; var third = (TimeSpan)method.Invoke(null, new object[] { 3 })!; var tenth = (TimeSpan)method.Invoke(null, new object[] { 10 })!; Assert.That(first, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1))); Assert.That(first, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1.25))); Assert.That(third, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(4))); Assert.That(third, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(4.25))); Assert.That(tenth, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(30))); Assert.That(tenth, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(30.25))); } [Test] public void SensitivePayloadLogging_Is_Gated_By_Default() { var client = new Client(DevelopmentClientSecurity); var productionServer = new Server(new CultCache(), new ServerSecurityOptions("prod-connection-key", "prod-session-signing-secret")); var developmentServer = new Server(new CultCache(), DevelopmentServerSecurity); Assert.That(client.LogSensitivePayloads, Is.False); Assert.That(productionServer.LogSensitivePayloads, Is.False); Assert.That(developmentServer.LogSensitivePayloads, Is.True); } [Test] public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server() { var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-{Guid.NewGuid():N}.msgpack"); try { var host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); Assert.That(host.Cache, Is.Not.Null); Assert.That(host.Store, Is.Not.Null); Assert.That(host.Server, Is.Not.Null); await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "host@example.test", PasswordHash = "hash", Username = "HostUser" }); await host.FlushAsync(); host.Dispose(); var reopened = await CultCacheMessagePack.OpenAsync(filePath); Assert.That(reopened.GetByName("HostUser"), Is.Not.Null); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public void CultNetLocal_CreateClient_Uses_Development_Defaults() { var client = CultNetLocal.CreateClient(); Assert.That(client, Is.Not.Null); Assert.That(client.LogSensitivePayloads, Is.False); Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.Idle)); } private sealed class EnvironmentVariableScope : IDisposable { private readonly (string Name, string? Value)[] _originalValues; public EnvironmentVariableScope(params (string Name, string? Value)[] values) { _originalValues = new (string Name, string? Value)[values.Length]; for (var i = 0; i < values.Length; i++) { _originalValues[i] = (values[i].Name, Environment.GetEnvironmentVariable(values[i].Name)); Environment.SetEnvironmentVariable(values[i].Name, values[i].Value); } } public void Dispose() { foreach (var original in _originalValues) { Environment.SetEnvironmentVariable(original.Name, original.Value); } } } [MessagePack.MessagePackObject] public sealed class PlayerDataPayload { [MessagePack.Key(0)] public Guid PlayerId { get; set; } [MessagePack.Key(1)] public string Email { get; set; } = string.Empty; [MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty; [MessagePack.Key(3)] public string Username { get; set; } = string.Empty; [MessagePack.Key(4)] public long SessionVersion { get; set; } } private static byte[] SerializePlayerDataPayload(PlayerData entry) { return MessagePack.MessagePackSerializer.Serialize(new PlayerDataPayload { PlayerId = entry.PlayerId, Email = entry.Email, PasswordHash = entry.PasswordHash, Username = entry.Username, SessionVersion = entry.SessionVersion }); } private static PlayerData DeserializePlayerDataPayload(byte[] payload) { var decoded = MessagePack.MessagePackSerializer.Deserialize(payload); return new PlayerData { PlayerId = decoded.PlayerId, Email = decoded.Email, PasswordHash = decoded.PasswordHash, Username = decoded.Username, SessionVersion = decoded.SessionVersion }; } } }