// Copyright (c) Microsoft. All rights reserved. using System; using System.Threading; using System.Threading.Tasks; using Moq; namespace Microsoft.Agents.AI.Hosting.UnitTests; /// /// Unit tests for . /// public class IsolationKeyScopedAgentSessionStoreTests { private const string TestIsolationKey = "test-key"; private const string TestConversationId = "test-conversation-id"; private readonly Mock _innerStoreMock; private readonly Mock _agentMock; private readonly AgentSession _testSession; /// /// Initializes a new instance of the class. /// public IsolationKeyScopedAgentSessionStoreTests() { this._innerStoreMock = new Mock(); this._agentMock = new Mock(); this._testSession = new TestAgentSession(); this._innerStoreMock .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(this._testSession); this._innerStoreMock .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); } #region Constructor Tests /// /// Verify that constructor throws ArgumentNullException when innerStore is null. /// [Fact] public void RequiresInnerStore() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); // Act & Assert Assert.Throws("innerStore", () => new IsolationKeyScopedAgentSessionStore(null!, provider)); } /// /// Verify that constructor uses default options when options is null. /// [Fact] public void UsesDefaultOptionsWhenNull() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); // Act & Assert - should not throw var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null); Assert.NotNull(store); } #endregion #region GetSessionAsync Tests /// /// Verify that GetSessionAsync scopes the conversation ID with the isolation key. /// [Fact] public async Task GetSessionAsyncScopesConversationIdWithKeyAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, $"{TestIsolationKey}::{TestConversationId}", It.IsAny()), Times.Once); } /// /// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode. /// [Fact] public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(null); var store = new IsolationKeyScopedAgentSessionStore( this._innerStoreMock.Object, provider, new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); // Act & Assert var exception = await Assert.ThrowsAsync( async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); Assert.Contains("Session isolation key is required", exception.Message); } /// /// Verify that GetSessionAsync does not throw when key is null in non-strict mode. /// [Fact] public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(null); var store = new IsolationKeyScopedAgentSessionStore( this._innerStoreMock.Object, provider, new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert - conversation ID should be passed through unmodified this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, TestConversationId, It.IsAny()), Times.Once); } /// /// Verify that GetSessionAsync returns the session from the inner store. /// [Fact] public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert Assert.Same(this._testSession, result); } #endregion #region SaveSessionAsync Tests /// /// Verify that SaveSessionAsync scopes the conversation ID with the isolation key. /// [Fact] public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); var sessionToSave = new TestAgentSession(); // Act await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); // Assert this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, $"{TestIsolationKey}::{TestConversationId}", sessionToSave, It.IsAny()), Times.Once); } /// /// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode. /// [Fact] public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(null); var store = new IsolationKeyScopedAgentSessionStore( this._innerStoreMock.Object, provider, new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); var sessionToSave = new TestAgentSession(); // Act & Assert var exception = await Assert.ThrowsAsync( async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); Assert.Contains("Session isolation key is required", exception.Message); } /// /// Verify that SaveSessionAsync does not throw when key is null in non-strict mode. /// [Fact] public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() { // Arrange var provider = new TestSessionIsolationKeyProvider(null); var store = new IsolationKeyScopedAgentSessionStore( this._innerStoreMock.Object, provider, new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); var sessionToSave = new TestAgentSession(); // Act - should not throw await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); // Assert - conversation ID should be passed through unmodified this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, TestConversationId, sessionToSave, It.IsAny()), Times.Once); } #endregion #region Escaping Tests /// /// Verify that colons in the isolation key are escaped. /// [Fact] public async Task EscapesColonsInIsolationKeyAsync() { // Arrange const string KeyWithColon = "key:with:colons"; var provider = new TestSessionIsolationKeyProvider(KeyWithColon); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert - colons should be escaped as \: this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, $"key\\:with\\:colons::{TestConversationId}", It.IsAny()), Times.Once); } /// /// Verify that backslashes in the isolation key are escaped. /// [Fact] public async Task EscapesBackslashesInIsolationKeyAsync() { // Arrange const string KeyWithBackslash = @"domain\key"; var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert - backslashes should be escaped as \\ this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, $"domain\\\\key::{TestConversationId}", It.IsAny()), Times.Once); } /// /// Verify that both backslashes and colons in the isolation key are escaped correctly. /// [Fact] public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync() { // Arrange const string KeyWithBoth = @"domain\key:role"; var provider = new TestSessionIsolationKeyProvider(KeyWithBoth); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert - backslashes escaped first, then colons this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, $"domain\\\\key\\:role::{TestConversationId}", It.IsAny()), Times.Once); } #endregion #region Isolation Tests /// /// Verify that different isolation keys result in different scoped conversation IDs. /// [Fact] public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() { // Arrange const string Key1 = "key-1"; const string Key2 = "key-2"; string? capturedConversationId1 = null; string? capturedConversationId2 = null; this._innerStoreMock .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, conversationId, _) => { if (capturedConversationId1 == null) { capturedConversationId1 = conversationId; } else { capturedConversationId2 = conversationId; } }) .ReturnsAsync(this._testSession); // Act - Key 1 var provider1 = new TestSessionIsolationKeyProvider(Key1); var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1); await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); // Act - Key 2 var provider2 = new TestSessionIsolationKeyProvider(Key2); var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2); await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1); Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2); Assert.NotEqual(capturedConversationId1, capturedConversationId2); } #endregion #region GetService Tests /// /// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain. /// [Fact] public void GetServiceReturnsIsolationKeyScopedAgentSessionStore() { // Arrange var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act var result = store.GetService(); // Assert Assert.Same(store, result); } /// /// Verify that GetService chains through to find inner store types. /// [Fact] public void GetServiceChainsToInnerStore() { // Arrange var concreteInnerStore = new ConcreteAgentSessionStore(); var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); // Act var result = store.GetService(); // Assert Assert.Same(concreteInnerStore, result); } #endregion #region Helper Classes /// /// Test implementation of for testing purposes. /// private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider { private readonly string? _key; public TestSessionIsolationKeyProvider(string? key) { this._key = key; } public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) { return new ValueTask(this._key); } } private sealed class TestAgentSession : AgentSession; /// /// Concrete (non-delegating) session store for testing GetService chaining. /// private sealed class ConcreteAgentSessionStore : AgentSessionStore { public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) => new(new TestAgentSession()); public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; } #endregion }