mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
e704aa7359
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy admin row a few storage round-trips later. In between the tenant was activated with no members, and auth/me seated any roleless caller of that moment as Admin; every member of the customer's Entra tenant could poll auth/me across the auto-approve moment. - TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional insert, best-effort, fresh config read), then the activation row (still the arbiter), then the welcome mail. The two tables cannot share a batch, so "before" is the guarantee. - AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes to that UPN only; other roleless callers stay roleless. Without one (rows from before the field existed) the first sign-in still becomes the admin. The grant logs at Warning. - Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync, 409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check. - Dead after this: IsTenantAdminAsync on the service, the repository interface and the table repository, plus its test. - Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the conditional write; each part was mutated back to the old code and failed its tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
373 lines
18 KiB
C#
373 lines
18 KiB
C#
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Functions.Services.Notifications;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="TenantApprovalService.ApproveWithSideEffectsAsync"/> — the
|
|
/// idempotency contract: the conditional whitelist insert decides which caller "wins" a
|
|
/// concurrent activation, and only the winner runs the side effects (auto-promote,
|
|
/// welcome mail). The loser returns false and touches nothing.
|
|
/// </summary>
|
|
public class TenantApprovalServiceTests
|
|
{
|
|
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
private const string ApprovedBy = "ga@contoso.com";
|
|
|
|
private readonly Mock<PreviewWhitelistService> _previewMock;
|
|
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
|
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
|
private readonly Mock<IEmailService> _emailMock;
|
|
private readonly List<OpsEventEntry> _opsEvents = new();
|
|
private readonly TenantApprovalService _sut;
|
|
|
|
public TenantApprovalServiceTests()
|
|
{
|
|
var configRepo = Mock.Of<IConfigRepository>();
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
|
|
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
|
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
|
{ CallBase = false };
|
|
|
|
_previewMock = new Mock<PreviewWhitelistService>(
|
|
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
|
{ CallBase = false };
|
|
|
|
_emailMock = new Mock<IEmailService>();
|
|
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Real OpsEventService over a capturing repository: its Record* helpers are not virtual,
|
|
// so the assertions below read the entries the service actually composes.
|
|
var opsRepo = new Mock<IOpsEventRepository>();
|
|
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
|
.Callback<OpsEventEntry>(e => { lock (_opsEvents) _opsEvents.Add(e); })
|
|
.Returns(Task.CompletedTask);
|
|
var adminConfig = new Mock<AdminConfigurationService>(
|
|
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
|
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
|
|
|
// TryAddTenantAdminAsync is virtual: ArrangeNewlyApproved answers it per test (true = seated,
|
|
// false = the row exists). The unset IsApprovedFreshAsync answers false: not activated yet.
|
|
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
|
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
|
|
|
|
_sut = new TenantApprovalService(
|
|
NullLogger<TenantApprovalService>.Instance,
|
|
_previewMock.Object,
|
|
_tenantConfigMock.Object,
|
|
_tenantAdminsMock.Object,
|
|
_emailMock.Object,
|
|
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch));
|
|
}
|
|
|
|
private OpsEventEntry? OpsEventOfType(string eventType)
|
|
{
|
|
lock (_opsEvents) return _opsEvents.FirstOrDefault(e => e.EventType == eventType);
|
|
}
|
|
|
|
private static TenantConfiguration ConfigWith(string? contactEmail, string domainName = "contoso.com")
|
|
{
|
|
var config = TenantConfiguration.CreateDefault(TenantId);
|
|
config.DomainName = domainName;
|
|
config.ContactEmail = contactEmail;
|
|
return config;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AlreadyApproved_ReturnsFalse_AndWritesNothing()
|
|
{
|
|
// The fresh pre-check decides, not the 5-minute activation cache: a re-activation right after a
|
|
// revoke on another instance must not be answered as "already activated" from that cache.
|
|
_previewMock.Setup(x => x.IsApprovedFreshAsync(TenantId)).ReturnsAsync(true);
|
|
|
|
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
Assert.False(newlyApproved);
|
|
_previewMock.Verify(x => x.ApproveAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
|
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
|
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
|
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_ReturnsTrue()
|
|
{
|
|
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
|
// Default config has no OnboardedBy → nothing to promote; no notification email → no
|
|
// mail. Only the return value is under test.
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
|
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
|
|
|
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
Assert.True(newlyApproved);
|
|
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
|
|
}
|
|
|
|
// --- Auto-promote on activation: only the onboarding member (OnboardedBy) becomes Tenant Admin.
|
|
|
|
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System", bool alreadyAdmin = false)
|
|
{
|
|
var config = ConfigWith(contactEmail: null);
|
|
config.OnboardedBy = onboardedBy;
|
|
config.UpdatedBy = updatedBy;
|
|
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
|
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy)).ReturnsAsync(!alreadyAdmin);
|
|
}
|
|
|
|
private void VerifyNoPromotion() =>
|
|
_tenantAdminsMock.Verify(
|
|
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_PromotesTheOnboardingMember()
|
|
{
|
|
// A sync job clobbering UpdatedBy with its sentinel once leaked into TenantAdmins; OnboardedBy decides alone.
|
|
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", updatedBy: "System (Global Rate Limit Sync)");
|
|
|
|
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
_tenantAdminsMock.Verify(
|
|
x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_OnboardingMemberAlreadySeated_IsLeftUntouchedAndActivationProceeds()
|
|
{
|
|
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", alreadyAdmin: true);
|
|
|
|
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
|
|
|
// The conditional insert is the only write: a row the tenant holds (disabled, demoted) is never overwritten.
|
|
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
|
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_NeverPromotesAnExternalAccountsUpn()
|
|
{
|
|
ArrangeNewlyApproved(onboardedBy: "alice_fabrikam.example#EXT#@contoso.example");
|
|
|
|
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
VerifyNoPromotion();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_WithoutOnboardedBy_NeverFallsBackToTheLastWriter()
|
|
{
|
|
// UpdatedBy is whoever wrote the row last (any signed-in user can, e.g. by accepting the DPA), not the requester.
|
|
ArrangeNewlyApproved(onboardedBy: null, updatedBy: "first.member@contoso.example");
|
|
|
|
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
VerifyNoPromotion();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_SeatsTheOnboardingMemberBeforeTheActivationRow()
|
|
{
|
|
// The invariant behind the activation race: no observable state has the tenant activated while the
|
|
// designated admin's row is still missing. The seat is written first, the activation row second.
|
|
var writes = new List<string>();
|
|
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
|
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy))
|
|
.Callback(() => writes.Add("seat")).ReturnsAsync(true);
|
|
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy))
|
|
.Callback(() => writes.Add("activate")).ReturnsAsync(true);
|
|
|
|
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
|
|
|
Assert.Equal(new[] { "seat", "activate" }, writes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LostActivationRace_SeatIsTheWinnersRow_ButNoMailAndNoOpsEvent()
|
|
{
|
|
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
|
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
|
|
|
|
Assert.False(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
|
|
|
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
|
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
|
Assert.Empty(_opsEvents);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewlyApproved_SeatWriteFails_ActivationStillProceeds()
|
|
{
|
|
// Best-effort on purpose: with the seat missing, auth/me seats the designated member on their next
|
|
// sign-in and nobody else.
|
|
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
|
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy))
|
|
.ThrowsAsync(new InvalidOperationException("Table Storage down"));
|
|
|
|
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
|
|
|
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
|
|
}
|
|
|
|
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
|
|
// notification-email save path. Ordering contract under test: the sent-marker must be
|
|
// consumed strictly AFTER the address check (a marker consumed with no address would
|
|
// permanently suppress the mail), and a lost marker race must not send.
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_NoAddressAnywhere_DefersWithoutConsumingMarker_AndRecordsSkip()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.False(sent);
|
|
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
|
|
// The silent skip is exactly what hid the August 2026 outage — it must leave a record.
|
|
var skipped = OpsEventOfType("WelcomeEmailSkipped");
|
|
Assert.NotNull(skipped);
|
|
Assert.Equal(OpsEventSeverity.Warning, skipped!.Severity);
|
|
Assert.Equal(TenantId, skipped.TenantId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_NoActivationAddress_FallsBackToTenantContactAddress()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
|
.ReturnsAsync(ConfigWith(contactEmail: "contact@contoso.com"));
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.True(sent);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("contact@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
Assert.Null(OpsEventOfType("WelcomeEmailSkipped"));
|
|
Assert.Contains("tenant contact address", OpsEventOfType("WelcomeEmailSent")!.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_ActivationAddressWinsOverContactAddress()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("chosen@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
|
.ReturnsAsync(ConfigWith(contactEmail: "contact@contoso.com"));
|
|
|
|
Assert.True(await _sut.TrySendWelcomeEmailAsync(TenantId));
|
|
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("chosen@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
Assert.Contains("activation page", OpsEventOfType("WelcomeEmailSent")!.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_SignupUpnIsNeverAFallback()
|
|
{
|
|
// Admin UPNs frequently have no mailbox; bouncing them costs sender reputation for
|
|
// every other tenant. Only the two deliberate addresses may be used.
|
|
var config = ConfigWith(contactEmail: null);
|
|
config.OnboardedBy = "adm.someone@contoso.com";
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
|
|
|
Assert.False(await _sut.TrySendWelcomeEmailAsync(TenantId));
|
|
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_ProviderRefused_ReleasesMarker_AndRecordsFailure()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
|
|
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.False(sent);
|
|
// The marker records delivery, not an attempt — a refused send must stay retryable.
|
|
_previewMock.Verify(x => x.ClearWelcomeEmailSentMarkerAsync(TenantId), Times.Once);
|
|
var failed = OpsEventOfType("WelcomeEmailFailed");
|
|
Assert.NotNull(failed);
|
|
Assert.Equal(OpsEventSeverity.Error, failed!.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_AddressPresent_MarkerWon_SendsOnce()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
|
var config = TenantConfiguration.CreateDefault(TenantId);
|
|
config.DomainName = "contoso.com";
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.True(sent);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_MarkerAlreadyConsumed_DoesNotSend()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(false);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.False(sent);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrySendWelcomeEmail_MarkerStorageError_ReturnsFalse_WithoutSending()
|
|
{
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId))
|
|
.ThrowsAsync(new InvalidOperationException("storage down"));
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
|
|
|
|
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
|
|
|
Assert.False(sent);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApproveWithSideEffects_AddressAlreadySaved_SendsWelcomeMail()
|
|
{
|
|
// The user saved their notification email BEFORE activation (manual-approve shape):
|
|
// the approval path itself must send.
|
|
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
|
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
|
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
|
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
|
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
|
|
|
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
|
|
|
Assert.True(newlyApproved);
|
|
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
}
|