Files
wehub-resource-sync db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

431 lines
15 KiB
C#

// 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 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 GetService Tests
/// <summary>
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
/// </summary>
[Fact]
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
// Assert
Assert.Same(store, result);
}
/// <summary>
/// Verify that GetService chains through to find inner store types.
/// </summary>
[Fact]
public void GetServiceChainsToInnerStore()
{
// Arrange
var concreteInnerStore = new ConcreteAgentSessionStore();
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
// Act
var result = store.GetService<ConcreteAgentSessionStore>();
// Assert
Assert.Same(concreteInnerStore, result);
}
#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;
/// <summary>
/// Concrete (non-delegating) session store for testing GetService chaining.
/// </summary>
private sealed class ConcreteAgentSessionStore : AgentSessionStore
{
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
#endregion
}