// Copyright (c) Microsoft. All rights reserved. using System; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.Core; using Microsoft.Agents.AI.Purview.Models.Common; using Microsoft.Agents.AI.Purview.Models.Requests; using Microsoft.Agents.AI.Purview.Models.Responses; using Microsoft.Agents.AI.Purview.Serialization; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.Purview.UnitTests; /// /// Unit tests for the class. /// public sealed class PurviewClientTests : IDisposable { private readonly HttpClient _httpClient; private readonly PurviewClientHttpMessageHandlerStub _handler; private readonly PurviewClient _client; private readonly PurviewSettings _settings; public PurviewClientTests() { this._handler = new PurviewClientHttpMessageHandlerStub(); this._httpClient = new HttpClient(this._handler, false); this._settings = new PurviewSettings("TestApp") { GraphBaseUri = new Uri("https://graph.microsoft.com/v1.0/") }; var tokenCredential = new MockTokenCredential(); this._client = new PurviewClient(tokenCredential, this._settings, this._httpClient, NullLogger.Instance); } #region ProcessContentAsync Tests [Fact] public async Task ProcessContentAsync_WithValidRequest_ReturnsSuccessResponseAsync() { // Arrange var request = CreateValidProcessContentRequest(); var expectedResponse = new ProcessContentResponse { Id = "test-id-123", ProtectionScopeState = ProtectionScopeState.NotModified, PolicyActions = [ new() { Action = DlpAction.NotifyUser } ] }; this._handler.StatusCodeToReturn = HttpStatusCode.OK; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); // Act var result = await this._client.ProcessContentAsync(request, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Equal(expectedResponse.Id, result.Id); Assert.Equal(ProtectionScopeState.NotModified, result.ProtectionScopeState); Assert.Single(result.PolicyActions!); Assert.Equal(DlpAction.NotifyUser, result.PolicyActions![0].Action); // Verify request Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/processContent", this._handler.RequestUri?.ToString()); Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); Assert.Contains("Bearer ", this._handler.AuthorizationHeader); } [Fact] public async Task ProcessContentAsync_WithAcceptedStatus_ReturnsSuccessResponseAsync() { // Arrange var request = CreateValidProcessContentRequest(); var expectedResponse = new ProcessContentResponse { Id = "test-id-456", ProtectionScopeState = ProtectionScopeState.Modified }; this._handler.StatusCodeToReturn = HttpStatusCode.Accepted; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); // Act var result = await this._client.ProcessContentAsync(request, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Equal(expectedResponse.Id, result.Id); Assert.Equal(ProtectionScopeState.Modified, result.ProtectionScopeState); } [Fact] public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHeaderAsync() { // Arrange var request = CreateValidProcessContentRequest(); request.ScopeIdentifier = "\"test-scope-123\""; // ETags must be quoted 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("\"test-scope-123\"", this._handler.IfNoneMatchHeader); } [Fact] public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = (HttpStatusCode)429; // Act & Assert await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); } [Fact] public async Task ProcessContentAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; // Act & Assert await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); } [Fact] public async Task ProcessContentAsync_WithForbiddenError_ThrowsPurviewAuthenticationExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = HttpStatusCode.Forbidden; // Act & Assert await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); } [Fact] public async Task ProcessContentAsync_WithPaymentRequiredError_ThrowsPurviewPaymentRequiredExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = HttpStatusCode.PaymentRequired; // Act & Assert await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); } [Fact] public async Task ProcessContentAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; // Act & Assert await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); } [Fact] public async Task ProcessContentAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.StatusCodeToReturn = HttpStatusCode.OK; this._handler.ResponseToReturn = "invalid json"; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); Assert.Contains("Failed to deserialize ProcessContent response", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } [Fact] public async Task ProcessContentAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() { // Arrange var request = CreateValidProcessContentRequest(); this._handler.ShouldThrowHttpRequestException = true; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.ProcessContentAsync(request, CancellationToken.None)); Assert.Equal("Http error occurred while processing content.", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } #endregion #region GetProtectionScopesAsync Tests [Fact] public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessResponseAsync() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id") { Activities = ProtectionScopeActivities.UploadText, Locations = [ new("microsoft.graph.policyLocationApplication", "app-123") ] }; var expectedResponse = new ProtectionScopesResponse { Scopes = [ new() { Activities = ProtectionScopeActivities.UploadText, Locations = [ new ("microsoft.graph.policyLocationApplication", "app-123") ] } ] }; this._handler.StatusCodeToReturn = HttpStatusCode.OK; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); this._handler.ETagToReturn = "\"scope-etag-123\""; // Act var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); // Assert Assert.NotNull(result); Assert.NotNull(result.Scopes); Assert.Single(result.Scopes); Assert.Equal("\"scope-etag-123\"", result.ScopeIdentifier); // ETags are stored with quotes // Verify request Assert.Equal("https://graph.microsoft.com/v1.0/users/test-user-id/dataSecurityAndGovernance/protectionScopes/compute", this._handler.RequestUri?.ToString()); Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); } [Fact] public async Task GetProtectionScopesAsync_SetsETagFromResponse_Async() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); var expectedResponse = new ProtectionScopesResponse { Scopes = [] }; this._handler.StatusCodeToReturn = HttpStatusCode.OK; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProtectionScopesResponse))); this._handler.ETagToReturn = "\"custom-etag-456\""; // Act var result = await this._client.GetProtectionScopesAsync(request, CancellationToken.None); // Assert Assert.Equal("\"custom-etag-456\"", result.ScopeIdentifier); } [Fact] public async Task GetProtectionScopesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); this._handler.StatusCodeToReturn = (HttpStatusCode)429; // Act & Assert await Assert.ThrowsAsync(() => this._client.GetProtectionScopesAsync(request, CancellationToken.None)); } [Fact] public async Task GetProtectionScopesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; // Act & Assert await Assert.ThrowsAsync(() => this._client.GetProtectionScopesAsync(request, CancellationToken.None)); } [Fact] public async Task GetProtectionScopesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); this._handler.StatusCodeToReturn = HttpStatusCode.OK; this._handler.ResponseToReturn = "invalid json"; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.GetProtectionScopesAsync(request, CancellationToken.None)); Assert.Contains("Failed to deserialize ProtectionScopes response", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } [Fact] public async Task GetProtectionScopesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() { // Arrange var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id"); this._handler.ShouldThrowHttpRequestException = true; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.GetProtectionScopesAsync(request, CancellationToken.None)); Assert.Equal("Http error occurred while retrieving protection scopes.", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } #endregion #region SendContentActivitiesAsync Tests [Fact] public async Task SendContentActivitiesAsync_WithValidRequest_ReturnsSuccessResponseAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); var expectedResponse = new ContentActivitiesResponse { StatusCode = HttpStatusCode.Created }; this._handler.StatusCodeToReturn = HttpStatusCode.Created; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); // Act var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Null(result.Error); // Verify request - note the endpoint is different from ProcessContent Assert.Equal("https://graph.microsoft.com/v1.0/test-user-id/dataSecurityAndGovernance/activities/contentActivities", this._handler.RequestUri?.ToString()); Assert.Equal(HttpMethod.Post, this._handler.RequestMethod); } [Fact] public async Task SendContentActivitiesAsync_WithError_ReturnsResponseWithErrorAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); var expectedResponse = new ContentActivitiesResponse { Error = new ErrorDetails { Code = "InvalidRequest", Message = "The request is invalid" } }; this._handler.StatusCodeToReturn = HttpStatusCode.Created; this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ContentActivitiesResponse))); // Act var result = await this._client.SendContentActivitiesAsync(request, CancellationToken.None); // Assert Assert.NotNull(result); Assert.NotNull(result.Error); Assert.Equal("InvalidRequest", result.Error.Code); Assert.Equal("The request is invalid", result.Error.Message); } [Fact] public async Task SendContentActivitiesAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); this._handler.StatusCodeToReturn = (HttpStatusCode)429; // Act & Assert await Assert.ThrowsAsync(() => this._client.SendContentActivitiesAsync(request, CancellationToken.None)); } [Fact] public async Task SendContentActivitiesAsync_WithUnauthorizedError_ThrowsPurviewAuthenticationExceptionAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); this._handler.StatusCodeToReturn = HttpStatusCode.Unauthorized; // Act & Assert await Assert.ThrowsAsync(() => this._client.SendContentActivitiesAsync(request, CancellationToken.None)); } [Fact] public async Task SendContentActivitiesAsync_WithBadRequestError_ThrowsPurviewRequestExceptionAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); this._handler.StatusCodeToReturn = HttpStatusCode.BadRequest; // Act & Assert await Assert.ThrowsAsync(() => this._client.SendContentActivitiesAsync(request, CancellationToken.None)); } [Fact] public async Task SendContentActivitiesAsync_WithInvalidJsonResponse_ThrowsPurviewExceptionAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); this._handler.StatusCodeToReturn = HttpStatusCode.Created; this._handler.ResponseToReturn = "invalid json"; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.SendContentActivitiesAsync(request, CancellationToken.None)); Assert.Contains("Failed to deserialize ContentActivities response", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } [Fact] public async Task SendContentActivitiesAsync_WithHttpRequestException_ThrowsPurviewRequestExceptionAsync() { // Arrange var contentToProcess = CreateValidContentToProcess(); var request = new ContentActivitiesRequest("test-user-id", "test-tenant-id", contentToProcess); this._handler.ShouldThrowHttpRequestException = true; // Act & Assert var exception = await Assert.ThrowsAsync(() => this._client.SendContentActivitiesAsync(request, CancellationToken.None)); Assert.Equal("Http error occurred while creating content activities.", exception.Message); Assert.NotNull(exception.InnerException); Assert.IsType(exception.InnerException); } #endregion #region Helper Methods private static ProcessContentRequest CreateValidProcessContentRequest() { var contentToProcess = CreateValidContentToProcess(); return new ProcessContentRequest(contentToProcess, "test-user-id", "test-tenant-id"); } private static ContentToProcess CreateValidContentToProcess() { var content = new PurviewTextContent("Test content"); var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message", "test-correlation-id"); var activityMetadata = new ActivityMetadata(Activity.UploadText); var deviceMetadata = new DeviceMetadata { OperatingSystemSpecifications = new OperatingSystemSpecifications { OperatingSystemPlatform = "Windows", OperatingSystemVersion = "10" } }; var integratedAppMetadata = new IntegratedAppMetadata { Name = "TestApp", Version = "1.0" }; var policyLocation = new PolicyLocation("microsoft.graph.policyLocationApplication", "app-123"); var protectedAppMetadata = new ProtectedAppMetadata(policyLocation) { Name = "TestApp", Version = "1.0" }; return new ContentToProcess( [metadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata ); } #endregion public void Dispose() { this._handler.Dispose(); this._httpClient.Dispose(); } /// /// Mock HTTP message handler for testing /// internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler { public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK; public string? ResponseToReturn { get; set; } public string? ETagToReturn { get; set; } public bool ShouldThrowHttpRequestException { get; set; } public Uri? RequestUri { get; private set; } public HttpMethod? RequestMethod { get; private set; } public string? AuthorizationHeader { get; private set; } public string? IfNoneMatchHeader { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // Capture request details this.RequestUri = request.RequestUri; this.RequestMethod = request.Method; if (request.Headers.Authorization != null) { this.AuthorizationHeader = request.Headers.Authorization.ToString(); } if (request.Headers.TryGetValues("If-None-Match", out var ifNoneMatchValues)) { this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues); } // Throw HttpRequestException if configured if (this.ShouldThrowHttpRequestException) { throw new HttpRequestException("Simulated network error"); } var response = new HttpResponseMessage(this.StatusCodeToReturn) { Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json") }; if (!string.IsNullOrEmpty(this.ETagToReturn)) { response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(this.ETagToReturn); } return await Task.FromResult(response); } } /// /// Mock token credential for testing /// internal sealed class MockTokenCredential : TokenCredential { public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); } public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) { return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); } } }