Add channel test providers for Email, Slack, Teams, and Webhook

- Implemented EmailChannelTestProvider to generate email preview payloads.
- Implemented SlackChannelTestProvider to create Slack message previews.
- Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews.
- Implemented WebhookChannelTestProvider to create webhook payloads.
- Added INotifyChannelTestProvider interface for channel-specific preview generation.
- Created ChannelTestPreviewContracts for request and response models.
- Developed NotifyChannelTestService to handle test send requests and generate previews.
- Added rate limit policies for test sends and delivery history.
- Implemented unit tests for service registration and binding.
- Updated project files to include necessary dependencies and configurations.
This commit is contained in:
master
2025-10-19 23:29:34 +03:00
parent a811f7ac47
commit a07f46231b
239 changed files with 17245 additions and 3155 deletions

View File

@@ -7,6 +7,7 @@ public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";

View File

@@ -632,15 +632,13 @@ public sealed class AuthorityClaimsEnrichmentContext
/// </summary>
public sealed record AuthorityClientDescriptor
{
/// <summary>
/// Initialises a new client descriptor.
/// </summary>
public AuthorityClientDescriptor(
string clientId,
string? displayName,
bool confidential,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
@@ -648,8 +646,9 @@ public sealed record AuthorityClientDescriptor
ClientId = ValidateRequired(clientId, nameof(clientId));
DisplayName = displayName;
Confidential = confidential;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = Normalize(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
@@ -657,60 +656,87 @@ public sealed record AuthorityClientDescriptor
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Permitted OAuth grant types.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Permitted scopes.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Registered redirect URIs.
/// </summary>
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Registered post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional plugin-defined metadata.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Client registration payload used when provisioning clients through plugins.
/// </summary>
public sealed record AuthorityClientCertificateBindingRegistration
{
public AuthorityClientCertificateBindingRegistration(
string thumbprint,
string? serialNumber = null,
string? subject = null,
string? issuer = null,
IReadOnlyCollection<string>? subjectAlternativeNames = null,
DateTimeOffset? notBefore = null,
DateTimeOffset? notAfter = null,
string? label = null)
{
Thumbprint = NormalizeThumbprint(thumbprint);
SerialNumber = Normalize(serialNumber);
Subject = Normalize(subject);
Issuer = Normalize(issuer);
SubjectAlternativeNames = subjectAlternativeNames is null || subjectAlternativeNames.Count == 0
? Array.Empty<string>()
: subjectAlternativeNames
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
NotBefore = notBefore;
NotAfter = notAfter;
Label = Normalize(label);
}
public string Thumbprint { get; }
public string? SerialNumber { get; }
public string? Subject { get; }
public string? Issuer { get; }
public IReadOnlyCollection<string> SubjectAlternativeNames { get; }
public DateTimeOffset? NotBefore { get; }
public DateTimeOffset? NotAfter { get; }
public string? Label { get; }
private static string NormalizeThumbprint(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Thumbprint is required.", nameof(value));
}
return value
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.ToUpperInvariant();
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record AuthorityClientRegistration
{
/// <summary>
/// Initialises a new registration.
/// </summary>
public AuthorityClientRegistration(
string clientId,
bool confidential,
@@ -718,9 +744,11 @@ public sealed record AuthorityClientRegistration
string? clientSecret,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
IReadOnlyDictionary<string, string?>? properties = null,
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
Confidential = confidential;
@@ -728,65 +756,42 @@ public sealed record AuthorityClientRegistration
ClientSecret = confidential
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
: clientSecret;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = Normalize(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
CertificateBindings = certificateBindings is null
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
: certificateBindings.ToArray();
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret handling).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Optional raw client secret (hashed by the plugin for storage).
/// </summary>
public string? ClientSecret { get; init; }
/// <summary>
/// Grant types to enable.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Scopes assigned to the client.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Redirect URIs permitted for the client.
/// </summary>
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional metadata for the plugin.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
/// <summary>
/// Creates a copy of the registration with the provided client secret.
/// </summary>
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, RedirectUris, PostLogoutRedirectUris, Properties);
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Properties, CertificateBindings);
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)