#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; using GameCult.Caching; using NUnit.Framework; using R3; using static GameCult.Networking.Tests.NetworkingTests; namespace GameCult.Networking.Tests { // A peer's put or delete is applied or forwarded only when the server's RemoteWriteAuthority grants it. The wire handlers // are reached through their private delegates with an uninitialized peer, as CultNetJournalTests.WireHandlers does: // anything the handler tries to send throws on that peer, so a handler that throws answered the peer with a refusal and a // handler that returns quietly sent nothing. // The tests' own grant-everyone authority, for tests whose subject is not the grant. The library ships none. internal sealed class TestGrantsEverything : ICultNetRemoteWriteAuthority { public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => CultNetRemoteWriteDecision.Grant(); } public sealed class RemoteWriteAuthorityTests { private const string ShardId = "authority-shard"; private static readonly CultRecordKey KeyA = new("tests:authority:a"); private static readonly CultRecordKey KeyB = new("tests:authority:b"); private static readonly CultRecordKey Unowned = new("unrelated:authority"); private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text }; private sealed class Rig { public CultCache Cache = null!; public CultNetDatabase Database = null!; public CultNetDatabaseServer DatabaseServer = null!; public Server Host = null!; public CultNetServerPeer Peer = null!; public CapturingLogger Logger = null!; public Func Put = null!; public Func Delete = null!; public string SchemaId = string.Empty; public List Published = new(); public IDisposable Subscription = null!; public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text; public int LogLength => Database.GetMutationLog(ShardId).Count; public CultNetDocumentPutRawMessage PutOf(CultRecordKey key, string text) { var message = Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(key), Note(text)); message.ShardId = ShardId; message.ShardEpoch = 1; return message; } public CultNetDocumentDeleteMessage DeleteOf(CultRecordKey key) => new() { MessageId = "wire-delete", SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 }; } private static Rig Build(ICultNetRemoteWriteAuthority? authority, bool primary = true, ICultNetShardWriteForwarder? forwarder = null) { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId], primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"]) ] }); var host = new Server(cache, ServerSecurityOptions.Development()); var logger = new CapturingLogger(); host.Logger = logger; var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = authority, ForwardNonPrimaryWrites = forwarder != null, WriteForwarder = forwarder }); var peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; var put = (Func)typeof(CultNetDatabaseServer).GetField("_putHandler", flags)!.GetValue(databaseServer)!; var delete = (Func)typeof(CultNetDatabaseServer).GetField("_deleteHandler", flags)!.GetValue(databaseServer)!; var rig = new Rig { Cache = cache, Database = database, DatabaseServer = databaseServer, Host = host, Peer = peer, Logger = logger, SchemaId = schemaId, Put = message => put(message, peer), Delete = message => delete(message, peer) }; rig.Subscription = database.WatchAllChanges().Subscribe(change => rig.Published.Add(change)); return rig; } // The server writes its own record through the host's door, which asks no remote authority. private static async Task Seed(Rig rig, CultRecordKey key, string text) => await rig.Database.ApplyPutAsync(rig.PutOf(key, text)); [Test] public async Task WithNoAuthorityAPeersPutAndDeleteAreRefusedAndChangeNothing() { var rig = Build(authority: null); await Seed(rig, KeyA, "server"); var publishedBefore = rig.Published.Count; var logBefore = rig.LogLength; Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "the host's own door needs no remote authority"); Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA)), "the peer is answered with a refusal"); Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyB, "peer")), "a new key is refused too"); Assert.That(rig.TextOf(KeyA), Is.EqualTo("server")); Assert.That(rig.TextOf(KeyB), Is.Null); Assert.That(rig.Published, Has.Count.EqualTo(publishedBefore), "a refusal publishes no change"); Assert.That(rig.LogLength, Is.EqualTo(logBefore), "a refusal appends no mutation log entry"); Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw put refused")); Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw delete refused")); Assert.That(rig.Logger.Errors, Is.Empty, "a refusal is not a fault"); } [Test] public async Task AGrantedPutAndDeleteCommitAndTheAuthoritySawThisPeer() { var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()); var rig = Build(authority); await rig.Put(rig.PutOf(KeyA, "peer")); Assert.That(rig.TextOf(KeyA), Is.EqualTo("peer")); Assert.That(rig.Published, Has.Count.EqualTo(1)); Assert.That(rig.LogLength, Is.EqualTo(1)); await rig.Delete(rig.DeleteOf(KeyA)); Assert.That(rig.TextOf(KeyA), Is.Null); Assert.That(authority.Requests, Has.Count.EqualTo(2), "asked once per received write"); Assert.That(authority.Requests.Select(request => request.Peer), Has.All.SameAs(rig.Peer)); Assert.That(rig.Logger.Errors, Is.Empty); } [Test] public async Task TheRequestCarriesTheResolvedWriteAndScopedGrantsRefuseTheirNeighbours() { // The put names a foreign alias of the schema; the payload routes it to the canonical descriptor. var recorded = new RecordingAuthority(request => request.Operation == CultNetRemoteWriteOperation.Put && request.RecordKey.Equals(KeyA) ? CultNetRemoteWriteDecision.Grant() : CultNetRemoteWriteDecision.Refuse("scoped")); var rig = Build(recorded); await Seed(rig, KeyA, "server"); var aliased = rig.PutOf(KeyA, "aliased"); aliased.Document.SchemaId = "runtime.generated.network-note.ui.42"; await rig.Put(aliased); var request = recorded.Requests.Single(); Assert.That(request.SchemaId, Is.EqualTo(rig.SchemaId), "the authority sees the canonical id, not the alias as sent"); Assert.That(request.Operation, Is.EqualTo(CultNetRemoteWriteOperation.Put)); Assert.That(request.RecordKey, Is.EqualTo(KeyA)); Assert.That(request.ShardId, Is.EqualTo(ShardId)); Assert.That(request.ShardEpoch, Is.EqualTo(1)); Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased")); Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyB, "other-key"))); Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA))); Assert.That(rig.TextOf(KeyB), Is.Null, "a grant scoped to key a refuses key b"); Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased"), "a grant scoped to Put refuses Delete"); Assert.That(recorded.Requests.Last().Operation, Is.EqualTo(CultNetRemoteWriteOperation.Delete)); Assert.That(recorded.Requests.Last().SchemaId, Is.EqualTo(rig.SchemaId)); } [Test] public async Task ARefusedWriteIsNeverForwardedAndAGrantedOneIs() { var forwarder = new CountingForwarder(); var refusing = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no")), primary: false, forwarder); Assert.CatchAsync(async () => await refusing.Put(refusing.PutOf(KeyA, "peer"))); Assert.CatchAsync(async () => await refusing.Delete(refusing.DeleteOf(KeyA))); Assert.That(forwarder.Puts, Is.EqualTo(0), "a refused put is not forwarded"); Assert.That(forwarder.Deletes, Is.EqualTo(0), "a refused delete is not forwarded"); var unconfigured = Build(authority: null, primary: false, forwarder); Assert.CatchAsync(async () => await unconfigured.Put(unconfigured.PutOf(KeyA, "peer"))); Assert.That(forwarder.Puts, Is.EqualTo(0), "no authority forwards nothing"); var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), primary: false, forwarder); await granting.Put(granting.PutOf(KeyA, "peer")); await granting.Delete(granting.DeleteOf(KeyA)); Assert.That(forwarder.Puts, Is.EqualTo(1), "a granted write on a non-primary shard is forwarded as before"); Assert.That(forwarder.Deletes, Is.EqualTo(1)); } [Test] public void RefusalPrecedesTheUnownedSchemaCheck() { var unowned = new MeshQuickstartNote { NoteId = "u", Body = "unowned" }; var rig = Build(authority: null); var put = rig.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned); put.ShardId = ShardId; put.ShardEpoch = 1; Assert.CatchAsync(async () => await rig.Put(put)); Assert.That(rig.Logger.Warnings, Has.Some.Contains("authority"), "an ungranted put of an unowned schema is answered write_not_authorized"); Assert.That(rig.Logger.Warnings, Has.None.Contains("No shard owns")); var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant())); var grantedPut = granting.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned); grantedPut.ShardId = ShardId; grantedPut.ShardEpoch = 1; Assert.CatchAsync(async () => await granting.Put(grantedPut)); Assert.That(granting.Logger.Warnings, Has.Some.Contains("No shard owns"), "a granted put still meets the unowned-schema refusal"); } [Test] public async Task AnAuthorityThatThrowsRefusesAndIsLoggedAsAnError() { var rig = Build(new RecordingAuthority(_ => throw new InvalidOperationException("authority exploded"))); await Seed(rig, KeyA, "server"); Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA))); Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "fail closed"); Assert.That(rig.Logger.Errors, Has.Some.Contains("authority exploded")); } [Test] public void TheRefusalNamesTheKeyAndTheCode() { var request = new CultNetRemoteWriteRequest(null!, CultNetRemoteWriteOperation.Delete, "s", KeyA, null, null); var message = CultNetErrorMessage.ForRemoteWriteRefused(request, "why not"); Assert.That(message.Code, Is.EqualTo("write_not_authorized")); Assert.That(message.Error, Is.EqualTo("write_not_authorized: why not")); Assert.That(message.Details!.Field, Is.EqualTo("recordKey")); Assert.That(message.Details.Value, Is.EqualTo(KeyA.Value)); Assert.That(message.RoutingHint, Is.Null); Assert.That(CultNetErrorMessage.ForRemoteWriteRefused(request, null).Error, Is.EqualTo("write_not_authorized")); } private sealed class RecordingAuthority : ICultNetRemoteWriteAuthority { private readonly Func _decide; public List Requests { get; } = new(); public RecordingAuthority(Func decide) => _decide = decide; public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) { Requests.Add(request); return _decide(request); } } private sealed class CountingForwarder : ICultNetShardWriteForwarder { public int Puts { get; private set; } public int Deletes { get; private set; } public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { Puts++; return Task.CompletedTask; } public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { Deletes++; return Task.CompletedTask; } } private sealed class CapturingLogger : GameCult.Logging.ILogger { public List Errors { get; } = new(); public List Warnings { get; } = new(); public void LogInfo(string message) { } public void LogWarning(string message) => Warnings.Add(message); public void LogError(string message) => Errors.Add(message); public void LogDebug(string message) { } } } }