Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs
T
Oliver Kieselbach e704aa7359 Activation seats the onboarding admin before it activates the tenant; the first-user seat belongs to that member alone (D-378)
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>
2026-10-22 20:47:53 +02:00

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);
}
}