Files
jobtrackingapp/JobTrackerApi.Tests/EmailProviderDeliveryTests.cs
T
cesnimda e9937accd8
CI and Deploy / test (pull_request) Failing after 1m37s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): add delivery adapters
Gmail and Graph request explicit send consent and classify provider rejection separately from uncertain transport failure. IMAP remains read-only; no send API is exposed.
2026-08-09 23:47:21 +02:00

147 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<EmailProviderDeliveryException>(() =>
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<EmailProviderDeliveryException>(() =>
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<EmailProviderDeliveryException>(() =>
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<string, string?>
{
["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<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request);
}
}