mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Purview: Parallelize PSPC cold-cache scope refresh (#5832)
* Parallelize Purview PSPC cold cache path * Cache Purview payment-required state for scope refresh * Cache Purview payment-required state for scope refresh * Align Purview policy action dedupe and 402 caching Deduplicate combined policy actions by action and restriction action so restriction-only actions are preserved without duplicating identical entries. Cache tenant-level payment-required state from background scope refresh so subsequent calls short-circuit consistently. * .NET: Implement best-effort caching for background job scope retrieval and add unit tests for cache write failures * Purview - feat: Enhance ScopedContentProcessor to queue ContentActivityJob when no applicable scopes are found and update related tests * docs: Update purview package README and AGENTS documentation to reflect caching optimizations and policy enforcement scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview;
|
||||
@@ -16,6 +20,7 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
{
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly IPurviewClient _purviewClient;
|
||||
private readonly ICacheProvider _cacheProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,12 +28,14 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
/// </summary>
|
||||
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
|
||||
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
|
||||
/// <param name="cacheProvider">The cache provider used to store protection scopes results.</param>
|
||||
/// <param name="logger">The logger used to log information about background jobs.</param>
|
||||
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
|
||||
{
|
||||
this._channelHandler = channelHandler;
|
||||
this._purviewClient = purviewClient;
|
||||
this._cacheProvider = cacheProvider;
|
||||
this._logger = logger;
|
||||
|
||||
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
|
||||
@@ -67,6 +74,28 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
break;
|
||||
case ContentActivityJob contentActivityJob:
|
||||
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case ScopeRetrievalJob scopeRetrievalJob:
|
||||
try
|
||||
{
|
||||
ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false);
|
||||
(bool shouldProcess, List<DlpActionInfo> _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response);
|
||||
if (!shouldProcess)
|
||||
{
|
||||
ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest;
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
|
||||
}
|
||||
}
|
||||
catch (PurviewPaymentRequiredException ex)
|
||||
{
|
||||
await this._cacheProvider.SetAsync(
|
||||
new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId),
|
||||
new PaymentRequiredCacheEntry(ex.Message),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Cached tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheEntry"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The payment required error message.</param>
|
||||
public PaymentRequiredCacheEntry(string? message)
|
||||
{
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment required error message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A cache key for tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The id of the tenant.</param>
|
||||
public PaymentRequiredCacheKey(string tenantId)
|
||||
{
|
||||
this.TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The id of the tenant.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Class representing a job that refreshes the protection scopes cache in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
|
||||
/// foreground ProcessContent call.
|
||||
/// </remarks>
|
||||
internal sealed class ScopeRetrievalJob : BackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScopeRetrievalJob"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The protection scopes request to send to Purview.</param>
|
||||
/// <param name="cacheKey">The cache key used to store the response.</param>
|
||||
/// <param name="processContentRequest">The original process content request that triggered scope retrieval.</param>
|
||||
public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest)
|
||||
{
|
||||
this.Request = request;
|
||||
this.CacheKey = cacheKey;
|
||||
this.ProcessContentRequest = processContentRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the protection scopes request.
|
||||
/// </summary>
|
||||
public ProtectionScopesRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache key used to store the response.
|
||||
/// </summary>
|
||||
public ProtectionScopesCacheKey CacheKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original process content request that triggered scope retrieval.
|
||||
/// </summary>
|
||||
public ProcessContentRequest ProcessContentRequest { get; }
|
||||
}
|
||||
@@ -53,4 +53,10 @@ internal sealed class ProcessContentRequest
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal string? ScopeIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the ProcessContent request should ask the service for inline evaluation.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal bool ProcessInline { get; set; }
|
||||
}
|
||||
|
||||
@@ -130,6 +130,11 @@ internal sealed class PurviewClient : IPurviewClient
|
||||
message.Headers.Add("If-None-Match", request.ScopeIdentifier);
|
||||
}
|
||||
|
||||
if (request.ProcessInline)
|
||||
{
|
||||
message.Headers.Add("Prefer", "evaluateInline");
|
||||
}
|
||||
|
||||
string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
|
||||
message.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe
|
||||
|
||||
The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
|
||||
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit.
|
||||
|
||||
## Exceptions
|
||||
| Exception | Scenario |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
@@ -193,43 +194,60 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
|
||||
|
||||
PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
new PaymentRequiredCacheKey(pcRequest.TenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cachedPaymentRequired != null)
|
||||
{
|
||||
throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required");
|
||||
}
|
||||
|
||||
ProtectionScopesCacheKey cacheKey = new(psRequest);
|
||||
|
||||
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ProtectionScopesResponse psResponse;
|
||||
|
||||
if (cacheResponse != null)
|
||||
{
|
||||
psResponse = cacheResponse;
|
||||
}
|
||||
else
|
||||
{
|
||||
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
|
||||
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
|
||||
}
|
||||
catch (PurviewJobException)
|
||||
{
|
||||
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> ProcessWithCachedScopesAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesResponse psResponse,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
|
||||
|
||||
(bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
if (shouldProcess)
|
||||
{
|
||||
pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline;
|
||||
|
||||
if (executionMode == ExecutionMode.EvaluateOffline)
|
||||
{
|
||||
this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
return pcResponse;
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
@@ -238,6 +256,30 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> CallProcessContentAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
List<DlpActionInfo>? dlpActions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (dlpActions?.Count > 0)
|
||||
{
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dedupe policy actions received from the service.
|
||||
/// </summary>
|
||||
@@ -248,9 +290,21 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
if (actionInfos?.Count > 0)
|
||||
{
|
||||
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
|
||||
actionInfos :
|
||||
[.. pcResponse.PolicyActions, .. actionInfos];
|
||||
List<DlpActionInfo> combinedActions = [];
|
||||
HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = [];
|
||||
IEnumerable<DlpActionInfo> allActions = pcResponse.PolicyActions is null
|
||||
? actionInfos
|
||||
: pcResponse.PolicyActions.Concat(actionInfos);
|
||||
|
||||
foreach (DlpActionInfo actionInfo in allActions)
|
||||
{
|
||||
if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction)))
|
||||
{
|
||||
combinedActions.Add(actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pcResponse.PolicyActions = combinedActions;
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
@@ -262,7 +316,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
/// <param name="pcRequest">The process content request.</param>
|
||||
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
|
||||
/// <returns>A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.</returns>
|
||||
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
{
|
||||
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
|
||||
|
||||
@@ -284,7 +338,11 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
|
||||
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
|
||||
{
|
||||
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
|
||||
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locationMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activityMatch && locationMatch)
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace Microsoft.Agents.AI.Purview.Serialization;
|
||||
[JsonSerializable(typeof(ContentActivitiesRequest))]
|
||||
[JsonSerializable(typeof(ContentActivitiesResponse))]
|
||||
[JsonSerializable(typeof(ProtectionScopesCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheEntry))]
|
||||
internal sealed partial class SourceGenerationContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -115,6 +115,24 @@ public sealed class PurviewClientTests : IDisposable
|
||||
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
request.ProcessInline = true;
|
||||
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("evaluateInline", this._handler.PreferHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
@@ -530,6 +548,7 @@ public sealed class PurviewClientTests : IDisposable
|
||||
public HttpMethod? RequestMethod { get; private set; }
|
||||
public string? AuthorizationHeader { get; private set; }
|
||||
public string? IfNoneMatchHeader { get; private set; }
|
||||
public string? PreferHeader { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -547,6 +566,11 @@ public sealed class PurviewClientTests : IDisposable
|
||||
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValues("Prefer", out var preferValues))
|
||||
{
|
||||
this.PreferHeader = string.Join(", ", preferValues);
|
||||
}
|
||||
|
||||
// Throw HttpRequestException if configured
|
||||
if (this.ShouldThrowHttpRequestException)
|
||||
{
|
||||
|
||||
+358
-40
@@ -3,12 +3,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
@@ -50,10 +52,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -70,8 +68,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -109,10 +107,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -129,8 +123,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -168,10 +162,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -188,8 +178,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -213,6 +203,99 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Test message")
|
||||
];
|
||||
PurviewSettings settings = CreateValidPurviewSettings();
|
||||
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block };
|
||||
ProcessContentResponse pcResponse = new()
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
processContentAction
|
||||
]
|
||||
};
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline,
|
||||
PolicyActions =
|
||||
[
|
||||
duplicateScopeAction,
|
||||
restrictionOnlyAction
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pcResponse.PolicyActions);
|
||||
Assert.Equal(2, pcResponse.PolicyActions.Count);
|
||||
Assert.Same(processContentAction, pcResponse.PolicyActions[0]);
|
||||
Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApplicableScopes_MatchesAnyLocationInScope()
|
||||
{
|
||||
// Arrange
|
||||
ProcessContentRequest pcRequest = CreateProcessContentRequest();
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123"),
|
||||
new("microsoft.graph.policyLocationApplication", "different-app")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
(bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
// Assert
|
||||
Assert.True(shouldProcess);
|
||||
Assert.Equal(ExecutionMode.EvaluateInline, executionMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
|
||||
{
|
||||
@@ -279,12 +362,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
ScopeIdentifier = "etag-1",
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
@@ -299,8 +379,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -336,10 +416,6 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -355,8 +431,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -432,13 +508,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -467,13 +539,9 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -484,10 +552,260 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal(userId, result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
new() { Action = DlpAction.BlockAccess }
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()))
|
||||
.Throws(new PurviewJobException("queue unavailable"));
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
It.IsAny<PaymentRequiredCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentRequiredCacheEntry("Payment required"));
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
|
||||
this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
|
||||
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = new("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
ProtectionScopesCacheKey cacheKey = new(request);
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
cacheProvider.Verify(x => x.SetAsync(
|
||||
It.Is<PaymentRequiredCacheKey>(key => key.TenantId == "tenant-123"),
|
||||
It.Is<PaymentRequiredCacheEntry>(entry => entry.Message == "Payment required"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = CreateProtectionScopesRequest();
|
||||
ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest());
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(job);
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
channelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProtectionScopesRequest CreateProtectionScopesRequest()
|
||||
{
|
||||
return new ProtectionScopesRequest("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessContentRequest CreateProcessContentRequest()
|
||||
{
|
||||
PurviewTextContent content = new("Test content");
|
||||
ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id");
|
||||
ActivityMetadata activityMetadata = new(Activity.UploadText);
|
||||
DeviceMetadata deviceMetadata = new()
|
||||
{
|
||||
OperatingSystemSpecifications = new()
|
||||
{
|
||||
OperatingSystemPlatform = "Windows",
|
||||
OperatingSystemVersion = "10"
|
||||
}
|
||||
};
|
||||
IntegratedAppMetadata integratedAppMetadata = new()
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123");
|
||||
ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
ContentToProcess contentToProcess = new(
|
||||
[metadata],
|
||||
activityMetadata,
|
||||
deviceMetadata,
|
||||
integratedAppMetadata,
|
||||
protectedAppMetadata);
|
||||
|
||||
return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123");
|
||||
}
|
||||
|
||||
private static PurviewSettings CreateValidPurviewSettings()
|
||||
{
|
||||
return new PurviewSettings("TestApp")
|
||||
|
||||
Reference in New Issue
Block a user