using System.Net; using System.Text; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Services.EmailProviders; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class EmailProviderDeliveryTests { [Fact] public async Task Authorization_urls_request_explicit_send_consent() { await using var db = TestHostFactory.CreateInMemoryDb(); var protection = new EphemeralDataProtectionProvider(); var clients = new ClientFactory(new Handler(_ => throw new InvalidOperationException("No HTTP expected."))); var gmail = new GmailOAuthService(Config(), db, protection, clients, new MemoryCache(new MemoryCacheOptions())); var graph = new MicrosoftGraphOAuthService(Config(), db, protection, clients, new MemoryCache(new MemoryCacheOptions())); Assert.Contains(GmailOAuthService.SendScope, Uri.UnescapeDataString(gmail.BuildAuthorizationUrl("user-1", "https://app.test/gmail"))); Assert.Contains(MicrosoftGraphOAuthService.SendScope, Uri.UnescapeDataString(graph.BuildAuthorizationUrl("user-1", "https://app.test/graph"))); } [Fact] public async Task Gmail_send_uses_send_scope_plain_text_and_thread_without_exposing_provider_errors() { await using var db = TestHostFactory.CreateInMemoryDb(); var protection = new EphemeralDataProtectionProvider(); var protector = protection.CreateProtector("gmail-oauth-tokens-v1"); db.GmailConnections.Add(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "owner@gmail.test", EncryptedAccessToken = protector.Protect("access-token"), EncryptedRefreshToken = protector.Protect("refresh-token"), AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1), Scope = $"https://www.googleapis.com/auth/gmail.readonly {GmailOAuthService.SendScope}", }); await db.SaveChangesAsync(); string? captured = null; var service = new GmailOAuthService(Config(), db, protection, new ClientFactory(new Handler(async request => { Assert.Equal("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", request.RequestUri!.ToString()); Assert.Equal("access-token", request.Headers.Authorization?.Parameter); captured = await request.Content!.ReadAsStringAsync(); return Json(HttpStatusCode.OK, "{\"id\":\"gmail-message-1\",\"threadId\":\"thread-1\"}"); })), new MemoryCache(new MemoryCacheOptions())); var result = await service.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Hei – intervju", "Plain body", "thread-1"), default); Assert.Equal("gmail-message-1", result.MessageId); Assert.Contains("thread-1", captured); Assert.DoesNotContain("Plain body", captured); } [Fact] public async Task Graph_send_uses_mail_send_scope_and_classifies_rejection_as_known() { await using var db = TestHostFactory.CreateInMemoryDb(); var protection = new EphemeralDataProtectionProvider(); var protector = protection.CreateProtector("microsoft-graph-oauth-tokens-v1"); db.MicrosoftGraphConnections.Add(new MicrosoftGraphConnection { OwnerUserId = "user-1", MailAddress = "owner@outlook.test", EncryptedAccessToken = protector.Protect("access-token"), EncryptedRefreshToken = protector.Protect("refresh-token"), AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1), Scope = "Mail.Read Mail.Send", }); await db.SaveChangesAsync(); var service = new MicrosoftGraphOAuthService(Config(), db, protection, new ClientFactory(new Handler(request => { Assert.Equal("https://graph.microsoft.com/v1.0/me/sendMail", request.RequestUri!.ToString()); return Task.FromResult(Json(HttpStatusCode.BadRequest, "{\"error\":{\"message\":\"private provider detail\"}}")); })), new MemoryCache(new MemoryCacheOptions())); var error = await Assert.ThrowsAsync(() => service.SendAsync("user-1", new MicrosoftGraphSendRequest("recruiter@example.test", "Subject", "Body"), default)); Assert.False(error.Uncertain); Assert.Equal("provider_rejected", error.Category); Assert.DoesNotContain("private provider detail", error.Message); } [Fact] public async Task Transport_interruption_is_uncertain_and_missing_scope_requires_reauthorization() { await using var db = TestHostFactory.CreateInMemoryDb(); var protection = new EphemeralDataProtectionProvider(); var protector = protection.CreateProtector("gmail-oauth-tokens-v1"); db.GmailConnections.Add(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "owner@gmail.test", EncryptedAccessToken = protector.Protect("access-token"), EncryptedRefreshToken = protector.Protect("refresh-token"), AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1), Scope = GmailOAuthService.SendScope, }); await db.SaveChangesAsync(); var interrupted = new GmailOAuthService(Config(), db, protection, new ClientFactory(new Handler(_ => throw new HttpRequestException("network detail"))), new MemoryCache(new MemoryCacheOptions())); var uncertain = await Assert.ThrowsAsync(() => interrupted.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Subject", "Body", null), default)); Assert.True(uncertain.Uncertain); Assert.Equal("transport_interrupted", uncertain.Category); Assert.DoesNotContain("network detail", uncertain.Message); var connection = await db.GmailConnections.SingleAsync(); connection.Scope = "https://www.googleapis.com/auth/gmail.readonly"; await db.SaveChangesAsync(); var reauthorization = await Assert.ThrowsAsync(() => interrupted.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Subject", "Body", null), default)); Assert.False(reauthorization.Uncertain); Assert.Equal("reauthorization_required", reauthorization.Category); } private static IConfiguration Config() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Google:ClientId"] = "google-client-test", ["Microsoft:ClientId"] = "microsoft-client-test", }).Build(); private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private sealed class ClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } private sealed class Handler(Func> handler) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request); } }