Files
jobtrackingapp/JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs
T
cesnimda cacad5cc94
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
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>
2026-07-11 18:08:11 +02:00

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();
}
}