mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-05 01:53:50 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6676565d5f | |||
| b9083c93df | |||
| 2c79d61003 | |||
| bb8ac2181e | |||
| 684f66f560 | |||
| 86e98f39d5 | |||
| 0be30de253 | |||
| 942650890a | |||
| a8f2fb2368 | |||
| 924ca6c916 | |||
| 3f40531b33 | |||
| 7b336c8cf0 | |||
| 74a0bc13a3 | |||
| c0e91ca0f9 | |||
| ccbc754b0e | |||
| 15cd78d81a | |||
| d95da2eeab | |||
| 967d38c7cc | |||
| b47487617a | |||
| f2b86dfc73 | |||
| 0c7b533107 | |||
| 5eec2d7e60 | |||
| e9cb51205e | |||
| cc5dd7d6c2 | |||
| 11731db5e4 | |||
| 6866fe79af | |||
| 6e34fa517c | |||
| dc05428dae | |||
| cd0c750e80 | |||
| cede4be654 | |||
| 0d63057725 | |||
| 7dd151b714 | |||
| 778eb44a34 | |||
| 00f37d869a | |||
| d88a118903 | |||
| 0ed3258d4f | |||
| 3941c3b984 | |||
| 3d831d16d7 | |||
| 46b895a70d | |||
| 77fae059ed | |||
| fd309da51d | |||
| 9c4039b3c3 | |||
| b0fb8eb5fa | |||
| dd2432fa62 |
+1
-1
Submodule .claude/commands updated: 45a18760a0...2c54f4acb8
+1
-1
Submodule internal updated: 70adb72c87...403cc96620
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -72,7 +73,8 @@ public class AppHomingServiceTests
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
_tenantConfigMock.Object,
|
||||
Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
_detectorMock = new Mock<IGraphFeatureDetector>();
|
||||
_maintenanceMock = new Mock<IMaintenanceRepository>();
|
||||
@@ -287,6 +289,32 @@ public class AppHomingServiceTests
|
||||
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_succeeds_when_the_budget_cuts_the_retries_of_a_missing_legacy_service_principal()
|
||||
{
|
||||
// The access check runs on 4 s and the first retry waits 5 s, so the answer arrives as
|
||||
// transient. The Entra code still says the SP is gone: nothing left to lose.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
|
||||
SetupLegacyToken(GraphTokenResult.TransientFailure(
|
||||
new EntraTokenRejection(401, EntraTokenRejection.ServicePrincipalMissingCode, "no service principal")));
|
||||
|
||||
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_stays_transient_when_the_budget_cuts_the_retries_of_any_other_legacy_rejection()
|
||||
{
|
||||
// Any other refusal proves nothing about the roles the tenant would lose with the flip.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
|
||||
SetupLegacyToken(GraphTokenResult.TransientFailure(
|
||||
new EntraTokenRejection(400, "AADSTS53003", "blocked by Conditional Access")));
|
||||
|
||||
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
|
||||
|
||||
Assert.False(probe.Succeeded);
|
||||
Assert.True(probe.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails()
|
||||
{
|
||||
@@ -349,6 +377,43 @@ public class AppHomingServiceTests
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_clears_the_graph_access_rejection_of_the_app_the_tenant_leaves()
|
||||
{
|
||||
_store.Row!.GraphAccessRejectedSince = DateTime.UtcNow.AddHours(-3);
|
||||
_store.Row.GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode;
|
||||
_store.Row.GraphAccessRejectionConfirmedAt = DateTime.UtcNow.AddHours(-2);
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
// The confirmed episode ends visibly: the ops trail never keeps an open rejection.
|
||||
var closed = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
|
||||
Assert.Contains("app-homing-flip", closed.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_clears_the_addon_episodes_and_closes_only_the_confirmed_ones()
|
||||
{
|
||||
_store.Row!.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddHours(-3);
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow.AddHours(-2);
|
||||
_store.Row.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-5);
|
||||
|
||||
await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
|
||||
var resolved = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.AddOnPermissionResolved);
|
||||
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, resolved.Details);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByAppHomingFlip, resolved.Details);
|
||||
// An unconfirmed rejection was never shown, so it ends without a trace.
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A radar pass reads one tenant at a time so its memory follows the largest tenant, and it never
|
||||
/// evaluates an empty list: re-arm would read that as "version drained" and delete open episodes.
|
||||
/// </summary>
|
||||
public class AppVersionRadarPassTests
|
||||
{
|
||||
private static List<AppInstallSummary> Rows(string tenantId, int count)
|
||||
{
|
||||
var rows = new List<AppInstallSummary>();
|
||||
for (var i = 0; i < count; i++)
|
||||
rows.Add(new AppInstallSummary { TenantId = tenantId });
|
||||
return rows;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_tenant_is_finished_before_the_next_one_is_loaded()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
|
||||
await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "a", "b" },
|
||||
tenantId =>
|
||||
{
|
||||
calls.Add($"load {tenantId}");
|
||||
return Task.FromResult(Rows(tenantId, 2));
|
||||
},
|
||||
(tenantId, summaries) =>
|
||||
{
|
||||
calls.Add($"evaluate {tenantId}");
|
||||
Assert.All(summaries, s => Assert.Equal(tenantId, s.TenantId));
|
||||
return Task.FromResult((0, 0, 0));
|
||||
},
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal(new[] { "load a", "evaluate a", "load b", "evaluate b" }, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_rows_is_not_evaluated()
|
||||
{
|
||||
var evaluated = new List<string>();
|
||||
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "empty", "full" },
|
||||
tenantId => Task.FromResult(tenantId == "empty" ? new List<AppInstallSummary>() : Rows(tenantId, 3)),
|
||||
(tenantId, _) =>
|
||||
{
|
||||
evaluated.Add(tenantId);
|
||||
return Task.FromResult((0, 0, 0));
|
||||
},
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal(new[] { "full" }, evaluated);
|
||||
Assert.Equal(1, pass.Tenants);
|
||||
Assert.Equal(3, pass.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_tenant_does_not_cost_the_others_their_pass()
|
||||
{
|
||||
var failed = new List<string>();
|
||||
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "load-fails", "evaluate-fails", "ok" },
|
||||
tenantId => tenantId == "load-fails"
|
||||
? throw new InvalidOperationException("load")
|
||||
: Task.FromResult(Rows(tenantId, 1)),
|
||||
(tenantId, _) => tenantId == "evaluate-fails"
|
||||
? throw new InvalidOperationException("evaluate")
|
||||
: Task.FromResult((1, 2, 3)),
|
||||
(tenantId, _) => failed.Add(tenantId));
|
||||
|
||||
Assert.Equal(new[] { "load-fails", "evaluate-fails" }, failed);
|
||||
Assert.Equal((1, 2, 3), (pass.Fired, pass.Refreshed, pass.Rearmed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_totals_add_up_over_the_tenants()
|
||||
{
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "a", "b" },
|
||||
tenantId => Task.FromResult(Rows(tenantId, 2)),
|
||||
(_, _) => Task.FromResult((1, 2, 3)),
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal((2, 4L, 2, 4, 6), pass);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<McpUserService> _mcpUserMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
@@ -84,10 +83,6 @@ public class AuthFunctionSideEffectTests
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_telegramMock = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
_globalNotificationMock = new Mock<GlobalNotificationService>(
|
||||
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
@@ -117,7 +112,6 @@ public class AuthFunctionSideEffectTests
|
||||
tenantAdmins ?? _tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
preview ?? _previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
@@ -136,9 +130,6 @@ public class AuthFunctionSideEffectTests
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_globalNotificationMock
|
||||
.Setup(x => x.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
@@ -233,7 +224,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -261,8 +252,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Null(written.DpaAcceptedBy);
|
||||
Assert.Null(written.DpaAcceptedAt);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
@@ -350,7 +340,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
@@ -371,7 +361,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
@@ -389,7 +379,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -624,7 +614,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -650,16 +640,17 @@ public class AuthFunctionSideEffectTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow()
|
||||
public async Task HandleNewTenantDomain_BellFailure_DoesNotThrow()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new HttpRequestException("Telegram unreachable"));
|
||||
_globalNotificationMock
|
||||
.Setup(x => x.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new HttpRequestException("Notification store unreachable"));
|
||||
|
||||
// Should not throw — Telegram is fire-and-forget
|
||||
// Should not throw — the bell is fire-and-forget
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// The write still landed before the fire-and-forget
|
||||
|
||||
@@ -69,7 +69,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
@@ -95,7 +95,7 @@ public class AutopilotDeviceValidatorTests
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("ap-1", result.AutopilotDeviceId);
|
||||
Assert.Equal("ap-1", result.Registration!.DeviceId);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
tokens.Verify(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
@@ -168,7 +168,7 @@ public class AutopilotDeviceValidatorTests
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("ap-1", result.AutopilotDeviceId);
|
||||
Assert.Equal("ap-1", result.Registration!.DeviceId);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
CancellationToken observed = default;
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
@@ -213,4 +213,64 @@ public class AutopilotDeviceValidatorTests
|
||||
|
||||
Assert.True(observed.CanBeCanceled);
|
||||
}
|
||||
|
||||
// ── Registration fields the session keeps ────────────────────────────────
|
||||
|
||||
private const string Upn = "first.last@example.invalid";
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
|
||||
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
|
||||
Assert.Equal("ap-1", registration.DeviceId);
|
||||
Assert.Equal("TEST", registration.GroupTag);
|
||||
Assert.Equal("PO-7", registration.PurchaseOrderId);
|
||||
Assert.Equal("LAB-01", registration.DisplayName);
|
||||
Assert.True(registration.UserAssigned);
|
||||
var kept = Newtonsoft.Json.JsonConvert.SerializeObject(registration);
|
||||
Assert.DoesNotContain(Upn, kept);
|
||||
Assert.DoesNotContain("First Last", kept);
|
||||
Assert.DoesNotContain("AAAAA-BBBBB", kept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
|
||||
Assert.Null(registration.GroupTag);
|
||||
Assert.Null(registration.PurchaseOrderId);
|
||||
Assert.Null(registration.DisplayName);
|
||||
Assert.False(registration.UserAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_CutsOverlongText()
|
||||
{
|
||||
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedValidation_CarriesTheRegistration()
|
||||
{
|
||||
var (sut, handler, _) = BuildSut(Json(
|
||||
"{\"value\":[{\"id\":\"ap-1\",\"serialNumber\":\"PF3XKQ7\",\"groupTag\":\"TEST\"}]}"));
|
||||
|
||||
await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
var cached = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal("TEST", cached.Registration!.GroupTag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,49 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_GraphAccessState_NoSnapshot()
|
||||
{
|
||||
// Token side effect: stamped and cleared without anyone changing the configuration.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.GraphAccessRejectedSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.GraphAccessRejectedCode = "AADSTS7000229";
|
||||
incoming.GraphAccessRejectionConfirmedAt = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
// Agent and token side effect, like the Graph access state.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow;
|
||||
incoming.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.CloudPcPermissionMissingConfirmedAt = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOpSave_IdenticalContent_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -5,9 +5,9 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
|
||||
/// Cover for the ConnectionType predicate used on the search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Sessions that
|
||||
/// predate the projection (null ConnectionType) must never match
|
||||
/// a set filter; an unset filter matches everything.
|
||||
/// </summary>
|
||||
public class ConnectionTypeFilterTests
|
||||
|
||||
@@ -65,7 +65,7 @@ public class CorporateIdentifierValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -12,8 +13,8 @@ using Moq;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
|
||||
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path),
|
||||
/// recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// send against a stubbed provider endpoint.
|
||||
/// </summary>
|
||||
public class EmailChannelProviderTests
|
||||
@@ -30,15 +31,6 @@ public class EmailChannelProviderTests
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<IEmailChannelSender> EmailMock()
|
||||
{
|
||||
var mock = new Mock<IEmailChannelSender>();
|
||||
@@ -50,6 +42,9 @@ public class EmailChannelProviderTests
|
||||
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
|
||||
|
||||
private static NotificationChannel Webhook(WebhookProviderType provider, string url)
|
||||
=> new() { Id = provider.ToString(), Name = provider.ToString(), ProviderType = (int)provider, Url = url, Enabled = true };
|
||||
|
||||
private static NotificationAlert Alert() => new()
|
||||
{
|
||||
EventType = "PushDeliveryFailed",
|
||||
@@ -67,9 +62,8 @@ public class EmailChannelProviderTests
|
||||
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
|
||||
@@ -77,7 +71,6 @@ public class EmailChannelProviderTests
|
||||
email.Verify(e => e.SendAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,7 +79,7 @@ public class EmailChannelProviderTests
|
||||
var email = EmailMock();
|
||||
email.Setup(e => e.SendAlertAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
@@ -99,7 +92,7 @@ public class EmailChannelProviderTests
|
||||
[Fact]
|
||||
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
|
||||
{
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
|
||||
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
@@ -108,6 +101,54 @@ public class EmailChannelProviderTests
|
||||
Assert.Contains("not registered", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Webhook_channel_reaches_the_webhook_path_and_never_the_email_sender()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, Platform);
|
||||
|
||||
webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_list_each_channel_reaches_its_own_transport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel("ops@example.invalid"),
|
||||
Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook"),
|
||||
}, alert, Platform);
|
||||
|
||||
email.Verify(e => e.SendAlertAsync("ops@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync("https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Channels_without_a_destination_are_skipped_on_every_transport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(null), Webhook(WebhookProviderType.Slack, "") }, Alert(), Platform);
|
||||
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Validation and gate ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
@@ -138,6 +179,10 @@ public class EmailChannelProviderTests
|
||||
|
||||
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
|
||||
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
|
||||
|
||||
// The branch is per provider: a recipient list on a webhook channel still meets the URL gate.
|
||||
var onWebhook = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.Slack, "ops@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(onWebhook));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -146,14 +191,45 @@ public class EmailChannelProviderTests
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
|
||||
|
||||
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, candidate, isGlobalAdmin: false));
|
||||
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_refuses_a_tenant_admin_who_flips_the_enabled_state()
|
||||
{
|
||||
var existing = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: true) });
|
||||
var disabled = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: false) });
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_ignores_webhook_channels()
|
||||
{
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_enforces_the_gate_for_every_client_write()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the AADSTS code out of Entra's token error body. The body below has the shape Entra
|
||||
/// really sends for a tenant whose enterprise application was removed; the ids are synthetic.
|
||||
/// </summary>
|
||||
public class EntraTokenRejectionTests
|
||||
{
|
||||
private const string MissingServicePrincipalBody =
|
||||
"{\"error\":\"invalid_client\"," +
|
||||
"\"error_description\":\"AADSTS7000229: The client application aaaaaaaa-0000-0000-0000-000000000001 is missing service principal in the tenant 11111111-1111-1111-1111-111111111111. See instructions here: https://go.microsoft.com/fwlink/?linkid=2225119\\r\\nTrace ID: 00000000-0000-0000-0000-000000000002\\r\\nCorrelation ID: 00000000-0000-0000-0000-000000000003\\r\\nTimestamp: 2026-11-01 08:00:00Z\"," +
|
||||
"\"error_codes\":[7000229]," +
|
||||
"\"timestamp\":\"2026-11-01 08:00:00Z\"," +
|
||||
"\"trace_id\":\"00000000-0000-0000-0000-000000000002\"," +
|
||||
"\"correlation_id\":\"00000000-0000-0000-0000-000000000003\"," +
|
||||
"\"error_uri\":\"https://login.microsoftonline.com/error?code=7000229\"}";
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_the_code_and_keeps_only_the_first_line_of_the_description()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(401, MissingServicePrincipalBody);
|
||||
|
||||
Assert.Equal(401, rejection.StatusCode);
|
||||
Assert.Equal("AADSTS7000229", rejection.ErrorCode);
|
||||
Assert.True(rejection.IsServicePrincipalMissing);
|
||||
Assert.False(rejection.IsOurCredential);
|
||||
Assert.StartsWith("AADSTS7000229: The client application", rejection.Description);
|
||||
Assert.DoesNotContain("Trace ID", rejection.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_falls_back_to_the_description_without_error_codes()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: Access has been blocked by Conditional Access policies.\"}");
|
||||
|
||||
Assert.Equal("AADSTS53003", rejection.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("<html>Bad Request</html>")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("{\"error\":\"invalid_request\"}")]
|
||||
public void Parse_keeps_only_the_status_when_the_body_carries_no_code(string? body)
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, body);
|
||||
|
||||
Assert.Equal(400, rejection.StatusCode);
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
Assert.False(rejection.IsServicePrincipalMissing);
|
||||
Assert.False(rejection.IsOurCredential);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_cuts_a_long_description()
|
||||
{
|
||||
var body = "{\"error_description\":\"AADSTS50000: " + new string('x', 1000) + "\"}";
|
||||
|
||||
Assert.Equal(300, EntraTokenRejection.Parse(400, body).Description!.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AADSTS7000215", true)]
|
||||
[InlineData("AADSTS7000222", true)]
|
||||
[InlineData("AADSTS7000218", true)]
|
||||
[InlineData("aadsts7000222", true)]
|
||||
[InlineData("AADSTS7000229", false)]
|
||||
[InlineData("AADSTS7000112", false)]
|
||||
[InlineData("AADSTS53003", false)]
|
||||
public void IsOurCredential_only_blames_our_own_secret(string code, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, new EntraTokenRejection(401, code, null).IsOurCredential);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
@@ -19,8 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
|
||||
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
|
||||
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
|
||||
/// alert carries neither the comment nor the sender.
|
||||
/// raises <c>FeedbackRatingReceived</c> and the Global Admin bell, and that the alert carries neither
|
||||
/// the comment nor the sender.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFunctionSubmitTests
|
||||
{
|
||||
@@ -34,7 +33,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
|
||||
|
||||
[Fact]
|
||||
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
|
||||
public async Task Rating_IsStored_RaisesTheEvent_AndRingsTheBell()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
@@ -64,13 +63,12 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAlert_CarriesNeitherCommentNorSender()
|
||||
{
|
||||
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
|
||||
// Channels bound to FeedbackRatingReceived (Slack, webhooks, e-mail) receive message
|
||||
// and details; the comment is read in the portal. Personal data must never ride along.
|
||||
var h = new Harness();
|
||||
|
||||
@@ -160,7 +158,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -183,7 +180,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
public FakeFeedbackRepository Repo { get; }
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public Mock<GlobalNotificationService> Bell { get; }
|
||||
public Mock<TelegramNotificationService> Telegram { get; }
|
||||
|
||||
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
|
||||
{
|
||||
@@ -203,9 +199,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
|
||||
Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
Telegram = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
@@ -216,7 +209,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
|
||||
Sut = new FeedbackFunction(
|
||||
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
|
||||
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
|
||||
Mock.Of<ISessionRepository>(), Repo,
|
||||
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
|
||||
}
|
||||
|
||||
@@ -228,8 +221,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
{
|
||||
Bell.Verify(b => b.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
|
||||
Telegram.Verify(t => t.SendFeedbackAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
|
||||
|
||||
@@ -23,7 +23,8 @@ public class FleetHealthPayloadTests
|
||||
string manufacturer = "Contoso",
|
||||
string model = "Laptop",
|
||||
string? failureReason = null,
|
||||
DateTime? startedAt = null)
|
||||
DateTime? startedAt = null,
|
||||
string? groupTag = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
@@ -34,8 +35,106 @@ public class FleetHealthPayloadTests
|
||||
Model = model,
|
||||
FailureReason = failureReason!,
|
||||
StartedAt = startedAt ?? DateTime.UtcNow,
|
||||
AutopilotGroupTag = groupTag,
|
||||
};
|
||||
|
||||
private static FleetGroupTagFilter Filter(string? excludeGroupTags, bool excludeUntagged = false)
|
||||
{
|
||||
Assert.True(FleetGroupTagFilter.TryParse(excludeGroupTags, excludeUntagged ? "true" : null, out var filter, out _));
|
||||
return filter;
|
||||
}
|
||||
|
||||
// ── Group tag breakdown and view filter ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GroupTagHealth_IsEmpty_WhenNoSessionCarriesATag()
|
||||
{
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(
|
||||
new List<SessionSummary> { S(SessionStatus.Succeeded), S(SessionStatus.Failed) }, days: 7);
|
||||
|
||||
Assert.Empty(payload.GroupTagHealth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupTagHealth_GroupsWithoutCase_InTheMostFrequentSpelling_WithANoTagBucket()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, groupTag: "TEST"),
|
||||
S(SessionStatus.Failed, groupTag: "TEST"),
|
||||
S(SessionStatus.Failed, groupTag: " test "),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
S(SessionStatus.InProgress),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
Assert.Equal(new[] { "TEST", "No group tag", "PROD" }.OrderBy(l => l), payload.GroupTagHealth.Select(g => g.Label).OrderBy(l => l));
|
||||
var test = payload.GroupTagHealth.Single(g => g.GroupTag == "TEST");
|
||||
Assert.Equal((3, 1, 2), (test.Total, test.Succeeded, test.Failed));
|
||||
Assert.Equal(string.Empty, payload.GroupTagHealth.Single(g => g.Label == "No group tag").GroupTag);
|
||||
Assert.Equal("TEST", payload.GroupTagHealth[0].GroupTag); // by volume
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_TakesExcludedTagsOutOfEveryFigure_ButKeepsThemFlaggedInTheBreakdown()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Failed, groupTag: "TEST", model: "Lab VM", failureReason: "lab"),
|
||||
S(SessionStatus.Failed, groupTag: "test", model: "Lab VM", failureReason: "lab"),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
S(SessionStatus.Succeeded),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter("Test"));
|
||||
|
||||
Assert.Equal(2, payload.ExcludedSessions);
|
||||
Assert.Equal(new[] { "Test" }, payload.ExcludedGroupTags);
|
||||
Assert.False(payload.ExcludeUntagged);
|
||||
Assert.Equal(2, payload.Stats.Total);
|
||||
Assert.Equal(100d, payload.Stats.SuccessRate);
|
||||
Assert.Empty(payload.FailureReasons);
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Lab VM");
|
||||
Assert.Equal(0, payload.DailyData.Sum(d => d.Failed));
|
||||
var excluded = Assert.Single(payload.GroupTagHealth, g => g.Excluded);
|
||||
Assert.Equal(("TEST", 2), (excluded.GroupTag, excluded.Total));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ExcludeUntagged_TakesTheSessionsWithoutATagOut()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Failed),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter(null, excludeUntagged: true));
|
||||
|
||||
Assert.Equal(1, payload.ExcludedSessions);
|
||||
Assert.True(payload.ExcludeUntagged);
|
||||
Assert.Equal(100d, payload.Stats.SuccessRate);
|
||||
Assert.True(payload.GroupTagHealth.Single(g => g.GroupTag.Length == 0).Excluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TrimsDeduplicatesAndCapsTheList()
|
||||
{
|
||||
Assert.True(FleetGroupTagFilter.TryParse(" TEST , test,,LAB ", null, out var filter, out _));
|
||||
Assert.Equal(new[] { "TEST", "LAB" }, filter.ExcludedTags);
|
||||
Assert.True(filter.IsActive);
|
||||
|
||||
Assert.True(FleetGroupTagFilter.TryParse(null, null, out var none, out _));
|
||||
Assert.False(none.IsActive);
|
||||
|
||||
var tooMany = string.Join(",", Enumerable.Range(0, FleetGroupTagFilter.MaxExcludedTags + 1).Select(i => $"T{i}"));
|
||||
Assert.False(FleetGroupTagFilter.TryParse(tooMany, null, out _, out var error));
|
||||
Assert.Contains("at most", error);
|
||||
|
||||
Assert.False(FleetGroupTagFilter.TryParse(new string('x', 300), null, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyInput_YieldsZeroedStats_AndOneDailyPointPerDay()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -34,10 +35,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// — and that forces a deliberate code review of whether the new field is non-sensitive.
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// Non-sensitive by review: booleans only, the timestamps stay in the admin-gated config.
|
||||
"addOnPermissionMissingBlocksAll",
|
||||
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
|
||||
// self-service migration banner).
|
||||
"appHomingFunnelActive",
|
||||
"bootstrapTokenEnabled",
|
||||
"cloudPcPermissionMissing",
|
||||
// Non-sensitive by review: bool only ("is a company name set"), never the value
|
||||
// itself (admin-gated full config only).
|
||||
"companyNameSet",
|
||||
@@ -55,6 +59,10 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"entitlements",
|
||||
// Non-sensitive by review: booleans only; the Entra code stays in the admin-gated config.
|
||||
"graphAccessAppMissing",
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
@@ -65,6 +73,122 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
}, fieldNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_GraphAccess_SuspicionAloneShowsNothing()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
GraphAccessRejectedSince = Now.AddMinutes(-5),
|
||||
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EntraTokenRejection.ServicePrincipalMissingCode, true)]
|
||||
[InlineData("AADSTS53003", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Payload_GraphAccess_ConfirmedShowsTheNotice_AndNamesOnlyTheMissingApp(string? code, bool appMissing)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
GraphAccessRejectedSince = Now.AddHours(-1),
|
||||
GraphAccessRejectedCode = code,
|
||||
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.Equal(appMissing, element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
Assert.DoesNotContain("AADSTS", element.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_GraphAccess_StaysSilentWithoutDeviceValidation()
|
||||
{
|
||||
// The switch being off has its own banner; the agent does not depend on Graph then.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
GraphAccessRejectedSince = Now.AddHours(-1),
|
||||
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
|
||||
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_SuspicionAloneShowsNothing()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddMinutes(-5),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_ConfirmedShowsTheNotice_PerValidation()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
ValidateCloudPcDevice = true,
|
||||
CloudPcPermissionMissingSince = Now.AddHours(-1),
|
||||
CloudPcPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("cloudPcPermissionMissing").GetBoolean());
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
// Autopilot still admits its devices.
|
||||
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_SwitchingTheValidationOffHidesTheNoticeAtOnce()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Only the binding is on: no device can be validated.
|
||||
[InlineData(false, false, true)]
|
||||
// The Cloud PC check is on and still works for Cloud PCs.
|
||||
[InlineData(true, false, false)]
|
||||
// Both are on and both are confirmed missing.
|
||||
[InlineData(true, true, true)]
|
||||
public void Payload_AddOn_BlocksAllOnlyWhenNoOtherValidationAdmits(bool cloudPcOn, bool cloudPcConfirmed, bool blocksAll)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
ValidateCloudPcDevice = cloudPcOn,
|
||||
CloudPcPermissionMissingSince = cloudPcConfirmed ? Now.AddHours(-1) : null,
|
||||
CloudPcPermissionMissingConfirmedAt = cloudPcConfirmed ? Now.AddMinutes(-45) : null,
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
Assert.Equal(blocksAll, element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_PassesThroughExplicitFieldValues()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two stages of a lost Graph access: one refusal only stamps a suspicion, a refusal at least
|
||||
/// 15 minutes later confirms it and alerts once, a freshly issued token ends the episode. Runs on
|
||||
/// the real configuration service over a stored row, so the storage round trip of the three fields
|
||||
/// and the decision on the fresh row are part of what is proven.
|
||||
/// </summary>
|
||||
public class GraphAccessHealthRecorderTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTimeOffset Start = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
|
||||
private static readonly EntraTokenRejection MissingApp = new(401, EntraTokenRejection.ServicePrincipalMissingCode, "missing");
|
||||
private static readonly EntraTokenRejection ConditionalAccess = new(400, "AADSTS53003", "blocked");
|
||||
private static readonly EntraTokenRejection ExpiredSecret = new(401, "AADSTS7000222", "expired");
|
||||
private static readonly string[] CoreRoles = { GraphAppPermissions.DeviceManagementServiceConfigReadAll };
|
||||
private static readonly string[] CoreAndBindingRoles =
|
||||
{ GraphAppPermissions.DeviceManagementServiceConfigReadAll, GraphAppPermissions.DeviceManagementManagedDevicesReadAll };
|
||||
private static readonly IReadOnlyList<AddOnValidation> Binding = new[] { AddOnValidation.IntuneDeviceBinding };
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly TestTimeProvider _time = new(Start);
|
||||
private readonly GraphAccessHealthRecorder _sut;
|
||||
|
||||
public GraphAccessHealthRecorderTests()
|
||||
{
|
||||
_store = _repo.BackWithRow(ValidatedRow());
|
||||
_sut = NewInstance();
|
||||
}
|
||||
|
||||
private static TenantConfiguration ValidatedRow()
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ValidateAutopilotDevice = true;
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>A backend instance of its own: its own config cache over the same stored row.</summary>
|
||||
private GraphAccessHealthRecorder NewInstance()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => _events.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
|
||||
return new GraphAccessHealthRecorder(
|
||||
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, _time);
|
||||
}
|
||||
|
||||
private void Advance(TimeSpan by) => _time.SetUtcNow(_time.GetUtcNow() + by);
|
||||
|
||||
// ── Rejected: the two stages ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task First_rejection_only_stamps_the_suspicion()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Further_rejections_inside_the_window_write_nothing()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(TimeSpan.FromMinutes(14));
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Single(_store.Writes);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejection_after_the_window_confirms_and_alerts_once()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, ConditionalAccess);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(2, _store.Writes.Count);
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row.GraphAccessRejectionConfirmedAt);
|
||||
// The code of the latest refusal counts: it selects the text and the repair.
|
||||
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, alert.Severity);
|
||||
Assert.Equal(TenantId, alert.TenantId);
|
||||
Assert.Contains(EntraTokenRejection.ServicePrincipalMissingCode, alert.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Across_instances_only_one_confirms_and_alerts()
|
||||
{
|
||||
var other = NewInstance();
|
||||
// The other instance holds a view from before the suspicion.
|
||||
await other.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await other.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Single(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lost_write_alerts_nothing_and_the_next_rejection_decides_again()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
_store.RejectWrites = true;
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
|
||||
_store.RejectWrites = false;
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Single(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_runs_through_the_same_two_stages()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
|
||||
Assert.Equal("NoApplicationRole", _store.Row!.GraphAccessRejectedCode);
|
||||
Assert.Empty(_events);
|
||||
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_device_validation_stays_untouched()
|
||||
{
|
||||
_store.Row = TenantConfiguration.CreateDefault(TenantId);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_a_row_stays_without_one()
|
||||
{
|
||||
_store.Row = null;
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// ── Our own credentials ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Our_expired_secret_never_touches_the_tenant_and_alerts_once_per_hour()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
Advance(TimeSpan.FromMinutes(59));
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAppCredentialRejected, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Critical, alert.Severity);
|
||||
|
||||
Advance(TimeSpan.FromMinutes(2));
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
|
||||
Assert.Equal(2, _events.Count);
|
||||
}
|
||||
|
||||
// ── Minted ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_writes_nothing_without_a_state()
|
||||
{
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_clears_a_suspicion_silently()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_ends_a_confirmed_episode_with_one_recovery_event()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
_events.Clear();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
var recovery = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRecovered, recovery.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, recovery.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_a_recovery_a_new_episode_starts_at_the_first_stage()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(_time.GetUtcNow().UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
// ── Add-on validations: the same two stages per validation ─────────────
|
||||
|
||||
private void SwitchOn(bool binding = true, bool cloudPc = false, bool autopilot = true)
|
||||
{
|
||||
_store.Row!.ValidateAutopilotDevice = autopilot;
|
||||
_store.Row.ValidateIntuneDeviceBinding = binding;
|
||||
_store.Row.ValidateCloudPcDevice = cloudPc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_device_kept_out_only_stamps_the_addon_suspicion()
|
||||
{
|
||||
SwitchOn();
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_kept_out_after_the_window_confirms_and_alerts_once()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(14));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(1));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Equal(2, _store.Writes.Count);
|
||||
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
|
||||
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, alert.Details);
|
||||
Assert.Contains(GraphAppPermissions.DeviceManagementManagedDevicesReadAll, alert.Details);
|
||||
Assert.Contains("\"blocksAll\":false", alert.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_addon_runs_its_own_episode()
|
||||
{
|
||||
SwitchOn(binding: true, cloudPc: true);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(10));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc });
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding });
|
||||
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.NotNull(_store.Row.CloudPcPermissionMissingSince);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_switched_off_validation_is_never_stamped()
|
||||
{
|
||||
SwitchOn(binding: false);
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_only_validation_reports_that_it_blocks_all()
|
||||
{
|
||||
SwitchOn(binding: true, autopilot: false);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Contains("\"blocksAll\":true", Assert.Single(_events).Details);
|
||||
Assert.True(AddOnValidation.BlocksAllValidation(_store.Row!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_with_the_permission_ends_a_suspicion_silently()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_with_the_permission_ends_a_confirmed_episode_with_one_event()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
_events.Clear();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, resolved.Severity);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_without_the_permission_confirms_only_after_the_window()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
Assert.Single(_store.Writes);
|
||||
|
||||
Advance(TimeSpan.FromMinutes(10));
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_without_an_addon_episode_writes_nothing()
|
||||
{
|
||||
SwitchOn();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_the_validation_off_ends_a_confirmed_episode_once()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
_events.Clear();
|
||||
_store.Row!.ValidateIntuneDeviceBinding = false;
|
||||
|
||||
// The maintenance run reads fresh, like another instance.
|
||||
var maintenance = NewInstance();
|
||||
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconciling_leaves_an_episode_alone_while_its_switch_is_on()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
await NewInstance().ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Single(_store.Writes);
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
}
|
||||
|
||||
// ── Fail-soft: the agent route must not notice ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_never_reaches_the_caller()
|
||||
{
|
||||
_store.ThrowOnRead = new InvalidOperationException("storage down");
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
await _sut.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_write_never_reaches_the_caller()
|
||||
{
|
||||
_store.ThrowOnWrite = new InvalidOperationException("storage down");
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance run asks Entra for every tenant with a suspicion, so the state does not depend on
|
||||
/// device traffic. Runs the real token service and recorder; only Entra's answer is scripted.
|
||||
/// </summary>
|
||||
public class GraphAccessRecheckMaintenanceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
|
||||
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
|
||||
private static readonly DateTimeOffset Now = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
|
||||
private static readonly string TokenBody = TestGraphToken.Body(TestGraphToken.ValidationRole);
|
||||
|
||||
private readonly ScriptedEntra _entra = new();
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly GraphTokenService _tokens;
|
||||
private readonly GraphAccessRecheckMaintenance _sut;
|
||||
|
||||
public GraphAccessRecheckMaintenanceTests()
|
||||
{
|
||||
_store = _repo.BackWithRow(ValidatedRow());
|
||||
_repo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(() => _store.Row == null
|
||||
? new List<TenantConfiguration>()
|
||||
: new List<TenantConfiguration> { ConfigRepoMockExtensions.Clone(_store.Row) });
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => _events.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
var recorder = new GraphAccessHealthRecorder(
|
||||
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, new TestTimeProvider(Now));
|
||||
|
||||
_tokens = NewTokenService(new SingleHandlerFactory(_entra), recorder, tenantConfig);
|
||||
_sut = new GraphAccessRecheckMaintenance(_repo.Object, _tokens, recorder, NullLogger<GraphAccessRecheckMaintenance>.Instance);
|
||||
}
|
||||
|
||||
private static GraphTokenService NewTokenService(
|
||||
IHttpClientFactory factory, IGraphAccessHealthRecorder recorder, TenantConfigurationService tenantConfig)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
return new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance),
|
||||
tenantConfig,
|
||||
recorder);
|
||||
}
|
||||
|
||||
private static TenantConfiguration ValidatedRow()
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ValidateAutopilotDevice = true;
|
||||
return row;
|
||||
}
|
||||
|
||||
private void Suspect(TimeSpan age, string code = EntraTokenRejection.ServicePrincipalMissingCode)
|
||||
{
|
||||
_store.Row!.GraphAccessRejectedSince = Now.UtcDateTime - age;
|
||||
_store.Row.GraphAccessRejectedCode = code;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirms_an_old_suspicion_without_device_traffic()
|
||||
{
|
||||
// A real 401 goes into the retry delay, which the budget cuts: the refusal must survive it.
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.Unauthorized, MissingAppBody);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(Now.UtcDateTime, _store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_young_suspicion_stays_a_suspicion()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(10));
|
||||
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clears_the_notice_after_a_repair_made_directly_in_Entra()
|
||||
{
|
||||
Suspect(TimeSpan.FromHours(5));
|
||||
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRecovered, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_keeps_the_notice_up()
|
||||
{
|
||||
// A user's sign-in re-created the removed enterprise application without any permission.
|
||||
Suspect(TimeSpan.FromHours(5));
|
||||
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
|
||||
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.NotNull(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_confirms_an_old_suspicion()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal("NoApplicationRole", _store.Row.GraphAccessRejectedCode);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cached_token_cannot_answer_the_question()
|
||||
{
|
||||
// The token this instance still holds stays valid after the enterprise app is gone.
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
await _tokens.GetAccessTokenAsync(TenantId);
|
||||
Assert.Equal(1, _entra.Posts);
|
||||
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Equal(2, _entra.Posts);
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_answer_from_Entra_decides_nothing()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.ServiceUnavailable, "{}");
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_a_suspicion_is_not_asked()
|
||||
{
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_that_switched_device_validation_off_is_not_asked()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_store.Row!.ValidateAutopilotDevice = false;
|
||||
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_read_ends_the_run_quietly()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_failing_tenant_does_not_stop_the_next()
|
||||
{
|
||||
// The stub throws on the unscripted first tenant, which the token service does not catch.
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var first = ValidatedRow();
|
||||
first.GraphAccessRejectedSince = Now.UtcDateTime;
|
||||
var second = ValidatedRow();
|
||||
second.TenantId = OtherTenantId;
|
||||
second.GraphAccessRejectedSince = Now.UtcDateTime;
|
||||
repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration> { first, second });
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var handler = new StubHttpMessageHandler().When($"/{OtherTenantId}/oauth2", HttpStatusCode.OK, TokenBody);
|
||||
var tenantConfig = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
var sut = new GraphAccessRecheckMaintenance(
|
||||
repo.Object, NewTokenService(new StubHttpClientFactory(handler), recorder.Object, tenantConfig), recorder.Object,
|
||||
NullLogger<GraphAccessRecheckMaintenance>.Instance);
|
||||
|
||||
Assert.Equal(2, await sut.RunAsync());
|
||||
|
||||
recorder.Verify(r => r.RecordMintedAsync(OtherTenantId, It.IsAny<IReadOnlyCollection<string>>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Add-on episodes ─────────────────────────────────────────────────────
|
||||
|
||||
private void AddOnSuspect(TimeSpan age, TimeSpan? confirmedAgo = null, bool switchedOn = true)
|
||||
{
|
||||
_store.Row!.ValidateIntuneDeviceBinding = switchedOn;
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingSince = Now.UtcDateTime - age;
|
||||
if (confirmedAgo is { } ago)
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = Now.UtcDateTime - ago;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirms_an_old_addon_suspicion_with_a_token_that_lacks_the_permission()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(Now.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ends_an_addon_episode_once_the_grant_arrives()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4));
|
||||
_entra.Answer(HttpStatusCode.OK,
|
||||
TestGraphToken.Body(TestGraphToken.ValidationRole, GraphAppPermissions.DeviceManagementManagedDevicesReadAll));
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_switched_off_addon_ends_without_asking_Entra()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4), switchedOn: false);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
|
||||
}
|
||||
|
||||
/// <summary>Entra's token endpoint with an answer that can change between calls.</summary>
|
||||
private sealed class ScriptedEntra : HttpMessageHandler
|
||||
{
|
||||
private HttpStatusCode _status = HttpStatusCode.OK;
|
||||
private string _body = "{}";
|
||||
|
||||
public int Posts { get; private set; }
|
||||
|
||||
public void Answer(HttpStatusCode status, string body)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Posts++;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class GraphAuthFailureTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
return new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -342,7 +342,8 @@ public class GraphFeatureDetectorTests
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
new MemoryCache(new MemoryCacheOptions())),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>());
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
|
||||
var detector = new GraphFeatureDetector(
|
||||
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
|
||||
@@ -497,7 +498,8 @@ public class GraphFeatureDetectorTests
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
StubTenantConfigService(),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -529,7 +531,8 @@ public class GraphFeatureDetectorTests
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
StubTenantConfigService(),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{
|
||||
_firstToken = firstToken;
|
||||
_laterToken = laterToken;
|
||||
@@ -567,7 +570,8 @@ public class GraphFeatureDetectorTests
|
||||
factory,
|
||||
cache,
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
StubTenantConfigService(),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{
|
||||
_onInvalidate = onInvalidate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
|
||||
/// <summary>What Entra's token endpoint answers on success, with the application roles of the tenant's service principal.</summary>
|
||||
internal static class TestGraphToken
|
||||
{
|
||||
public const string ValidationRole = "DeviceManagementServiceConfig.Read.All";
|
||||
|
||||
public static string Body(params string[] roles)
|
||||
{
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: "https://sts.windows.net/test",
|
||||
audience: "https://graph.microsoft.com",
|
||||
claims: roles.Select(r => new Claim("roles", r)),
|
||||
notBefore: null,
|
||||
expires: DateTime.UtcNow.AddHours(1));
|
||||
return $"{{\"access_token\":\"{new JwtSecurityTokenHandler().WriteToken(jwt)}\",\"expires_in\":3599}}";
|
||||
}
|
||||
}
|
||||
@@ -43,16 +43,21 @@ public class GraphTokenServiceTests
|
||||
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
|
||||
/// lookup is never consulted for the decision — a null-returning repo is fine.
|
||||
/// </summary>
|
||||
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
|
||||
private static GraphTokenService NewService(
|
||||
IConfiguration config,
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
IGraphAccessHealthRecorder? accessHealth = null,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
|
||||
new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
logger ?? NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
new MemoryCache(new MemoryCacheOptions())),
|
||||
accessHealth ?? Moq.Mock.Of<IGraphAccessHealthRecorder>());
|
||||
|
||||
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
|
||||
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
|
||||
@@ -240,7 +245,8 @@ public class GraphTokenServiceTests
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
new MemoryCache(new MemoryCacheOptions())),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>());
|
||||
|
||||
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
@@ -260,6 +266,196 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(3, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
// ── Entra's refusal ─────────────────────────────────────────────────────
|
||||
|
||||
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
|
||||
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
|
||||
|
||||
private static GraphTokenService ServiceFor(
|
||||
StubHttpMessageHandler handler,
|
||||
Moq.Mock<IGraphAccessHealthRecorder> accessHealth,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
return NewService(config, new StubHttpClientFactory(handler), accessHealth.Object, logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejection_survives_a_budget_that_ends_the_retries()
|
||||
{
|
||||
// The validators run on 8 s and the recheck on 3 s; the chain's first retry waits 5 s.
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.Unauthorized, MissingAppBody);
|
||||
var (svc, _) = Create(handler);
|
||||
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
|
||||
|
||||
Assert.Null(result.AccessToken);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.NotNull(result.Rejection);
|
||||
Assert.Equal(401, result.Rejection!.StatusCode);
|
||||
Assert.True(result.Rejection.IsServicePrincipalMissing);
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.RequestTimeout)]
|
||||
public async Task A_server_error_or_throttle_is_no_rejection(HttpStatusCode status)
|
||||
{
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", status, MissingAppBody);
|
||||
var (svc, _) = Create(handler);
|
||||
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
|
||||
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Null(result.Rejection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_reports_a_rejection()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
|
||||
|
||||
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.False(result.IsTransient);
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, Moq.It.Is<EntraTokenRejection>(r => r.ErrorCode == "AADSTS53003")), Moq.Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_portal_path_does_not_report_a_rejection()
|
||||
{
|
||||
// The consent flows see refusals as a matter of course while a consent propagates.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_a_fresh_token_counts_as_proof_of_access()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)), health);
|
||||
|
||||
var minted = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
var cached = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.True(minted.IsFreshlyMinted);
|
||||
Assert.False(cached.IsFreshlyMinted);
|
||||
// The roles travel along: they decide the add-on episodes.
|
||||
health.Verify(h => h.RecordMintedAsync(TenantA, Moq.It.Is<IReadOnlyCollection<string>>(r => r.Contains(TestGraphToken.ValidationRole))), Moq.Times.Once);
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_is_no_proof_of_access()
|
||||
{
|
||||
// A sign-in consent re-created the removed enterprise application without any permission.
|
||||
// The notice must survive it: this is the moment an admin signs in to read it.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_counts_a_token_without_a_role_as_a_refusal()
|
||||
{
|
||||
// Graph refuses every call made with it, so the agent is locked out all the same.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.NotNull(result.AccessToken);
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
|
||||
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_does_not_judge_a_token_from_the_cache()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_probe_under_explicit_credentials_reports_nothing()
|
||||
{
|
||||
// A token of the app the tenant is not homed on says nothing about the tenant's access.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
foreach (var (status, body) in new[]
|
||||
{
|
||||
(HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)),
|
||||
(HttpStatusCode.OK, TestGraphToken.Body()),
|
||||
(HttpStatusCode.BadRequest, ConditionalAccessBody),
|
||||
})
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = NewService(config, new StubHttpClientFactory(new StubHttpMessageHandler().When($"/{TenantA}/oauth2", status, body)), health.Object);
|
||||
|
||||
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
|
||||
health.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entra_refusal_is_logged_once_per_window()
|
||||
{
|
||||
var logger = new Moq.Mock<Microsoft.Extensions.Logging.ILogger<GraphTokenService>>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody),
|
||||
new Moq.Mock<IGraphAccessHealthRecorder>(), logger.Object);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
logger.Verify(l => l.Log(
|
||||
Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.IsAny<Exception?>(),
|
||||
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the group-tag search filter and for the device-snapshot path's in-memory twin of the
|
||||
/// OData push-down: a filter must answer the same on both search paths.
|
||||
/// </summary>
|
||||
public class GroupTagSearchFilterTests
|
||||
{
|
||||
private static SessionSummary Session(string? groupTag = null) => new()
|
||||
{
|
||||
SessionId = "s", TenantId = "t", AutopilotGroupTag = groupTag,
|
||||
Manufacturer = "Contoso", Model = "EliteBook 840", EnrollmentType = "v1",
|
||||
DeviceName = "LAB-01", OsBuild = "26100.1000", Status = SessionStatus.Failed,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("TEST", true)]
|
||||
[InlineData("test", true)] // Intune does not fix the casing of a tag
|
||||
[InlineData(" Test ", true)]
|
||||
[InlineData("TEST-LAB", false)] // exact, not prefix
|
||||
[InlineData(null, false)] // session without a recorded tag
|
||||
public void GroupTagFilter_IsCaseInsensitiveAndExact(string? sessionTag, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { GroupTag = "Test" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesGroupTag(Session(sessionTag), filter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoGroupTagFilter_MatchesEverySession()
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter()));
|
||||
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter { GroupTag = " " }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Manufacturer", "Contoso", true)]
|
||||
[InlineData("Manufacturer", "contoso", false)] // OData eq is ordinal: the twin is too
|
||||
[InlineData("Model", "EliteBook 840", true)]
|
||||
[InlineData("Model", "EliteBook", false)]
|
||||
[InlineData("EnrollmentType", "v2", false)]
|
||||
[InlineData("DeviceName", "LAB", true)] // prefix, like the ge/lt range
|
||||
[InlineData("DeviceName", "PC", false)]
|
||||
[InlineData("OsBuild", "26100", true)]
|
||||
[InlineData("OsBuild", "22631", false)]
|
||||
[InlineData("Status", "Failed", true)]
|
||||
[InlineData("Status", "Succeeded", false)]
|
||||
public void DeviceSnapshotPath_AppliesThePushedDownColumns(string column, string value, bool expected)
|
||||
{
|
||||
var filter = column switch
|
||||
{
|
||||
"Manufacturer" => new SessionSearchFilter { Manufacturer = value },
|
||||
"Model" => new SessionSearchFilter { Model = value },
|
||||
"EnrollmentType" => new SessionSearchFilter { EnrollmentType = value },
|
||||
"DeviceName" => new SessionSearchFilter { DeviceName = value },
|
||||
"OsBuild" => new SessionSearchFilter { OsBuild = value },
|
||||
_ => new SessionSearchFilter { Status = value },
|
||||
};
|
||||
Assert.Equal(expected, TableStorageService.MatchesPushedDownColumns(Session(), filter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsTheGroupTag()
|
||||
{
|
||||
// A continuation token minted for one group tag must not be replayed against another.
|
||||
var a = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "TEST" });
|
||||
var b = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "PROD" });
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public class IntuneEnrollmentValidationTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokens = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
@@ -290,7 +290,8 @@ public class IntuneEnrollmentValidationTests
|
||||
|
||||
// ------------------------------------------------------------------ SecurityValidator chain
|
||||
|
||||
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
|
||||
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(
|
||||
TenantConfiguration config, IGraphAccessHealthRecorder? accessHealth = null, CloudPcDeviceValidator? cloudPc = null)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
@@ -318,7 +319,9 @@ public class IntuneEnrollmentValidationTests
|
||||
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
|
||||
NullLogger.Instance,
|
||||
intuneDeviceBindingValidator: binding.Object);
|
||||
cloudPcDeviceValidator: cloudPc,
|
||||
intuneDeviceBindingValidator: binding.Object,
|
||||
accessHealth: accessHealth);
|
||||
return (validator, binding);
|
||||
}
|
||||
|
||||
@@ -407,6 +410,118 @@ public class IntuneEnrollmentValidationTests
|
||||
Assert.Equal(retryAfter, result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ missing add-on permission
|
||||
|
||||
private static Mock<CloudPcDeviceValidator> CloudPcAnswering(CloudPcValidationResult result)
|
||||
{
|
||||
var cloudPc = new Mock<CloudPcDeviceValidator>(
|
||||
NullLogger<CloudPcDeviceValidator>.Instance, Mock.Of<IHttpClientFactory>(),
|
||||
new MemoryCache(new MemoryCacheOptions()), null!)
|
||||
{ CallBase = false };
|
||||
cloudPc.Setup(c => c.ValidateCloudPcAsync(CertTenant, DeviceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
return cloudPc;
|
||||
}
|
||||
|
||||
private static readonly CloudPcValidationResult CloudPcPermissionMissing =
|
||||
new() { IsValid = false, PermissionMissing = true, ErrorMessage = "not permitted" };
|
||||
|
||||
private static Task<SecurityValidationResult> Validate(SecurityValidator validator, bool selfReportedCloudPc = false)
|
||||
=> validator.ValidateRequestAsync(CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant,
|
||||
selfReportedCloudPc: selfReportedCloudPc);
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_MissingBindingPermission_ReportsTheAddOn_AndKeepsTheResponse()
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (withRecorder, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
var (withoutRecorder, bindingWithout) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
SetupAdmitting(bindingWithout, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
|
||||
var reported = await Validate(withRecorder);
|
||||
var silent = await Validate(withoutRecorder);
|
||||
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.IntuneDeviceBinding)), Times.Once);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, reported.StatusCode);
|
||||
Assert.Equal(silent.StatusCode, reported.StatusCode);
|
||||
Assert.Equal(silent.RetryAfterSeconds, reported.RetryAfterSeconds);
|
||||
Assert.Equal(silent.Details, reported.Details);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.Transient)]
|
||||
public async Task Chain_OtherBindingOutcomes_ReportNothing(IntuneDeviceBindingOutcome outcome)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "x" });
|
||||
|
||||
await Validate(validator);
|
||||
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // a laptop not (yet) in Autopilot or a Hyper-V test VM: the grant would not admit it either
|
||||
public async Task Chain_MissingCloudPcPermission_KeepsThe403_AndReportsOnlyASelfReportedCloudPc(bool selfReportedCloudPc)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, _) = BuildChain(new TenantConfiguration { ValidateCloudPcDevice = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
|
||||
var result = await Validate(validator, selfReportedCloudPc);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Contains("not permitted", result.Details);
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.CloudPc)),
|
||||
selfReportedCloudPc ? Times.Once() : Times.Never());
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_MissingCloudPcPermission_ReportsNothing_WhenTheBindingAdmits()
|
||||
{
|
||||
// The device got in: the missing grant had no consequence.
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(
|
||||
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId });
|
||||
|
||||
var result = await Validate(validator);
|
||||
|
||||
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // a Hyper-V VM outside Autopilot: only the binding's grant would admit it
|
||||
public async Task Chain_BothPermissionsMissing_ReportsInChainOrder_CloudPcOnlyWhenSelfReported(bool selfReportedCloudPc)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(
|
||||
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
AddOnValidation[] expected = selfReportedCloudPc
|
||||
? [AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding]
|
||||
: [AddOnValidation.IntuneDeviceBinding];
|
||||
|
||||
var result = await Validate(validator, selfReportedCloudPc);
|
||||
|
||||
// The binding's missing grant keeps the device waiting rather than rejected (D-282).
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.SequenceEqual(expected))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Maintenance;
|
||||
using Xunit;
|
||||
@@ -7,6 +8,18 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class MaintenanceStepTimerTests
|
||||
{
|
||||
private const long Mb = 1024 * 1024;
|
||||
|
||||
private static MaintenanceStepTimer TimerOver(params (long PeakMb, long AllocatedMb)[] readings)
|
||||
{
|
||||
var queue = new Queue<(long PeakMb, long AllocatedMb)>(readings);
|
||||
return new MaintenanceStepTimer(() =>
|
||||
{
|
||||
var (peakMb, allocatedMb) = queue.Dequeue();
|
||||
return new MaintenanceMemoryReading(peakMb * Mb, allocatedMb * Mb, 1740 * Mb, 2088 * Mb);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Records_every_step_in_order_and_returns_the_body_result()
|
||||
{
|
||||
@@ -30,4 +43,49 @@ public class MaintenanceStepTimerTests
|
||||
|
||||
Assert.True(timer.Steps.ContainsKey("boom"));
|
||||
}
|
||||
|
||||
// The memory columns have to name the step that set the peak: each step records the process
|
||||
// peak after it ran and only the allocation of its own span.
|
||||
[Fact]
|
||||
public async Task Each_step_reports_the_process_peak_after_it_and_its_own_allocation()
|
||||
{
|
||||
var timer = TimerOver((400, 100), (400, 150), (400, 150), (1900, 1450), (1900, 1450));
|
||||
|
||||
await timer.RunAsync("light", () => Task.CompletedTask);
|
||||
await timer.RunAsync<int>("heavy", () => Task.FromResult(7));
|
||||
|
||||
var report = timer.MemoryReport();
|
||||
Assert.Equal(400, report.StepsPeakMb["light"]);
|
||||
Assert.Equal(50, report.StepsAllocMb["light"]);
|
||||
Assert.Equal(1900, report.StepsPeakMb["heavy"]);
|
||||
Assert.Equal(1300, report.StepsAllocMb["heavy"]);
|
||||
Assert.Equal(1740, report.GcHeapLimitMb);
|
||||
Assert.Equal(2088, report.GcHighLoadMb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_step_still_reports_its_memory()
|
||||
{
|
||||
var timer = TimerOver((300, 10), (800, 60), (800, 60));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
|
||||
|
||||
var report = timer.MemoryReport();
|
||||
Assert.Equal(800, report.StepsPeakMb["boom"]);
|
||||
Assert.Equal(50, report.StepsAllocMb["boom"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_real_reading_sees_the_process_peak_and_the_gc_limits()
|
||||
{
|
||||
GC.Collect();
|
||||
|
||||
var reading = MaintenanceMemoryReading.Capture();
|
||||
|
||||
Assert.True(reading.PeakWorkingSetBytes > 0);
|
||||
Assert.True(reading.AllocatedBytes > 0);
|
||||
Assert.True(reading.GcHeapLimitBytes > 0);
|
||||
Assert.True(reading.GcHighLoadBytes > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,10 +435,10 @@ public class NotificationChannelHealthTests
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(webhookResult);
|
||||
}
|
||||
var telegram = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
|
||||
return new NotificationChannelDispatcher(webhook.Object, telegram.Object, (push ?? new Mock<IPushChannelSender>()).Object,
|
||||
Mock.Of<IEmailChannelSender>(), recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
email.Setup(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
|
||||
return new NotificationChannelDispatcher(webhook.Object, (push ?? new Mock<IPushChannelSender>()).Object,
|
||||
email.Object, recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -447,13 +447,13 @@ public class NotificationChannelHealthTests
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = Dispatcher(recorder, push: push);
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"), It.Is<NotificationSendResult>(x => !x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(It.IsAny<NotificationScope>(), It.Is<NotificationChannel>(c => c.Id == "push"), It.IsAny<NotificationSendResult>(), It.IsAny<ChannelSendKind>()), Times.Never);
|
||||
push.Verify(p => p.SendAsync(Tenant, It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
@@ -474,13 +474,13 @@ public class NotificationChannelHealthTests
|
||||
{
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var dispatcher = Dispatcher(recorder, webhookResult: () => throw new InvalidOperationException("boom"));
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"),
|
||||
It.Is<NotificationSendResult>(x => !x.Success && x.Message == "Unexpected error: boom"), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
|
||||
}
|
||||
|
||||
// ── Read endpoint projection ──────────────────────────────────────────
|
||||
|
||||
@@ -36,6 +36,7 @@ public class NotificationChannelsTests
|
||||
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
|
||||
ChannelJson("none-provider", providerType: 0),
|
||||
ChannelJson("retired-teams-connector", providerType: 1),
|
||||
ChannelJson("retired-provider", providerType: 40),
|
||||
ChannelJson("unknown-provider", providerType: 99)) + "]";
|
||||
|
||||
var parsed = NotificationChannel.ParseList(json);
|
||||
@@ -153,6 +154,20 @@ public class NotificationChannelsTests
|
||||
Assert.Null(redacted[1].SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopy_MasksTheRecipientsOfAnEmailChannel()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
NotificationChannelsJson = "[" + ChannelJson("mail", providerType: 60, url: "ops@example.invalid") + "]",
|
||||
};
|
||||
|
||||
var redacted = NotificationChannel.ParseList(cfg.RedactedCopyForReader().NotificationChannelsJson);
|
||||
|
||||
Assert.Equal("mail", Assert.Single(redacted).Id);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopy_UnparseableChannelsJson_FallsBackToWholeStringRedaction()
|
||||
{
|
||||
@@ -218,6 +233,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":40}]", "invalid provider type")] // retired, never reused
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
@@ -238,6 +254,7 @@ public class NotificationChannelsTests
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)] // retired Teams Office 365 Connector
|
||||
[InlineData(40, false)] // retired, never reused
|
||||
[InlineData(99, false)]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
@@ -257,8 +274,6 @@ public class NotificationChannelsTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40, false)] // Telegram
|
||||
[InlineData(40, true)]
|
||||
[InlineData(50, false)] // Push
|
||||
[InlineData(50, true)]
|
||||
[InlineData(60, false)] // E-mail
|
||||
@@ -275,7 +290,6 @@ public class NotificationChannelsTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40)]
|
||||
[InlineData(50)]
|
||||
[InlineData(60)]
|
||||
public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType)
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers),
|
||||
/// readable facts (card + e-mail formats) and verbatim under "data" (generic JSON consumers),
|
||||
/// and only the channels whose rule opted in get it — on the platform scope, like every ops send.
|
||||
/// Before this, an alert carried only category/event/severity and a tenant GUID.
|
||||
/// </summary>
|
||||
@@ -117,10 +117,10 @@ public class OpsAlertPayloadTests
|
||||
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
|
||||
|
||||
// ── Telegram text carries the same information ────────────────────────
|
||||
// ── E-mail text carries the same information ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramRendering_IncludesFlattenedFacts()
|
||||
public void EmailRendering_IncludesFlattenedFacts()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
@@ -130,7 +130,7 @@ public class OpsAlertPayloadTests
|
||||
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
|
||||
};
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
|
||||
Assert.Contains("Domain Name: contoso.example", text);
|
||||
Assert.Contains("Pro trial started", text);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
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>
|
||||
/// The ops events behind a customer submission are the only signal an alert channel gets. They
|
||||
/// name ids and the portal path, never the submitter or a comment; the submitter stays in the
|
||||
/// row's user id, which the dispatch never forwards.
|
||||
/// </summary>
|
||||
public class OpsEventSubmissionSignalsTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Submitter = "someone@contoso.invalid";
|
||||
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly OpsEventService _sut;
|
||||
|
||||
public OpsEventSubmissionSignalsTests()
|
||||
{
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(_events.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
|
||||
_sut = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
}
|
||||
|
||||
private OpsEventEntry AssertSingleSignal(string eventType, string portalPath)
|
||||
{
|
||||
var entry = Assert.Single(_events);
|
||||
Assert.Equal(eventType, entry.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
|
||||
Assert.Equal(TenantId, entry.TenantId);
|
||||
Assert.Contains(portalPath, entry.Details!);
|
||||
|
||||
Assert.Equal(Submitter, entry.UserId);
|
||||
Assert.DoesNotContain(Submitter, entry.Message);
|
||||
Assert.DoesNotContain(Submitter, entry.Details!);
|
||||
return entry;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantSignup_names_the_tenant_page()
|
||||
{
|
||||
await _sut.RecordTenantSignupAsync(TenantId, Submitter);
|
||||
|
||||
AssertSingleSignal(OpsEventTypes.TenantSignup, $"/admin/tenants/management?tenantId={TenantId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionReport_names_the_session_and_the_report_only()
|
||||
{
|
||||
await _sut.RecordSessionReportSubmittedAsync(TenantId, Submitter, "session-1", "report-1");
|
||||
|
||||
var entry = AssertSingleSignal(OpsEventTypes.SessionReportSubmitted, "/admin/reports/session-reports?reportId=report-1");
|
||||
Assert.Contains("session-1", entry.Details!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagFilesReport_names_the_report_only()
|
||||
{
|
||||
await _sut.RecordDiagFilesReportSubmittedAsync(TenantId, Submitter, "report-2");
|
||||
|
||||
AssertSingleSignal(OpsEventTypes.DiagFilesReportSubmitted, "/admin/reports/session-reports?reportId=report-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleSubmission_names_the_submission_and_the_rule_only()
|
||||
{
|
||||
await _sut.RecordRuleSubmissionReceivedAsync(TenantId, Submitter, "abc123def456", "analyze", "ANALYZE-X-1");
|
||||
|
||||
var entry = AssertSingleSignal(OpsEventTypes.RuleSubmissionReceived, "/admin/reports/rule-submissions?submissionId=abc123def456");
|
||||
Assert.Contains("ANALYZE-X-1", entry.Details!);
|
||||
}
|
||||
}
|
||||
@@ -12,28 +12,12 @@ using Moq;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
|
||||
/// behavior survives the migration untouched, per-rule channel routing, the platform dispatch
|
||||
/// scope (an ops alert's tenant is its subject, never the channel owner), and redaction of the
|
||||
/// new secret-bearing field.
|
||||
/// Platform (ops) notification channels: the stored list, per-rule channel routing, the platform
|
||||
/// dispatch scope (an ops alert's tenant is its subject, never the channel owner), and redaction of
|
||||
/// the secret-bearing field.
|
||||
/// </summary>
|
||||
public class OpsNotificationChannelsTests
|
||||
{
|
||||
private static AdminConfiguration LegacyConfig(
|
||||
bool telegramEnabled = true, string telegramChat = "-1003785642894",
|
||||
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
|
||||
bool slackEnabled = false, string slackUrl = "")
|
||||
=> new()
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
OpsAlertTelegramEnabled = telegramEnabled,
|
||||
OpsAlertTelegramChatId = telegramChat,
|
||||
OpsAlertTeamsEnabled = teamsEnabled,
|
||||
OpsAlertTeamsWebhookUrl = teamsUrl,
|
||||
OpsAlertSlackEnabled = slackEnabled,
|
||||
OpsAlertSlackWebhookUrl = slackUrl,
|
||||
};
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
|
||||
|
||||
@@ -61,44 +45,20 @@ public class OpsNotificationChannelsTests
|
||||
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
|
||||
|
||||
// ── Legacy synthesis ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
|
||||
{
|
||||
var channels = LegacyConfig().GetOpsNotificationChannels();
|
||||
|
||||
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
|
||||
var telegram = channels[0];
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
|
||||
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
|
||||
Assert.Equal("-1003785642894", telegram.Url);
|
||||
Assert.True(telegram.Enabled);
|
||||
|
||||
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
|
||||
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
|
||||
{
|
||||
// A configured-but-disabled provider must stay disabled, or the migration would start
|
||||
// sending to a destination the operator had deliberately switched off.
|
||||
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
|
||||
|
||||
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
|
||||
}
|
||||
// ── Stored list ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
|
||||
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
|
||||
public void GetOpsNotificationChannels_ReturnsTheStoredList()
|
||||
{
|
||||
var config = LegacyConfig();
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "t",
|
||||
OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") }),
|
||||
};
|
||||
|
||||
var channels = config.GetOpsNotificationChannels();
|
||||
|
||||
|
||||
@@ -46,6 +46,25 @@ public class OrphanSweepOpsEventTests
|
||||
Assert.Contains("\"aggregateMetrics\":90000", evt.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaintenanceCompleted_carries_the_step_memory_and_the_gc_limits()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
var memory = new MaintenanceMemoryReport(
|
||||
new Dictionary<string, long> { ["appVersionRadar"] = 1900 },
|
||||
new Dictionary<string, long> { ["appVersionRadar"] = 1300 },
|
||||
GcHeapLimitMb: 1740,
|
||||
GcHighLoadMb: 2088);
|
||||
|
||||
await service.RecordMaintenanceCompletedAsync(123456, "Timer", memory: memory);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Contains("\"stepsPeakMb\":{\"appVersionRadar\":1900}", evt.Details);
|
||||
Assert.Contains("\"stepsAllocMb\":{\"appVersionRadar\":1300}", evt.Details);
|
||||
Assert.Contains("\"gcHeapLimitMb\":1740", evt.Details);
|
||||
Assert.Contains("\"gcHighLoadMb\":2088", evt.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanEventsCleaned_is_a_Warning_with_per_table_totals_and_the_legacy_count()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The memory metric is a percentage of the limits the GC works against, so a reading keeps its
|
||||
/// meaning when the instance size changes, and an alert can name one number for every size.
|
||||
/// </summary>
|
||||
public class ProcessMemoryPressureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Heap_is_committed_memory_against_the_heap_limit()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { CommittedBytes = 1305, TotalAvailableMemoryBytes = 1740 };
|
||||
|
||||
Assert.Equal(75.0, ProcessMemoryPressure.HeapPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_is_memory_load_against_the_high_load_threshold()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2088, HighMemoryLoadThresholdBytes = 2088 };
|
||||
|
||||
Assert.Equal(100.0, ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_value_past_the_limit_is_not_capped()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2320, HighMemoryLoadThresholdBytes = 2088 };
|
||||
|
||||
Assert.Equal(111.1, ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_first_gc_there_is_no_reading()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { WorkingSetBytes = 500 };
|
||||
|
||||
Assert.Null(ProcessMemoryPressure.HeapPercent(snapshot));
|
||||
Assert.Null(ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_captured_snapshot_after_a_gc_has_both_readings()
|
||||
{
|
||||
GC.Collect();
|
||||
|
||||
var snapshot = ProcessSnapshot.Capture();
|
||||
|
||||
Assert.True(ProcessMemoryPressure.HeapPercent(snapshot) > 0);
|
||||
Assert.True(ProcessMemoryPressure.LoadPercent(snapshot) > 0);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class ProcessStallClassifierTests
|
||||
PendingWorkItems: pending,
|
||||
CompletedWorkItems: completed,
|
||||
WorkingSetBytes: 0, HeapSizeBytes: 0, CommittedBytes: 0,
|
||||
TotalAvailableMemoryBytes: 0, MemoryLoadBytes: 0, HighMemoryLoadThresholdBytes: 0,
|
||||
LastGcGeneration: 0, LastGcPause: TimeSpan.Zero, LastGcCompacted: false);
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only Telegram and E-mail stay behind the GA gate — and one Push channel per scope),
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
@@ -35,15 +35,6 @@ public class PushChannelRoutingTests
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel PushChannel(string id = "push", bool enabled = true) =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled };
|
||||
|
||||
@@ -56,9 +47,8 @@ public class PushChannelRoutingTests
|
||||
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
|
||||
@@ -66,7 +56,6 @@ public class PushChannelRoutingTests
|
||||
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +64,7 @@ public class PushChannelRoutingTests
|
||||
// A pair stored before validation allowed only one: each would reach every paired device.
|
||||
var webhook = WebhookMock();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, TelegramMock().Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
var slack = new NotificationChannel { Id = "slack", Name = "slack", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x", Enabled = true };
|
||||
|
||||
@@ -94,7 +83,7 @@ public class PushChannelRoutingTests
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, push.Object);
|
||||
|
||||
var result = await dispatcher.SendTestAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
@@ -109,7 +98,6 @@ public class PushChannelRoutingTests
|
||||
Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination());
|
||||
Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination());
|
||||
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram));
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Email));
|
||||
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack));
|
||||
}
|
||||
@@ -127,23 +115,23 @@ public class PushChannelRoutingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram()
|
||||
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_email()
|
||||
{
|
||||
var json = Json(PushChannel());
|
||||
|
||||
// Add, disable, rename and toggle: all the tenant admin's own.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
|
||||
var renamed = PushChannel();
|
||||
renamed.Name = "Phones";
|
||||
renamed.NotifyOnFailure = true;
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(renamed), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(renamed), json, isGlobalAdmin: false));
|
||||
|
||||
// The same channel id turned into Telegram still needs a Global Administrator.
|
||||
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
Assert.Equal("Telegram channels can only be configured by a Global Administrator.",
|
||||
TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: true));
|
||||
// The same channel id turned into E-mail still needs a Global Administrator.
|
||||
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.",
|
||||
TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -5,9 +5,9 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the RebootCount range predicate used on every search path that does NOT push the
|
||||
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Without it a <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
|
||||
/// Cover for the RebootCount range predicate used on the search path that does NOT push the
|
||||
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Without it a
|
||||
/// <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
|
||||
/// with RebootCount < 5 (the medium finding). Min is inclusive (">= many reboots").
|
||||
/// </summary>
|
||||
public class RebootCountFilterBoundsTests
|
||||
|
||||
@@ -255,7 +255,7 @@ public class ReportsWireParityTests
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetDistressReportsFunction — { success, count, reports }
|
||||
// GetDistressReportsFunction — { success, count, reports, truncated }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
@@ -287,13 +287,15 @@ public class ReportsWireParityTests
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports
|
||||
reports,
|
||||
truncated = false
|
||||
},
|
||||
new DistressReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports
|
||||
Reports = reports,
|
||||
Truncated = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ public class SessionDeletionWorkerTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
// PR-B audit consolidation: deletion_poisoned audit moved to SessionDeletionPoisoned
|
||||
// OpsEvent (operator-bound, Telegram-routable). Tenant-audit row is no longer written.
|
||||
// OpsEvent (operator-bound, routable through alert rules). Tenant-audit row is no longer written.
|
||||
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
|
||||
Assert.Equal(TenantId, poisoned.TenantId);
|
||||
Assert.Equal(SessionId, poisoned.SessionId);
|
||||
|
||||
@@ -29,6 +29,7 @@ public class SessionFreeTextSearchTests
|
||||
DeviceName = "DESKTOP-A1B2C3",
|
||||
SerialNumber = "5CG1234XYZ",
|
||||
Manufacturer = "Contoso",
|
||||
AutopilotGroupTag = "LAB-WEST",
|
||||
Model = "EliteBook 840",
|
||||
Status = SessionStatus.Failed,
|
||||
GeoCountry = "DE",
|
||||
@@ -178,6 +179,7 @@ public class SessionFreeTextSearchTests
|
||||
DeviceName = F("deviceName"),
|
||||
SerialNumber = F("serialNumber"),
|
||||
Manufacturer = F("manufacturer"),
|
||||
AutopilotGroupTag = F("autopilotGroupTag"),
|
||||
Model = F("model"),
|
||||
Status = Enum.Parse<SessionStatus>(F("status")),
|
||||
SessionId = F("sessionId"),
|
||||
|
||||
@@ -83,6 +83,11 @@ public class SessionIndexFieldManifestTests
|
||||
["EspSoftFailure"] = true,
|
||||
["CompletionSource"] = "esp_exit",
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
["AutopilotDeviceId"] = "ap-1",
|
||||
["AutopilotGroupTag"] = "TEST",
|
||||
["AutopilotPurchaseOrderId"] = "PO-7",
|
||||
["AutopilotDisplayName"] = "LAB-01",
|
||||
["AutopilotUserAssigned"] = true,
|
||||
["FailureSnapshotJson"] = "{\"x\":1}",
|
||||
["AdminMarkedAction"] = "failed",
|
||||
["VerdictPath"] = "sweep:r5_incomplete",
|
||||
|
||||
@@ -1013,7 +1013,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient());
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance), Mock.Of<IPushChannelSender>());
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, Mock.Of<IPushChannelSender>());
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
|
||||
+224
-12
@@ -60,7 +60,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
DeviceName = "PC-1",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
@@ -95,7 +95,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
DeviceName = "PC-1",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
@@ -134,7 +134,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsSelfDeployingProfile = false, // fresh read failed / degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
@@ -166,7 +166,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsSelfDeployingProfile = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
@@ -199,7 +199,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsCloudPc = false, // fresh marker read degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
@@ -233,7 +233,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
EnrollmentType = "v1", // degraded registry read on restart
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
@@ -265,7 +265,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
EnrollmentType = "v2",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
@@ -295,7 +295,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsCloudPc = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
@@ -327,7 +327,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -358,7 +358,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var boundAt = new DateTime(2026, 1, 1, 9, 0, 1, DateTimeKind.Utc);
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -401,7 +401,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(fresh, new Mock<Response>().Object));
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 3, 8, 0, 0, DateTimeKind.Utc),
|
||||
@@ -419,7 +419,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
public async Task StoreSessionAsync_fresh_row_carries_the_stamped_owner()
|
||||
{
|
||||
var harness = new Harness(existing: null);
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -431,6 +431,214 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.False(harness.Written.ContainsKey("OwnerThumbprint"));
|
||||
}
|
||||
|
||||
// ── Carry-over of the columns the registration does not own ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_carries_every_declared_column_it_does_not_own()
|
||||
{
|
||||
// Driven by the manifest: a column added to SessionIndexFieldManifest later is carried
|
||||
// through an agent restart without anyone touching StoreSessionAsync. Merge-written columns
|
||||
// such as ImeAgentVersion, LastIngestAt, ExcessiveEventsAlerted, Block* and the pending
|
||||
// server actions used to be dropped by the Replace.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
};
|
||||
foreach (var column in TableStorageService.CarriedColumns)
|
||||
existing[column] = $"carried:{column}";
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Contains("ImeAgentVersion", TableStorageService.CarriedColumns);
|
||||
Assert.Contains("PendingActionsJson", TableStorageService.CarriedColumns);
|
||||
foreach (var column in TableStorageService.CarriedColumns)
|
||||
Assert.Equal($"carried:{column}", harness.Written![column]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistrationOwnedColumns_name_only_declared_columns()
|
||||
{
|
||||
// A typo in the owned list would silently turn a column into a carried one.
|
||||
var declared = SessionIndexFieldManifest.All
|
||||
.Concat(SessionIndexFieldManifest.PrimaryOnly)
|
||||
.Append("IndexRowKey") // Sessions-only bookkeeping, deliberately outside the index manifest
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Assert.All(TableStorageService.RegistrationOwnedColumns, column => Assert.Contains(column, declared));
|
||||
Assert.Equal(
|
||||
TableStorageService.RegistrationOwnedColumns.Length,
|
||||
TableStorageService.RegistrationOwnedColumns.Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cas_retry_carries_the_values_of_its_own_fresh_read()
|
||||
{
|
||||
var startedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc));
|
||||
var initial = new TableEntity(TenantId, SessionId) { ["StartedAt"] = startedAt, ["Status"] = "InProgress" };
|
||||
initial.ETag = new ETag("0xINITIAL");
|
||||
var firstFresh = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = startedAt, ["Status"] = "InProgress",
|
||||
["ImeAgentVersion"] = "1.0", ["LastIngestAt"] = startedAt.AddMinutes(5),
|
||||
};
|
||||
firstFresh.ETag = new ETag("0xFRESH1");
|
||||
var secondFresh = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = startedAt, ["Status"] = "InProgress",
|
||||
["ImeAgentVersion"] = "2.0",
|
||||
};
|
||||
secondFresh.ETag = new ETag("0xFRESH2");
|
||||
|
||||
var harness = new Harness(initial);
|
||||
harness.Sessions.SetupSequence(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(firstFresh, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(secondFresh, new Mock<Response>().Object));
|
||||
|
||||
var attempts = 0;
|
||||
TableEntity? written = null;
|
||||
harness.Sessions.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
if (++attempts == 1)
|
||||
return Task.FromException<Response>(new RequestFailedException(412, "ConditionNotMet"));
|
||||
written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.Equal("2.0", written!.GetString("ImeAgentVersion"));
|
||||
// Carried by the failed attempt, gone from the second fresh read: not carried again.
|
||||
Assert.False(written.ContainsKey("LastIngestAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_does_not_carry_the_terminal_tuple_of_a_non_terminal_row()
|
||||
{
|
||||
// The terminal tuple is owned: it is re-stamped only while the fresh status is terminal.
|
||||
// A WhiteGlove Part 1 row resuming into Part 2 keeps the old behaviour of dropping it.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "Pending",
|
||||
["CompletionSource"] = "whiteglove_part1",
|
||||
["FailureSnapshotJson"] = "{}",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("InProgress", harness.Written!.GetString("Status"));
|
||||
Assert.False(harness.Written.ContainsKey("CompletionSource"));
|
||||
Assert.False(harness.Written.ContainsKey("FailureSnapshotJson"));
|
||||
}
|
||||
|
||||
// ── Server facts: validator and Autopilot registration ──────────────────
|
||||
|
||||
private static SessionRegistration Reregistration() => new()
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
private static TableEntity ExistingRow(params (string Column, object Value)[] columns)
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
};
|
||||
foreach (var (column, value) in columns)
|
||||
row[column] = value;
|
||||
row.ETag = new ETag("0xEXISTING");
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_writes_the_autopilot_registration_of_a_new_session()
|
||||
{
|
||||
var harness = new Harness(existing: null);
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
|
||||
{
|
||||
ValidatedBy = ValidatorType.AutopilotV1,
|
||||
AutopilotRegistration = new AutopilotRegistration
|
||||
{
|
||||
DeviceId = "ap-1", GroupTag = "TEST", PurchaseOrderId = "PO-7", DisplayName = "LAB-01", UserAssigned = true,
|
||||
},
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
|
||||
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
|
||||
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
|
||||
Assert.Equal("PO-7", harness.Written.GetString("AutopilotPurchaseOrderId"));
|
||||
Assert.Equal("LAB-01", harness.Written.GetString("AutopilotDisplayName"));
|
||||
Assert.True(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_a_fresh_autopilot_registration_replaces_the_stored_one_whole()
|
||||
{
|
||||
// The group tag was removed in Intune between two agent starts: the column clears.
|
||||
var harness = new Harness(ExistingRow(
|
||||
("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "OLD"), ("AutopilotPurchaseOrderId", "PO-1"),
|
||||
("AutopilotDisplayName", "LAB-01"), ("AutopilotUserAssigned", true)));
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
|
||||
{
|
||||
ValidatedBy = ValidatorType.AutopilotV1,
|
||||
AutopilotRegistration = new AutopilotRegistration { DeviceId = "ap-1", PurchaseOrderId = "PO-2" },
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.False(harness.Written!.ContainsKey("AutopilotGroupTag"));
|
||||
Assert.False(harness.Written.ContainsKey("AutopilotDisplayName"));
|
||||
Assert.Equal("PO-2", harness.Written.GetString("AutopilotPurchaseOrderId"));
|
||||
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_without_an_autopilot_registration_keeps_the_stored_one_and_the_validator()
|
||||
{
|
||||
// A re-registration another path admitted (or with validation switched off) proves nothing
|
||||
// about the Autopilot registration: the stored values and the recorded validator stay.
|
||||
var harness = new Harness(ExistingRow(
|
||||
("ValidatedBy", "AutopilotV1"), ("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "TEST"),
|
||||
("AutopilotUserAssigned", false)));
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
|
||||
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
|
||||
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
|
||||
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
@@ -483,5 +691,9 @@ public class StoreSessionReregistrationPreserveTests
|
||||
|
||||
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Stores with no server facts unless a test hands them in.</summary>
|
||||
public Task<bool> Store(SessionRegistration registration, SessionOwner? owner = null, SessionServerFacts? facts = null)
|
||||
=> Sut.StoreSessionAsync(registration, facts ?? new SessionServerFacts(), owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
{
|
||||
var harness = new Harness(existingOwner: null);
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
|
||||
@@ -54,7 +54,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
{
|
||||
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Contains("session-write", harness.Order);
|
||||
@@ -67,7 +67,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
var harness = new Harness(existingOwner: TenantA);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
|
||||
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
|
||||
() => harness.Sut.StoreSessionAsync(Registration(TenantB), new SessionServerFacts()));
|
||||
|
||||
Assert.Equal(SessionId, ex.SessionId);
|
||||
Assert.Equal(TenantB, ex.RequestedTenantId);
|
||||
@@ -87,7 +87,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.DoesNotContain("session-write", harness.Order);
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
|
||||
[Fact]
|
||||
public async Task TheAlert_CarriesNeitherTextNorSenderNorContactAddress()
|
||||
{
|
||||
// Channels bound to FeedbackReceived (Telegram, Slack, webhooks) receive message + details;
|
||||
// Channels bound to FeedbackReceived (Slack, webhooks, e-mail) receive message + details;
|
||||
// the feedback itself is read in the portal. Personal data must never ride along.
|
||||
var h = new Harness();
|
||||
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
Assert.Equal("Pricing was too high for our use case.", h.Repo.PersistedOffboardingFeedback.Comment);
|
||||
Assert.NotNull(h.Repo.PersistedOffboardingFeedback.InteractedAt);
|
||||
|
||||
// OpsEvent emitted with the canonical type catalog-registered for Telegram-routing.
|
||||
// OpsEvent emitted with the canonical type catalog-registered for alert routing.
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
|
||||
Assert.Equal(TenantId, ops.TenantId);
|
||||
@@ -283,7 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
|
||||
// Alert-dispatch dependencies are inert: empty AdminConfig service + bare HTTP
|
||||
// clients. The feedback path emits Info-severity so even if alerts ARE evaluated,
|
||||
// no Telegram/webhook fires for an unconfigured rule set.
|
||||
// no channel fires for an unconfigured rule set.
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ranking of <see cref="TableDistressReportRepository.KeepNewestAsync"/>: the table yields rows in
|
||||
/// tenant order, so a cap must keep the newest rows across tenants, never the first rows read.
|
||||
/// </summary>
|
||||
public class TableDistressReportRepositoryNewestTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DistressReportEntry Entry(string tenantId, int minute) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = "DeviceNotRegistered",
|
||||
SerialNumber = $"SN-{tenantId[0]}-{minute}",
|
||||
IngestedAt = T0.AddMinutes(minute),
|
||||
};
|
||||
|
||||
private static async IAsyncEnumerable<DistressReportEntry> Source(params DistressReportEntry[] entries)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant order as the table returns it: all rows of tenant "a", then tenant "b", each newest first.
|
||||
private static DistressReportEntry[] TwoTenantsInterleavedInTime() =>
|
||||
[
|
||||
Entry("aaaaaaaa", 50), Entry("aaaaaaaa", 30), Entry("aaaaaaaa", 10),
|
||||
Entry("bbbbbbbb", 60), Entry("bbbbbbbb", 40), Entry("bbbbbbbb", 20),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task OverTheCap_KeepsNewestAcrossTenants_NewestFirst()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
|
||||
Source(TwoTenantsInterleavedInTime()), maxResults: 4);
|
||||
|
||||
Assert.True(truncated);
|
||||
Assert.Equal(new[] { 60, 50, 40, 30 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtTheCap_ReturnsAll_NotTruncated()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
|
||||
Source(TwoTenantsInterleavedInTime()), maxResults: 6);
|
||||
|
||||
Assert.False(truncated);
|
||||
Assert.Equal(new[] { 60, 50, 40, 30, 20, 10 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyTable_ReturnsEmpty_NotTruncated()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(Source(), maxResults: 4);
|
||||
|
||||
Assert.Empty(reports);
|
||||
Assert.False(truncated);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class TableLifecycleBucketTests
|
||||
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
|
||||
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
|
||||
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
|
||||
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
|
||||
[Constants.TableNames.PreviewConfig] = "global e-mail template overrides",
|
||||
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
|
||||
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
|
||||
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
|
||||
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
|
||||
/// tenant admin from sending through the platform-owned bot.
|
||||
/// </summary>
|
||||
public class TelegramChannelProviderTests
|
||||
{
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
{
|
||||
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
||||
|
||||
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
|
||||
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync("-1003785642894", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform);
|
||||
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
|
||||
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
||||
}, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_SkipsChannelsWithoutDestination()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, null, "tg"),
|
||||
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
||||
}, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTest_RoutesTelegramToBot()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
var result = await dispatcher.SendTestAsync(
|
||||
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
||||
new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
Assert.True(result.Success);
|
||||
telegram.Verify(t => t.SendAlertAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Chat-ID validation ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("-1003785642894")]
|
||||
[InlineData("123456789")]
|
||||
[InlineData("@salesdesk")]
|
||||
[InlineData("@Sales_Desk_01")]
|
||||
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://api.telegram.org/bot123/sendMessage")]
|
||||
[InlineData("-100abc")]
|
||||
[InlineData("@abc")] // too short
|
||||
[InlineData("@1sales")] // must start with a letter
|
||||
[InlineData("@sales-desk")] // hyphen not allowed
|
||||
[InlineData("123456789012345678901")] // 21 digits
|
||||
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
|
||||
{
|
||||
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
|
||||
// provider-specific destination branch is wired, not just the format helper.
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
|
||||
|
||||
// ── Global-Admin gate ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_GlobalAdminMayAddChannel()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotAddChannel()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
|
||||
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
|
||||
{
|
||||
// The gate must not turn a GA-created Telegram channel into a permanent save block
|
||||
// for the tenant admin's unrelated configuration changes.
|
||||
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_IgnoresNonTelegramChannels()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
|
||||
|
||||
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,13 @@ public class TenantConfigPatchServiceTests
|
||||
[InlineData("domainName")]
|
||||
[InlineData("lastUpdated")]
|
||||
[InlineData("updatedBy")]
|
||||
[InlineData("graphAccessRejectedSince")]
|
||||
[InlineData("graphAccessRejectedCode")]
|
||||
[InlineData("graphAccessRejectionConfirmedAt")]
|
||||
[InlineData("intuneDeviceBindingPermissionMissingSince")]
|
||||
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
|
||||
[InlineData("cloudPcPermissionMissingSince")]
|
||||
[InlineData("cloudPcPermissionMissingConfirmedAt")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
@@ -33,6 +33,9 @@ public class TenantConfigProjectionTests
|
||||
"onboardedAt", "onboardedBy", "dpaVersion", "dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
|
||||
"lastUpdated", "dataRetentionDays",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
|
||||
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"cloudPcPermissionMissingSince", "cloudPcPermissionMissingConfirmedAt",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -213,6 +213,66 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.LastAuthClientIdSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_GraphAccessFields_SurviveStoreAndMap()
|
||||
{
|
||||
var since = new DateTime(2026, 11, 2, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
GraphAccessRejectedSince = since,
|
||||
GraphAccessRejectedCode = "AADSTS7000229",
|
||||
GraphAccessRejectionConfirmedAt = since.AddMinutes(20),
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(since, mapped.GraphAccessRejectedSince);
|
||||
Assert.Equal("AADSTS7000229", mapped.GraphAccessRejectedCode);
|
||||
Assert.Equal(since.AddMinutes(20), mapped.GraphAccessRejectionConfirmedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_LegacyRow_WithoutGraphAccessColumns_ReadsNull()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
||||
|
||||
Assert.Null(mapped.GraphAccessRejectedSince);
|
||||
Assert.Null(mapped.GraphAccessRejectedCode);
|
||||
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_AddOnPermissionFields_SurviveStoreAndMap()
|
||||
{
|
||||
var since = new DateTime(2026, 11, 4, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
IntuneDeviceBindingPermissionMissingSince = since,
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = since.AddMinutes(15),
|
||||
CloudPcPermissionMissingSince = since.AddHours(1),
|
||||
CloudPcPermissionMissingConfirmedAt = since.AddHours(2),
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(since, mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Equal(since.AddMinutes(15), mapped.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Equal(since.AddHours(1), mapped.CloudPcPermissionMissingSince);
|
||||
Assert.Equal(since.AddHours(2), mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(0)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
@@ -10,11 +9,10 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> but do not
|
||||
/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare
|
||||
/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport
|
||||
/// resolves its bot URL from a mocked repository that returns nothing.
|
||||
/// HttpClient it never uses (no channel is configured in these stubs).
|
||||
/// <para>
|
||||
/// Tests that DO assert on dispatch should mock the transports directly — see
|
||||
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
|
||||
/// <c>EmailChannelProviderTests</c> — rather than extending this helper.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TestNotifications
|
||||
@@ -22,8 +20,6 @@ internal static class TestNotifications
|
||||
internal static NotificationChannelDispatcher InertDispatcher()
|
||||
=> new(
|
||||
new WebhookNotificationService(new HttpClient()),
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
Mock.Of<IPushChannelSender>());
|
||||
|
||||
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
|
||||
|
||||
+10
@@ -376,6 +376,11 @@ public class TypedContractCompletionWireParityTests
|
||||
bootstrapTokenEnabled = true,
|
||||
diagnosticsUploadConfigured = false,
|
||||
deviceValidationEnabled = true,
|
||||
graphAccessRejected = true,
|
||||
graphAccessAppMissing = false,
|
||||
intuneDeviceBindingPermissionMissing = true,
|
||||
cloudPcPermissionMissing = false,
|
||||
addOnPermissionMissingBlocksAll = false,
|
||||
appHomingFunnelActive = false,
|
||||
showScriptOutput = true,
|
||||
enableSoftwareInventoryAnalyzer = false,
|
||||
@@ -402,6 +407,11 @@ public class TypedContractCompletionWireParityTests
|
||||
BootstrapTokenEnabled = true,
|
||||
DiagnosticsUploadConfigured = false,
|
||||
DeviceValidationEnabled = true,
|
||||
GraphAccessRejected = true,
|
||||
GraphAccessAppMissing = false,
|
||||
IntuneDeviceBindingPermissionMissing = true,
|
||||
CloudPcPermissionMissing = false,
|
||||
AddOnPermissionMissingBlocksAll = false,
|
||||
AppHomingFunnelActive = false,
|
||||
ShowScriptOutput = true,
|
||||
EnableSoftwareInventoryAnalyzer = false,
|
||||
|
||||
@@ -88,8 +88,7 @@ public class WhatsNewNotificationServiceTests
|
||||
public Harness()
|
||||
{
|
||||
var webhook = new WebhookNotificationService(new HttpClient());
|
||||
var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance);
|
||||
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, telegram, Mock.Of<IPushChannelSender>());
|
||||
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, Mock.Of<IPushChannelSender>());
|
||||
Dispatcher
|
||||
.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>(), It.IsAny<NotificationScope>()))
|
||||
.Returns<IEnumerable<NotificationChannel>, NotificationAlert, NotificationScope>((channels, alert, scope) =>
|
||||
@@ -434,14 +433,14 @@ public class WhatsNewNotificationServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWhatsNewAlert_TelegramText_CarriesSectionsAndLinks()
|
||||
public void BuildWhatsNewAlert_EmailText_CarriesSectionsAndLinks()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(
|
||||
new List<WhatsNewFeedEntry> { Entry("platform", "p", title: "**Lead**", body: "Body.") },
|
||||
null,
|
||||
WhatsNewNotificationService.PortalWhatsNewUrl("platform"));
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
|
||||
Assert.Contains("What's new in Autopilot Monitor", text);
|
||||
Assert.Contains("Platform \u00b7 Sep 8, 2026 \u2014 Lead", text);
|
||||
|
||||
+20
-70
@@ -32,12 +32,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
/// <summary>
|
||||
/// Changes to ONLY these properties do not snapshot the tenant-config row: the
|
||||
/// LastAuthClientId pair flips as an auth-flow side effect and would flood the
|
||||
/// two backup slots with states nobody ever wants to revert to.
|
||||
/// LastAuthClientId pair flips as an auth-flow side effect and the Graph access state as
|
||||
/// a token side effect; both would flood the two backup slots with states nobody ever
|
||||
/// wants to revert to.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> TenantBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LastUpdated", "UpdatedBy", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -541,62 +545,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preview Config ---
|
||||
|
||||
public async Task<Dictionary<string, string>> GetPreviewConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = new Dictionary<string, string>();
|
||||
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
|
||||
|
||||
foreach (var kvp in entity.Value)
|
||||
{
|
||||
if (kvp.Key == "odata.etag" || kvp.Key == "PartitionKey" || kvp.Key == "RowKey" || kvp.Key == "Timestamp")
|
||||
continue;
|
||||
config[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading preview config");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SavePreviewConfigAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get existing entity or create new one
|
||||
TableEntity entity;
|
||||
try
|
||||
{
|
||||
var existing = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
|
||||
entity = existing.Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
entity = new TableEntity("TelegramBot", "config");
|
||||
}
|
||||
|
||||
entity[key] = value;
|
||||
await _previewConfigTableClient.UpsertEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving preview config key {Key}", key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email Template Overrides ---
|
||||
|
||||
private const string EmailTemplatesPartition = "EmailTemplates";
|
||||
@@ -871,6 +819,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "HomedAppClientId", config.HomedAppClientId },
|
||||
{ "LastAuthClientId", config.LastAuthClientId },
|
||||
{ "LastAuthClientIdSince", config.LastAuthClientIdSince },
|
||||
{ "GraphAccessRejectedSince", config.GraphAccessRejectedSince },
|
||||
{ "GraphAccessRejectedCode", config.GraphAccessRejectedCode },
|
||||
{ "GraphAccessRejectionConfirmedAt", config.GraphAccessRejectionConfirmedAt },
|
||||
{ "IntuneDeviceBindingPermissionMissingSince", config.IntuneDeviceBindingPermissionMissingSince },
|
||||
{ "IntuneDeviceBindingPermissionMissingConfirmedAt", config.IntuneDeviceBindingPermissionMissingConfirmedAt },
|
||||
{ "CloudPcPermissionMissingSince", config.CloudPcPermissionMissingSince },
|
||||
{ "CloudPcPermissionMissingConfirmedAt", config.CloudPcPermissionMissingConfirmedAt },
|
||||
{ "PlanTier", config.PlanTier },
|
||||
{ "TrialExpiresUtc", config.TrialExpiresUtc },
|
||||
{ "TrialStartedUtc", config.TrialStartedUtc },
|
||||
@@ -994,6 +949,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
HomedAppClientId = entity.GetString("HomedAppClientId"),
|
||||
LastAuthClientId = entity.GetString("LastAuthClientId"),
|
||||
LastAuthClientIdSince = entity.GetDateTime("LastAuthClientIdSince"),
|
||||
GraphAccessRejectedSince = entity.GetDateTime("GraphAccessRejectedSince"),
|
||||
GraphAccessRejectedCode = entity.GetString("GraphAccessRejectedCode"),
|
||||
GraphAccessRejectionConfirmedAt = entity.GetDateTime("GraphAccessRejectionConfirmedAt"),
|
||||
IntuneDeviceBindingPermissionMissingSince = entity.GetDateTime("IntuneDeviceBindingPermissionMissingSince"),
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = entity.GetDateTime("IntuneDeviceBindingPermissionMissingConfirmedAt"),
|
||||
CloudPcPermissionMissingSince = entity.GetDateTime("CloudPcPermissionMissingSince"),
|
||||
CloudPcPermissionMissingConfirmedAt = entity.GetDateTime("CloudPcPermissionMissingConfirmedAt"),
|
||||
PlanTier = entity.GetString("PlanTier") ?? "free",
|
||||
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
|
||||
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
|
||||
@@ -1054,12 +1016,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "SlaNotificationCooldownHours", config.SlaNotificationCooldownHours },
|
||||
// Ops Alert settings
|
||||
{ "OpsAlertRulesJson", config.OpsAlertRulesJson ?? string.Empty },
|
||||
{ "OpsAlertTelegramEnabled", config.OpsAlertTelegramEnabled },
|
||||
{ "OpsAlertTelegramChatId", config.OpsAlertTelegramChatId ?? string.Empty },
|
||||
{ "OpsAlertTeamsEnabled", config.OpsAlertTeamsEnabled },
|
||||
{ "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty },
|
||||
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
|
||||
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
|
||||
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
|
||||
// Per-line agent binary integrity: written by the release pipeline via Merge.
|
||||
// The backend never changes them (the PATCH gate rejects them), so the
|
||||
@@ -1130,12 +1086,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
SlaNotificationCooldownHours = entity.GetInt32("SlaNotificationCooldownHours") ?? 24,
|
||||
// Ops Alert settings
|
||||
OpsAlertRulesJson = entity.GetString("OpsAlertRulesJson"),
|
||||
OpsAlertTelegramEnabled = entity.GetBoolean("OpsAlertTelegramEnabled") ?? false,
|
||||
OpsAlertTelegramChatId = entity.GetString("OpsAlertTelegramChatId"),
|
||||
OpsAlertTeamsEnabled = entity.GetBoolean("OpsAlertTeamsEnabled") ?? false,
|
||||
OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"),
|
||||
OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false,
|
||||
OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"),
|
||||
OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"),
|
||||
// Per-line agent binary integrity. V2 is the only wired line (V1 retired).
|
||||
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
|
||||
|
||||
+33
-7
@@ -78,18 +78,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<DistressReportEntry>> GetAllDistressReportsAsync(int maxResults = 500)
|
||||
public Task<(List<DistressReportEntry> Reports, bool Truncated)> GetNewestDistressReportsAsync(int maxResults)
|
||||
{
|
||||
var result = new List<DistressReportEntry>();
|
||||
// A cross-partition query returns rows in tenant order, so the first N rows are not the
|
||||
// newest N: the whole table is read (bounded by the 14-day cleanup) and ranked here.
|
||||
return KeepNewestAsync(MapAll(_table.QueryAsync<TableEntity>(maxPerPage: 1000)), maxResults);
|
||||
}
|
||||
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(maxPerPage: maxResults))
|
||||
private static async IAsyncEnumerable<DistressReportEntry> MapAll(IAsyncEnumerable<TableEntity> entities)
|
||||
{
|
||||
await foreach (var entity in entities)
|
||||
yield return MapToEntry(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The newest <paramref name="maxResults"/> entries by IngestedAt, newest first. A min-heap keeps
|
||||
/// memory at <paramref name="maxResults"/> however many rows the source yields.
|
||||
/// </summary>
|
||||
internal static async Task<(List<DistressReportEntry> Reports, bool Truncated)> KeepNewestAsync(
|
||||
IAsyncEnumerable<DistressReportEntry> entries, int maxResults)
|
||||
{
|
||||
var newest = new PriorityQueue<DistressReportEntry, DateTime>();
|
||||
var seen = 0;
|
||||
|
||||
await foreach (var entry in entries)
|
||||
{
|
||||
result.Add(MapToEntry(entity));
|
||||
if (result.Count >= maxResults)
|
||||
break;
|
||||
seen++;
|
||||
if (newest.Count < maxResults)
|
||||
newest.Enqueue(entry, entry.IngestedAt);
|
||||
else
|
||||
newest.EnqueueDequeue(entry, entry.IngestedAt);
|
||||
}
|
||||
|
||||
return result;
|
||||
var reports = new List<DistressReportEntry>(newest.Count);
|
||||
while (newest.TryDequeue(out var entry, out _))
|
||||
reports.Add(entry);
|
||||
reports.Reverse();
|
||||
|
||||
return (reports, seen > maxResults);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteDistressReportsOlderThanAsync(string tenantId, DateTime cutoff)
|
||||
|
||||
+2
-5
@@ -22,9 +22,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
_publisher = publisher;
|
||||
}
|
||||
|
||||
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null)
|
||||
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionServerFacts serverFacts, SessionOwner? ownerToStamp = null)
|
||||
{
|
||||
var result = await _storage.StoreSessionAsync(registration, ownerToStamp);
|
||||
var result = await _storage.StoreSessionAsync(registration, serverFacts, ownerToStamp);
|
||||
if (result)
|
||||
await _publisher.PublishAsync("session.created", registration, registration.TenantId);
|
||||
return result;
|
||||
@@ -266,9 +266,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
|
||||
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
|
||||
|
||||
public Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
|
||||
=> _storage.SearchSessionsAsync(tenantId, filter);
|
||||
|
||||
public Task<RawPage<SessionSummary>> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
||||
=> _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation);
|
||||
|
||||
|
||||
+3
-1
@@ -302,13 +302,15 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
/// <summary>
|
||||
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
|
||||
/// one per serial-based validation (Autopilot, corporate identifier, and
|
||||
/// "device-preparation" for Device Association Validation).
|
||||
/// "device-preparation" for Device Association Validation), plus "restore" for repairing the
|
||||
/// Graph access of validations that are already on.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"autopilot",
|
||||
"corporate",
|
||||
"device-preparation",
|
||||
"restore",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,17 +14,20 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
private readonly MaintenanceService _maintenanceService;
|
||||
private readonly Services.Push.PushMaintenanceService _pushMaintenance;
|
||||
private readonly Services.Notifications.NotificationChannelHealthMaintenance _channelHealthMaintenance;
|
||||
private readonly GraphAccessRecheckMaintenance _graphAccessRecheck;
|
||||
private readonly ILogger<MaintenanceFunction> _logger;
|
||||
|
||||
public MaintenanceFunction(
|
||||
MaintenanceService maintenanceService,
|
||||
Services.Push.PushMaintenanceService pushMaintenance,
|
||||
Services.Notifications.NotificationChannelHealthMaintenance channelHealthMaintenance,
|
||||
GraphAccessRecheckMaintenance graphAccessRecheck,
|
||||
ILogger<MaintenanceFunction> logger)
|
||||
{
|
||||
_maintenanceService = maintenanceService;
|
||||
_pushMaintenance = pushMaintenance;
|
||||
_channelHealthMaintenance = channelHealthMaintenance;
|
||||
_graphAccessRecheck = graphAccessRecheck;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -45,6 +48,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
await _pushMaintenance.RunAsync();
|
||||
// Health rows of deleted notification channels (D-342); fail-soft inside.
|
||||
await _channelHealthMaintenance.RunAsync();
|
||||
// Confirms or clears Graph access suspicions without waiting for device traffic; fail-soft inside.
|
||||
await _graphAccessRecheck.RunAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
/// Timer-triggered watcher for the Azure SignalR plan limits (Standard S1
|
||||
/// x1 unit: 1,000 concurrent connections / 1M messages per UTC day). Runs
|
||||
/// every full hour and emits OpsEvents when the configured thresholds are
|
||||
/// crossed so operators get a Telegram ping before clients start getting
|
||||
/// crossed so operators get an alert before clients start getting
|
||||
/// 429'd or message overage is billed.
|
||||
///
|
||||
/// Separate from the 2h MaintenanceFunction: the SignalR limits can be
|
||||
|
||||
+1
-2
@@ -178,8 +178,7 @@ public class SubmitOffboardingFeedbackFunction
|
||||
}
|
||||
|
||||
// Best-effort OpsEvent so operators see "feedback received" on the Ops dashboard.
|
||||
// Telegram-wiring done by the admin via OpsAlertRules UI (memory:
|
||||
// feedback_ops_event_types_dual_register).
|
||||
// Alert channels are bound by the admin in the Ops Alert Rules UI.
|
||||
try
|
||||
{
|
||||
await _opsEvents.RecordOffboardingFeedbackReceivedAsync(
|
||||
|
||||
+2
-19
@@ -3,7 +3,6 @@ using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -37,24 +36,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
// Ops channels: a tenant that predates named channels stores an empty list, and the
|
||||
// dispatcher synthesizes one channel per configured legacy provider slot. Project the
|
||||
// SAME synthesis into the read model so the editor shows what actually receives
|
||||
// alerts today — the alternative is a second copy of the synthesis rules in the web
|
||||
// app, drifting from the one the dispatcher uses. The first save then persists
|
||||
// exactly the list the operator was shown. Never mutates the cached instance.
|
||||
if (string.IsNullOrWhiteSpace(config.OpsNotificationChannelsJson))
|
||||
{
|
||||
var synthesized = config.GetOpsNotificationChannels();
|
||||
if (synthesized.Count > 0)
|
||||
{
|
||||
config = config.ShallowCopy();
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(synthesized);
|
||||
}
|
||||
}
|
||||
|
||||
// A read-only GlobalReader gets secrets (NvdApiKey, SAS, ops webhook URLs and the
|
||||
// per-channel destinations) redacted; a Global Admin gets the full config. Redaction
|
||||
// A read-only GlobalReader gets secrets (NvdApiKey, SAS and the per-channel
|
||||
// destinations) redacted; a Global Admin gets the full config. Redaction
|
||||
// returns a copy — the cached instance is never mutated.
|
||||
if (requestCtx.IsGlobalReader)
|
||||
config = config.RedactedCopyForReader();
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly KillSwitchEvaluator _killSwitchEvaluator;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
|
||||
public GetAgentConfigFunction(
|
||||
ILogger<GetAgentConfigFunction> logger,
|
||||
@@ -42,8 +43,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
KillSwitchEvaluator killSwitchEvaluator)
|
||||
KillSwitchEvaluator killSwitchEvaluator,
|
||||
IGraphAccessHealthRecorder accessHealth)
|
||||
{
|
||||
_accessHealth = accessHealth;
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -85,7 +88,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
|
||||
accessHealth: _accessHealth
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
+12
@@ -74,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc);
|
||||
var entitlements = FeatureEntitlementCatalog.Get(resolution);
|
||||
var isTrial = resolution.IsTrial;
|
||||
var graphAccessRejected = config.HasAnyDeviceValidation() && config.GraphAccessRejectionConfirmedAt != null;
|
||||
|
||||
return new TenantFeatureFlagsResponse
|
||||
{
|
||||
@@ -87,6 +88,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||
// Drives the "no device validation enabled" dashboard banner (useTenantSecurityConfig).
|
||||
DeviceValidationEnabled = config.HasAnyDeviceValidation(),
|
||||
// Drives the "enrollment monitoring is interrupted" notice. A suspicion alone shows
|
||||
// nothing; the error code itself stays in the admin-gated full config.
|
||||
GraphAccessRejected = graphAccessRejected,
|
||||
GraphAccessAppMissing = graphAccessRejected
|
||||
&& string.Equals(config.GraphAccessRejectedCode, EntraTokenRejection.ServicePrincipalMissingCode, StringComparison.OrdinalIgnoreCase),
|
||||
// Drive the "devices aren't being monitored" notice: a switched-on validation kept
|
||||
// devices out and still lacks its optional permission. Switching it off hides the
|
||||
// notice at once; the state itself ends in the next maintenance run.
|
||||
IntuneDeviceBindingPermissionMissing = AddOnValidation.IntuneDeviceBinding.IsConfirmedMissing(config),
|
||||
CloudPcPermissionMissing = AddOnValidation.CloudPc.IsConfirmedMissing(config),
|
||||
AddOnPermissionMissingBlocksAll = AddOnValidation.BlocksAllValidation(config),
|
||||
// Dual app-reg self-service migration: when true, running the consent flow (or
|
||||
// "Detect existing access") targets the NEW app registration and auto-flips this
|
||||
// tenant's homing after verification — drives the explanatory banner in the
|
||||
|
||||
+2
-2
@@ -207,12 +207,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
|
||||
// chat ID or webhook URL would silently break every alert, so restore per channel id.
|
||||
// recipient or webhook URL would silently break every alert, so restore per channel id.
|
||||
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
draft.OpsNotificationChannelsJson, storedChannels);
|
||||
|
||||
// Same structural gate and one-Push-channel rule the tenant channel list passes. No GA
|
||||
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
|
||||
// gate on platform providers here: this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson)
|
||||
?? TenantConfigValidation.ValidateSinglePushChannel(draft.OpsNotificationChannelsJson, storedChannels);
|
||||
if (channelsError != null)
|
||||
|
||||
+5
-1
@@ -58,6 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
private readonly ISessionDeletionInventoryReader _sessionRowReader;
|
||||
@@ -73,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
IGraphAccessHealthRecorder accessHealth,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics,
|
||||
ISessionDeletionInventoryReader sessionRowReader,
|
||||
@@ -89,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||||
_accessHealth = accessHealth;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
}
|
||||
@@ -141,7 +144,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
|
||||
accessHealth: _accessHealth
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
@@ -21,7 +21,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly IFeedbackRepository _feedbackRepo;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
@@ -32,7 +31,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
TenantConfigurationService tenantConfigService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
ISessionRepository sessionRepo,
|
||||
IFeedbackRepository feedbackRepo,
|
||||
OpsEventService opsEvents,
|
||||
@@ -42,7 +40,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_sessionRepo = sessionRepo;
|
||||
_feedbackRepo = feedbackRepo;
|
||||
_opsEvents = opsEvents;
|
||||
@@ -185,7 +182,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
|
||||
// Only a rating that was actually recorded is announced — at most once per person.
|
||||
if (stored is { Submitted: true } && stored.Rating is int rating)
|
||||
await AnnounceRatingAsync(tenantId, upn, displayName, stored.FeedbackId!, rating, comment);
|
||||
await AnnounceRatingAsync(tenantId, upn, stored.FeedbackId!, rating, comment);
|
||||
|
||||
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
||||
stored == null ? "unchanged" : body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
||||
@@ -196,13 +193,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
/// <summary>
|
||||
/// The signals of a recorded rating, all best effort: a stored rating never fails because of
|
||||
/// them. The ops event carries the stars and the comment length only, so an alert channel
|
||||
/// receives no personal data; the Telegram ping keeps sender and comment until it is retired.
|
||||
/// receives no personal data.
|
||||
/// </summary>
|
||||
private async Task AnnounceRatingAsync(
|
||||
string tenantId, string upn, string displayName, string feedbackId, int rating, string? comment)
|
||||
string tenantId, string upn, string feedbackId, int rating, string? comment)
|
||||
{
|
||||
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
|
||||
|
||||
var normalizedTenantId = tenantId.ToLowerInvariant();
|
||||
// The domain makes the alert and the bell readable. The lookup never creates a
|
||||
// configuration row; an unknown tenant simply keeps its GUID label.
|
||||
|
||||
@@ -31,7 +31,6 @@ public class AuthFunction
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
|
||||
@@ -41,7 +40,7 @@ public class AuthFunction
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
private readonly Services.Push.PushOwnerSignInService? _pushSignIn;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -51,7 +50,6 @@ public class AuthFunction
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IMetricsRepository metricsRepo,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
McpUserService mcpUserService,
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
@@ -60,8 +58,8 @@ public class AuthFunction
|
||||
LiveStreamRevoker streams,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo,
|
||||
Services.Push.PushOwnerSignInService? pushSignIn = null,
|
||||
OpsEventService? opsEvents = null)
|
||||
OpsEventService opsEvents,
|
||||
Services.Push.PushOwnerSignInService? pushSignIn = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_pushSignIn = pushSignIn;
|
||||
@@ -76,7 +74,6 @@ public class AuthFunction
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_metricsRepo = metricsRepo;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_mcpUserService = mcpUserService;
|
||||
_tenantAutoApproveEnqueuer = tenantAutoApproveEnqueuer;
|
||||
@@ -444,7 +441,7 @@ public class AuthFunction
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
||||
/// and fires best-effort notifications (Telegram + global notification). The write lands
|
||||
/// and fires the signup signals (ops event + global notification). The write lands
|
||||
/// only while the STORED row still has no domain (conditional create or update), and only
|
||||
/// the login that wrote it fires the signup side effects.
|
||||
/// </summary>
|
||||
@@ -559,15 +556,8 @@ public class AuthFunction
|
||||
Seed(tenantConfig);
|
||||
|
||||
// The ops event is the routable record (alert rules → any channel, Push included); it is
|
||||
// written before the response and never throws. The Telegram ping below stays until retired.
|
||||
if (_opsEvents != null)
|
||||
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
|
||||
|
||||
// Fire-and-forget: Telegram
|
||||
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
// written before the response and never throws.
|
||||
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
|
||||
|
||||
// Fire-and-forget: Global notification
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
/// <summary>
|
||||
/// Receives Azure Monitor alert notifications (Common Alert Schema) from an Action Group
|
||||
/// webhook receiver and re-emits them as ops events, so platform alerts flow through the
|
||||
/// same OpsEventService → OpsAlertDispatchService fan-out (Telegram/Teams/Slack providers +
|
||||
/// same OpsEventService → OpsAlertDispatchService fan-out (the notification channels +
|
||||
/// the portal ops-events list) as every internally raised operational event.
|
||||
///
|
||||
/// The delivery path is deliberately independent of the portal: Azure Monitor → this
|
||||
|
||||
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly KillSwitchEvaluator _killSwitchEvaluator;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
@@ -73,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
IGraphAccessHealthRecorder accessHealth,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
KillSwitchEvaluator killSwitchEvaluator,
|
||||
SessionDeletionGuard deletionGuard,
|
||||
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||||
_accessHealth = accessHealth;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_killSwitchEvaluator = killSwitchEvaluator;
|
||||
_deletionGuard = deletionGuard;
|
||||
@@ -125,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator);
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
|
||||
accessHealth: _accessHealth);
|
||||
|
||||
if (errorResponse != null) return AsOutput(errorResponse);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
@@ -58,6 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
IGraphAccessHealthRecorder accessHealth,
|
||||
TelemetryClient telemetryClient,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
ISessionRepository sessionRepo,
|
||||
@@ -75,6 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||||
_accessHealth = accessHealth;
|
||||
_telemetryClient = telemetryClient;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_sessionRepo = sessionRepo;
|
||||
@@ -115,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
|
||||
accessHealth: _accessHealth
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
+2
-2
@@ -29,12 +29,12 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
/// Independent cadence from the 2h <c>Maintenance</c> function so cascade-lifecycle
|
||||
/// work cannot starve rule-stats aggregation / cert-expiry checks, and operators get a
|
||||
/// dedicated OpsEvent stream (<see cref="OpsEventService.RecordSessionDeletionMaintenanceLongRunningAsync"/>
|
||||
/// + siblings) wired to Telegram via the standard alert-rules UI.
|
||||
/// + siblings) wired to channels via the standard alert-rules UI.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 30-minute watchdog emits a Warning OpsEvent; 60-minute watchdog escalates to Error. The
|
||||
/// 60-minute mark matches the Flex Consumption hard <c>functionTimeout</c> (host.json) so the
|
||||
/// operator gets a Telegram alert before the Azure runtime abort. The retention fanout stops
|
||||
/// operator gets an alert before the Azure runtime abort. The retention fanout stops
|
||||
/// itself at the <see cref="RunBudget"/> (50min) deadline so a growing backlog degrades into
|
||||
/// a clean <c>BudgetExceeded</c> + partial sweep instead of a host abort. Cron
|
||||
/// <c>0 0 */12 * * *</c> (every 12h at 00:00 / 12:00 UTC) — const per repo convention;
|
||||
|
||||
+3
-1
@@ -36,13 +36,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!FleetGroupTagFilter.TryParse(query["excludeGroupTags"], query["excludeUntagged"], out var groupTagFilter, out var filterError))
|
||||
return await req.BadRequestAsync(filterError!);
|
||||
|
||||
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
|
||||
|
||||
var sessions = await _sessionRepo.GetSessionsAsync(tenantId, days);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
|
||||
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days, groupTagFilter));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+3
-1
@@ -36,6 +36,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!FleetGroupTagFilter.TryParse(query["excludeGroupTags"], query["excludeUntagged"], out var groupTagFilter, out var filterError))
|
||||
return await req.BadRequestAsync(filterError!);
|
||||
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
|
||||
@@ -47,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter, days);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
|
||||
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days, groupTagFilter));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
return await req.BadRequestAsync(pagination.Error);
|
||||
}
|
||||
|
||||
// Build search filter — Limit is ignored, pagination owns size.
|
||||
// Build search filter — pagination owns size.
|
||||
var filter = new SessionSearchFilter
|
||||
{
|
||||
Status = string.IsNullOrEmpty(status) ? null : status,
|
||||
@@ -128,6 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
|
||||
var connectionType = query["connectionType"];
|
||||
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
|
||||
var groupTag = query["groupTag"];
|
||||
if (!string.IsNullOrWhiteSpace(groupTag)) filter.GroupTag = groupTag.Trim();
|
||||
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
|
||||
+8
-3
@@ -9,11 +9,15 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
/// <summary>
|
||||
/// Global Admin endpoint: returns all distress reports from the DistressReports table.
|
||||
/// Global Admin endpoint: returns the newest distress reports from the DistressReports table.
|
||||
/// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
/// </summary>
|
||||
public class GetDistressReportsFunction
|
||||
{
|
||||
// About twice the rows a 14-day retention window holds at today's peak rate; the portal
|
||||
// searches this list client-side, so it should normally hold the whole table.
|
||||
private const int MaxReports = 2000;
|
||||
|
||||
private readonly ILogger<GetDistressReportsFunction> _logger;
|
||||
private readonly IDistressReportRepository _repository;
|
||||
|
||||
@@ -31,14 +35,15 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
try
|
||||
{
|
||||
var reports = await _repository.GetAllDistressReportsAsync(maxResults: 500);
|
||||
var (reports, truncated) = await _repository.GetNewestDistressReportsAsync(MaxReports);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new DistressReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports
|
||||
Reports = reports,
|
||||
Truncated = truncated
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
+3
-11
@@ -22,24 +22,21 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
private readonly ILogger<SubmitDiagFilesReportFunction> _logger;
|
||||
private readonly SessionReportService _sessionReportService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly ReportUploadGate _uploadGate;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
|
||||
public SubmitDiagFilesReportFunction(
|
||||
ILogger<SubmitDiagFilesReportFunction> logger,
|
||||
SessionReportService sessionReportService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
ReportUploadGate uploadGate,
|
||||
OpsEventService? opsEvents = null)
|
||||
OpsEventService opsEvents)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionReportService = sessionReportService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_uploadGate = uploadGate;
|
||||
_opsEvents = opsEvents;
|
||||
@@ -122,12 +119,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
}
|
||||
|
||||
// Ops event (routable through alert rules, Push included) — written before the response, never throws
|
||||
if (_opsEvents != null)
|
||||
await _opsEvents.RecordDiagFilesReportSubmittedAsync(request.TenantId, userIdentifier, metadata.ReportId);
|
||||
|
||||
// Telegram notification — best effort
|
||||
_ = _telegramNotificationService.SendDiagFilesReportAsync(
|
||||
request.TenantId, userIdentifier, metadata.ReportId, request.Comment ?? string.Empty);
|
||||
await _opsEvents.RecordDiagFilesReportSubmittedAsync(request.TenantId, userIdentifier, metadata.ReportId);
|
||||
|
||||
// Persistent in-app notification for Global Admins — best effort
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
|
||||
+3
-11
@@ -16,24 +16,21 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
private readonly ILogger<SubmitSessionReportFunction> _logger;
|
||||
private readonly SessionReportService _sessionReportService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly ReportUploadGate _uploadGate;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
|
||||
public SubmitSessionReportFunction(
|
||||
ILogger<SubmitSessionReportFunction> logger,
|
||||
SessionReportService sessionReportService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
ReportUploadGate uploadGate,
|
||||
OpsEventService? opsEvents = null)
|
||||
OpsEventService opsEvents)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionReportService = sessionReportService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_uploadGate = uploadGate;
|
||||
_opsEvents = opsEvents;
|
||||
@@ -131,12 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
}
|
||||
|
||||
// Ops event (routable through alert rules, Push included) — written before the response, never throws
|
||||
if (_opsEvents != null)
|
||||
await _opsEvents.RecordSessionReportSubmittedAsync(request.TenantId, userIdentifier, sessionId, metadata.ReportId);
|
||||
|
||||
// Telegram notification — best effort
|
||||
_ = _telegramNotificationService.SendSessionReportAsync(
|
||||
request.TenantId, userIdentifier, sessionId, metadata.ReportId, request.Comment ?? string.Empty);
|
||||
await _opsEvents.RecordSessionReportSubmittedAsync(request.TenantId, userIdentifier, sessionId, metadata.ReportId);
|
||||
|
||||
// Persistent in-app notification for Global Admins — best effort
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
|
||||
+3
-8
@@ -18,22 +18,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
private readonly ILogger<SubmitRuleSubmissionsFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegram;
|
||||
private readonly GlobalNotificationService _globalNotifications;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
|
||||
public SubmitRuleSubmissionsFunction(
|
||||
ILogger<SubmitRuleSubmissionsFunction> logger,
|
||||
RuleSubmissionService service,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegram,
|
||||
GlobalNotificationService globalNotifications,
|
||||
OpsEventService? opsEvents = null)
|
||||
OpsEventService opsEvents)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegram = telegram;
|
||||
_globalNotifications = globalNotifications;
|
||||
_opsEvents = opsEvents;
|
||||
}
|
||||
@@ -112,9 +109,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
foreach (var s in created)
|
||||
{
|
||||
// Ops event (routable through alert rules, Push included) — written before the response, never throws
|
||||
if (_opsEvents != null)
|
||||
await _opsEvents.RecordRuleSubmissionReceivedAsync(request.TenantId, userIdentifier, s.SubmissionId, s.RuleKind, s.SourceRuleId);
|
||||
_ = _telegram.SendRuleSubmissionAsync(s.SubmissionId, request.TenantId, userIdentifier, s.RuleKind, s.SourceRuleId, s.Title, s.Comment ?? string.Empty);
|
||||
await _opsEvents.RecordRuleSubmissionReceivedAsync(request.TenantId, userIdentifier, s.SubmissionId, s.RuleKind, s.SourceRuleId);
|
||||
_ = _globalNotifications.CreateNotificationAsync(
|
||||
"rule_submission",
|
||||
$"Rule submission {s.SubmissionId}",
|
||||
|
||||
+15
-8
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly NotificationChannelDispatcher _channelDispatcher;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
@@ -52,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
IGraphAccessHealthRecorder accessHealth,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
NotificationChannelDispatcher channelDispatcher,
|
||||
SessionDeletionGuard deletionGuard,
|
||||
@@ -69,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||||
_accessHealth = accessHealth;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_channelDispatcher = channelDispatcher;
|
||||
_deletionGuard = deletionGuard;
|
||||
@@ -115,7 +118,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
|
||||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
|
||||
accessHealth: _accessHealth,
|
||||
selfReportedCloudPc: registration.IsCloudPc
|
||||
);
|
||||
|
||||
if (errorResponse2 != null)
|
||||
@@ -141,12 +146,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
|
||||
|
||||
// Server-populated: record which device-validation path accepted this request
|
||||
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap / CloudPc /
|
||||
// IntuneEnrollment) so the
|
||||
// session row shows HOW the backend admitted the device. Overwrites any
|
||||
// agent-sent value unconditionally — this is the server's verdict, not input.
|
||||
registration.ValidatedBy = validation.ValidatedBy;
|
||||
// What the backend established about the device — which validator admitted it and the
|
||||
// Autopilot registration that validator read — travels apart from the agent's body, so
|
||||
// a device can never report its own group tag or validator.
|
||||
var serverFacts = new SessionServerFacts
|
||||
{
|
||||
ValidatedBy = validation.ValidatedBy,
|
||||
AutopilotRegistration = validation.AutopilotRegistration,
|
||||
};
|
||||
|
||||
// Device-supplied session anchor: clamp before ANY consumer sees it. StartedAt drives the
|
||||
// SessionsIndex RowKey (list order), the retention filter ("StartedAt lt cutoff"), the
|
||||
@@ -205,7 +212,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
bool stored;
|
||||
try
|
||||
{
|
||||
stored = await _sessionRepo.StoreSessionAsync(registration, ownerDecision.OwnerToStamp);
|
||||
stored = await _sessionRepo.StoreSessionAsync(registration, serverFacts, ownerDecision.OwnerToStamp);
|
||||
}
|
||||
catch (SessionTenantConflictException conflict)
|
||||
{
|
||||
|
||||
@@ -148,9 +148,9 @@ public class SearchSessionsFunction
|
||||
AgentVersionPrefix = query["agentVersionPrefix"],
|
||||
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
|
||||
ConnectionType = query["connectionType"],
|
||||
GroupTag = string.IsNullOrWhiteSpace(query["groupTag"]) ? null : query["groupTag"]!.Trim(),
|
||||
// Free-text search (dashboard search box); trimmed, empty → null.
|
||||
Q = string.IsNullOrWhiteSpace(query["q"]) ? null : query["q"]!.Trim(),
|
||||
// Limit field is a no-op in the paged path — pagination drives count.
|
||||
};
|
||||
|
||||
if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp;
|
||||
@@ -225,6 +225,11 @@ public class SearchSessionsFunction
|
||||
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
|
||||
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
|
||||
if (fields.Contains("connectionType")) dict["connectionType"] = s.ConnectionType;
|
||||
if (fields.Contains("autopilotGroupTag")) dict["autopilotGroupTag"] = s.AutopilotGroupTag;
|
||||
if (fields.Contains("autopilotPurchaseOrderId")) dict["autopilotPurchaseOrderId"] = s.AutopilotPurchaseOrderId;
|
||||
if (fields.Contains("autopilotDisplayName")) dict["autopilotDisplayName"] = s.AutopilotDisplayName;
|
||||
if (fields.Contains("autopilotDeviceId")) dict["autopilotDeviceId"] = s.AutopilotDeviceId;
|
||||
if (fields.Contains("autopilotUserAssigned")) dict["autopilotUserAssigned"] = s.AutopilotUserAssigned;
|
||||
|
||||
if (dict.Count == 0)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// The Fleet Health view filter by Autopilot group tag: sessions whose group tag is listed, or that
|
||||
/// carry none with <see cref="ExcludeUntagged"/>, leave every figure of the view. Nothing is stored —
|
||||
/// the filter lives in the request, and alerts never see it. Tags compare trimmed and without case.
|
||||
/// </summary>
|
||||
public sealed class FleetGroupTagFilter
|
||||
{
|
||||
/// <summary>Most group tags one request may exclude.</summary>
|
||||
internal const int MaxExcludedTags = 20;
|
||||
|
||||
public static readonly FleetGroupTagFilter None = new(Array.Empty<string>(), excludeUntagged: false);
|
||||
|
||||
private readonly HashSet<string> _excluded;
|
||||
|
||||
private FleetGroupTagFilter(IReadOnlyList<string> excludedTags, bool excludeUntagged)
|
||||
{
|
||||
ExcludedTags = excludedTags;
|
||||
ExcludeUntagged = excludeUntagged;
|
||||
_excluded = new HashSet<string>(excludedTags, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>The excluded group tags as requested: trimmed, de-duplicated without case.</summary>
|
||||
public IReadOnlyList<string> ExcludedTags { get; }
|
||||
|
||||
/// <summary>Whether the sessions without a group tag leave the view.</summary>
|
||||
public bool ExcludeUntagged { get; }
|
||||
|
||||
public bool IsActive => ExcludedTags.Count > 0 || ExcludeUntagged;
|
||||
|
||||
/// <summary>The bucket key of a session: its trimmed group tag, empty when it has none.</summary>
|
||||
internal static string KeyOf(SessionSummary session) => session.AutopilotGroupTag?.Trim() ?? string.Empty;
|
||||
|
||||
public bool Excludes(SessionSummary session) => ExcludesKey(KeyOf(session));
|
||||
|
||||
internal bool ExcludesKey(string key) => key.Length == 0 ? ExcludeUntagged : _excluded.Contains(key);
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>excludeGroupTags</c> (comma-separated, at most <see cref="MaxExcludedTags"/>) and
|
||||
/// <c>excludeUntagged</c> ("true"). False with an error message for too many or overlong tags.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? excludeGroupTags, string? excludeUntagged, out FleetGroupTagFilter filter, out string? error)
|
||||
{
|
||||
filter = None;
|
||||
error = null;
|
||||
var tags = (excludeGroupTags ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (tags.Count > MaxExcludedTags)
|
||||
{
|
||||
error = $"excludeGroupTags takes at most {MaxExcludedTags} comma-separated group tags";
|
||||
return false;
|
||||
}
|
||||
if (tags.Any(t => t.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength))
|
||||
{
|
||||
error = $"a group tag in excludeGroupTags is longer than {AutopilotDeviceValidator.MaxRegistrationFieldLength} characters";
|
||||
return false;
|
||||
}
|
||||
|
||||
var untagged = string.Equals(excludeUntagged?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (tags.Count > 0 || untagged)
|
||||
filter = new FleetGroupTagFilter(tags, untagged);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,10 +192,19 @@ public static class MetricsMath
|
||||
/// it. Duration stats (average, median, P90) count every non-in-progress session that carries a
|
||||
/// positive duration (failures included); the cards lead with the median because enrollment
|
||||
/// durations are heavily right-skewed and a handful of multi-hour outliers make the mean
|
||||
/// meaningless as a "typical enrollment" answer.
|
||||
/// meaningless as a "typical enrollment" answer. The group-tag view filter takes its sessions out
|
||||
/// of every figure except the group tag breakdown, which keeps them flagged so they can be shown
|
||||
/// again; <see cref="FleetHealthMetrics.ExcludedSessions"/> says how many left.
|
||||
/// </summary>
|
||||
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
|
||||
public static FleetHealthMetrics BuildFleetHealthPayload(
|
||||
IReadOnlyList<SessionSummary> allSessions, int days, FleetGroupTagFilter? groupTagFilter = null)
|
||||
{
|
||||
var filter = groupTagFilter ?? FleetGroupTagFilter.None;
|
||||
var groupTagHealth = BuildFleetGroupTagHealth(allSessions, filter);
|
||||
IReadOnlyList<SessionSummary> sessions = filter.IsActive
|
||||
? allSessions.Where(s => !filter.Excludes(s)).ToList()
|
||||
: allSessions;
|
||||
|
||||
int succeeded = 0, failed = 0, inProgress = 0, incomplete = 0;
|
||||
var completedDurationSeconds = new List<double>();
|
||||
|
||||
@@ -241,10 +250,60 @@ public static class MetricsMath
|
||||
ModelHealth = BuildFleetModelHealth(sessions),
|
||||
SlowestModels = BuildFleetSlowestModels(sessions),
|
||||
TopFailingModels = BuildFleetTopFailingModels(sessions),
|
||||
GroupTagHealth = groupTagHealth,
|
||||
ExcludedSessions = allSessions.Count - sessions.Count,
|
||||
ExcludedGroupTags = filter.ExcludedTags.ToList(),
|
||||
ExcludeUntagged = filter.ExcludeUntagged,
|
||||
ComputedAt = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Upper bound of the group tag breakdown; the global view spans every tenant's tags.</summary>
|
||||
internal const int MaxGroupTagBuckets = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Sessions per group tag (trimmed, compared without case, shown in the most frequent spelling)
|
||||
/// over the unfiltered window, so a bucket the view filter excluded stays visible and can be
|
||||
/// shown again. Empty when no session carries a tag — the breakdown would be one "No group tag" row.
|
||||
/// </summary>
|
||||
private static List<FleetGroupTagHealth> BuildFleetGroupTagHealth(IReadOnlyList<SessionSummary> sessions, FleetGroupTagFilter filter)
|
||||
{
|
||||
if (!sessions.Any(s => FleetGroupTagFilter.KeyOf(s).Length > 0))
|
||||
return new List<FleetGroupTagHealth>();
|
||||
|
||||
var buckets = new Dictionary<string, (FleetGroupTagHealth Row, Dictionary<string, int> Spellings)>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
var key = FleetGroupTagFilter.KeyOf(s);
|
||||
if (!buckets.TryGetValue(key, out var bucket))
|
||||
{
|
||||
bucket = (new FleetGroupTagHealth { Excluded = filter.ExcludesKey(key) }, new Dictionary<string, int>(StringComparer.Ordinal));
|
||||
buckets[key] = bucket;
|
||||
}
|
||||
bucket.Row.Total++;
|
||||
if (s.Status == SessionStatus.Succeeded) bucket.Row.Succeeded++;
|
||||
else if (s.Status == SessionStatus.Failed) bucket.Row.Failed++;
|
||||
bucket.Spellings[key] = bucket.Spellings.TryGetValue(key, out var seen) ? seen + 1 : 1;
|
||||
}
|
||||
|
||||
foreach (var (_, bucket) in buckets)
|
||||
{
|
||||
var spelling = bucket.Spellings
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.ThenBy(kv => kv.Key, StringComparer.Ordinal)
|
||||
.First().Key;
|
||||
bucket.Row.GroupTag = spelling;
|
||||
bucket.Row.Label = spelling.Length == 0 ? "No group tag" : spelling;
|
||||
}
|
||||
|
||||
return buckets.Values
|
||||
.Select(b => b.Row)
|
||||
.OrderByDescending(r => r.Total)
|
||||
.ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxGroupTagBuckets)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One point per day in the window, oldest-first, so every day renders even with zero
|
||||
/// enrollments. Sessions are bucketed by their StartedAt calendar day, treated as UTC to
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class ResiliencePolicies
|
||||
public IAsyncPolicy<HttpResponseMessage> ExternalDataApi { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy for notification clients (webhook, Telegram):
|
||||
/// Policy for notification clients (webhook):
|
||||
/// 2 retries with 2s fixed delay. No circuit breaker — a single failing endpoint
|
||||
/// should not block all notifications across tenants.
|
||||
/// </summary>
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class SessionSearchQuery
|
||||
new("serial", s => s.SerialNumber),
|
||||
new("model", s => s.Model),
|
||||
new("manufacturer", s => s.Manufacturer),
|
||||
new("grouptag", s => s.AutopilotGroupTag),
|
||||
new("status", s => s.Status.ToString()),
|
||||
new("session", s => s.SessionId),
|
||||
new("country", s => s.GeoCountry),
|
||||
|
||||
@@ -52,6 +52,15 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
("homedAppClientId", c => c.HomedAppClientId),
|
||||
("lastAuthClientId", c => c.LastAuthClientId),
|
||||
("lastAuthClientIdSince", c => c.LastAuthClientIdSince),
|
||||
// Graph access state: two timestamps and a Microsoft error code, not secrets.
|
||||
("graphAccessRejectedSince", c => c.GraphAccessRejectedSince),
|
||||
("graphAccessRejectedCode", c => c.GraphAccessRejectedCode),
|
||||
("graphAccessRejectionConfirmedAt", c => c.GraphAccessRejectionConfirmedAt),
|
||||
// Missing add-on permission behind a switched-on validation: four timestamps.
|
||||
("intuneDeviceBindingPermissionMissingSince", c => c.IntuneDeviceBindingPermissionMissingSince),
|
||||
("intuneDeviceBindingPermissionMissingConfirmedAt", c => c.IntuneDeviceBindingPermissionMissingConfirmedAt),
|
||||
("cloudPcPermissionMissingSince", c => c.CloudPcPermissionMissingSince),
|
||||
("cloudPcPermissionMissingConfirmedAt", c => c.CloudPcPermissionMissingConfirmedAt),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -187,8 +187,8 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return "Invalid webhook provider type.";
|
||||
|
||||
// ...and only a webhook provider: its destination passed the webhook URL gate above, never
|
||||
// the channel gates of Telegram, Push or E-mail. Only a CHANGED value is checked, so a
|
||||
// stored one stays saveable.
|
||||
// the channel gates of Push or E-mail. Only a CHANGED value is checked, so a stored one
|
||||
// stays saveable.
|
||||
if (candidate.WebhookProviderType != existing.WebhookProviderType
|
||||
&& candidate.WebhookProviderType != 0
|
||||
&& !Shared.Models.Notifications.NotificationChannel.IsWebhookUrlProvider(candidate.WebhookProviderType))
|
||||
@@ -202,10 +202,10 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (channelsError != null)
|
||||
return $"Invalid notification channels: {channelsError}";
|
||||
|
||||
var telegramGateError = ValidateTelegramChannelGate(
|
||||
var platformProviderError = ValidatePlatformProviderGate(
|
||||
candidate.NotificationChannelsJson, existing.NotificationChannelsJson, isGlobalAdmin);
|
||||
if (telegramGateError != null)
|
||||
return telegramGateError;
|
||||
if (platformProviderError != null)
|
||||
return platformProviderError;
|
||||
|
||||
var pushCountError = ValidateSinglePushChannel(candidate.NotificationChannelsJson, existing.NotificationChannelsJson);
|
||||
if (pushCountError != null)
|
||||
@@ -365,15 +365,11 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (!Shared.Models.Notifications.NotificationChannel.IsSupportedProviderType(channel.ProviderType))
|
||||
return $"channel \"{label}\" has an invalid provider type.";
|
||||
|
||||
// Telegram's destination is a chat ID, not a URL — the SSRF gate does not apply
|
||||
// (there is no caller-supplied endpoint; the bot URL is platform-owned) and would
|
||||
// reject every valid value. Format-check the chat ID instead. A Push channel has
|
||||
// no destination at all: a URL on it is refused so it can never fall through to
|
||||
// the webhook path should a future branch forget the provider.
|
||||
// A Push channel has no destination at all: a URL on it is refused so it can never
|
||||
// fall through to the webhook path should a future branch forget the provider. An
|
||||
// e-mail channel names recipients, not a URL, so the SSRF gate does not apply.
|
||||
var destinationError =
|
||||
channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram
|
||||
? ValidateTelegramChatId(channel.Url)
|
||||
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push
|
||||
channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push
|
||||
? ValidatePushDestination(channel.Url)
|
||||
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Email
|
||||
? ValidateEmailDestination(channel.Url)
|
||||
@@ -393,45 +389,6 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a Telegram destination chat ID. Returns an error message, or null when valid.
|
||||
/// Accepts a numeric id (negative for groups/channels, e.g. -1003785642894) or an @username.
|
||||
/// Empty is rejected: unlike a webhook URL, a Telegram channel with no chat ID cannot be
|
||||
/// saved as "configured but idle" — it would silently drop every message.
|
||||
/// </summary>
|
||||
internal static string? ValidateTelegramChatId(string? chatId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(chatId))
|
||||
return "a Telegram chat ID is required.";
|
||||
|
||||
var trimmed = chatId!.Trim();
|
||||
|
||||
if (trimmed.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
// Telegram public usernames: 5-32 chars, letters/digits/underscore, letter first.
|
||||
var name = trimmed.Substring(1);
|
||||
if (name.Length < 5 || name.Length > 32 || !char.IsLetter(name[0]))
|
||||
return "the @username must be 5-32 characters and start with a letter.";
|
||||
foreach (var ch in name)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(ch) && ch != '_')
|
||||
return "the @username may only contain letters, digits and underscores.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var digits = trimmed.StartsWith("-", StringComparison.Ordinal) ? trimmed.Substring(1) : trimmed;
|
||||
if (digits.Length == 0 || digits.Length > 20)
|
||||
return "must be a numeric chat ID (up to 20 digits) or an @username.";
|
||||
foreach (var ch in digits)
|
||||
{
|
||||
if (!char.IsDigit(ch))
|
||||
return "must be a numeric chat ID (up to 20 digits) or an @username.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Push channel carries no destination (its recipients are the scope's paired devices,
|
||||
/// resolved at send time); a URL on it is refused rather than ignored.
|
||||
@@ -467,9 +424,8 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
/// <summary>
|
||||
/// Global-Admin gate for the providers that send through PLATFORM-owned infrastructure
|
||||
/// (<see cref="Shared.Models.Notifications.NotificationChannel.IsGlobalAdminOnlyProvider"/>):
|
||||
/// Telegram through the platform bot (the token lives in PreviewConfig, not in tenant
|
||||
/// config), E-mail through the platform's sender identity and provider key. A tenant admin
|
||||
/// must not be able to create or retarget one — hiding the provider in the UI is not a control.
|
||||
/// E-mail through the platform's sender identity and provider key. A tenant admin must not be
|
||||
/// able to create or retarget one — hiding the provider in the UI is not a control.
|
||||
/// <para>
|
||||
/// Compares against the stored config so a non-GA caller can still save unrelated changes
|
||||
/// while a GA-created gated channel is present: only ADDING one, changing an existing
|
||||
@@ -477,7 +433,7 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
/// deletion stay with the tenant admin. Same shape as the retention-cap check.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateTelegramChannelGate(string? candidateJson, string? existingJson, bool isGlobalAdmin)
|
||||
internal static string? ValidatePlatformProviderGate(string? candidateJson, string? existingJson, bool isGlobalAdmin)
|
||||
{
|
||||
if (isGlobalAdmin)
|
||||
return null;
|
||||
@@ -501,12 +457,7 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
|| !string.Equals(stored.Url ?? string.Empty, channel.Url ?? string.Empty, StringComparison.Ordinal)
|
||||
|| stored.Enabled != channel.Enabled)
|
||||
{
|
||||
var provider = channel.ProviderType switch
|
||||
{
|
||||
(int)Shared.Models.Notifications.WebhookProviderType.Email => "E-mail",
|
||||
_ => "Telegram",
|
||||
};
|
||||
return $"{provider} channels can only be configured by a Global Administrator.";
|
||||
return "E-mail channels can only be configured by a Global Administrator.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
|
||||
new KeyValuePair<string, string?>("groupTag", filter.GroupTag),
|
||||
new KeyValuePair<string, string?>("q", filter.Q),
|
||||
};
|
||||
if (filter.DeviceProperties != null)
|
||||
|
||||
@@ -458,6 +458,10 @@ builder.Services.AddSingleton<RegisterSessionFunction>();
|
||||
builder.Services.AddSingleton<GetAgentConfigFunction>();
|
||||
builder.Services.AddSingleton<ReportAgentErrorFunction>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.EntraAppRegistry>();
|
||||
builder.Services.AddSingleton<
|
||||
AutopilotMonitor.Functions.Security.IGraphAccessHealthRecorder,
|
||||
AutopilotMonitor.Functions.Security.GraphAccessHealthRecorder>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphAccessRecheckMaintenance>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
|
||||
@@ -473,10 +477,8 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolutio
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolution.IScriptDisplayNameResolver,
|
||||
AutopilotMonitor.Functions.Services.GraphResolution.ScriptDisplayNameResolver>();
|
||||
builder.Services.AddWebhookNotificationHttpClient();
|
||||
builder.Services.AddHttpClient<TelegramNotificationService>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
|
||||
// vs. the platform Telegram bot). Transient: both transports are typed HttpClients.
|
||||
// Channel-level send API — routes each NotificationChannel to its transport. Transient: the
|
||||
// webhook transport is a typed HttpClient.
|
||||
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.NotificationChannelDispatcher>();
|
||||
// Every channel send lands in the channel's health row (status dot in the portal, failing bell).
|
||||
builder.Services.AddSingleton<
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// A device validation that needs an optional Graph permission (an add-on grant) instead of the
|
||||
/// default consent: its switch, its feature in <see cref="GraphFeatureCatalog"/>, and the two
|
||||
/// tenant fields of its missing-permission episode. <see cref="GraphAccessHealthRecorder"/> runs the
|
||||
/// episodes in the same two stages as the token state.
|
||||
/// </summary>
|
||||
public sealed class AddOnValidation
|
||||
{
|
||||
public static readonly AddOnValidation CloudPc = new(
|
||||
GraphFeatureCatalog.FeatureW365CloudPcValidation,
|
||||
c => c.ValidateCloudPcDevice,
|
||||
c => c.CloudPcPermissionMissingSince, (c, v) => c.CloudPcPermissionMissingSince = v,
|
||||
c => c.CloudPcPermissionMissingConfirmedAt, (c, v) => c.CloudPcPermissionMissingConfirmedAt = v);
|
||||
|
||||
public static readonly AddOnValidation IntuneDeviceBinding = new(
|
||||
GraphFeatureCatalog.FeatureIntuneDeviceBinding,
|
||||
c => c.ValidateIntuneDeviceBinding,
|
||||
c => c.IntuneDeviceBindingPermissionMissingSince, (c, v) => c.IntuneDeviceBindingPermissionMissingSince = v,
|
||||
c => c.IntuneDeviceBindingPermissionMissingConfirmedAt, (c, v) => c.IntuneDeviceBindingPermissionMissingConfirmedAt = v);
|
||||
|
||||
/// <summary>Both, in chain order.</summary>
|
||||
public static readonly IReadOnlyList<AddOnValidation> All = new[] { CloudPc, IntuneDeviceBinding };
|
||||
|
||||
private readonly Func<TenantConfiguration, bool> _isEnabled;
|
||||
private readonly Func<TenantConfiguration, DateTime?> _since;
|
||||
private readonly Action<TenantConfiguration, DateTime?> _setSince;
|
||||
private readonly Func<TenantConfiguration, DateTime?> _confirmedAt;
|
||||
private readonly Action<TenantConfiguration, DateTime?> _setConfirmedAt;
|
||||
|
||||
private AddOnValidation(
|
||||
string feature,
|
||||
Func<TenantConfiguration, bool> isEnabled,
|
||||
Func<TenantConfiguration, DateTime?> since, Action<TenantConfiguration, DateTime?> setSince,
|
||||
Func<TenantConfiguration, DateTime?> confirmedAt, Action<TenantConfiguration, DateTime?> setConfirmedAt)
|
||||
{
|
||||
Feature = feature;
|
||||
Permission = string.Join(", ", GraphFeatureCatalog.RequiredPermissions(feature));
|
||||
_isEnabled = isEnabled;
|
||||
_since = since;
|
||||
_setSince = setSince;
|
||||
_confirmedAt = confirmedAt;
|
||||
_setConfirmedAt = setConfirmedAt;
|
||||
}
|
||||
|
||||
/// <summary>Feature id of the grant script and of the permission-status endpoint.</summary>
|
||||
public string Feature { get; }
|
||||
|
||||
/// <summary>The Graph application permission the add-on grant adds.</summary>
|
||||
public string Permission { get; }
|
||||
|
||||
public bool IsEnabled(TenantConfiguration config) => _isEnabled(config);
|
||||
|
||||
public bool IsGranted(IEnumerable<string> roles) => GraphFeatureCatalog.IsFeatureGranted(Feature, roles);
|
||||
|
||||
public DateTime? MissingSince(TenantConfiguration config) => _since(config);
|
||||
|
||||
public DateTime? MissingConfirmedAt(TenantConfiguration config) => _confirmedAt(config);
|
||||
|
||||
internal void StampSince(TenantConfiguration config, DateTime at) => _setSince(config, at);
|
||||
|
||||
internal void StampConfirmed(TenantConfiguration config, DateTime at) => _setConfirmedAt(config, at);
|
||||
|
||||
internal void ClearEpisode(TenantConfiguration config)
|
||||
{
|
||||
_setSince(config, null);
|
||||
_setConfirmedAt(config, null);
|
||||
}
|
||||
|
||||
/// <summary>Switched on and its missing permission confirmed: the portal shows the notice.</summary>
|
||||
public bool IsConfirmedMissing(TenantConfiguration config) => IsEnabled(config) && MissingConfirmedAt(config) != null;
|
||||
|
||||
/// <summary>
|
||||
/// True when every switched-on validation is an add-on whose missing permission is confirmed:
|
||||
/// then no device can be validated at all.
|
||||
/// </summary>
|
||||
public static bool BlocksAllValidation(TenantConfiguration config) =>
|
||||
config.HasAnyDeviceValidation()
|
||||
&& !config.ValidateAutopilotDevice
|
||||
&& !config.ValidateCorporateIdentifier
|
||||
&& !config.ValidateDeviceAssociation
|
||||
&& !All.Any(a => a.IsEnabled(config) && !a.IsConfirmedMissing(config));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -114,7 +115,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new AutopilotDeviceValidationResult
|
||||
@@ -219,7 +220,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
AutopilotDeviceId = exactDevice["id"]?.ToString()
|
||||
Registration = ReadRegistration(exactDevice)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -227,7 +228,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
tenantId,
|
||||
sessionId ?? "<none>",
|
||||
normalizedSerial,
|
||||
result.AutopilotDeviceId ?? "<none>");
|
||||
result.Registration?.DeviceId ?? "<none>");
|
||||
|
||||
return CacheAndReturn(cacheKey, result, isPositive: true);
|
||||
}
|
||||
@@ -271,6 +272,31 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return $"autopilot-device-validation:{tenantId}:{serialNumber}";
|
||||
}
|
||||
|
||||
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
|
||||
internal const int MaxRegistrationFieldLength = 256;
|
||||
|
||||
/// <summary>
|
||||
/// The registration fields a session keeps, read from the object this lookup already holds. The
|
||||
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
|
||||
/// never read.
|
||||
/// </summary>
|
||||
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
|
||||
{
|
||||
DeviceId = RegistrationText(device["id"]),
|
||||
GroupTag = RegistrationText(device["groupTag"]),
|
||||
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
|
||||
DisplayName = RegistrationText(device["displayName"]),
|
||||
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
|
||||
};
|
||||
|
||||
private static string? RegistrationText(JToken? token)
|
||||
{
|
||||
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
|
||||
}
|
||||
|
||||
private AutopilotDeviceValidationResult CacheAndReturn(
|
||||
string cacheKey,
|
||||
AutopilotDeviceValidationResult result,
|
||||
@@ -303,7 +329,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public int? RetryAfterSeconds { get; set; }
|
||||
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? AutopilotDeviceId { get; set; }
|
||||
|
||||
/// <summary>The device's Autopilot registration; set exactly when <see cref="IsValid"/>.</summary>
|
||||
public AutopilotRegistration? Registration { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
_graphTokenService = graphTokenService;
|
||||
}
|
||||
|
||||
public async Task<CloudPcValidationResult> ValidateCloudPcAsync(
|
||||
public virtual async Task<CloudPcValidationResult> ValidateCloudPcAsync(
|
||||
string tenantId,
|
||||
string? intuneDeviceId,
|
||||
string? sessionId = null,
|
||||
@@ -119,7 +119,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new CloudPcValidationResult
|
||||
@@ -174,6 +174,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return CacheAndReturn(cacheKey, new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
PermissionMissing = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = "Cloud PC lookup not permitted — grant the optional 'W365CloudPcValidation' Graph feature (CloudPC.Read.All)"
|
||||
}, isPositive: false);
|
||||
@@ -303,6 +304,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public bool IsTransient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when Graph refused the lookup after a fresh token because CloudPC.Read.All is not
|
||||
/// granted: a definitive negative about the tenant's configuration, not about the device.
|
||||
/// </summary>
|
||||
public bool PermissionMissing { get; set; }
|
||||
|
||||
/// <summary>Intune device id extracted from the MDM client certificate's Subject CN.</summary>
|
||||
public string? IntuneDeviceId { get; set; }
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new CorporateIdentifierValidationResult
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new DeviceAssociationResult
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Microsoft Entra's answer when it refuses an app token: the HTTP status plus the AADSTS code
|
||||
/// from the response body. Only a client error is a rejection; a 5xx, a throttle or a network
|
||||
/// failure says nothing about the tenant and never becomes one.
|
||||
/// </summary>
|
||||
public sealed record EntraTokenRejection(int StatusCode, string? ErrorCode, string? Description)
|
||||
{
|
||||
/// <summary>The app has no service principal in the tenant: its enterprise application was removed.</summary>
|
||||
public const string ServicePrincipalMissingCode = "AADSTS7000229";
|
||||
|
||||
/// <summary>
|
||||
/// Not a refusal in Entra's words but the same lockout: a token without any application role,
|
||||
/// which Graph refuses on every call. Typically a removed enterprise application that a user's
|
||||
/// sign-in consent re-created. The repair is the same admin consent.
|
||||
/// </summary>
|
||||
public static readonly EntraTokenRejection NoApplicationRole =
|
||||
new(200, "NoApplicationRole", "The app token carries no application permission");
|
||||
|
||||
private const string CodePrefix = "AADSTS";
|
||||
private const int MaxDescriptionLength = 300;
|
||||
|
||||
// Wrong, expired or missing client secret: our configuration, never the customer's tenant.
|
||||
private static readonly string[] OurCredentialCodes = { "AADSTS7000215", "AADSTS7000222", "AADSTS7000218" };
|
||||
|
||||
public bool IsServicePrincipalMissing =>
|
||||
string.Equals(ErrorCode, ServicePrincipalMissingCode, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsOurCredential =>
|
||||
ErrorCode != null && OurCredentialCodes.Contains(ErrorCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reads the code and the description from a token error body; a body that is not JSON keeps only the status.</summary>
|
||||
public static EntraTokenRejection Parse(int statusCode, string? body)
|
||||
{
|
||||
var json = TryParseObject(body);
|
||||
var description = (json?["error_description"] as JValue)?.Value as string;
|
||||
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
|
||||
}
|
||||
|
||||
private static JObject? TryParseObject(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadCode(JObject? json, string? description)
|
||||
{
|
||||
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
|
||||
if (first != null) return CodePrefix + first.Value<long>();
|
||||
|
||||
// Fallback for a body without error_codes: the description starts with the code.
|
||||
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
|
||||
if (start < 0) return null;
|
||||
var digits = new string(description!.Skip(start + CodePrefix.Length).TakeWhile(char.IsDigit).ToArray());
|
||||
return digits.Length == 0 ? null : CodePrefix + digits;
|
||||
}
|
||||
|
||||
private static string? Shorten(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description)) return null;
|
||||
var line = description.Split('\n')[0].Trim();
|
||||
return line.Length <= MaxDescriptionLength ? line : line.Substring(0, MaxDescriptionLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>Folds what Entra and Graph answer for a tenant's app token into the tenant's Graph access state.</summary>
|
||||
public interface IGraphAccessHealthRecorder
|
||||
{
|
||||
/// <summary>Entra refused the token on an agent request. Never throws.</summary>
|
||||
Task RecordRejectedAsync(string tenantId, EntraTokenRejection rejection);
|
||||
|
||||
/// <summary>
|
||||
/// A token with an application role was freshly minted for the app acting on this tenant;
|
||||
/// <paramref name="roles"/> are its application roles. Never throws.
|
||||
/// </summary>
|
||||
Task RecordMintedAsync(string tenantId, IReadOnlyCollection<string> roles);
|
||||
|
||||
/// <summary>
|
||||
/// An agent request was not admitted while these switched-on validations lacked their optional
|
||||
/// Graph permission. Never throws.
|
||||
/// </summary>
|
||||
Task RecordAddOnPermissionMissingAsync(string tenantId, IReadOnlyList<AddOnValidation> addOns);
|
||||
|
||||
/// <summary>Ends the missing-permission episodes of validations that are switched off. Never throws.</summary>
|
||||
Task ReconcileSwitchedOffAddOnsAsync(string tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two stages, so one failed request never reaches a customer. The first rejection only stamps
|
||||
/// the suspicion. A rejection at least <see cref="ConfirmAfter"/> later confirms it: only then
|
||||
/// does the portal show its notice and the operator get an alert. A freshly minted token with an
|
||||
/// application role ends the episode. A token from the cache proves nothing and never reaches
|
||||
/// this class: it stays valid for up to 55 minutes after the enterprise application is gone.
|
||||
/// Neither does a token without a role, or the notice would vanish the moment an admin signs in
|
||||
/// to read it: that sign-in re-creates the application without any permission.
|
||||
/// <para>
|
||||
/// The add-on validations (<see cref="AddOnValidation"/>) run the same two stages per validation:
|
||||
/// a device that was not admitted because the switched-on validation lacks its optional
|
||||
/// permission stamps the suspicion; a further one, or a fresh token still without the permission,
|
||||
/// at least <see cref="ConfirmAfter"/> later confirms it. A fresh token that carries the
|
||||
/// permission, or the switch turned off, ends the episode.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every decision runs on the fresh row, so across instances only one write wins and the ops
|
||||
/// event fires once. Codes that blame our own credentials never touch the tenant: an expired
|
||||
/// secret would otherwise tell every customer to repair something on their side.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GraphAccessHealthRecorder : IGraphAccessHealthRecorder
|
||||
{
|
||||
public static readonly TimeSpan ConfirmAfter = TimeSpan.FromMinutes(15);
|
||||
|
||||
// Resolutions of an add-on episode, as AddOnPermissionResolved reports them.
|
||||
internal const string ResolvedByGrant = "granted";
|
||||
internal const string ResolvedBySwitchOff = "switched_off";
|
||||
internal const string ResolvedByAppHomingFlip = "app_homing_flip";
|
||||
|
||||
private const string BackupSource = "graph-access";
|
||||
private static readonly TimeSpan CredentialAlertInterval = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly TenantConfigurationService _tenantConfig;
|
||||
private readonly Lazy<OpsEventService> _opsEvents;
|
||||
private readonly ILogger<GraphAccessHealthRecorder> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
private long _lastCredentialAlertTicks;
|
||||
|
||||
public GraphAccessHealthRecorder(
|
||||
TenantConfigurationService tenantConfig,
|
||||
Lazy<OpsEventService> opsEvents,
|
||||
ILogger<GraphAccessHealthRecorder> logger,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_tenantConfig = tenantConfig;
|
||||
_opsEvents = opsEvents;
|
||||
_logger = logger;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
private enum Stage { Unchanged, Suspect, Confirmed }
|
||||
|
||||
private enum AddOnChange { None, Confirm, ResolveGranted, ResolveSwitchedOff }
|
||||
|
||||
private sealed record AddOnOutcome(AddOnValidation AddOn, AddOnChange Change, DateTime Since, bool WasConfirmed);
|
||||
|
||||
/// <summary>
|
||||
/// One sighting of an episode: the first stamps the suspicion, one at least
|
||||
/// <see cref="ConfirmAfter"/> later confirms it, anything else changes nothing.
|
||||
/// </summary>
|
||||
private static Stage Advance(DateTime? since, DateTime? confirmedAt, DateTime now)
|
||||
{
|
||||
if (confirmedAt != null)
|
||||
return Stage.Unchanged;
|
||||
if (since is not { } s)
|
||||
return Stage.Suspect;
|
||||
return now - s < ConfirmAfter ? Stage.Unchanged : Stage.Confirmed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a fresh token (<paramref name="roles"/>), or a switch check without one (null),
|
||||
/// means for an open add-on episode.
|
||||
/// </summary>
|
||||
private static AddOnChange PlanAddOnChange(AddOnValidation addOn, TenantConfiguration row, IReadOnlyCollection<string>? roles, DateTime now)
|
||||
{
|
||||
if (addOn.MissingSince(row) is not { } since)
|
||||
return AddOnChange.None;
|
||||
if (!addOn.IsEnabled(row))
|
||||
return AddOnChange.ResolveSwitchedOff;
|
||||
if (roles == null)
|
||||
return AddOnChange.None;
|
||||
if (addOn.IsGranted(roles))
|
||||
return AddOnChange.ResolveGranted;
|
||||
return Advance(since, addOn.MissingConfirmedAt(row), now) == Stage.Confirmed ? AddOnChange.Confirm : AddOnChange.None;
|
||||
}
|
||||
|
||||
private static void ApplyAddOnChanges(TenantConfiguration row, IReadOnlyCollection<string>? roles, DateTime now, List<AddOnOutcome> outcomes)
|
||||
{
|
||||
foreach (var addOn in AddOnValidation.All)
|
||||
{
|
||||
var change = PlanAddOnChange(addOn, row, roles, now);
|
||||
if (change == AddOnChange.None)
|
||||
continue;
|
||||
outcomes.Add(new AddOnOutcome(addOn, change, addOn.MissingSince(row)!.Value, addOn.MissingConfirmedAt(row) != null));
|
||||
if (change == AddOnChange.Confirm)
|
||||
addOn.StampConfirmed(row, now);
|
||||
else
|
||||
addOn.ClearEpisode(row);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordRejectedAsync(string tenantId, EntraTokenRejection rejection)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
if (rejection.IsOurCredential)
|
||||
{
|
||||
await AlertOurCredentialAsync(tenantId, rejection, now).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap pre-filter on the cached view; the write decides again on the fresh row.
|
||||
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
|
||||
if (cached == null || !cached.HasAnyDeviceValidation()
|
||||
|| Advance(cached.GraphAccessRejectedSince, cached.GraphAccessRejectionConfirmedAt, now) == Stage.Unchanged)
|
||||
return;
|
||||
|
||||
var stage = Stage.Unchanged;
|
||||
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
stage = Advance(row.GraphAccessRejectedSince, row.GraphAccessRejectionConfirmedAt, now);
|
||||
if (stage == Stage.Unchanged)
|
||||
return false;
|
||||
if (stage == Stage.Suspect)
|
||||
row.GraphAccessRejectedSince = now;
|
||||
else
|
||||
row.GraphAccessRejectionConfirmedAt = now;
|
||||
row.GraphAccessRejectedCode = rejection.ErrorCode;
|
||||
return true;
|
||||
}, BackupSource, "Entra rejected the app token").ConfigureAwait(false);
|
||||
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
return;
|
||||
|
||||
if (stage == Stage.Suspect)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Graph access for tenant {TenantId} is suspect: Entra rejected the app token ({ErrorCode}, status {StatusCode})",
|
||||
tenantId, rejection.ErrorCode, rejection.StatusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Graph access for tenant {TenantId} is confirmed lost: Entra still rejects the app token ({ErrorCode}, status {StatusCode})",
|
||||
tenantId, rejection.ErrorCode, rejection.StatusCode);
|
||||
await _opsEvents.Value.RecordGraphAccessRejectedAsync(tenantId, rejection.ErrorCode, rejection.StatusCode).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Recording the Graph token rejection of tenant {TenantId} failed", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordMintedAsync(string tenantId, IReadOnlyCollection<string> roles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
|
||||
if (cached == null
|
||||
|| (cached.GraphAccessRejectedSince == null
|
||||
&& AddOnValidation.All.All(a => PlanAddOnChange(a, cached, roles, now) == AddOnChange.None)))
|
||||
return;
|
||||
|
||||
var tokenCleared = false;
|
||||
var tokenWasConfirmed = false;
|
||||
string? code = null;
|
||||
var outcomes = new List<AddOnOutcome>();
|
||||
var blocksAll = false;
|
||||
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
tokenCleared = false;
|
||||
outcomes.Clear();
|
||||
if (row.GraphAccessRejectedSince != null)
|
||||
{
|
||||
tokenCleared = true;
|
||||
tokenWasConfirmed = row.GraphAccessRejectionConfirmedAt != null;
|
||||
code = row.GraphAccessRejectedCode;
|
||||
row.GraphAccessRejectedSince = null;
|
||||
row.GraphAccessRejectedCode = null;
|
||||
row.GraphAccessRejectionConfirmedAt = null;
|
||||
}
|
||||
ApplyAddOnChanges(row, roles, now, outcomes);
|
||||
blocksAll = AddOnValidation.BlocksAllValidation(row);
|
||||
return tokenCleared || outcomes.Count > 0;
|
||||
}, BackupSource, "A fresh app token arrived").ConfigureAwait(false);
|
||||
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
return;
|
||||
|
||||
if (tokenCleared && tokenWasConfirmed)
|
||||
{
|
||||
_logger.LogWarning("Graph access for tenant {TenantId} is back: Entra issued an app token again (was {ErrorCode})", tenantId, code);
|
||||
await _opsEvents.Value.RecordGraphAccessRecoveredAsync(tenantId, code).ConfigureAwait(false);
|
||||
}
|
||||
await ReportAddOnOutcomesAsync(tenantId, outcomes, blocksAll).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Recording the fresh app token of tenant {TenantId} failed", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordAddOnPermissionMissingAsync(string tenantId, IReadOnlyList<AddOnValidation> addOns)
|
||||
{
|
||||
if (addOns.Count == 0)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
|
||||
if (cached == null
|
||||
|| !addOns.Any(a => a.IsEnabled(cached) && Advance(a.MissingSince(cached), a.MissingConfirmedAt(cached), now) != Stage.Unchanged))
|
||||
return;
|
||||
|
||||
var stages = new List<(AddOnValidation AddOn, Stage Stage, DateTime Since)>();
|
||||
var blocksAll = false;
|
||||
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
stages.Clear();
|
||||
foreach (var addOn in addOns)
|
||||
{
|
||||
if (!addOn.IsEnabled(row))
|
||||
continue;
|
||||
var stage = Advance(addOn.MissingSince(row), addOn.MissingConfirmedAt(row), now);
|
||||
if (stage == Stage.Suspect)
|
||||
addOn.StampSince(row, now);
|
||||
else if (stage == Stage.Confirmed)
|
||||
addOn.StampConfirmed(row, now);
|
||||
else
|
||||
continue;
|
||||
stages.Add((addOn, stage, addOn.MissingSince(row)!.Value));
|
||||
}
|
||||
blocksAll = AddOnValidation.BlocksAllValidation(row);
|
||||
return stages.Count > 0;
|
||||
}, BackupSource, "An optional Graph permission is missing").ConfigureAwait(false);
|
||||
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
return;
|
||||
|
||||
foreach (var (addOn, stage, since) in stages)
|
||||
{
|
||||
if (stage == Stage.Suspect)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Add-on permission for tenant {TenantId} is suspect: {Feature} is switched on, but the app token lacks {Permission} and a device was not admitted",
|
||||
tenantId, addOn.Feature, addOn.Permission);
|
||||
continue;
|
||||
}
|
||||
await ReportConfirmedAsync(tenantId, addOn, since, blocksAll).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Recording the missing add-on permission of tenant {TenantId} failed", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReconcileSwitchedOffAddOnsAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
|
||||
if (cached == null || AddOnValidation.All.All(a => PlanAddOnChange(a, cached, null, now) == AddOnChange.None))
|
||||
return;
|
||||
|
||||
var outcomes = new List<AddOnOutcome>();
|
||||
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
outcomes.Clear();
|
||||
ApplyAddOnChanges(row, null, now, outcomes);
|
||||
return outcomes.Count > 0;
|
||||
}, BackupSource, "A validation that lacked its optional Graph permission was switched off").ConfigureAwait(false);
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
await ReportAddOnOutcomesAsync(tenantId, outcomes, blocksAll: false).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reconciling the add-on permission state of tenant {TenantId} failed", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportAddOnOutcomesAsync(string tenantId, List<AddOnOutcome> outcomes, bool blocksAll)
|
||||
{
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
if (outcome.Change == AddOnChange.Confirm)
|
||||
{
|
||||
await ReportConfirmedAsync(tenantId, outcome.AddOn, outcome.Since, blocksAll).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
// An episode that was never confirmed was never shown: it ends without a trace.
|
||||
if (!outcome.WasConfirmed)
|
||||
continue;
|
||||
var resolution = outcome.Change == AddOnChange.ResolveGranted ? ResolvedByGrant : ResolvedBySwitchOff;
|
||||
_logger.LogWarning(
|
||||
"Add-on permission for tenant {TenantId} is resolved: {Feature} ({Resolution})",
|
||||
tenantId, outcome.AddOn.Feature, resolution);
|
||||
await _opsEvents.Value.RecordAddOnPermissionResolvedAsync(tenantId, outcome.AddOn.Feature, resolution).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportConfirmedAsync(string tenantId, AddOnValidation addOn, DateTime since, bool blocksAll)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Add-on permission for tenant {TenantId} is confirmed missing: {Feature} still lacks {Permission} (since {Since:o}, blocks all validation: {BlocksAll})",
|
||||
tenantId, addOn.Feature, addOn.Permission, since, blocksAll);
|
||||
await _opsEvents.Value.RecordAddOnPermissionMissingAsync(tenantId, addOn.Feature, addOn.Permission, since, blocksAll).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task AlertOurCredentialAsync(string tenantId, EntraTokenRejection rejection, DateTime now)
|
||||
{
|
||||
// Hits every tenant at once: one alert per instance and hour says everything.
|
||||
var last = Interlocked.Read(ref _lastCredentialAlertTicks);
|
||||
if (now.Ticks - last < CredentialAlertInterval.Ticks)
|
||||
return;
|
||||
if (Interlocked.CompareExchange(ref _lastCredentialAlertTicks, now.Ticks, last) != last)
|
||||
return;
|
||||
|
||||
_logger.LogError(
|
||||
"Entra rejects our app credentials ({ErrorCode}, status {StatusCode}, seen on tenant {TenantId}); device validation fails for every tenant on that app",
|
||||
rejection.ErrorCode, rejection.StatusCode, tenantId);
|
||||
await _opsEvents.Value.RecordGraphAppCredentialRejectedAsync(tenantId, rejection.ErrorCode, rejection.StatusCode).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -30,6 +31,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
private static readonly TimeSpan MaxTokenTtl = TimeSpan.FromMinutes(55);
|
||||
private static readonly TimeSpan DefaultTokenLifetime = TimeSpan.FromMinutes(35); // when AAD omits expires_in
|
||||
private static readonly TimeSpan TokenGateWait = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan RejectionLogWindow = TimeSpan.FromMinutes(15);
|
||||
|
||||
// One coalescing gate per tenant (see GetAccessTokenAsync). A SemaphoreSlim is tiny and
|
||||
// tenant cardinality is bounded + validated upstream, so these are never evicted.
|
||||
@@ -52,19 +54,22 @@ namespace AutopilotMonitor.Functions.Security
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly Services.TenantConfigurationService _tenantConfigService;
|
||||
private readonly IGraphAccessHealthRecorder _accessHealth;
|
||||
|
||||
public GraphTokenService(
|
||||
ILogger<GraphTokenService> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
EntraAppRegistry appRegistry,
|
||||
Services.TenantConfigurationService tenantConfigService)
|
||||
Services.TenantConfigurationService tenantConfigService,
|
||||
IGraphAccessHealthRecorder accessHealth)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_appRegistry = appRegistry;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_accessHealth = accessHealth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -138,7 +143,41 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// the resolved client id, so a tenant flipped between apps can never be served a token
|
||||
// minted under the other app.
|
||||
var credentials = await ResolveCredentialsAsync(tenantId);
|
||||
return await GetAccessTokenWithCredentialsAsync(tenantId, credentials, ct);
|
||||
var result = await GetAccessTokenWithCredentialsAsync(tenantId, credentials, ct);
|
||||
if (result.IsFreshlyMinted && TryGetApplicationRoles(result.AccessToken, out var roles))
|
||||
await _accessHealth.RecordMintedAsync(tenantId, roles);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="GetAccessTokenAsync"/> for the device validators of the agent routes and the
|
||||
/// maintenance recheck, the only callers whose rejection counts as evidence of lost access.
|
||||
/// The consent flows in the portal see 401s as a matter of course while a consent propagates.
|
||||
/// </summary>
|
||||
public async Task<GraphTokenResult> GetAccessTokenForAgentValidationAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await GetAccessTokenAsync(tenantId, ct);
|
||||
var refusal = result.Rejection
|
||||
?? (result.IsFreshlyMinted && !TryGetApplicationRoles(result.AccessToken, out _) ? EntraTokenRejection.NoApplicationRole : null);
|
||||
if (refusal != null)
|
||||
await _accessHealth.RecordRejectedAsync(tenantId, refusal);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a token can do anything at Graph, and with which application roles. A user's
|
||||
/// sign-in consent re-creates a removed enterprise application without any application
|
||||
/// permission: Entra issues tokens again, and Graph refuses every call made with them. Only a
|
||||
/// role proves access.
|
||||
/// </summary>
|
||||
private static bool TryGetApplicationRoles(string? accessToken, out IReadOnlySet<string> roles)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(accessToken)
|
||||
&& GraphFeatureDetector.TryParseToken(accessToken, out roles, out _)
|
||||
&& roles.Count > 0)
|
||||
return true;
|
||||
roles = new HashSet<string>();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -238,6 +277,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
string? responseBody = null;
|
||||
int attempt = 0;
|
||||
bool lastAttemptWasTransient = false;
|
||||
// Survives the budget: a caller cut off between retries still learns what Entra said.
|
||||
EntraTokenRejection? rejection = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -245,7 +286,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
// Caller's budget exhausted — treat as transient so we never cache "no consent"
|
||||
// for a tenant just because the optional path timed out.
|
||||
return GraphTokenResult.TransientFailure();
|
||||
return GraphTokenResult.TransientFailure(rejection);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -271,7 +312,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// the token's own expires_in, minus a skew margin, clamped to a safe band.
|
||||
CacheToken(tenantId, clientId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
|
||||
}
|
||||
return GraphTokenResult.Success(accessToken);
|
||||
return GraphTokenResult.Minted(accessToken);
|
||||
}
|
||||
|
||||
// Classify the error: consent-propagation errors are retryable but ultimately permanent (no consent).
|
||||
@@ -287,6 +328,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|| isConsentError || isServerError;
|
||||
|
||||
lastAttemptWasTransient = isServerError;
|
||||
rejection = isServerError ? null : EntraTokenRejection.Parse(statusCode, responseBody);
|
||||
if (rejection != null)
|
||||
LogRejectionOncePerWindow(tenantId, clientId, rejection);
|
||||
|
||||
if (!isRetryable || attempt >= retryDelays.Length)
|
||||
{
|
||||
@@ -300,7 +344,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// Server errors are transient (infrastructure issue), consent errors are permanent (no consent granted)
|
||||
return lastAttemptWasTransient
|
||||
? GraphTokenResult.TransientFailure()
|
||||
: GraphTokenResult.PermanentFailure();
|
||||
: GraphTokenResult.PermanentFailure(rejection);
|
||||
}
|
||||
|
||||
var delay = retryDelays[attempt];
|
||||
@@ -328,7 +372,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Caller budget exhausted between retries — transient by definition.
|
||||
return GraphTokenResult.TransientFailure();
|
||||
return GraphTokenResult.TransientFailure(rejection);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
@@ -338,7 +382,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
_logger.LogWarning(ex,
|
||||
"Graph token acquisition for tenant {TenantId} failed with network error after {Attempts} attempt(s)",
|
||||
tenantId, attempt + 1);
|
||||
return GraphTokenResult.TransientFailure();
|
||||
return GraphTokenResult.TransientFailure(rejection);
|
||||
}
|
||||
|
||||
var delay = retryDelays[attempt];
|
||||
@@ -349,13 +393,29 @@ namespace AutopilotMonitor.Functions.Security
|
||||
try { await Task.Delay(delay, ct); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return GraphTokenResult.TransientFailure();
|
||||
return GraphTokenResult.TransientFailure(rejection);
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retry chain's own failure line only comes after its 50 s, which the validation budget
|
||||
/// never reaches, so without this line Entra's reason would not reach App Insights at all.
|
||||
/// </summary>
|
||||
private void LogRejectionOncePerWindow(string tenantId, string clientId, EntraTokenRejection rejection)
|
||||
{
|
||||
var key = $"graph-token-rejection-logged:{tenantId}:{clientId.ToLowerInvariant()}";
|
||||
if (_cache.TryGetValue(key, out _))
|
||||
return;
|
||||
_cache.Set(key, true, RejectionLogWindow);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Graph token rejected by Entra for tenant {TenantId}, app {ClientId}: status {StatusCode}, {ErrorCode}. {Description}",
|
||||
tenantId, clientId, rejection.StatusCode, rejection.ErrorCode, rejection.Description);
|
||||
}
|
||||
|
||||
// Keys include the resolved client id: during the dual app-reg window a tenant flip
|
||||
// (HomedAppClientId change) must never be served a token/verdict minted under the other
|
||||
// app. Normalized to lowercase so casing differences in config can't split the cache.
|
||||
@@ -440,9 +500,16 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public bool IsTransient { get; private set; }
|
||||
|
||||
/// <summary>True when Entra issued this token just now; false for one from the cache.</summary>
|
||||
public bool IsFreshlyMinted { get; private set; }
|
||||
|
||||
/// <summary>What Entra last refused with, also when the caller's budget ended the retries; null without a refusal.</summary>
|
||||
public EntraTokenRejection? Rejection { get; private set; }
|
||||
|
||||
public static GraphTokenResult Success(string? token) => new() { AccessToken = token };
|
||||
public static GraphTokenResult PermanentFailure() => new() { AccessToken = null, IsTransient = false };
|
||||
public static GraphTokenResult TransientFailure() => new() { AccessToken = null, IsTransient = true };
|
||||
public static GraphTokenResult Minted(string? token) => new() { AccessToken = token, IsFreshlyMinted = !string.IsNullOrEmpty(token) };
|
||||
public static GraphTokenResult PermanentFailure(EntraTokenRejection? rejection = null) => new() { AccessToken = null, IsTransient = false, Rejection = rejection };
|
||||
public static GraphTokenResult TransientFailure(EntraTokenRejection? rejection = null) => new() { AccessToken = null, IsTransient = true, Rejection = rejection };
|
||||
}
|
||||
|
||||
public class GraphConsentStatusResult
|
||||
|
||||
@@ -247,7 +247,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var ct = attemptCts.Token;
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new IntuneDeviceBindingResult
|
||||
|
||||
@@ -36,10 +36,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
BootstrapSessionService? bootstrapSessionService = null,
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null,
|
||||
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
|
||||
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
|
||||
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null,
|
||||
IGraphAccessHealthRecorder? accessHealth = null,
|
||||
bool selfReportedCloudPc = false)
|
||||
{
|
||||
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
|
||||
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator, accessHealth);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -76,6 +77,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
|
||||
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
|
||||
private readonly BootstrapSessionService? _bootstrapSessionService;
|
||||
private readonly IGraphAccessHealthRecorder? _accessHealth;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public SecurityValidator(
|
||||
@@ -88,7 +90,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
BootstrapSessionService? bootstrapSessionService = null,
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null,
|
||||
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
|
||||
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
|
||||
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null,
|
||||
IGraphAccessHealthRecorder? accessHealth = null)
|
||||
{
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -99,6 +102,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_accessHealth = accessHealth;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -129,8 +133,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
/// <param name="req">HTTP request</param>
|
||||
/// <param name="tenantId">Tenant ID for configuration lookup</param>
|
||||
/// <param name="selfReportedCloudPc">
|
||||
/// The agent's own Cloud PC verdict (<c>SessionRegistration.IsCloudPc</c>, two local W365 markers).
|
||||
/// Never an admission input: it only decides whether a Cloud PC lookup blocked by the missing
|
||||
/// add-on permission counts toward the add-on notice.
|
||||
/// </param>
|
||||
/// <returns>Security validation result</returns>
|
||||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null)
|
||||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
|
||||
{
|
||||
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
@@ -415,7 +424,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
: null;
|
||||
// TLS-proven device identity: the Intune managedDevice id in the certificate CN.
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
||||
string? autopilotDeviceId = null;
|
||||
AutopilotRegistration? autopilotRegistration = null;
|
||||
bool deviceValidated = false;
|
||||
bool deviceValidationTransient = false;
|
||||
// Largest Retry-After any transient validator asked for (GraphAuthFailure: a fresh
|
||||
@@ -423,6 +432,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
int? deviceValidationRetryAfter = null;
|
||||
string? deviceValidationError = null;
|
||||
ValidatorType validatedBy = ValidatorType.Unknown;
|
||||
// Switched-on validations that could not decide because their optional Graph
|
||||
// permission is missing (GraphAccessHealthRecorder turns them into tenant state).
|
||||
List<AddOnValidation>? addOnsMissingPermission = null;
|
||||
|
||||
// One time budget for the whole (sequential) validator chain — see
|
||||
// DeviceValidationBudget: a stuck Graph call must not park the request past the
|
||||
@@ -436,7 +448,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
if (autopilotResult.IsValid)
|
||||
{
|
||||
deviceValidated = true;
|
||||
autopilotDeviceId = autopilotResult.AutopilotDeviceId;
|
||||
autopilotRegistration = autopilotResult.Registration;
|
||||
validatedBy = ValidatorType.AutopilotV1;
|
||||
}
|
||||
else
|
||||
@@ -506,6 +518,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
|
||||
deviceValidationTransient |= cloudPcResult.IsTransient;
|
||||
// Every device that missed the serial lookups lands here (laptops registered in
|
||||
// Autopilot only during enrollment, Hyper-V test VMs), and the grant admits none of
|
||||
// them. Only a device reporting itself as a Cloud PC was kept out by the missing grant.
|
||||
// A Cloud PC is never admitted without it, so each of its runs registers: the register
|
||||
// request is the only one carrying the flag, and it is enough.
|
||||
if (cloudPcResult.PermissionMissing && selfReportedCloudPc)
|
||||
(addOnsMissingPermission ??= new List<AddOnValidation>()).Add(AddOnValidation.CloudPc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,12 +561,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
deviceValidationTransient = true;
|
||||
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, GraphAuthFailure.PermissionMissingRetryAfterSeconds);
|
||||
(addOnsMissingPermission ??= new List<AddOnValidation>()).Add(AddOnValidation.IntuneDeviceBinding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.HasAnyDeviceValidation() && !deviceValidated)
|
||||
{
|
||||
// Only a device that stayed out counts as a consequence of the missing grant: the
|
||||
// notice must not appear while another validation admits every device.
|
||||
if (addOnsMissingPermission != null && _accessHealth != null)
|
||||
await _accessHealth.RecordAddOnPermissionMissingAsync(tenantId, addOnsMissingPermission);
|
||||
|
||||
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
|
||||
// Definitive failures (device not registered) → 403 Forbidden
|
||||
if (deviceValidationTransient)
|
||||
@@ -605,7 +630,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serialNumber,
|
||||
AutopilotDeviceId = autopilotDeviceId,
|
||||
AutopilotRegistration = autopilotRegistration,
|
||||
RateLimitResult = rateLimitResult,
|
||||
ValidatedBy = validatedBy
|
||||
};
|
||||
@@ -809,9 +834,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Autopilot device ID from Intune (if Autopilot device validation is enabled and succeeded)
|
||||
/// The device's Autopilot registration as the Autopilot validator read it — set only when that
|
||||
/// validator admitted the device. Stored with the session as a server fact.
|
||||
/// </summary>
|
||||
public string? AutopilotDeviceId { get; set; }
|
||||
public AutopilotRegistration? AutopilotRegistration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit result (if validation succeeded)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ namespace AutopilotMonitor.Functions.Services.Activation
|
||||
/// <summary>
|
||||
/// Queue message contract for <c>tenant-auto-approve</c>. One envelope per new tenant
|
||||
/// signup, enqueued with <see cref="ActivationDelay"/> visibility delay so the operator's
|
||||
/// Telegram / portal notification lands before the activation happens.
|
||||
/// alert and portal notification land before the activation happens.
|
||||
/// </summary>
|
||||
public sealed class TenantAutoApproveEnvelope
|
||||
{
|
||||
|
||||
@@ -144,7 +144,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
var legacy = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, legacyApp, ct);
|
||||
if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient)
|
||||
// A missing service principal is a fact even when the caller's budget cut the retries
|
||||
// short: the access check runs on 4 s, its first retry waits 5 s.
|
||||
if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient
|
||||
&& legacy.Rejection?.IsServicePrincipalMissing != true)
|
||||
{
|
||||
return new AppHomingProbeResult(Succeeded: false, IsTransient: true);
|
||||
}
|
||||
@@ -231,6 +234,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
var normalizedTarget = EntraAppRegistry.NormalizeClientId(targetClientId);
|
||||
string? oldValue = null;
|
||||
string? closedRejectionCode = null;
|
||||
var closedRejection = false;
|
||||
var closedAddOns = new List<AddOnValidation>();
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (string.Equals(row.HomedAppClientId, normalizedTarget, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -238,6 +244,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
oldValue = row.HomedAppClientId;
|
||||
row.HomedAppClientId = normalizedTarget;
|
||||
row.UpdatedBy = actorUpn;
|
||||
// A Graph access rejection and the add-on grants belong to the app the tenant just
|
||||
// left; the new app's next fresh token decides again. A confirmed episode ends
|
||||
// with its closing event, so the ops trail never shows an open one.
|
||||
closedRejection = row.GraphAccessRejectionConfirmedAt != null;
|
||||
closedRejectionCode = row.GraphAccessRejectedCode;
|
||||
row.GraphAccessRejectedSince = null;
|
||||
row.GraphAccessRejectedCode = null;
|
||||
row.GraphAccessRejectionConfirmedAt = null;
|
||||
closedAddOns.Clear();
|
||||
foreach (var addOn in AddOnValidation.All)
|
||||
{
|
||||
if (addOn.MissingConfirmedAt(row) != null)
|
||||
closedAddOns.Add(addOn);
|
||||
addOn.ClearEpisode(row);
|
||||
}
|
||||
return true;
|
||||
}, "app-homing", reason);
|
||||
|
||||
@@ -297,6 +318,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
});
|
||||
|
||||
await _opsEventService.RecordAppHomingFlippedAsync(tenantId, actorUpn, oldLabel, newLabel, reason, forced);
|
||||
if (closedRejection)
|
||||
await _opsEventService.RecordGraphAccessClosedByAppHomingFlipAsync(tenantId, closedRejectionCode);
|
||||
foreach (var addOn in closedAddOns)
|
||||
await _opsEventService.RecordAddOnPermissionResolvedAsync(tenantId, addOn.Feature, GraphAccessHealthRecorder.ResolvedByAppHomingFlip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
-1
@@ -336,7 +336,7 @@ namespace AutopilotMonitor.Functions.Services.Backup.Queue
|
||||
await _jobs.TryUpdateWithCasAsync(lostJob, lostETag.Value, ct).ConfigureAwait(false);
|
||||
}
|
||||
// Codex-Hotfix Wave2 #2: emit OpsEvent for the lease-loss terminal path so
|
||||
// Telegram alert rules fire on this manual-queue failure mode the same way
|
||||
// alert rules fire on this manual-queue failure mode the same way
|
||||
// they do for the timer path.
|
||||
await TryRecordOpsEventAsync(() => _opsEvents.RecordCriticalTableBackupFailedAsync(
|
||||
backupIdForEvent, leaseLossReason, runningJobRequestedBy)).ConfigureAwait(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user