feat(notifications): configurable OAuth2 authority + scope per SMTP configuration (defaults preserve M365) — entity + EF + migration + token service
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -169,6 +169,48 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Task 19: configurable OAuth2 authority + scope per SMTP configuration ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_CustomAuthorityAndScope_PostsToConfiguredEndpoint()
|
||||
{
|
||||
// A non-M365 IdP: the stored authority template carries a {tenant} placeholder
|
||||
// and the scope is provider-specific. Both must flow verbatim into the request.
|
||||
var handler = new CapturingHttpMessageHandler(
|
||||
TokenJson("custom-token", expiresIn: 3600));
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var token = await service.GetTokenAsync(
|
||||
"T1:client:secret",
|
||||
authority: "https://idp.example/{tenant}/token",
|
||||
scope: "smtp.send");
|
||||
|
||||
Assert.Equal("custom-token", token);
|
||||
Assert.Equal("https://idp.example/T1/token", handler.LastRequestUri!.ToString());
|
||||
Assert.Equal("smtp.send", handler.LastForm!["scope"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_DefaultsPreserved_M365()
|
||||
{
|
||||
// No authority/scope → today's hardcoded Microsoft 365 endpoint + scope.
|
||||
var handler = new CapturingHttpMessageHandler(
|
||||
TokenJson("m365-token", expiresIn: 3600));
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var token = await service.GetTokenAsync("tenant:client:secret");
|
||||
|
||||
Assert.Equal("m365-token", token);
|
||||
Assert.Equal(
|
||||
"https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
|
||||
handler.LastRequestUri!.ToString());
|
||||
Assert.Equal("https://outlook.office365.com/.default", handler.LastForm!["scope"]);
|
||||
}
|
||||
|
||||
private static string TokenJson(string accessToken, int expiresIn) =>
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -250,6 +292,38 @@ public class OAuth2TokenServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP handler that captures the request URI and the posted form fields so a
|
||||
/// test can assert the token endpoint and scope actually sent.
|
||||
/// </summary>
|
||||
private class CapturingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _response;
|
||||
public Uri? LastRequestUri { get; private set; }
|
||||
public IReadOnlyDictionary<string, string>? LastForm { get; private set; }
|
||||
|
||||
public CapturingHttpMessageHandler(string response) => _response = response;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri;
|
||||
var body = request.Content == null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
LastForm = body
|
||||
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(pair => pair.Split('=', 2))
|
||||
.ToDictionary(
|
||||
p => Uri.UnescapeDataString(p[0]),
|
||||
p => p.Length > 1 ? Uri.UnescapeDataString(p[1]) : string.Empty);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(_response)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple mock HTTP handler that returns a fixed response.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user