diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index b8332eb..e2a7cbc 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -737,6 +737,46 @@ public sealed class Account : IDisposable return new AccountClaimUpdateResult(Changed: false, ChangedFields: [], UpdateCount: Volatile.Read(ref _claimUpdateCount)); } + // Reverse response map for cross-account request-reply routing. + // When a service import rewrites the reply subject, a reverse mapping is stored so that + // when the response arrives it can be forwarded back to the original requester's account. + // Go reference: server/accounts.go — addRespMapEntry / checkForReverseEntries. + private readonly ConcurrentDictionary _reverseResponseMap = + new(StringComparer.Ordinal); + + /// + /// Adds (or overwrites) a reverse response mapping for . + /// Records which origin account and original reply subject to route the response back to. + /// Go reference: accounts.go addRespMapEntry. + /// + public void AddReverseRespMapEntry(string replySubject, string originAccount, string originalReply) => + _reverseResponseMap[replySubject] = new ReverseResponseMapEntry( + replySubject, originAccount, originalReply, DateTime.UtcNow); + + /// + /// Looks up the reverse response map entry for . + /// Returns when no mapping exists. + /// Go reference: accounts.go checkForReverseEntries. + /// + public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) => + _reverseResponseMap.TryGetValue(replySubject, out var entry) ? entry : null; + + /// + /// Removes the reverse response mapping for . + /// Returns if the entry was found and removed. + /// + public bool RemoveReverseRespMapEntry(string replySubject) => + _reverseResponseMap.TryRemove(replySubject, out _); + + /// The number of active reverse response map entries. + public int ReverseResponseMapCount => _reverseResponseMap.Count; + + /// Removes all reverse response map entries. + public void ClearReverseResponseMap() => _reverseResponseMap.Clear(); + + /// Returns a snapshot of all reply subjects currently in the reverse response map. + public IReadOnlyList GetReverseResponseMapKeys() => [.. _reverseResponseMap.Keys]; + public void Dispose() => SubList.Dispose(); } @@ -861,3 +901,19 @@ public sealed record AccountClaimUpdateResult( bool Changed, IReadOnlyList ChangedFields, int UpdateCount); + +/// +/// A single entry in an account's reverse response map. +/// Maps a rewritten reply subject back to the original requester's account and subject +/// so that service responses can be routed across account boundaries. +/// Go reference: server/accounts.go — respMapEntry struct used by addRespMapEntry / checkForReverseEntries. +/// +/// The rewritten reply subject used by the service provider. +/// The name of the account that originated the request. +/// The reply subject the originating client is listening on. +/// The UTC time at which this mapping was recorded. +public sealed record ReverseResponseMapEntry( + string ReplySubject, + string OriginAccount, + string OriginalReply, + DateTime CreatedAt); diff --git a/tests/NATS.Server.Tests/Auth/ReverseResponseMapTests.cs b/tests/NATS.Server.Tests/Auth/ReverseResponseMapTests.cs new file mode 100644 index 0000000..e768c86 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/ReverseResponseMapTests.cs @@ -0,0 +1,174 @@ +// Tests for Account.AddReverseRespMapEntry / CheckForReverseEntries and related helpers. +// Go reference: server/accounts.go — addRespMapEntry (~line 2800), checkForReverseEntries (~line 2810). + +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class ReverseResponseMapTests +{ + // --------------------------------------------------------------------------- + // AddReverseRespMapEntry / CheckForReverseEntries + // --------------------------------------------------------------------------- + + [Fact] + public void AddReverseRespMapEntry_StoresEntry() + { + // Go ref: accounts.go addRespMapEntry — stores respMapEntry keyed by rewritten reply + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.abc", "B", "reply.1"); + + account.ReverseResponseMapCount.ShouldBe(1); + } + + [Fact] + public void CheckForReverseEntries_Found_ReturnsEntry() + { + // Go ref: accounts.go checkForReverseEntries — returns entry when key exists + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.xyz", "origin-account", "original.reply.subject"); + + var entry = account.CheckForReverseEntries("_R_.xyz"); + + entry.ShouldNotBeNull(); + entry.ReplySubject.ShouldBe("_R_.xyz"); + entry.OriginAccount.ShouldBe("origin-account"); + entry.OriginalReply.ShouldBe("original.reply.subject"); + } + + [Fact] + public void CheckForReverseEntries_NotFound_ReturnsNull() + { + // Go ref: accounts.go checkForReverseEntries — returns nil when key absent + var account = new Account("A"); + + var entry = account.CheckForReverseEntries("_R_.nonexistent"); + + entry.ShouldBeNull(); + } + + // --------------------------------------------------------------------------- + // RemoveReverseRespMapEntry + // --------------------------------------------------------------------------- + + [Fact] + public void RemoveReverseRespMapEntry_Found_ReturnsTrue() + { + // Go ref: accounts.go — reverse map cleanup after response is routed + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.del", "B", "orig.reply"); + + var removed = account.RemoveReverseRespMapEntry("_R_.del"); + + removed.ShouldBeTrue(); + account.ReverseResponseMapCount.ShouldBe(0); + } + + [Fact] + public void RemoveReverseRespMapEntry_NotFound_ReturnsFalse() + { + // Go ref: accounts.go — removing an absent entry is a no-op + var account = new Account("A"); + + var removed = account.RemoveReverseRespMapEntry("_R_.missing"); + + removed.ShouldBeFalse(); + } + + // --------------------------------------------------------------------------- + // ReverseResponseMapCount + // --------------------------------------------------------------------------- + + [Fact] + public void ReverseResponseMapCount_MatchesEntries() + { + // Go ref: accounts.go — map length reflects outstanding response mappings + var account = new Account("A"); + + account.ReverseResponseMapCount.ShouldBe(0); + + account.AddReverseRespMapEntry("_R_.1", "B", "r1"); + account.AddReverseRespMapEntry("_R_.2", "C", "r2"); + account.AddReverseRespMapEntry("_R_.3", "D", "r3"); + + account.ReverseResponseMapCount.ShouldBe(3); + + account.RemoveReverseRespMapEntry("_R_.2"); + + account.ReverseResponseMapCount.ShouldBe(2); + } + + // --------------------------------------------------------------------------- + // ClearReverseResponseMap + // --------------------------------------------------------------------------- + + [Fact] + public void ClearReverseResponseMap_EmptiesAll() + { + // Go ref: accounts.go — clearing map after bulk expiry / account teardown + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.a", "B", "ra"); + account.AddReverseRespMapEntry("_R_.b", "C", "rb"); + + account.ClearReverseResponseMap(); + + account.ReverseResponseMapCount.ShouldBe(0); + account.CheckForReverseEntries("_R_.a").ShouldBeNull(); + account.CheckForReverseEntries("_R_.b").ShouldBeNull(); + } + + // --------------------------------------------------------------------------- + // GetReverseResponseMapKeys + // --------------------------------------------------------------------------- + + [Fact] + public void GetReverseResponseMapKeys_ReturnsAllKeys() + { + // Go ref: accounts.go — iterating active respMapEntry keys for diagnostics + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.k1", "B", "r1"); + account.AddReverseRespMapEntry("_R_.k2", "C", "r2"); + + var keys = account.GetReverseResponseMapKeys(); + + keys.Count.ShouldBe(2); + keys.ShouldContain("_R_.k1"); + keys.ShouldContain("_R_.k2"); + } + + // --------------------------------------------------------------------------- + // Overwrite and CreatedAt preservation + // --------------------------------------------------------------------------- + + [Fact] + public void AddReverseRespMapEntry_OverwritesPrevious() + { + // Go ref: accounts.go addRespMapEntry — map assignment overwrites existing key + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.ov", "B", "first.reply"); + account.AddReverseRespMapEntry("_R_.ov", "C", "second.reply"); + + var entry = account.CheckForReverseEntries("_R_.ov"); + + entry.ShouldNotBeNull(); + entry.OriginAccount.ShouldBe("C"); + entry.OriginalReply.ShouldBe("second.reply"); + account.ReverseResponseMapCount.ShouldBe(1); + } + + [Fact] + public void ReverseRespMapEntry_PreservesCreatedAt() + { + // Go ref: accounts.go respMapEntry — timestamp recorded at map insertion time + var before = DateTime.UtcNow; + var account = new Account("A"); + account.AddReverseRespMapEntry("_R_.ts", "B", "ts.reply"); + var after = DateTime.UtcNow; + + var entry = account.CheckForReverseEntries("_R_.ts"); + + entry.ShouldNotBeNull(); + entry.CreatedAt.ShouldBeGreaterThanOrEqualTo(before); + entry.CreatedAt.ShouldBeLessThanOrEqualTo(after); + } +}