feat: add reverse response mapping for cross-account request-reply (Gap 9.9)
This commit is contained in:
@@ -737,6 +737,46 @@ public sealed class Account : IDisposable
|
|||||||
return new AccountClaimUpdateResult(Changed: false, ChangedFields: [], UpdateCount: Volatile.Read(ref _claimUpdateCount));
|
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<string, ReverseResponseMapEntry> _reverseResponseMap =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds (or overwrites) a reverse response mapping for <paramref name="replySubject"/>.
|
||||||
|
/// Records which origin account and original reply subject to route the response back to.
|
||||||
|
/// Go reference: accounts.go addRespMapEntry.
|
||||||
|
/// </summary>
|
||||||
|
public void AddReverseRespMapEntry(string replySubject, string originAccount, string originalReply) =>
|
||||||
|
_reverseResponseMap[replySubject] = new ReverseResponseMapEntry(
|
||||||
|
replySubject, originAccount, originalReply, DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up the reverse response map entry for <paramref name="replySubject"/>.
|
||||||
|
/// Returns <see langword="null"/> when no mapping exists.
|
||||||
|
/// Go reference: accounts.go checkForReverseEntries.
|
||||||
|
/// </summary>
|
||||||
|
public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) =>
|
||||||
|
_reverseResponseMap.TryGetValue(replySubject, out var entry) ? entry : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the reverse response mapping for <paramref name="replySubject"/>.
|
||||||
|
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||||
|
/// </summary>
|
||||||
|
public bool RemoveReverseRespMapEntry(string replySubject) =>
|
||||||
|
_reverseResponseMap.TryRemove(replySubject, out _);
|
||||||
|
|
||||||
|
/// <summary>The number of active reverse response map entries.</summary>
|
||||||
|
public int ReverseResponseMapCount => _reverseResponseMap.Count;
|
||||||
|
|
||||||
|
/// <summary>Removes all reverse response map entries.</summary>
|
||||||
|
public void ClearReverseResponseMap() => _reverseResponseMap.Clear();
|
||||||
|
|
||||||
|
/// <summary>Returns a snapshot of all reply subjects currently in the reverse response map.</summary>
|
||||||
|
public IReadOnlyList<string> GetReverseResponseMapKeys() => [.. _reverseResponseMap.Keys];
|
||||||
|
|
||||||
public void Dispose() => SubList.Dispose();
|
public void Dispose() => SubList.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,3 +901,19 @@ public sealed record AccountClaimUpdateResult(
|
|||||||
bool Changed,
|
bool Changed,
|
||||||
IReadOnlyList<string> ChangedFields,
|
IReadOnlyList<string> ChangedFields,
|
||||||
int UpdateCount);
|
int UpdateCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ReplySubject">The rewritten reply subject used by the service provider.</param>
|
||||||
|
/// <param name="OriginAccount">The name of the account that originated the request.</param>
|
||||||
|
/// <param name="OriginalReply">The reply subject the originating client is listening on.</param>
|
||||||
|
/// <param name="CreatedAt">The UTC time at which this mapping was recorded.</param>
|
||||||
|
public sealed record ReverseResponseMapEntry(
|
||||||
|
string ReplySubject,
|
||||||
|
string OriginAccount,
|
||||||
|
string OriginalReply,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|||||||
174
tests/NATS.Server.Tests/Auth/ReverseResponseMapTests.cs
Normal file
174
tests/NATS.Server.Tests/Auth/ReverseResponseMapTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user