diff --git a/.editorconfig b/.editorconfig index bf68510..f64f1d7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -101,7 +101,7 @@ dotnet_diagnostic.SA1600.severity = none dotnet_diagnostic.SA1602.severity = none # Verify -[*.{received,verified}.{txt}] +[*.{received,verified}.{html}] charset = utf-8-bom end_of_line = lf indent_size = unset diff --git a/.gitattributes b/.gitattributes index 02cd1f1..e73dc5f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -*.verified.txt text eol=lf working-tree-encoding=UTF-8 +*.verified.html text eol=lf working-tree-encoding=UTF-8 *.verified.xml text eol=lf working-tree-encoding=UTF-8 *.verified.json text eol=lf working-tree-encoding=UTF-8 *.verified.bin binary \ No newline at end of file diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index d1a7a68..5dc9868 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -7,7 +7,7 @@ on: type: string description: The version of the application required: true - default: 1.0.0 + default: 1.1.0 VersionSuffix: type: string description: The version suffix of the application (for example rc.1) @@ -42,6 +42,7 @@ jobs: tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} files: ./PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.net9.0.zip overwrite_files: true - draft: ${{ !github.event.inputs.VersionSuffix }} + draft: ${{ github.event.inputs.VersionSuffix == '' }} + prerelease: ${{ github.event.inputs.VersionSuffix != '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 57dedcc..7dc0a65 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,27 +1,30 @@  true + + 1.0.0 - - + + - - + + - + - - - + + + + - + diff --git a/README.md b/README.md index 9affa25..842b16a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,14 @@ executable with the monitoring logic already packaged. ## Features - Monitor secrets across one or multiple Entra ID tenants (Entra ID, Azure B2C, Entra External ID,...). - Send a consolidated report at a customizable interval (cron-based). +- Quick summary with the number of secrets valid, expired and expiring soon. +- Customizable threshold of expiration date for the secrets. +- Date/Time formatting customizable to avoid issued between US / European expiration date formats. - Simple deployment to Azure Functions (pre-packaged, no build/CD required). - Runs on Azure Functions Consumption plan (**NO COST!!!**). +![Report example](./docs/ReportExample.png) + ## How it works - Enumerates App Registrations and checks client secrets and certificates nearing expiration. - Sends a summary report by email using Microsoft Graph. @@ -46,6 +51,8 @@ To send the e-mail using Graph API: Client ID of the App Registration used to query secrets across tenants. If omitted, the Function managed identity is used (single-tenant only). - `APP_SECRET_WATCHER_CLIENT_SECRET`: Client secret of the App Registration. Not required if using managed identity or certificate auth. +- `APP_SECRET_WATCHER_CULTURE`: + Culture name used to format the dates and times for the reports. (`en-US` will be used if not specified). - `APP_SECRET_WATCHER_EXPIRATION_THRESHOLD`: Time span threshold to raise warnings before secret expiration. Example: `30.00:00:00` for 30 days. - `APP_SECRET_WATCHER_FREQUENCY`: diff --git a/docs/ReportExample.html b/docs/ReportExample.html new file mode 100644 index 0000000..08b1314 --- /dev/null +++ b/docs/ReportExample.html @@ -0,0 +1,301 @@ + + + + + + +

Entra ID app registrations secret expiration report

+
+

Summary

+
+ + + + + + +
+
+
1
+
Expired
+
+
+
+
1
+
Expiring Soon
+
+
+
+
4
+
Valid
+
+
+
+
+
+

Contoso Corp

+ +
+
+

Human Resources

+ +
+
+

Secret first semester

+
+
+
Status:
+
Expired
+
+
+
Expiration date:
+
1-Jan-2025
+
+
+
Expired since:
+
10 days
+
+
+
+
+

Auto generated secret

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
15-Sep-2025
+
+
+
Days before expiration:
+
20 days
+
+
+
+
+
+
+

Web portal

+ +
+
No secret
+
+
+
+
+
+

Customers Identity

+ +
+
+

Sales Frontend

+ +
+
+

Generated by Terraform

+
+
+
Status:
+
ExpiringSoon
+
+
+
Expiration date:
+
5-Feb-2025
+
+
+
Days before expiration:
+
50 days
+
+
+
+
+
+
+

Sales Backend

+ +
+
+

Secret 2-1-1

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
6-juin-2025
+
+
+
Days before expiration:
+
60 days
+
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/docs/ReportExample.png b/docs/ReportExample.png new file mode 100644 index 0000000..053c31a Binary files /dev/null and b/docs/ReportExample.png differ diff --git a/src/Core/AppRegistrationSecretCheckResult.cs b/src/Core/AppRegistrationSecretCheckResult.cs index 96e0554..8631da1 100644 --- a/src/Core/AppRegistrationSecretCheckResult.cs +++ b/src/Core/AppRegistrationSecretCheckResult.cs @@ -8,11 +8,18 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { public class AppRegistrationSecretCheckResult { - public AppRegistrationSecretCheckResult(IReadOnlyList tenants) + public AppRegistrationSecretCheckResult(IReadOnlyList tenants, DateTime dateTime) { + Guard.IsUtc(dateTime, nameof(dateTime)); + + this.DateTime = dateTime; this.Tenants = new ReadOnlyCollection(tenants.ToArray()); } public ReadOnlyCollection Tenants { get; } + + public DateTime DateTime { get; } + + public bool HasExpiredSecrets => this.Tenants.Any(t => t.HasExpiredSecrets); } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplication.cs b/src/Core/AppRegistrationSecretCheckResultApplication.cs index 4dbae03..f1e5ae7 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplication.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplication.cs @@ -20,5 +20,7 @@ public AppRegistrationSecretCheckResultApplication(string id, string displayName public string DisplayName { get; } public ReadOnlyCollection Secrets { get; } + + public bool HasExpiredSecrets => this.Secrets.Any(s => s.Status == AppRegistrationSecretStatus.Expired); } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultTenant.cs b/src/Core/AppRegistrationSecretCheckResultTenant.cs index 35bc697..459fbe0 100644 --- a/src/Core/AppRegistrationSecretCheckResultTenant.cs +++ b/src/Core/AppRegistrationSecretCheckResultTenant.cs @@ -20,5 +20,7 @@ public AppRegistrationSecretCheckResultTenant(string id, string displayName, IRe public string DisplayName { get; } public ReadOnlyCollection Applications { get; } + + public bool HasExpiredSecrets => this.Applications.Any(a => a.HasExpiredSecrets); } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index 354c118..476faf6 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -15,19 +15,16 @@ public class AppRegistrationSecretManager : IAppRegistrationSecretManager { private readonly IEntraIdClient entraIdApplicationClient; - private readonly IEmailProvider emailProvider; - - private readonly IEmailGenerator emailGenerator; + private readonly IEmailManager emailManager; private readonly AppRegistrationSecretManagerOptions options; private readonly TimeProvider timeProvider; - public AppRegistrationSecretManager(IEntraIdClient entraIdApplicationClient, IEmailProvider emailProvider, IEmailGenerator emailGenerator, TimeProvider timeProvider, IOptions options) + public AppRegistrationSecretManager(IEntraIdClient entraIdApplicationClient, IEmailManager emailManager, TimeProvider timeProvider, IOptions options) { this.entraIdApplicationClient = entraIdApplicationClient; - this.emailProvider = emailProvider; - this.emailGenerator = emailGenerator; + this.emailManager = emailManager; this.timeProvider = timeProvider; this.options = options.Value; } @@ -42,11 +39,8 @@ public async Task CheckAsync(AppRegistrationSe // Check the result var result = this.BuildResult(applicationsByTenant, now, parameters.ExpirationThreshold); - // Generate the e-mail - var emailContent = await this.emailGenerator.GenerateAsync(result, cancellationToken); - // Send e-mail - await this.SendEmailAsync(emailContent, now, cancellationToken); + await this.SendEmailAsync(result, cancellationToken); return result; } @@ -82,7 +76,7 @@ private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList(); } - public EmailAddress EmailSender { get; set; } = default!; - public Collection EmailRecipients { get; } } } \ No newline at end of file diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 1346af2..ce12f0f 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -1,21 +1,11 @@ - + - - - - - - - - - - PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.EmailTemplate.html - + diff --git a/src/Core/Emailing/EmailContact.cs b/src/Core/Emailing/EmailContact.cs deleted file mode 100644 index b378c1d..0000000 --- a/src/Core/Emailing/EmailContact.cs +++ /dev/null @@ -1,23 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing -{ - using PosInformatique.Foundations.EmailAddresses; - - public class EmailContact - { - public EmailContact(EmailAddress email, string displayName) - { - this.Email = email; - this.DisplayName = displayName; - } - - public EmailAddress Email { get; } - - public string DisplayName { get; } - } -} \ No newline at end of file diff --git a/src/Core/Emailing/EmailMessage.cs b/src/Core/Emailing/EmailMessage.cs deleted file mode 100644 index af6c6ac..0000000 --- a/src/Core/Emailing/EmailMessage.cs +++ /dev/null @@ -1,27 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing -{ - public sealed class EmailMessage - { - public EmailMessage(EmailContact from, EmailContact to, string subject, string htmlContent) - { - this.From = from; - this.To = to; - this.Subject = subject; - this.HtmlContent = htmlContent; - } - - public EmailContact From { get; } - - public EmailContact To { get; } - - public string Subject { get; } - - public string HtmlContent { get; } - } -} \ No newline at end of file diff --git a/src/Core/Emailing/EmailTemplate.html b/src/Core/Emailing/EmailTemplate.html deleted file mode 100644 index 663b063..0000000 --- a/src/Core/Emailing/EmailTemplate.html +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - -

Entra ID app registrations secret expiration report

- - {{ - func status_to_css(status) - case status - when "Expired" - "expired" - when "ExpiringSoon" - "expiring-soon" - else - "valid" - end - end - }} - - {{ for tenant in Tenants }} -
-

{{ tenant.DisplayName }}

- -
- {{ if tenant.Applications.size == 0 }} -
No application
- {{ else }} - {{ for application in tenant.Applications }} -
-

{{ application.DisplayName }}

- -
- {{ if application.Secrets.size == 0 }} -
No secret
- {{ else }} - {{ for secret in application.Secrets }} -
-

{{ secret.DisplayName }}

-
-
-
Status:
-
{{ secret.Status }}
-
-
-
Expiration date:
-
{{ secret.EndDate | date.to_string '%e-%b-%Y' }}
-
-
- {{ if secret.DaysBeforeExpiration > 0 }} -
Days before expiration:
-
{{ secret.DaysBeforeExpiration }} days
- {{ else }} -
Expired since:
-
{{ -secret.DaysBeforeExpiration }} days
- {{ end }} -
-
-
- {{ end }} - {{ end }} -
-
- {{ end }} - {{ end }} -
-
- {{ end }} - - \ No newline at end of file diff --git a/src/Core/Emailing/EmailTemplates.cs b/src/Core/Emailing/EmailTemplates.cs new file mode 100644 index 0000000..311c282 --- /dev/null +++ b/src/Core/Emailing/EmailTemplates.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +{ + using System.Diagnostics.CodeAnalysis; + using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.Emailing.Templates.Razor; + + [ExcludeFromCodeCoverage] + public static class EmailTemplates + { + public static EmailTemplateIdentifier ReportIdentifier { get; } = EmailTemplateIdentifier.Create(); + + public static EmailTemplate Report { get; } = RazorEmailTemplate.Create(); + } +} \ No newline at end of file diff --git a/src/Core/Emailing/Graph/GraphEmailProvider.cs b/src/Core/Emailing/Graph/GraphEmailProvider.cs deleted file mode 100644 index 7f6bcb6..0000000 --- a/src/Core/Emailing/Graph/GraphEmailProvider.cs +++ /dev/null @@ -1,54 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing.Graph -{ - using Microsoft.Graph; - using Microsoft.Graph.Models; - using Microsoft.Graph.Users.Item.SendMail; - - public sealed class GraphEmailProvider : IEmailProvider - { - private readonly GraphServiceClient serviceClient; - - public GraphEmailProvider(GraphServiceClient serviceClient) - { - this.serviceClient = serviceClient; - } - - public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) - { - var graphMessage = new Message() - { - Body = new ItemBody - { - ContentType = BodyType.Html, - Content = message.HtmlContent, - }, - Subject = message.Subject, - ToRecipients = new List - { - new() - { - EmailAddress = new EmailAddress - { - Address = message.To.Email.ToString(), - Name = message.To.DisplayName, - }, - }, - }, - }; - - var body = new SendMailPostRequestBody() - { - Message = graphMessage, - SaveToSentItems = false, - }; - - await this.serviceClient.Users[message.From.Email.ToString()].SendMail.PostAsync(body, cancellationToken: cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/Core/Emailing/IEmailProvider.cs b/src/Core/Emailing/IEmailProvider.cs deleted file mode 100644 index 301b294..0000000 --- a/src/Core/Emailing/IEmailProvider.cs +++ /dev/null @@ -1,13 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing -{ - public interface IEmailProvider - { - Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/Core/Emailing/ReportEmailTemplateBody.razor b/src/Core/Emailing/ReportEmailTemplateBody.razor new file mode 100644 index 0000000..8b0d6db --- /dev/null +++ b/src/Core/Emailing/ReportEmailTemplateBody.razor @@ -0,0 +1,273 @@ +@namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +@inherits RazorEmailTemplateBody +@inject ICulture Culture + + + + + + + +

Entra ID app registrations secret expiration report

+ +
+

Summary

+
+ + + + + + +
+
+
+ @Model.Tenants.SelectMany(t => t.Applications).SelectMany(a => a.Secrets).Count(s => s.Status == AppRegistrationSecretStatus.Expired) +
+
Expired
+
+
+
+
+ @Model.Tenants.SelectMany(t => t.Applications).SelectMany(a => a.Secrets).Count(s => s.Status == AppRegistrationSecretStatus.ExpiringSoon) +
+
Expiring Soon
+
+
+
+
+ @Model.Tenants.SelectMany(t => t.Applications).SelectMany(a => a.Secrets).Count(s => s.Status == AppRegistrationSecretStatus.Valid) +
+
Valid
+
+
+
+
+ + @foreach (var tenant in this.Model.Tenants) + { +
+

@tenant.DisplayName

+ +
+ @if (tenant.Applications.Count == 0) + { +
No application
+ } + else + { + foreach (var application in tenant.Applications) + { +
+

@application.DisplayName

+ +
+ @if (application.Secrets.Count == 0) + { +
No secret
+ } + else + { + foreach (var secret in application.Secrets) + { +
+

@secret.DisplayName

+
+
+
Status:
+
@secret.Status
+
+
+
Expiration date:
+
@secret.EndDate.ToString("d-MMM-yyyy", this.Culture.Current)
+
+
+ @if (secret.DaysBeforeExpiration > 0) + { +
Days before expiration:
+
@secret.DaysBeforeExpiration days
+ } + else + { +
Expired since:
+
@(-secret.DaysBeforeExpiration) days
+ } +
+
+
+ } + } +
+
+ } + } +
+
+ } + + + +@code +{ + public static string StatusToCss(AppRegistrationSecretStatus status) + { + return status switch + { + AppRegistrationSecretStatus.Expired => "expired", + AppRegistrationSecretStatus.ExpiringSoon => "expiring-soon", + _ => "valid", + }; + } +} \ No newline at end of file diff --git a/src/Core/Emailing/ReportEmailTemplateSubject.razor b/src/Core/Emailing/ReportEmailTemplateSubject.razor new file mode 100644 index 0000000..f990314 --- /dev/null +++ b/src/Core/Emailing/ReportEmailTemplateSubject.razor @@ -0,0 +1,4 @@ +@namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +@inherits RazorEmailTemplateSubject +@inject ICulture Culture +Entra ID app registrations secret expiration report - [@this.Model.DateTime.ToString("d", this.Culture.Current)] \ No newline at end of file diff --git a/src/Core/Emailing/ScribanEmailGenerator.cs b/src/Core/Emailing/ScribanEmailGenerator.cs deleted file mode 100644 index 42ae33e..0000000 --- a/src/Core/Emailing/ScribanEmailGenerator.cs +++ /dev/null @@ -1,39 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing -{ - using Scriban; - using Scriban.Runtime; - - public class ScribanEmailGenerator : IEmailGenerator - { - public async Task GenerateAsync(AppRegistrationSecretCheckResult result, CancellationToken cancellationToken = default) - { - using var htmlTemplateStream = typeof(ScribanEmailGenerator).Assembly.GetManifestResourceStream("PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.EmailTemplate.html")!; - using var reader = new StreamReader(htmlTemplateStream); - - var htmlTemplate = await reader.ReadToEndAsync(cancellationToken); - - var scribanTemplate = Template.Parse(htmlTemplate); - - var scriptObject = new ScriptObject - { - { nameof(result.Tenants), result.Tenants }, - }; - - var context = new TemplateContext() - { - MemberRenamer = r => r.Name, - MemberFilter = null, - }; - - context.PushGlobal(scriptObject); - - return await scribanTemplate.RenderAsync(context); - } - } -} \ No newline at end of file diff --git a/src/Core/FixedCulture.cs b/src/Core/FixedCulture.cs new file mode 100644 index 0000000..2ffdb76 --- /dev/null +++ b/src/Core/FixedCulture.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + using System.Globalization; + + public class FixedCulture : ICulture + { + private readonly CultureInfo current; + + public FixedCulture(string name) + { + this.current = CultureInfo.GetCultureInfo(name); + } + + public CultureInfo Current => this.current; + } +} \ No newline at end of file diff --git a/src/Core/Emailing/IEmailGenerator.cs b/src/Core/ICulture.cs similarity index 55% rename from src/Core/Emailing/IEmailGenerator.cs rename to src/Core/ICulture.cs index 8372c9c..c2352a5 100644 --- a/src/Core/Emailing/IEmailGenerator.cs +++ b/src/Core/ICulture.cs @@ -1,13 +1,15 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- -namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { - public interface IEmailGenerator + using System.Globalization; + + public interface ICulture { - Task GenerateAsync(AppRegistrationSecretCheckResult result, CancellationToken cancellationToken = default); + CultureInfo Current { get; } } } \ No newline at end of file diff --git a/src/Core/_Imports.razor b/src/Core/_Imports.razor new file mode 100644 index 0000000..cec21aa --- /dev/null +++ b/src/Core/_Imports.razor @@ -0,0 +1,2 @@ +@using PosInformatique.Foundations.Emailing.Templates.Razor +@using Microsoft.AspNetCore.Components.Web \ No newline at end of file diff --git a/src/Functions/AppRegistrationSecretWatcherApplication.cs b/src/Functions/AppRegistrationSecretWatcherApplication.cs index 8775b53..ea3406b 100644 --- a/src/Functions/AppRegistrationSecretWatcherApplication.cs +++ b/src/Functions/AppRegistrationSecretWatcherApplication.cs @@ -1,4 +1,4 @@ -//----------------------------------------------------------------------- +//----------------------------------------------------------------------- // // Copyright (c) P.O.S Informatique. All rights reserved. // @@ -12,12 +12,9 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; - using Microsoft.Graph; using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing; using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId; using PosInformatique.Foundations.EmailAddresses; - using PosInformatique.Foundations.Emailing; - using PosInformatique.Foundations.Emailing.Graph; public static class AppRegistrationSecretWatcherApplication { @@ -85,10 +82,26 @@ public static async Task Main(string[] args) expirationThreshold = expirationThresholdParsed; } + // Check the APP_SECRET_WATCHER_CULTURE + var cultureName = "en-US"; + + if (!string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_CULTURE"])) + { + var cultureNameSettings = builder.Configuration["APP_SECRET_WATCHER_CULTURE"]!; + + if (!CultureInfo.GetCultures(CultureTypes.AllCultures).Any(c => c.Name.Equals(cultureNameSettings, StringComparison.Ordinal))) + { + throw new InvalidOperationException("The culture specified for the app registrations watcher is invalid (Invalid setting: APP_SECRET_WATCHER_CULTURE)."); + } + + cultureName = cultureNameSettings; + } + builder.ConfigureFunctionsWebApplication(); // Infrastructure builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(new FixedCulture(cultureName)); // Add Application Insights builder.Services @@ -96,16 +109,18 @@ public static async Task Main(string[] args) .ConfigureFunctionsApplicationInsights(); // Emailing - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddEmailing(opt => + { + opt.SenderEmailAddress = emailSender; + + opt.RegisterTemplate(EmailTemplates.ReportIdentifier, EmailTemplates.Report); + }) + .UseGraph(new DefaultAzureCredential()) + .UseRazorEmailTemplates(); // Graph API builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => - { - return new GraphServiceClient(new DefaultAzureCredential()); - }); builder.Services.Configure(opt => { @@ -114,12 +129,10 @@ public static async Task Main(string[] args) }); // App registrations secret manager - builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.Configure(opt => { - opt.EmailSender = emailSender; - foreach (var recipientEmailAddress in recipientEmailAddresses) { opt.EmailRecipients.Add(recipientEmailAddress); diff --git a/src/Functions/Functions.csproj b/src/Functions/Functions.csproj index 772275c..8e0cf1a 100644 --- a/src/Functions/Functions.csproj +++ b/src/Functions/Functions.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs index ded27c9..c399aa7 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -14,12 +14,30 @@ public void Constructor() var secrets = new[] { new AppRegistrationSecretCheckResultApplicationSecret(default, default, default), - new AppRegistrationSecretCheckResultApplicationSecret(default, default, default), + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + }; + + var application = new AppRegistrationSecretCheckResultApplication("The ID", "The display name", secrets); + + application.DisplayName.Should().Be("The display name"); + application.HasExpiredSecrets.Should().BeFalse(); + application.Id.Should().Be("The ID"); + application.Secrets.Should().Equal(secrets); + } + + [Fact] + public void HasExpiredSecrets() + { + var secrets = new[] + { + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Expired }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, }; var application = new AppRegistrationSecretCheckResultApplication("The ID", "The display name", secrets); application.DisplayName.Should().Be("The display name"); + application.HasExpiredSecrets.Should().BeTrue(); application.Id.Should().Be("The ID"); application.Secrets.Should().Equal(secrets); } diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs index db5a211..3652f31 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -13,14 +13,56 @@ public void Constructor() { var applications = new[] { - new AppRegistrationSecretCheckResultApplication(default, default, []), - new AppRegistrationSecretCheckResultApplication(default, default, []), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), }; var tenant = new AppRegistrationSecretCheckResultTenant("The ID", "The display name", applications); tenant.Applications.Should().Equal(applications); tenant.DisplayName.Should().Be("The display name"); + tenant.HasExpiredSecrets.Should().BeFalse(); + tenant.Id.Should().Be("The ID"); + } + + [Fact] + public void HasExpiredSecrets() + { + var applications = new[] + { + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Expired }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), + }; + + var tenant = new AppRegistrationSecretCheckResultTenant("The ID", "The display name", applications); + + tenant.Applications.Should().Equal(applications); + tenant.DisplayName.Should().Be("The display name"); + tenant.HasExpiredSecrets.Should().BeTrue(); tenant.Id.Should().Be("The ID"); } } diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs index bd140b4..b23fd27 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs @@ -13,12 +13,106 @@ public void Constructor() { var tenants = new[] { - new AppRegistrationSecretCheckResultTenant(default, default, []), - new AppRegistrationSecretCheckResultTenant(default, default, []), + new AppRegistrationSecretCheckResultTenant( + default, + default, + [ + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), + ]), + new AppRegistrationSecretCheckResultTenant( + default, + default, + [ + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), + ]), }; - var tenant = new AppRegistrationSecretCheckResult(tenants); + var dateTime = new DateTime(2024, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc); + var tenant = new AppRegistrationSecretCheckResult(tenants, dateTime); + + tenant.DateTime.Should().Be(dateTime); + tenant.HasExpiredSecrets.Should().BeFalse(); + tenant.Tenants.Should().Equal(tenants); + } + + [Fact] + public void HasExpiredSecrets() + { + var tenants = new[] + { + new AppRegistrationSecretCheckResultTenant( + default, + default, + [ + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), + ]), + new AppRegistrationSecretCheckResultTenant( + default, + default, + [ + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Expired }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]), + new AppRegistrationSecretCheckResultApplication( + default, + default, + [ + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default) { Status = AppRegistrationSecretStatus.Valid }, + ]), + ]), + }; + + var dateTime = new DateTime(2024, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc); + + var tenant = new AppRegistrationSecretCheckResult(tenants, dateTime); + + tenant.DateTime.Should().Be(dateTime); + tenant.HasExpiredSecrets.Should().BeTrue(); tenant.Tenants.Should().Equal(tenants); } } diff --git a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs index 6e51917..a1b75bb 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs @@ -6,8 +6,6 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests { - using PosInformatique.Foundations.EmailAddresses; - public class AppRegistrationSecretManagerOptionsTest { [Fact] @@ -15,20 +13,7 @@ public void Constructor() { var options = new AppRegistrationSecretManagerOptions(); - options.EmailSender.Should().BeNull(); options.EmailRecipients.Should().BeEmpty(); } - - [Fact] - public void EmailSender_ValueChanged() - { - var options = new AppRegistrationSecretManagerOptions(); - - var sender = EmailAddress.Parse("email@domain.com"); - - options.EmailSender = sender; - - options.EmailSender.Should().Be(sender); - } } } \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index dc321a1..9375e27 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -55,7 +55,7 @@ public async Task CheckAsync() "2-1", "App 2-1", [ - new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(-100)), + new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(100)), ]), new EntraIdApplication( "2-2", @@ -65,81 +65,192 @@ public async Task CheckAsync() ]) ])); - var emailProvider = new Mock(MockBehavior.Strict); - emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email1@domain.com"), cancellationToken)) - .Callback((EmailMessage m, CancellationToken _) => + var email = new Email(EmailTemplates.Report); + + var emailManager = new Mock(MockBehavior.Strict); + emailManager.Setup(em => em.Create(EmailTemplates.ReportIdentifier)) + .Returns(email); + + emailManager.Setup(g => g.SendAsync(It.IsAny>(), cancellationToken)) + .Callback((Email e, CancellationToken _) => { - m.HtmlContent.Should().Be("The content"); - m.From.DisplayName.Should().BeEmpty(); - m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); - m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); - m.To.DisplayName.Should().BeEmpty(); + e.Should().BeSameAs(email); + + expectedResult = email.Recipients[0].Model; + + e.Importance.Should().Be(EmailImportance.Normal); + + e.Recipients.Should().HaveCount(2); + + e.Recipients[0].Address.Should().Be(EmailAddress.Parse("email1@domain.com")); + e.Recipients[0].DisplayName.Should().BeEmpty(); + + e.Recipients[1].Address.Should().Be(EmailAddress.Parse("email2@domain.com")); + e.Recipients[1].DisplayName.Should().BeEmpty(); + e.Recipients[1].Model.Should().BeSameAs(expectedResult); }) .Returns(Task.CompletedTask); - emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email2@domain.com"), cancellationToken)) - .Callback((EmailMessage m, CancellationToken _) => + + var timeProvider = new Mock(MockBehavior.Strict); + timeProvider.Setup(tp => tp.GetUtcNow()) + .Returns(now); + timeProvider.Setup(tp => tp.LocalTimeZone) + .Returns(TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila")); + + var options = Options.Create(new AppRegistrationSecretManagerOptions() + { + EmailRecipients = { - m.HtmlContent.Should().Be("The content"); - m.From.DisplayName.Should().BeEmpty(); - m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); - m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); - m.To.DisplayName.Should().BeEmpty(); - }) - .Returns(Task.CompletedTask); + EmailAddress.Parse("email1@domain.com"), + EmailAddress.Parse("email2@domain.com"), + }, + }); + + var parameters = new AppRegistrationSecretCheckParameters() + { + ExpirationThreshold = TimeSpan.FromDays(5), + TenantIds = + { + "Tenant 1", + "Tenant 2", + }, + }; + + var manager = new AppRegistrationSecretManager(entraIdClient.Object, emailManager.Object, timeProvider.Object, options); + + var result = await manager.CheckAsync(parameters, cancellationToken); + + result.Should().BeSameAs(expectedResult); + + result.DateTime.Should().Be(now).And.BeIn(DateTimeKind.Utc); + + result.Tenants.Should().HaveCount(2); + + result.Tenants[0].DisplayName.Should().Be("The tenant display name 1"); + result.Tenants[0].Id.Should().Be("The tenant ID 1"); + result.Tenants[0].Applications.Should().HaveCount(2); + result.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); + result.Tenants[0].Applications[0].Id.Should().Be("1-1"); + result.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); + result.Tenants[0].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(60); + result.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); + result.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + result.Tenants[0].Applications[0].Secrets[1].DaysBeforeExpiration.Should().Be(10); + result.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); + result.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[0].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); + result.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); + result.Tenants[0].Applications[1].Id.Should().Be("1-2"); + result.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); + result.Tenants[0].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(30); + result.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); + result.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + result.Tenants[0].Applications[1].Secrets[1].DaysBeforeExpiration.Should().Be(120); + result.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); + result.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[1].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); + + result.Tenants[1].DisplayName.Should().Be("The tenant display name 2"); + result.Tenants[1].Id.Should().Be("The tenant ID 2"); + result.Tenants[1].Applications.Should().HaveCount(2); + result.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); + result.Tenants[1].Applications[0].Id.Should().Be("2-1"); + result.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); + result.Tenants[1].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(100); + result.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); + result.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(100).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[1].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + result.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); + result.Tenants[1].Applications[1].Id.Should().Be("2-2"); + result.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); + result.Tenants[1].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(300); + result.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); + result.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[1].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + + emailManager.VerifyAll(); + entraIdClient.VerifyAll(); + timeProvider.VerifyAll(); + } + + [Fact] + public async Task CheckAsync_WithImportance() + { + var now = new DateTime(2025, 6, 15, 1, 2, 3, 4, 5, DateTimeKind.Utc); + + var cancellationToken = new CancellationTokenSource().Token; + + AppRegistrationSecretCheckResult expectedResult = null; - var emailGenerator = new Mock(MockBehavior.Strict); - emailGenerator.Setup(g => g.GenerateAsync(It.IsAny(), cancellationToken)) - .Callback((AppRegistrationSecretCheckResult r, CancellationToken _) => + var entraIdClient = new Mock(MockBehavior.Strict); + entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 1", cancellationToken)) + .ReturnsAsync( + new EntraIdTenant( + "The tenant ID 1", + "The tenant display name 1", + [ + new EntraIdApplication( + "1-1", + "App 1-1", + [ + new EntraIdApplicationPasswordCredential("Secret 1-1-1", now.AddDays(60)), + new EntraIdApplicationPasswordCredential("Secret 1-1-2", now.AddDays(10)), + ]), + new EntraIdApplication( + "1-2", + "App 1-2", + [ + new EntraIdApplicationPasswordCredential("Secret 1-2-1", now.AddDays(30)), + new EntraIdApplicationPasswordCredential("Secret 1-2-2", now.AddDays(120)), + ]) + ])); + entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 2", cancellationToken)) + .ReturnsAsync( + new EntraIdTenant( + "The tenant ID 2", + "The tenant display name 2", + [ + new EntraIdApplication( + "2-1", + "App 2-1", + [ + new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(-100)), + ]), + new EntraIdApplication( + "2-2", + "App 2-2", + [ + new EntraIdApplicationPasswordCredential("Secret 2-2-1", now.AddDays(300)), + ]) + ])); + + var email = new Email(EmailTemplates.Report); + + var emailManager = new Mock(MockBehavior.Strict); + emailManager.Setup(em => em.Create(EmailTemplates.ReportIdentifier)) + .Returns(email); + + emailManager.Setup(g => g.SendAsync(It.IsAny>(), cancellationToken)) + .Callback((Email e, CancellationToken _) => { - r.Tenants.Should().HaveCount(2); - - r.Tenants[0].DisplayName.Should().Be("The tenant display name 1"); - r.Tenants[0].Id.Should().Be("The tenant ID 1"); - r.Tenants[0].Applications.Should().HaveCount(2); - r.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); - r.Tenants[0].Applications[0].Id.Should().Be("1-1"); - r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); - r.Tenants[0].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(60); - r.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); - r.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); - r.Tenants[0].Applications[0].Secrets[1].DaysBeforeExpiration.Should().Be(10); - r.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); - r.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[0].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); - r.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); - r.Tenants[0].Applications[1].Id.Should().Be("1-2"); - r.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); - r.Tenants[0].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(30); - r.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); - r.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); - r.Tenants[0].Applications[1].Secrets[1].DaysBeforeExpiration.Should().Be(120); - r.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); - r.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[1].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); - - r.Tenants[1].DisplayName.Should().Be("The tenant display name 2"); - r.Tenants[1].Id.Should().Be("The tenant ID 2"); - r.Tenants[1].Applications.Should().HaveCount(2); - r.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); - r.Tenants[1].Applications[0].Id.Should().Be("2-1"); - r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); - r.Tenants[1].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(-100); - r.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); - r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(-100).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[1].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Expired); - r.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); - r.Tenants[1].Applications[1].Id.Should().Be("2-2"); - r.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); - r.Tenants[1].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(300); - r.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); - r.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[1].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); - - expectedResult = r; + e.Should().BeSameAs(email); + + expectedResult = email.Recipients[0].Model; + + e.Importance.Should().Be(EmailImportance.High); + + e.Recipients.Should().HaveCount(2); + + e.Recipients[0].Address.Should().Be(EmailAddress.Parse("email1@domain.com")); + e.Recipients[0].DisplayName.Should().BeEmpty(); + + e.Recipients[1].Address.Should().Be(EmailAddress.Parse("email2@domain.com")); + e.Recipients[1].DisplayName.Should().BeEmpty(); + e.Recipients[1].Model.Should().BeSameAs(expectedResult); }) - .ReturnsAsync("The content"); + .Returns(Task.CompletedTask); var timeProvider = new Mock(MockBehavior.Strict); timeProvider.Setup(tp => tp.GetUtcNow()) @@ -154,7 +265,6 @@ public async Task CheckAsync() EmailAddress.Parse("email1@domain.com"), EmailAddress.Parse("email2@domain.com"), }, - EmailSender = EmailAddress.Parse("sender@domain.com"), }); var parameters = new AppRegistrationSecretCheckParameters() @@ -167,14 +277,61 @@ public async Task CheckAsync() }, }; - var manager = new AppRegistrationSecretManager(entraIdClient.Object, emailProvider.Object, emailGenerator.Object, timeProvider.Object, options); + var manager = new AppRegistrationSecretManager(entraIdClient.Object, emailManager.Object, timeProvider.Object, options); var result = await manager.CheckAsync(parameters, cancellationToken); result.Should().BeSameAs(expectedResult); - emailGenerator.VerifyAll(); - emailProvider.VerifyAll(); + result.DateTime.Should().Be(now).And.BeIn(DateTimeKind.Utc); + + result.Tenants.Should().HaveCount(2); + + result.Tenants[0].DisplayName.Should().Be("The tenant display name 1"); + result.Tenants[0].Id.Should().Be("The tenant ID 1"); + result.Tenants[0].Applications.Should().HaveCount(2); + result.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); + result.Tenants[0].Applications[0].Id.Should().Be("1-1"); + result.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); + result.Tenants[0].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(60); + result.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); + result.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + result.Tenants[0].Applications[0].Secrets[1].DaysBeforeExpiration.Should().Be(10); + result.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); + result.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[0].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); + result.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); + result.Tenants[0].Applications[1].Id.Should().Be("1-2"); + result.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); + result.Tenants[0].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(30); + result.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); + result.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); + result.Tenants[0].Applications[1].Secrets[1].DaysBeforeExpiration.Should().Be(120); + result.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); + result.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[0].Applications[1].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); + + result.Tenants[1].DisplayName.Should().Be("The tenant display name 2"); + result.Tenants[1].Id.Should().Be("The tenant ID 2"); + result.Tenants[1].Applications.Should().HaveCount(2); + result.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); + result.Tenants[1].Applications[0].Id.Should().Be("2-1"); + result.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); + result.Tenants[1].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(-100); + result.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); + result.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(-100).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[1].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Expired); + result.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); + result.Tenants[1].Applications[1].Id.Should().Be("2-2"); + result.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); + result.Tenants[1].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(300); + result.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); + result.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); + result.Tenants[1].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + + emailManager.VerifyAll(); entraIdClient.VerifyAll(); timeProvider.VerifyAll(); } diff --git a/tests/Core.Tests/Emailing/EmailContactTest.cs b/tests/Core.Tests/Emailing/EmailContactTest.cs deleted file mode 100644 index a8a99f0..0000000 --- a/tests/Core.Tests/Emailing/EmailContactTest.cs +++ /dev/null @@ -1,24 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing.Tests -{ - using PosInformatique.Foundations.EmailAddresses; - - public class EmailContactTest - { - [Fact] - public void Constructor() - { - var emailAddress = EmailAddress.Parse("user@domain.com"); - - var contact = new EmailContact(emailAddress, "The display name"); - - contact.Email.Should().BeSameAs(emailAddress); - contact.DisplayName.Should().Be("The display name"); - } - } -} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/EmailMessageTest.cs b/tests/Core.Tests/Emailing/EmailMessageTest.cs deleted file mode 100644 index 42443db..0000000 --- a/tests/Core.Tests/Emailing/EmailMessageTest.cs +++ /dev/null @@ -1,29 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing.Tests -{ - public class EmailMessageTest - { - [Fact] - public void Constructor() - { - var from = new EmailContact(default, default); - var to = new EmailContact(default, default); - - var emailMessage = new EmailMessage( - from, - to, - "The subject", - "HTML content"); - - emailMessage.From.Should().Be(from); - emailMessage.HtmlContent.Should().Be("HTML content"); - emailMessage.Subject.Should().Be("The subject"); - emailMessage.To.Should().Be(to); - } - } -} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/EmailTemplatesTest.ReportEmailTemplateBody_Render.verified.html b/tests/Core.Tests/Emailing/EmailTemplatesTest.ReportEmailTemplateBody_Render.verified.html new file mode 100644 index 0000000..4810117 --- /dev/null +++ b/tests/Core.Tests/Emailing/EmailTemplatesTest.ReportEmailTemplateBody_Render.verified.html @@ -0,0 +1,211 @@ + + +

Entra ID app registrations secret expiration report

+ +

Summary

+
+ +
1
+
Expired
1
+
Expiring Soon
4
+
Valid

The tenant 1

+ +

The app 1-1

+ +

Secret 1-1-1

+
Status:
+
Expired
+
Expiration date:
+
1-janv.-2025
+
Expired since:
+
10 days

Secret 1-1-2

+
Status:
+
Valid
+
Expiration date:
+
2-févr.-2025
+
Days before expiration:
+
20 days

The app 1-2

+ +

Secret 1-2-1

+
Status:
+
Valid
+
Expiration date:
+
3-mars-2025
+
Days before expiration:
+
30 days

Secret 1-2-2

+
Status:
+
ExpiringSoon
+
Expiration date:
+
4-avr.-2025
+
Days before expiration:
+
40 days

The app 1-3

+ +
No secret

The tenant 2

+ +

The app 2-1

+ +

Secret 2-1-1

+
Status:
+
Valid
+
Expiration date:
+
5-mai-2025
+
Days before expiration:
+
50 days

The app 2-2

+ +

Secret 2-1-1

+
Status:
+
Valid
+
Expiration date:
+
6-juin-2025
+
Days before expiration:
+
60 days

The tenant 3

+ +
No application
\ No newline at end of file diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs b/tests/Core.Tests/Emailing/EmailTemplatesTest.cs similarity index 65% rename from tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs rename to tests/Core.Tests/Emailing/EmailTemplatesTest.cs index e6e9059..6a25f88 100644 --- a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs +++ b/tests/Core.Tests/Emailing/EmailTemplatesTest.cs @@ -1,15 +1,18 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.Tests { - public class ScribanEmailGeneratorTest + using System.Globalization; + using Microsoft.Extensions.DependencyInjection; + + public class EmailTemplatesTest { [Fact] - public async Task GenerateAsync() + public async Task ReportEmailTemplateBody_Render() { var checkResult = new AppRegistrationSecretCheckResult( [ @@ -57,13 +60,42 @@ public async Task GenerateAsync() "Id 3", "The tenant 3", []), - ]); + ], + new DateTime(2025, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); + + var culture = new Mock(MockBehavior.Strict); + culture.Setup(c => c.Current) + .Returns(new CultureInfo("fr")); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(culture.Object); + + var content = await RazorTemplateTools.RenderAsync(checkResult, serviceCollection); + + await Verify(content, "html"); + + culture.VerifyAll(); + } + + [Fact] + public async Task ReportEmailTemplateSubject_Render() + { + var checkResult = new AppRegistrationSecretCheckResult( + [], + new DateTime(2025, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); + + var culture = new Mock(MockBehavior.Strict); + culture.Setup(c => c.Current) + .Returns(new CultureInfo("fr")); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(culture.Object); - var generator = new ScribanEmailGenerator(); + var content = await RazorTemplateTools.RenderAsync(checkResult, serviceCollection); - var content = await generator.GenerateAsync(checkResult, CancellationToken.None); + content.Should().Be("Entra ID app registrations secret expiration report - [02/01/2025]"); - await Verify(content); + culture.VerifyAll(); } } } \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs deleted file mode 100644 index 455e078..0000000 --- a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Foundations.Emailing.Graph.Tests -{ - using Microsoft.Graph; - using Microsoft.Graph.Models; - using Microsoft.Graph.Users.Item.SendMail; - using Microsoft.Kiota.Abstractions; - using Microsoft.Kiota.Abstractions.Serialization; - using Microsoft.Kiota.Serialization.Json; - - public class GraphEmailProviderTest - { - [Fact] - public async Task SendAsync() - { - var cancellationToken = new CancellationTokenSource().Token; - - var serializationWriterFactory = new Mock(MockBehavior.Strict); - serializationWriterFactory.Setup(f => f.GetSerializationWriter("application/json")) - .Returns(new JsonSerializationWriter()); - - var requestAdapter = new Mock(MockBehavior.Strict); - requestAdapter.Setup(r => r.BaseUrl) - .Returns("http://base/url"); - requestAdapter.Setup(r => r.EnableBackingStore(null)); - requestAdapter.Setup(r => r.SerializationWriterFactory) - .Returns(serializationWriterFactory.Object); - requestAdapter.Setup(r => r.SendNoContentAsync(It.IsAny(), It.IsNotNull>>(), cancellationToken)) - .Callback((RequestInformation requestInfo, Dictionary> _, CancellationToken _) => - { - requestInfo.HttpMethod.Should().Be(Method.POST); - requestInfo.URI.Should().Be("http://base/url/users/sender%40domain.com/sendMail"); - - var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); - - jsonMessage.Message.Attachments.Should().BeNull(); - jsonMessage.Message.Body.Content.Should().Be("The HTML content"); - jsonMessage.Message.Body.ContentType.Should().Be(BodyType.Html); - jsonMessage.Message.BccRecipients.Should().BeNull(); - jsonMessage.Message.CcRecipients.Should().BeNull(); - jsonMessage.Message.ToRecipients.Should().HaveCount(1); - jsonMessage.Message.ToRecipients[0].EmailAddress.Address.Should().Be("recipient@domain.com"); - jsonMessage.Message.ToRecipients[0].EmailAddress.Name.Should().Be("The recipient"); - jsonMessage.SaveToSentItems.Should().BeFalse(); - }) - .Returns(Task.CompletedTask); - - var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); - - var client = new GraphEmailProvider(graphServiceClient.Object); - - var from = new EmailContact(EmailAddresses.EmailAddress.Parse("sender@domain.com"), "The sender"); - var to = new EmailContact(EmailAddresses.EmailAddress.Parse("recipient@domain.com"), "The recipient"); - - var message = new EmailMessage(from, to, "The subject", "The HTML content"); - - await client.SendAsync(message, cancellationToken); - - graphServiceClient.VerifyAll(); - requestAdapter.VerifyAll(); - serializationWriterFactory.VerifyAll(); - } - } -} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/RazorTemplateTools.cs b/tests/Core.Tests/Emailing/RazorTemplateTools.cs new file mode 100644 index 0000000..580d264 --- /dev/null +++ b/tests/Core.Tests/Emailing/RazorTemplateTools.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique +{ + using System.Diagnostics; + using Microsoft.AspNetCore.Components; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.JSInterop; + using PosInformatique.Foundations.Text.Templating; + using PosInformatique.Foundations.Text.Templating.Razor; + + public static class RazorTemplateTools + { + /*public static void DisplayHtmlPage(string content, string testName) + { + if (!Debugger.IsAttached) + { + return; + } + + var temporaryFolder = Path.Combine(UnitTestsFolders.TemporaryRoot, "Emailing Template Tests"); + var temporaryFile = Path.Combine(temporaryFolder, $"{testName}.html"); + + if (!Directory.Exists(temporaryFolder)) + { + Directory.CreateDirectory(temporaryFolder); + } + + File.WriteAllText(temporaryFile, content); + + Process.Start(new ProcessStartInfo + { + FileName = temporaryFile, + UseShellExecute = true, + }); + }*/ + + public static async Task RenderAsync(object model, IServiceCollection services) + where TComponent : ComponentBase + { +#pragma warning disable PosInfoMoq1000 // VerifyAll() method should be called when instantiate a Mock instances + var jsRuntime = new Mock(MockBehavior.Strict); +#pragma warning restore PosInfoMoq1000 // VerifyAll() method should be called when instantiate a Mock instances + + services.AddLogging(); + services.AddSingleton(jsRuntime.Object); + services.AddRazorTextTemplating(); + + var serviceProvider = services.BuildServiceProvider(); + + var context = new Mock(MockBehavior.Strict); + context.Setup(c => c.ServiceProvider) + .Returns(serviceProvider); + + var template = new RazorTextTemplate(typeof(TComponent)); + + var output = new StringWriter(); + + await template.RenderAsync(model, output, context.Object); + + context.VerifyAll(); + + return output.ToString(); + } + } +} diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt deleted file mode 100644 index 941dcff..0000000 --- a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt +++ /dev/null @@ -1,341 +0,0 @@ - - - - - - -

Entra ID app registrations secret expiration report

- - - - -
-

The tenant 1

- -
- - -
-

The app 1-1

- -
- - -
-

Secret 1-1-1

-
-
-
Status:
-
Expired
-
-
-
Expiration date:
-
1-Jan-2025
-
-
- -
Expired since:
-
10 days
- -
-
-
- -
-

Secret 1-1-2

-
-
-
Status:
-
Valid
-
-
-
Expiration date:
-
2-Feb-2025
-
-
- -
Days before expiration:
-
20 days
- -
-
-
- - -
-
- -
-

The app 1-2

- -
- - -
-

Secret 1-2-1

-
-
-
Status:
-
Valid
-
-
-
Expiration date:
-
3-Mar-2025
-
-
- -
Days before expiration:
-
30 days
- -
-
-
- -
-

Secret 1-2-2

-
-
-
Status:
-
ExpiringSoon
-
-
-
Expiration date:
-
4-Apr-2025
-
-
- -
Days before expiration:
-
40 days
- -
-
-
- - -
-
- -
-

The app 1-3

- -
- -
No secret
- -
-
- - -
-
- -
-

The tenant 2

- -
- - -
-

The app 2-1

- -
- - -
-

Secret 2-1-1

-
-
-
Status:
-
Valid
-
-
-
Expiration date:
-
5-May-2025
-
-
- -
Days before expiration:
-
50 days
- -
-
-
- - -
-
- -
-

The app 2-2

- -
- - -
-

Secret 2-1-1

-
-
-
Status:
-
Valid
-
-
-
Expiration date:
-
6-Jun-2025
-
-
- -
Days before expiration:
-
60 days
- -
-
-
- - -
-
- - -
-
- -
-

The tenant 3

- -
- -
No application
- -
-
- - - \ No newline at end of file diff --git a/tests/Core.Tests/FixedCultureTest.cs b/tests/Core.Tests/FixedCultureTest.cs new file mode 100644 index 0000000..fcb57df --- /dev/null +++ b/tests/Core.Tests/FixedCultureTest.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + using System.Globalization; + + public class FixedCultureTest + { + [Fact] + public void Constructor() + { + var culture = new FixedCulture("fr"); + + culture.Current.Should().BeSameAs(CultureInfo.GetCultureInfo("fr")); + } + } +} \ No newline at end of file