mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Split UserIdentityScopedSessionStore into a separate IsolationKeyProvider and IsolationKeyScopedSessionStore
This commit is contained in:
+78
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// 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 <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> 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 <see cref="HttpContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </remarks>
|
||||
public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserIdentityScopedSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="contextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current user's claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, this.GetScopedConversationId(conversationId), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, this.GetScopedConversationId(conversationId), session, cancellationToken);
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="UserIdentityScopedSessionStore"/>.
|
||||
/// </summary>
|
||||
public class UserIdentityScopedSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for scoping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>.
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the specified claim is not found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If <see langword="true"/>, an exception is thrown when the specified claim is not found.
|
||||
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the claim is absent.
|
||||
/// Defaults to <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = Throw.IfNull(keyProvider);
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// 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.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
+251
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
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<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
+390
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when keyProvider is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresKeyProvider()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("keyProvider", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[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<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, 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
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
-510
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="UserIdentityScopedSessionStore"/> class.
|
||||
/// </summary>
|
||||
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<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserIdentityScopedSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public UserIdentityScopedSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object, CreateOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull() =>
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: null!)));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty() =>
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: string.Empty)));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace() =>
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: " ")));
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the user's claim value.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when claim is missing in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when claim is missing in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the user's claim value.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when claim is missing in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when claim is missing in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different users get different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentUsersGetDifferentScopedConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string? capturedConversationId1 = null;
|
||||
string? capturedConversationId2 = null;
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in user claim values are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in user claim values are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in user claim values are escaped correctly.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user