CultLib/tests/GameCult.Networking.Tests/NetworkingTests.cs

582 lines
26 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
using R3;
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<InvalidOperationException>().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<InvalidOperationException>()
.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<TargetInvocationException>()
.With.InnerException.InstanceOf<MessagePack.MessagePackSerializationException>());
}
[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<PlayerData>(
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<PlayerData>().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<PlayerData>("PlayerId", sourceEntry.PlayerId.ToString("D"));
Assert.That(replicated, Is.Not.Null);
Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload));
}
[Test]
public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
var key = new CultRecordKey("player:watch");
var playerId = Guid.NewGuid();
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database
.WatchByIndex<PlayerData>("PlayerId", playerId.ToString("D"))
.Subscribe(change => changes.Add(change));
var player = new PlayerData
{
PlayerId = playerId,
Email = "watch@example.test",
PasswordHash = "hash",
Username = "Watcher"
};
await database.PutAsync(key, player);
var roundTrip = await database.GetAsync<PlayerData>(key);
Assert.That(roundTrip, Is.SameAs(player));
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
Assert.That(changes[0].Key, Is.EqualTo(key));
Assert.That(changes[0].Shard.IsPrimary, Is.True);
}
[Test]
public void CultNetDatabase_Rejects_LocalWrites_To_ReadOnlyShard()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards =
[
CultNetShardDescriptor.ReadOnly(
"remote-players",
"remote-runtime",
schemaIds: [schemaId])
]
});
Assert.That(
async () => await database.PutAsync(
new CultRecordKey("player:remote"),
new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "remote@example.test",
PasswordHash = "hash",
Username = "Remote"
}),
Throws.TypeOf<CultNetShardAuthorityException>()
.With.Property(nameof(CultNetShardAuthorityException.Shard))
.Property(nameof(CultNetShardDescriptor.ShardId))
.EqualTo("remote-players"));
}
[Test]
public async Task CultNetDatabase_ApplyRawPut_Publishes_DomainChange()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
var key = new CultRecordKey("player:raw");
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "raw@example.test",
PasswordHash = "hash",
Username = "Raw"
};
var changes = new List<CultNetDatabaseChange<PlayerData>>();
using var subscription = database.WatchRecord<PlayerData>(key)
.Subscribe(change => changes.Add(change));
var message = registry.CreateRawDocumentPutMessage(
"put-raw",
new CultRecordHandle<PlayerData>(key),
player);
var applied = await database.ApplyPutAsync(message);
Assert.That(applied, Is.TypeOf<PlayerData>());
Assert.That(cache.Get<PlayerData>(key), Is.Not.Null);
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added));
Assert.That(changes[0].Document, Is.Not.Null);
Assert.That(changes[0].Document!.Username, Is.EqualTo("Raw"));
}
[Test]
public async Task CultNetDatabaseServer_Creates_Filtered_SnapshotResponse()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var player = new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "snapshot@example.test",
PasswordHash = "hash",
Username = "Snapshot"
};
var handle = await database.PutAsync(new CultRecordKey("player:snapshot"), player);
var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest(
"snapshot-request",
recordKeys: [handle.Key.Value]));
Assert.That(response.MessageId, Is.EqualTo("snapshot-request"));
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(handle.Key.Value));
Assert.That(response.Documents[0].Payload, Is.EqualTo(SerializePlayerDataPayload(player)));
}
[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<PlayerData>("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<PlayerDataPayload>(payload);
return new PlayerData
{
PlayerId = decoded.PlayerId,
Email = decoded.Email,
PasswordHash = decoded.PasswordHash,
Username = decoded.Username,
SessionVersion = decoded.SessionVersion
};
}
}
}