feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)
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>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user