diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs new file mode 100644 index 0000000000..3d61b9bea1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// A that extracts the session isolation key from a claim +/// in the current user's identity, as provided by ASP.NET Core's . +/// +/// +/// +/// This provider is suitable for ASP.NET Core web applications where session isolation is based on +/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier) +/// from the ambient . +/// +/// +/// If the is unavailable, the user is not authenticated, or the specified claim +/// is missing, the provider returns . The consuming +/// will then enforce strict or pass-through behavior based on its configuration. +/// +/// +/// This class relies on , which uses +/// to provide access to the current . +/// +/// +public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider +{ + private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly string _claimType; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The used to retrieve the current HTTP context and user claims. + /// + /// The options for configuring the provider. If null, defaults are used. + /// + /// is null, empty, or whitespace. + /// + public ClaimsIdentitySessionIsolationKeyProvider( + IHttpContextAccessor? httpContextAccessor, + ClaimsIdentitySessionIsolationKeyProviderOptions? options = null) + { + options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions(); + this._httpContextAccessor = httpContextAccessor; + this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); + } + + /// + /// Extracts the session isolation key from the current user's claims. + /// + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the value of the + /// configured claim type from the current user's identity, or if the claim + /// is not present or the HTTP context is unavailable. + /// + /// + /// This method retrieves the claim value from HttpContext.User.Claims. If multiple claims + /// of the specified type exist, the first match is returned. + /// + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + Claim? claim = this._httpContextAccessor? + .HttpContext? + .User?.Claims.FirstOrDefault(c => c.Type == this._claimType); + + return new ValueTask(claim?.Value); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs new file mode 100644 index 0000000000..13845bc680 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Claims; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class ClaimsIdentitySessionIsolationKeyProviderOptions +{ + /// + /// Gets or sets the claim type to extract from the user's identity for session isolation. + /// + /// + /// + /// Defaults to , which typically corresponds to + /// the user's name or unique identifier claim. + /// + /// + /// Common alternatives include: + /// + /// ClaimTypes.NameIdentifier — Stable user identifier + /// ClaimTypes.Email — Email address + /// Custom claim types specific to your authentication provider + /// + /// + /// + public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs deleted file mode 100644 index 8d9416f002..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Linq; -using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// A delegating that scopes session keys by a claim value -/// extracted from the current user's identity, ensuring that sessions are isolated per user. -/// The current user is extracted from the ambient ASP.NET . -/// -/// -/// This relies on , which uses -/// to provide access to the current . -/// -public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore -{ - private readonly IHttpContextAccessor? _httpContextAccessor; - private readonly string _claimType; - private readonly bool _strict; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying to delegate to. - /// - /// The used to retrieve the current user's claims. - /// - /// The options for configuring the session store. If null, defaults are used. - public UserIdentityScopedSessionStore( - AgentSessionStore innerStore, - IHttpContextAccessor? contextAccessor, - UserIdentityScopedSessionStoreOptions? options = null) : base(innerStore) - { - options ??= new UserIdentityScopedSessionStoreOptions(); - - this._httpContextAccessor = contextAccessor; - this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); - this._strict = options.Strict; - } - - private string? GetScopeFromIdentity() - { - Claim? claim = this._httpContextAccessor? - .HttpContext? - .User?.Claims.FirstOrDefault(c => c.Type == this._claimType); - - if (this._strict && claim == null) - { - throw new InvalidOperationException($"No claim of type '{this._claimType}' found in principal."); - } - - return claim?.Value; - } - - private string? ScopeId => this.GetScopeFromIdentity(); - - private static string EscapeScopeId(string scopeId) => scopeId.Replace("\\", "\\\\").Replace(":", "\\:"); - - private string GetScopedConversationId(string bareConversationId) - { - string? scopeId = this.ScopeId; - if (scopeId == null) - { - return bareConversationId; - } - - return $"{EscapeScopeId(scopeId)}::{bareConversationId}"; - } - - /// - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) - => this.InnerStore.GetSessionAsync(agent, this.GetScopedConversationId(conversationId), cancellationToken); - - /// - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) - => this.InnerStore.SaveSessionAsync(agent, this.GetScopedConversationId(conversationId), session, cancellationToken); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs deleted file mode 100644 index 40c8b08b1b..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Security.Claims; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Options for configuring . -/// -public class UserIdentityScopedSessionStoreOptions -{ - /// - /// Gets or sets the claim type to extract from the user's identity for scoping. - /// - /// - /// Defaults to . - /// - public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; - - /// - /// Gets or sets a value indicating whether an exception should be thrown when the specified claim is not found. - /// - /// - /// If , an exception is thrown when the specified claim is not found. - /// If , the conversation ID is passed through unmodified when the claim is absent. - /// Defaults to . - /// - public bool Strict { get; set; } = true; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs new file mode 100644 index 0000000000..a967c35f28 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// A delegating that scopes session keys by an isolation key +/// provided by a , ensuring that sessions are isolated +/// per logical partition (e.g., user, tenant, or composite key). +/// +public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore +{ + private readonly SessionIsolationKeyProvider _keyProvider; + private readonly bool _strict; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying to delegate to. + /// + /// The used to retrieve the isolation key for the current context. + /// + /// The options for configuring the session store. If null, defaults are used. + /// + /// is . + /// + public IsolationKeyScopedAgentSessionStore( + AgentSessionStore innerStore, + SessionIsolationKeyProvider? keyProvider, + IsolationKeyScopedAgentSessionStoreOptions? options = null) + : base(innerStore) + { + this._keyProvider = Throw.IfNull(keyProvider); + options ??= new IsolationKeyScopedAgentSessionStoreOptions(); + this._strict = options.Strict; + } + + /// + /// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode. + /// + /// The cancellation token. + /// + /// The isolation key string, or if no key is available and non-strict mode is enabled. + /// + /// + /// The provider returned and strict mode is enabled. + /// + private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) + { + string? key = await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + if (this._strict && key == null) + { + throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider."); + } + + return key; + } + + /// + /// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs. + /// + /// The raw isolation key. + /// The escaped isolation key. + /// + /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:). + /// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly. + /// + private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); + + /// + /// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key. + /// + /// The original conversation ID. + /// The cancellation token. + /// + /// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID + /// if no isolation key is available and non-strict mode is enabled. + /// + private async ValueTask GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken) + { + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + if (key == null) + { + return bareConversationId; + } + + return $"{EscapeIsolationKey(key)}::{bareConversationId}"; + } + + /// + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs new file mode 100644 index 0000000000..94f00f01bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class IsolationKeyScopedAgentSessionStoreOptions +{ + /// + /// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined. + /// + /// + /// + /// If (default), the store will throw an + /// when returns . + /// + /// + /// If , the conversation ID is passed through unmodified when the isolation key is absent, + /// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios + /// or mixed environments where not all requests have isolation keys. + /// + /// + public bool Strict { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs new file mode 100644 index 0000000000..61ea82bd34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions. +/// +/// +/// +/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage +/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes +/// implement the key resolution logic appropriate to their hosting environment. +/// +/// +/// When a key is unavailable or cannot be determined, implementations should return . +/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back +/// to unscoped storage based on its configuration. +/// +/// +public abstract class SessionIsolationKeyProvider +{ + /// + /// Asynchronously retrieves the session isolation key for the current request or execution context. + /// + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the isolation key string, + /// or if no key is available in the current context. + /// + /// + /// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims, + /// or environment variables). If the key cannot be determined, return to allow + /// the caller to decide on strict vs. pass-through behavior. + /// + public abstract ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..e22feec1a9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class ClaimsIdentitySessionIsolationKeyProviderTests +{ + private const string TestUserId = "test-user-id"; + private const string CustomClaimType = "custom-claim-type"; + private const string CustomClaimValue = "custom-claim-value"; + + private readonly Mock _httpContextAccessorMock; + + /// + /// Initializes a new instance of the class. + /// + public ClaimsIdentitySessionIsolationKeyProviderTests() + { + this._httpContextAccessorMock = new Mock(); + } + + #region Constructor Tests + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor accepts null IHttpContextAccessor. + /// + [Fact] + public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is null. + /// + [Fact] + public void RequiresClaimType_NotNull() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is empty. + /// + [Fact] + public void RequiresClaimType_NotEmpty() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is whitespace. + /// + [Fact] + public void RequiresClaimType_NotWhitespace() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " })); + } + + #endregion + + #region GetSessionIsolationKeyAsync Tests + + /// + /// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(TestUserId, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); + var provider = new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType }); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(CustomClaimValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor returns null HttpContext. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync() + { + // Arrange + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor itself is null. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync() + { + // Arrange + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync() + { + // Arrange + const string FirstValue = "first-value"; + const string SecondValue = "second-value"; + var claims = new[] + { + new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue), + new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(FirstValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync handles empty claim values. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(string.Empty, result); + } + + #endregion + + #region Helper Methods + + private void SetupHttpContextWithClaim(string claimType, string claimValue) + { + var claims = new[] { new Claim(claimType, claimValue) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs new file mode 100644 index 0000000000..f2635162ae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -0,0 +1,390 @@ +// 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 throws ArgumentNullException when keyProvider is null. + /// + [Fact] + public void RequiresKeyProvider() + { + // Act & Assert + Assert.Throws("keyProvider", () => + new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, null!)); + } + + /// + /// 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 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; + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..00bf2cd373 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for and its contract. +/// +public class SessionIsolationKeyProviderTests +{ + /// + /// Verify that a concrete provider can return a non-null isolation key. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync() + { + // Arrange + const string ExpectedKey = "test-key"; + var provider = new TestSessionIsolationKeyProvider(ExpectedKey); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(ExpectedKey, result); + } + + /// + /// Verify that a concrete provider can return null when no key is available. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that cancellation token is passed through to the provider implementation. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync() + { + // Arrange + var provider = new TestCancellableSessionIsolationKeyProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await provider.GetSessionIsolationKeyAsync(cts.Token)); + } + + #region Test Implementations + + /// + /// 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); + } + } + + /// + /// Test implementation that respects cancellation tokens. + /// + private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + public override async ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + await Task.Delay(1000, cancellationToken); + return "key"; + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs deleted file mode 100644 index 3098ad9021..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs +++ /dev/null @@ -1,510 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Moq; - -namespace Microsoft.Agents.AI.Hosting.UnitTests; - -/// -/// Unit tests for the class. -/// -public class UserIdentityScopedSessionStoreTests -{ - private const string TestUserId = "test-user-id"; - private const string TestConversationId = "test-conversation-id"; - private const string CustomClaimType = "custom-claim-type"; - private const string CustomClaimValue = "custom-claim-value"; - private const string User1 = "user-1"; - private const string User2 = "user-2"; - - private readonly Mock _innerStoreMock; - private readonly Mock _agentMock; - private readonly Mock _httpContextAccessorMock; - private readonly AgentSession _testSession; - - /// - /// Initializes a new instance of the class. - /// - public UserIdentityScopedSessionStoreTests() - { - this._innerStoreMock = new Mock(); - this._agentMock = new Mock(); - this._httpContextAccessorMock = 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() => - Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object, CreateOptions())); - - /// - /// Verify that constructor uses default options when options is null. - /// - [Fact] - public void UsesDefaultOptionsWhenNull() - { - // Arrange - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - - // Act - should not throw and use default claim type - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, options: null); - - // Assert - Assert.NotNull(store); - } - - /// - /// Verify that constructor accepts null IHttpContextAccessor. - /// - [Fact] - public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() - { - // Act & Assert - should not throw - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, CreateOptions(strict: false)); - Assert.NotNull(store); - } - - /// - /// Verify that constructor throws ArgumentException when claimType is null. - /// - [Fact] - public void RequiresClaimType_NotNull() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: null!))); - - /// - /// Verify that constructor throws ArgumentException when claimType is empty. - /// - [Fact] - public void RequiresClaimType_NotEmpty() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: string.Empty))); - - /// - /// Verify that constructor throws ArgumentException when claimType is whitespace. - /// - [Fact] - public void RequiresClaimType_NotWhitespace() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: " "))); - - #endregion - - #region GetSessionAsync Tests - - /// - /// Verify that GetSessionAsync scopes the conversation ID with the user's claim value. - /// - [Fact] - public async Task GetSessionAsyncScopesConversationIdWithUserClaimAsync() - { - // Arrange - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"{TestUserId}::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync uses custom claim type when specified. - /// - [Fact] - public async Task GetSessionAsyncUsesCustomClaimTypeAsync() - { - // Arrange - this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(claimType: CustomClaimType)); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"{CustomClaimValue}::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync throws InvalidOperationException when claim is missing in strict mode. - /// - [Fact] - public async Task GetSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() - { - // Arrange - this.SetupHttpContextWithClaim("other-claim", "value"); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(strict: true)); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); - - Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message); - } - - /// - /// Verify that GetSessionAsync does not throw when claim is missing in non-strict mode. - /// - [Fact] - public async Task GetSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsync() - { - // Arrange - this.SetupHttpContextWithClaim("other-claim", "value"); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(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 - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // 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 user's claim value. - /// - [Fact] - public async Task SaveSessionAsyncScopesConversationIdWithUserClaimAsync() - { - // Arrange - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - var sessionToSave = new TestAgentSession(); - - // Act - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - - // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - $"{TestUserId}::{TestConversationId}", - sessionToSave, - It.IsAny()), - Times.Once); - } - - /// - /// Verify that SaveSessionAsync uses custom claim type when specified. - /// - [Fact] - public async Task SaveSessionAsyncUsesCustomClaimTypeAsync() - { - // Arrange - this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(claimType: CustomClaimType)); - var sessionToSave = new TestAgentSession(); - - // Act - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - - // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - $"{CustomClaimValue}::{TestConversationId}", - sessionToSave, - It.IsAny()), - Times.Once); - } - - /// - /// Verify that SaveSessionAsync throws InvalidOperationException when claim is missing in strict mode. - /// - [Fact] - public async Task SaveSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() - { - // Arrange - this.SetupHttpContextWithClaim("other-claim", "value"); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(strict: true)); - var sessionToSave = new TestAgentSession(); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); - - Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message); - } - - /// - /// Verify that SaveSessionAsync does not throw when claim is missing in non-strict mode. - /// - [Fact] - public async Task SaveSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsync() - { - // Arrange - this.SetupHttpContextWithClaim("other-claim", "value"); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(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 Edge Cases - - /// - /// Verify behavior when HttpContextAccessor returns null HttpContext. - /// - [Fact] - public async Task WhenHttpContextIsNullAndStrictThrowsAsync() - { - // Arrange - this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(strict: true)); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); - } - - /// - /// Verify behavior when HttpContextAccessor returns null HttpContext in non-strict mode. - /// - [Fact] - public async Task WhenHttpContextIsNullAndNonStrictProceedsAsync() - { - // Arrange - this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); - var store = new UserIdentityScopedSessionStore( - this._innerStoreMock.Object, - this._httpContextAccessorMock.Object, - CreateOptions(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 different users get different scoped conversation IDs. - /// - [Fact] - public async Task DifferentUsersGetDifferentScopedConversationIdsAsync() - { - // Arrange - 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 - User 1 - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User1); - var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Act - User 2 - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User2); - var store2 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - Assert.Equal($"{User1}::{TestConversationId}", capturedConversationId1); - Assert.Equal($"{User2}::{TestConversationId}", capturedConversationId2); - Assert.NotEqual(capturedConversationId1, capturedConversationId2); - } - - /// - /// Verify that colons in user claim values are escaped. - /// - [Fact] - public async Task EscapesColonsInUserClaimValueAsync() - { - // Arrange - const string UserIdWithColon = "user:with:colons"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithColon); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - colons should be escaped as \: - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"user\\:with\\:colons::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that backslashes in user claim values are escaped. - /// - [Fact] - public async Task EscapesBackslashesInUserClaimValueAsync() - { - // Arrange - const string UserIdWithBackslash = @"domain\user"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBackslash); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes should be escaped as \\ - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\user::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that both backslashes and colons in user claim values are escaped correctly. - /// - [Fact] - public async Task EscapesBothBackslashesAndColonsInUserClaimValueAsync() - { - // Arrange - const string UserIdWithBoth = @"domain\user:role"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBoth); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes escaped first, then colons - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\user\\:role::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - #endregion - - #region Helper Methods - - private static UserIdentityScopedSessionStoreOptions CreateOptions( - string? claimType = ClaimsIdentity.DefaultNameClaimType, - bool strict = true) - { - return new UserIdentityScopedSessionStoreOptions - { - ClaimType = claimType!, - Strict = strict - }; - } - - private void SetupHttpContextWithClaim(string claimType, string claimValue) - { - var claims = new[] { new Claim(claimType, claimValue) }; - var identity = new ClaimsIdentity(claims); - var principal = new ClaimsPrincipal(identity); - - var httpContext = new DefaultHttpContext - { - User = principal - }; - - this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); - } - - private sealed class TestAgentSession : AgentSession; - - #endregion -}