cacad5cc94
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape end-to-end so the two stay structurally interchangeable: - MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL, same shape as GmailConnection: encrypted refresh/access token, sync state). - MicrosoftGraphOAuthService: auth-code + offline-access flow against login.microsoftonline.com, encrypted token storage via IDataProtector, message search/thread/detail fetch against Microsoft Graph (conversationId stands in for Gmail's threadId), attachment listing. - MicrosoftGraphProvider implements IEmailProvider — no contract changes; the existing seam was already provider-neutral. - MicrosoftGraphController: connect-url/oauth/callback/status/disconnect, mirrors GmailController's OAuth surface exactly (including the popup postMessage handshake). Job-matching/review endpoints stay Gmail-only for now, per the roadmap — generalising those needs the frontend provider picker work, not this slice. - Registered in DI + IEmailProviderRegistry (multi-registration of IEmailProvider, resolved by ProviderKey). - Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through docker-compose.yml + .env.example alongside the existing Google:Gmail* keys. - Tests: MicrosoftGraphControllerTests (OAuth lifecycle) + MicrosoftGraphProviderTests (DTO mapping onto the neutral contract). 147/147 green (135 existing + 12 new). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
6.6 KiB
C#
158 lines
6.6 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class MicrosoftGraphControllerTests
|
|
{
|
|
[Fact]
|
|
public async Task Status_returns_sync_state_fields_for_connected_account()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new MicrosoftGraphConnection
|
|
{
|
|
OwnerUserId = "user-1",
|
|
MailAddress = "user@outlook.test",
|
|
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
|
LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
|
LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
|
LastSyncMode = "list-messages",
|
|
LastSyncSource = "custom-query",
|
|
LastSyncStatus = "error",
|
|
LastSyncError = "Token refresh failed"
|
|
});
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = await controller.Status(CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
|
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(ok.Value);
|
|
Assert.True(payload.Connected);
|
|
Assert.Equal("user@outlook.test", payload.MailAddress);
|
|
Assert.Equal("list-messages", payload.LastSyncMode);
|
|
Assert.Equal("custom-query", payload.LastSyncSource);
|
|
Assert.Equal("error", payload.LastSyncStatus);
|
|
Assert.Equal("Token refresh failed", payload.LastSyncError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Status_reports_not_connected_when_no_connection_exists()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((MicrosoftGraphConnection?)null);
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = await controller.Status(CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
|
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(ok.Value);
|
|
Assert.False(payload.Connected);
|
|
Assert.Null(payload.MailAddress);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectUrl_returns_authorization_url_from_service()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.BuildAuthorizationUrl("user-1", It.IsAny<string>()))
|
|
.Returns("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test");
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = controller.ConnectUrl();
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var url = ok.Value!.GetType().GetProperty("url")!.GetValue(ok.Value) as string;
|
|
Assert.Equal("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test", url);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callback_returns_error_html_when_state_is_invalid()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.ConsumeState("bad-state")).Returns((string?)null);
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = await controller.Callback("auth-code", "bad-state", null, CancellationToken.None);
|
|
|
|
var content = Assert.IsType<ContentResult>(result);
|
|
Assert.Contains("no longer valid", content.Content);
|
|
graph.Verify(service => service.ExchangeCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callback_returns_error_html_when_provider_returns_error()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>(MockBehavior.Strict);
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
|
|
var result = await controller.Callback(null, null, "access_denied", CancellationToken.None);
|
|
|
|
var content = Assert.IsType<ContentResult>(result);
|
|
Assert.Contains("access_denied", content.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callback_exchanges_code_and_reports_connected_mail_address()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.ConsumeState("good-state")).Returns("user-1");
|
|
graph.Setup(service => service.ExchangeCodeAsync("user-1", "auth-code", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new MicrosoftGraphOAuthExchangeResult("user@outlook.test"));
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = await controller.Callback("auth-code", "good-state", null, CancellationToken.None);
|
|
|
|
var content = Assert.IsType<ContentResult>(result);
|
|
Assert.Contains("user@outlook.test", content.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disconnect_calls_service_for_authenticated_user()
|
|
{
|
|
var graph = new Mock<IMicrosoftGraphOAuthService>();
|
|
graph.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
|
|
|
var controller = CreateController(graph.Object, "user-1");
|
|
var result = await controller.Disconnect(CancellationToken.None);
|
|
|
|
Assert.IsType<NoContentResult>(result);
|
|
graph.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
private static MicrosoftGraphController CreateController(IMicrosoftGraphOAuthService graph, string userId)
|
|
{
|
|
return new MicrosoftGraphController(graph, BuildConfig())
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
|
}, "test"))
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private static IConfiguration BuildConfig()
|
|
{
|
|
return new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>())
|
|
.Build();
|
|
}
|
|
}
|