mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
Push notifications for every tenant: one Push channel per scope that gates every send, app-role members equal, alerts name the tenant (D-368, D-369)
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only - One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send - The channel also gates session-watch completions and the device test; paired devices stay listed while it is off - Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in - SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact - The legacy single webhook accepts URL webhook providers only - Portal: Conditional Access hint in the channel editor, neutral role texts - Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates" - Privacy, about and FAQ describe push notifications Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -273,4 +273,30 @@ public class AdminConfigurationChangedFieldsTests
|
||||
Assert.NotNull(typeError);
|
||||
Assert.DoesNotContain("secret.example", typeError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ops_channels_refuse_a_second_push_channel_but_keep_a_stored_pair_saveable()
|
||||
{
|
||||
var one = OpsPushChannels("phones");
|
||||
var pair = OpsPushChannels("phones", "phones-2");
|
||||
|
||||
var draft = AdminConfiguration.CreateDefault();
|
||||
draft.OpsNotificationChannelsJson = one;
|
||||
Assert.Equal(
|
||||
"Invalid ops notification channels: only one Push channel is allowed — a single channel already reaches every paired device.",
|
||||
PatchAdminConfigurationFunction.ApplyFields(draft, new JObject { ["opsNotificationChannelsJson"] = pair }));
|
||||
|
||||
// A pair stored before the rule saves unchanged and may be reduced.
|
||||
var stored = AdminConfiguration.CreateDefault();
|
||||
stored.OpsNotificationChannelsJson = pair;
|
||||
Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = pair }));
|
||||
Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = one }));
|
||||
}
|
||||
|
||||
private static string OpsPushChannels(params string[] ids)
|
||||
=> AutopilotMonitor.Shared.Models.Notifications.NotificationChannel.SerializeList(ids.Select(id =>
|
||||
new AutopilotMonitor.Shared.Models.Notifications.NotificationChannel
|
||||
{
|
||||
Id = id, Name = id, ProviderType = (int)AutopilotMonitor.Shared.Models.Notifications.WebhookProviderType.Push, Enabled = true,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -41,6 +42,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GlobalAdminService> _globalAdminMock;
|
||||
private readonly Func<PushOwnerSignInService?, AuthFunction> _buildSut;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -56,9 +59,10 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
var globalAdminMock = new Mock<GlobalAdminService>(
|
||||
_globalAdminMock = new Mock<GlobalAdminService>(
|
||||
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
{ CallBase = false };
|
||||
var globalAdminMock = _globalAdminMock;
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
@@ -102,7 +106,7 @@ public class AuthFunctionSideEffectTests
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new AuthFunction(
|
||||
_buildSut = pushSignIn => new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
delegatedAdminMock.Object,
|
||||
@@ -122,7 +126,9 @@ public class AuthFunctionSideEffectTests
|
||||
_signalR,
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo,
|
||||
pushSignIn: pushSignIn,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
_sut = _buildSut(null);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -860,7 +866,7 @@ public class AuthFunctionSideEffectTests
|
||||
/// A validated token as the middleware hands it on: tid, oid and idp only under their mapped names. The
|
||||
/// member's idp names its own tenant, the guest's another one — the same principal shape otherwise.
|
||||
/// </summary>
|
||||
private static ClaimsPrincipal SignedIn(bool external) => new(new ClaimsIdentity(new[]
|
||||
private static ClaimsPrincipal SignedIn(bool external, params string[] appRoles) => new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", TenantId),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", ObjectId),
|
||||
@@ -868,7 +874,7 @@ public class AuthFunctionSideEffectTests
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external
|
||||
? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/"
|
||||
: $"https://sts.windows.net/{TenantId}/"),
|
||||
}, "test"));
|
||||
}.Concat(appRoles.Select(role => new Claim("roles", role))), "test"));
|
||||
|
||||
private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved)
|
||||
{
|
||||
@@ -933,4 +939,69 @@ public class AuthFunctionSideEffectTests
|
||||
external ? Times.Never() : Times.Once());
|
||||
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser: the push sign-in (stamp with the app-role claim, resume)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>An onboarded, activated tenant with app roles on and an admin already present (no auto-admin).</summary>
|
||||
private void ArrangeActivatedTenantWithAppRoles(TableMemberState memberRow)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.EntraAppRolesEnabled = true;
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantAdminRow> { new() { TenantId = TenantId, Upn = "first@contoso.com" } });
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null));
|
||||
}
|
||||
|
||||
private (AuthFunction Sut, Push.PushDeliveryServiceTests.Harness Push) BuildWithPushSignIn()
|
||||
{
|
||||
var push = new Push.PushDeliveryServiceTests.Harness();
|
||||
var signIn = new PushOwnerSignInService(push.Repo, new Mock<IAdminRepository>().Object, push.Settings, push.Sut,
|
||||
Mock.Of<ILogger<PushOwnerSignInService>>());
|
||||
return (_buildSut(signIn), push);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TableMemberState.NotPresent, Constants.TenantRoles.Admin)]
|
||||
[InlineData(TableMemberState.Enabled, null)]
|
||||
[InlineData(TableMemberState.Disabled, null)]
|
||||
public async Task GetCurrentUser_PushStamp_CarriesTheAppRoleClaimOnlyWithoutAMemberRow(TableMemberState memberRow, string? expected)
|
||||
{
|
||||
ArrangeActivatedTenantWithAppRoles(memberRow);
|
||||
var (sut, push) = BuildWithPushSignIn();
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false, Constants.TenantRoles.Admin));
|
||||
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var owner = push.Repo.Owners[(TenantId, ObjectId)];
|
||||
Assert.Equal(expected, owner.ClaimRole);
|
||||
Assert.Equal(Upn, owner.Upn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_GlobalAdminBackFromInactivity_ResumesTheirPlatformPause()
|
||||
{
|
||||
ArrangeActivatedTenantWithAppRoles(TableMemberState.Enabled);
|
||||
_globalAdminMock.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
var (sut, push) = BuildWithPushSignIn();
|
||||
push.Repo.Owners[(TenantId, ObjectId)] = new PushOwner
|
||||
{
|
||||
HomeTenantId = TenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = DateTime.UtcNow.AddDays(-45),
|
||||
};
|
||||
var device = push.AddDevice("ga-phone", Constants.Push.DeviceStatus.Paused, upn: Upn,
|
||||
scope: Constants.Push.PlatformScope, statusReason: "owner_inactive");
|
||||
device.OwnerObjectId = ObjectId;
|
||||
device.OwnerHomeTenantId = TenantId;
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false));
|
||||
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,21 @@ public class GenericWebhookTests
|
||||
Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_SlaAlert_CarriesTheTenantNameAndTheTenantIdAsFacts()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaResolvedAlert("11111111-1111-1111-1111-111111111111", "Contoso",
|
||||
"Duration", 42, 60, firstBreachAt: null, resolvedAt: new DateTime(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
using var doc = JsonDocument.Parse(new GenericJsonRenderer().RenderToJson(alert));
|
||||
var facts = doc.RootElement.GetProperty("facts");
|
||||
|
||||
Assert.Equal("Tenant", facts[0].GetProperty("name").GetString());
|
||||
Assert.Equal("Contoso", facts[0].GetProperty("value").GetString());
|
||||
Assert.Equal("Tenant ID", facts[1].GetProperty("name").GetString());
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", facts[1].GetProperty("value").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "enrollment_succeeded")]
|
||||
[InlineData(false, "enrollment_failed")]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The SLA alerts name the tenant the way its people know it: the summary and the "Tenant" fact
|
||||
/// carry the display name (company name, else domain, else id — <see cref="TenantDisplayName"/>),
|
||||
/// and the id travels on in the "Tenant ID" fact right after it, for integrations that key on it.
|
||||
/// </summary>
|
||||
public class NotificationAlertBuilderSlaTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTime ResolvedAt = new(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Breach_alert_names_the_tenant_and_carries_its_id_right_after()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaBreachAlert(TenantId, "Contoso", 80, 95, 10, 2, "SuccessRate");
|
||||
|
||||
AssertTenantFacts(alert, "Contoso");
|
||||
Assert.DoesNotContain(TenantId, alert.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolved_alert_names_the_tenant_in_its_summary()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaResolvedAlert(TenantId, "Contoso", "SuccessRate", 97, 95,
|
||||
ResolvedAt.AddHours(-3), ResolvedAt);
|
||||
|
||||
Assert.Equal("SLA breach resolved for Contoso: Success Rate is back within target.", alert.Summary);
|
||||
AssertTenantFacts(alert, "Contoso");
|
||||
Assert.Equal("Breach Type", alert.Facts[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consecutive_failures_alert_names_the_tenant_in_its_summary()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(TenantId, "Contoso", 3, "DESKTOP-0042", "timeout");
|
||||
|
||||
Assert.Equal("Alert: 3 enrollments failed in a row for Contoso", alert.Summary);
|
||||
AssertTenantFacts(alert, "Contoso");
|
||||
Assert.Equal("Consecutive Failures", alert.Facts[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tenant_without_names_falls_back_to_its_id()
|
||||
{
|
||||
var name = TenantDisplayName.Of(new TenantConfiguration { TenantId = TenantId }, TenantId);
|
||||
var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(TenantId, name, 5, null, null);
|
||||
|
||||
Assert.Equal($"Alert: 5 enrollments failed in a row for {TenantId}", alert.Summary);
|
||||
AssertTenantFacts(alert, TenantId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Contoso Ltd", "contoso.com", "Contoso Ltd")]
|
||||
[InlineData(" ", "contoso.com", "contoso.com")]
|
||||
[InlineData(null, null, TenantId)]
|
||||
[InlineData(null, "", TenantId)]
|
||||
public void Display_name_prefers_the_company_then_the_domain_then_the_id(string? companyName, string? domainName, string expected)
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, CompanyName = companyName, DomainName = domainName! };
|
||||
|
||||
Assert.Equal(expected, TenantDisplayName.Of(config, TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Display_name_without_a_configuration_is_the_id()
|
||||
=> Assert.Equal(TenantId, TenantDisplayName.Of(null, TenantId));
|
||||
|
||||
private static void AssertTenantFacts(NotificationAlert alert, string expectedName)
|
||||
{
|
||||
Assert.Equal("Tenant", alert.Facts[0].Name);
|
||||
Assert.Equal(expectedName, alert.Facts[0].Value);
|
||||
Assert.Equal("Tenant ID", alert.Facts[1].Name);
|
||||
Assert.Equal(TenantId, alert.Facts[1].Value);
|
||||
Assert.Single(alert.Facts, f => f.Name == "Tenant ID");
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,58 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("Invalid webhook provider type.", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40, false)] // Telegram
|
||||
[InlineData(40, true)]
|
||||
[InlineData(50, false)] // Push
|
||||
[InlineData(50, true)]
|
||||
[InlineData(60, false)] // E-mail
|
||||
[InlineData(60, true)]
|
||||
public void ValidateModel_LegacyWebhook_RefusesANewlySetNonWebhookProvider_ForEveryCaller(int providerType, bool isGlobalAdmin)
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant-1");
|
||||
var candidate = TenantConfiguration.CreateDefault("tenant-1");
|
||||
candidate.WebhookProviderType = providerType;
|
||||
candidate.WebhookUrl = "https://hooks.example/x";
|
||||
|
||||
Assert.Equal("The legacy webhook supports webhook providers only; use a notification channel for the others.",
|
||||
TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40)]
|
||||
[InlineData(50)]
|
||||
[InlineData(60)]
|
||||
public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType)
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant-1");
|
||||
existing.WebhookProviderType = providerType;
|
||||
existing.WebhookUrl = "https://hooks.example/x";
|
||||
var candidate = TenantConfiguration.CreateDefault("tenant-1");
|
||||
candidate.WebhookProviderType = providerType;
|
||||
candidate.WebhookUrl = "https://hooks.example/x";
|
||||
candidate.CompanyName = "Contoso"; // an unrelated change
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
[InlineData(30)]
|
||||
[InlineData(0)]
|
||||
public void ValidateModel_LegacyWebhook_TakesWebhookProvidersOrNone(int providerType)
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant-1");
|
||||
existing.WebhookProviderType = 50; // even switching away from a stored Push value
|
||||
var candidate = TenantConfiguration.CreateDefault("tenant-1");
|
||||
candidate.WebhookProviderType = providerType;
|
||||
candidate.WebhookUrl = "https://hooks.example/x";
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
||||
{
|
||||
|
||||
@@ -95,11 +95,13 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
public Task<PushOwner?> GetOwnerAsync(string homeTenantId, string objectId)
|
||||
=> Task.FromResult(Owners.TryGetValue((homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()), out var o) ? o : null);
|
||||
|
||||
public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc)
|
||||
/// <summary>Replaces the whole row, like the production upsert: a null claim role clears the stored one.</summary>
|
||||
public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole)
|
||||
{
|
||||
Owners[(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant())] = new PushOwner
|
||||
{
|
||||
HomeTenantId = homeTenantId.ToLowerInvariant(), ObjectId = objectId.ToLowerInvariant(), Upn = upn, LastSignInUtc = signInUtc,
|
||||
ClaimRole = claimRole,
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,22 @@ public class PushAlertProjectorTests
|
||||
Assert.DoesNotContain(Sentinel, json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sla_alert_shows_the_tenant_name_and_never_carries_the_tenant_id()
|
||||
{
|
||||
const string tenantId = "11111111-1111-1111-1111-111111111111";
|
||||
var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(tenantId, "Contoso", 5, "DESKTOP-0042", null,
|
||||
Constants.PortalBaseUrl + "/sla");
|
||||
|
||||
var payload = PushAlertProjector.Project(alert, Tenant, "e8", Now);
|
||||
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload));
|
||||
|
||||
Assert.Contains(("Tenant", "Contoso"), payload.Facts);
|
||||
Assert.DoesNotContain(payload.Facts, f => f.Name == "Tenant ID");
|
||||
Assert.Equal("Alert: 5 enrollments failed in a row for Contoso", payload.Body);
|
||||
Assert.DoesNotContain(tenantId, json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Platform_projection_never_uses_the_ops_message()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -15,12 +16,15 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path), the validation rules and the GA gate, the raw-table deny-list and the
|
||||
/// dependency redaction.
|
||||
/// 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),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
|
||||
private const string TenantKey = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OnePushChannelOnly = "only one Push channel is allowed — a single channel already reaches every paired device.";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantKey);
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
{
|
||||
@@ -40,8 +44,13 @@ public class PushChannelRoutingTests
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel PushChannel(string id = "push") =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = true };
|
||||
private static NotificationChannel PushChannel(string id = "push", bool enabled = true) =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled };
|
||||
|
||||
private static string Json(params NotificationChannel[] channels) => NotificationChannel.SerializeList(channels);
|
||||
|
||||
private static TenantConfiguration Config(params NotificationChannel[] channels)
|
||||
=> new() { TenantId = TenantKey, NotificationChannelsJson = Json(channels) };
|
||||
|
||||
[Fact]
|
||||
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
|
||||
@@ -60,6 +69,25 @@ public class PushChannelRoutingTests
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_push_channels_in_one_send_fan_out_once()
|
||||
{
|
||||
// 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 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 };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { PushChannel("phones"), slack, PushChannel("phones-2") }, alert, Tenant);
|
||||
|
||||
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
|
||||
push.VerifyNoOtherCalls();
|
||||
// The other channels of the send are untouched by the guard.
|
||||
webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Push_channel_without_url_is_not_skipped_as_destination_less()
|
||||
{
|
||||
@@ -80,8 +108,9 @@ public class PushChannelRoutingTests
|
||||
Assert.True(PushChannel().HasDestination());
|
||||
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.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -98,28 +127,60 @@ public class PushChannelRoutingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_lets_only_a_global_admin_add_or_flip_a_push_channel()
|
||||
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram()
|
||||
{
|
||||
var json = NotificationChannel.SerializeList(new[] { PushChannel() });
|
||||
var json = Json(PushChannel());
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: true));
|
||||
Assert.Contains("Push channels can only be configured by a Global Administrator",
|
||||
TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
|
||||
// Unchanged GA-created channel: a tenant admin may still save unrelated fields.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
|
||||
var disabled = PushChannel();
|
||||
disabled.Enabled = false;
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
NotificationChannel.SerializeList(new[] { disabled }), json, isGlobalAdmin: false));
|
||||
|
||||
// Renames and toggles stay with the tenant admin.
|
||||
// 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));
|
||||
var renamed = PushChannel();
|
||||
renamed.Name = "Phones";
|
||||
renamed.NotifyOnFailure = true;
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
NotificationChannel.SerializeList(new[] { renamed }), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tenant_admin_may_add_the_first_push_channel()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateModel(Config(PushChannel()), Config(), isGlobalAdmin: false));
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void A_second_push_channel_is_refused_for_every_caller(bool isGlobalAdmin)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateModel(
|
||||
Config(PushChannel("phones"), PushChannel("phones-2")), Config(PushChannel("phones")), isGlobalAdmin);
|
||||
|
||||
Assert.Equal("Invalid notification channels: " + OnePushChannelOnly, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_disabled_push_channel_counts_toward_the_one()
|
||||
=> Assert.Equal(OnePushChannelOnly,
|
||||
TenantConfigValidation.ValidateSinglePushChannel(Json(PushChannel("phones"), PushChannel("spare", enabled: false)), existingJson: null));
|
||||
|
||||
[Fact]
|
||||
public void A_stored_pair_stays_saveable_until_it_is_reduced()
|
||||
{
|
||||
var pair = Json(PushChannel("phones"), PushChannel("phones-2"));
|
||||
|
||||
// Saved before the rule: unrelated saves and the reduction pass, growing it does not.
|
||||
Assert.Null(TenantConfigValidation.ValidateSinglePushChannel(pair, pair));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(Config(PushChannel("phones"), PushChannel("phones-2")),
|
||||
Config(PushChannel("phones"), PushChannel("phones-2")), isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateSinglePushChannel(Json(PushChannel("phones")), pair));
|
||||
Assert.Equal(OnePushChannelOnly, TenantConfigValidation.ValidateSinglePushChannel(
|
||||
Json(PushChannel("phones"), PushChannel("phones-2"), PushChannel("phones-3")), pair));
|
||||
|
||||
// Once reduced, the pair cannot come back.
|
||||
Assert.Equal(OnePushChannelOnly, TenantConfigValidation.ValidateSinglePushChannel(pair, Json(PushChannel("phones"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -14,9 +14,11 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="PushEligibility"/> over mocked role and configuration reads: the channel
|
||||
/// switch per scope (K4), the table-backed role predicate for tenant members (Admin/Operator,
|
||||
/// never Viewer) and for Global Admins (GlobalAdmin with a matching identity binding, never a
|
||||
/// Global Reader), and the owner-freshness window (K3).
|
||||
/// switch per scope (K4), the role predicate for tenant members (Admin/Operator, never Viewer —
|
||||
/// the TenantAdmins row first, else the Entra app-role claim recorded at sign-in, home tenant
|
||||
/// only and only while app roles stay enabled), the live claim a pairing records, Global Admins
|
||||
/// (GlobalAdmin with a matching identity binding, never a Global Reader), and the owner-freshness
|
||||
/// window (K3).
|
||||
/// </summary>
|
||||
public class PushEligibilityTests
|
||||
{
|
||||
@@ -27,6 +29,7 @@ public class PushEligibilityTests
|
||||
private const string ObjectId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string OtherObjectId = "aaaaaaaa-0000-0000-0000-000000000002";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
private static readonly NotificationScope Home = NotificationScope.Tenant(HomeTenantId);
|
||||
private static readonly DateTime Now = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── HasPushChannelAsync ──────────────────────────────────────────────────
|
||||
@@ -197,6 +200,128 @@ public class PushEligibilityTests
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── IsEligibleAsync — tenant scope, Entra app-role claim ─────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
public async Task Claim_only_member_with_an_alerting_role_is_eligible_in_the_home_tenant(string role)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now, claimRole: role);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_only_viewer_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now, claimRole: Constants.TenantRoles.Viewer);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true)] // the tenant switched app roles off since the sign-in
|
||||
[InlineData(true, false)] // no stored configuration at all
|
||||
public async Task Claim_role_counts_only_while_the_tenant_keeps_app_roles_enabled(bool appRolesEnabled, bool configExists)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now, claimRole: Constants.TenantRoles.Admin);
|
||||
h.HomeConfig(appRolesEnabled, configExists);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_member_row_beats_the_claim_role()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.HomeMember(Constants.TenantRoles.Admin, enabled: false);
|
||||
h.Owner(Now, claimRole: Constants.TenantRoles.Admin);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Viewer_member_row_beats_an_admin_claim_role()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.HomeMember(Constants.TenantRoles.Viewer);
|
||||
h.Owner(Now, claimRole: Constants.TenantRoles.Admin);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_role_never_reaches_a_tenant_other_than_the_home_tenant()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now, claimRole: Constants.TenantRoles.Admin);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
h.TenantConfig.Verify(t => t.TryGetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_role_eligibility_leaves_freshness_to_its_own_check()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now.AddDays(-(h.Settings.OwnerInactivityDays + 10)), claimRole: Constants.TenantRoles.Admin);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(Home, Upn, ObjectId, HomeTenantId));
|
||||
Assert.False(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
// ── ClaimRoleAsync (the live claim a pairing records) ───────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { Constants.TenantRoles.Admin }, Constants.TenantRoles.Admin)]
|
||||
[InlineData(new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Operator }, Constants.TenantRoles.Operator)]
|
||||
[InlineData(new[] { Constants.TenantRoles.Viewer }, Constants.TenantRoles.Viewer)]
|
||||
[InlineData(new[] { "SomethingElse" }, null)]
|
||||
public async Task Live_claim_without_a_member_row_maps_to_its_highest_member_role(string[] appRoles, string? expected)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.Equal(expected, await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, appRoles));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Live_claim_is_not_recorded_where_a_member_row_decides(bool rowEnabled)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.HomeMember(Constants.TenantRoles.Operator, enabled: rowEnabled);
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
|
||||
Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, "Admin@Contoso.INVALID", new[] { Constants.TenantRoles.Admin }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_claim_is_not_recorded_with_app_roles_off_without_roles_or_for_an_application()
|
||||
{
|
||||
var off = new Harness();
|
||||
off.HomeConfig(appRolesEnabled: false);
|
||||
Assert.Null(await off.Sut.ClaimRoleAsync(HomeTenantId, Upn, new[] { Constants.TenantRoles.Admin }));
|
||||
|
||||
var h = new Harness();
|
||||
h.HomeConfig(appRolesEnabled: true);
|
||||
Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, null));
|
||||
Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId, Upn, Array.Empty<string>()));
|
||||
Assert.Null(await h.Sut.ClaimRoleAsync(HomeTenantId,
|
||||
Constants.PrincipalKeys.ForApplication("22222222-2222-2222-2222-222222222222"), new[] { Constants.TenantRoles.Admin }));
|
||||
}
|
||||
|
||||
// ── IsEligibleAsync — platform scope ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -393,10 +518,19 @@ public class PushEligibilityTests
|
||||
=> AdminRepo.Setup(r => r.GetIdentityBindingAsync(Upn))
|
||||
.ReturnsAsync(new AdminIdentityBinding { Upn = Upn, TenantId = tenantId, ObjectId = objectId });
|
||||
|
||||
public void Owner(DateTime lastSignInUtc)
|
||||
public void Owner(DateTime lastSignInUtc, string? claimRole = null)
|
||||
=> Repo.Owners[(HomeTenantId, ObjectId)] = new PushOwner
|
||||
{
|
||||
HomeTenantId = HomeTenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc,
|
||||
HomeTenantId = HomeTenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc, ClaimRole = claimRole,
|
||||
};
|
||||
|
||||
/// <summary>The home tenant's configuration: its app-role switch (no channel needed for eligibility).</summary>
|
||||
public void HomeConfig(bool appRolesEnabled, bool exists = true)
|
||||
=> TenantConfig.Setup(t => t.TryGetConfigurationAsync(HomeTenantId))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = HomeTenantId, EntraAppRolesEnabled = appRolesEnabled }, exists));
|
||||
|
||||
public void HomeMember(string role, bool enabled = true)
|
||||
=> AdminRepo.Setup(r => r.GetTenantMemberAsync(HomeTenantId, Upn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = HomeTenantId, Upn = Upn, Role = role, IsEnabled = enabled });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// What a portal sign-in does to the person's push devices: the stamp carries the app-role claim
|
||||
/// role (null clears it); losing that role with no TenantAdmins row taking its place revokes the
|
||||
/// home-tenant devices (wipe push, then the row is gone); and a sign-in after the inactivity window
|
||||
/// — or the first one ever — resumes this owner's owner_inactive pauses at once, the platform ones
|
||||
/// only for a Global Admin. Fail-soft towards the sign-in.
|
||||
/// </summary>
|
||||
public class PushOwnerSignInServiceTests
|
||||
{
|
||||
// The delivery harness's tenant, and the person its AddDevice defaults to (oid derived from the UPN).
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Upn = "admin@contoso.invalid";
|
||||
private const string ObjectId = "oid-admin@contoso.invalid";
|
||||
private static readonly DateTime Now = new(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
[InlineData(null)]
|
||||
public async Task Sign_in_stamps_the_time_and_the_claim_role(string? claimRole)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-1), Constants.TenantRoles.Operator);
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole, isGlobalAdmin: false, Now);
|
||||
|
||||
var owner = h.Push.Repo.Owners[(TenantId, ObjectId)];
|
||||
Assert.Equal(Now, owner.LastSignInUtc);
|
||||
Assert.Equal(claimRole, owner.ClaimRole);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
public async Task Losing_the_claim_role_revokes_the_home_tenant_devices(string? claimRole)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-1), Constants.TenantRoles.Admin);
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.Push.AddDevice("d2", Constants.Push.DeviceStatus.Active, upn: "other@contoso.invalid");
|
||||
h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole, isGlobalAdmin: false, Now);
|
||||
|
||||
Assert.False(h.Push.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d2")));
|
||||
Assert.True(h.Push.Repo.Devices.ContainsKey((Constants.Push.PlatformScope, "p1")));
|
||||
// The wipe push went to the revoked device first.
|
||||
Assert.EndsWith("/d1", Assert.Single(h.Push.Http.Requests).RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_member_row_that_took_the_claims_place_keeps_the_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-1), Constants.TenantRoles.Admin);
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.AdminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantId, Upn = Upn, Role = Constants.TenantRoles.Admin, IsEnabled = true });
|
||||
|
||||
// With a row the sign-in records no claim role — that is not a loss.
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now);
|
||||
|
||||
Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
Assert.Empty(h.Push.Http.Requests);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin, Constants.TenantRoles.Operator)]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(Constants.TenantRoles.Viewer, null)]
|
||||
public async Task A_sign_in_that_loses_no_alerting_claim_role_revokes_nothing(string? before, string? after)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-1), before);
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, after, isGlobalAdmin: false, Now);
|
||||
|
||||
Assert.True(h.Push.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sign_in_after_the_inactivity_window_resumes_only_this_owners_inactivity_pauses()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-(h.Push.Settings.OwnerInactivityDays + 5)), claimRole: null);
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive");
|
||||
h.Push.AddDevice("d2", Constants.Push.DeviceStatus.Paused, statusReason: "delivery_failures");
|
||||
h.Push.AddDevice("d3", Constants.Push.DeviceStatus.Paused, upn: "other@contoso.invalid", statusReason: "owner_inactive");
|
||||
h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Paused, scope: Constants.Push.PlatformScope, statusReason: "owner_inactive");
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now);
|
||||
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(TenantId, "d1")].Status);
|
||||
Assert.Null(h.Push.Repo.Devices[(TenantId, "d1")].StatusReason);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d2")].Status);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d3")].Status);
|
||||
// Not a Global Admin: the platform partition is not theirs to resume.
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
|
||||
Assert.Empty(h.Push.Http.Requests); // a resume is silent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_global_admins_sign_in_resumes_their_platform_pauses_too()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-(h.Push.Settings.OwnerInactivityDays + 5)), claimRole: null);
|
||||
h.Push.AddDevice("p1", Constants.Push.DeviceStatus.Paused, scope: Constants.Push.PlatformScope, statusReason: "owner_inactive");
|
||||
h.Push.AddDevice("p2", Constants.Push.DeviceStatus.Paused, upn: "ga2@contoso.invalid", scope: Constants.Push.PlatformScope, statusReason: "owner_inactive");
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: true, Now);
|
||||
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(Constants.Push.PlatformScope, "p2")].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_first_sign_in_ever_resumes_too()
|
||||
{
|
||||
var h = new Harness(); // no PushOwners row yet
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive");
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: false, Now);
|
||||
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Push.Repo.Devices[(TenantId, "d1")].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sign_in_within_the_window_resumes_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Previous(Now.AddDays(-1), claimRole: null);
|
||||
h.Push.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive");
|
||||
|
||||
await h.Sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, claimRole: null, isGlobalAdmin: true, Now);
|
||||
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Push.Repo.Devices[(TenantId, "d1")].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_never_reaches_the_sign_in()
|
||||
{
|
||||
var push = new PushDeliveryServiceTests.Harness();
|
||||
var repo = new Mock<IPushDeviceRepository>();
|
||||
repo.Setup(r => r.GetOwnerAsync(It.IsAny<string>(), It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var sut = new PushOwnerSignInService(repo.Object, new Mock<IAdminRepository>().Object, push.Settings, push.Sut,
|
||||
NullLogger<PushOwnerSignInService>.Instance);
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => sut.OnPortalSignInAsync(TenantId, ObjectId, Upn, null, isGlobalAdmin: false, Now));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public PushDeliveryServiceTests.Harness Push { get; } = new();
|
||||
public Mock<IAdminRepository> AdminRepo { get; } = new();
|
||||
public PushOwnerSignInService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
=> Sut = new PushOwnerSignInService(Push.Repo, AdminRepo.Object, Push.Settings, Push.Sut, NullLogger<PushOwnerSignInService>.Instance);
|
||||
|
||||
/// <summary>The stamp the previous sign-in left.</summary>
|
||||
public void Previous(DateTime lastSignInUtc, string? claimRole)
|
||||
=> Push.Repo.Owners[(TenantId, ObjectId)] = new PushOwner
|
||||
{
|
||||
HomeTenantId = TenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc, ClaimRole = claimRole,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Push;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
|
||||
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
|
||||
/// </summary>
|
||||
public class PushPairingFunctionsTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_tenant_pairing_names_the_member_list()
|
||||
{
|
||||
var sut = RefusingFunctions();
|
||||
|
||||
var response = await sut.Create(EndpointHarness.Request(TenantId).Req);
|
||||
|
||||
await AssertForbiddenAsync(response, "Push notifications need an Admin or Operator role in this tenant's member list.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_platform_pairing_names_the_global_administrator_role()
|
||||
{
|
||||
var sut = RefusingFunctions();
|
||||
|
||||
var response = await sut.CreatePlatform(EndpointHarness.Request(TenantId).Req);
|
||||
|
||||
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
|
||||
}
|
||||
|
||||
private static PushPairingFunctions RefusingFunctions()
|
||||
{
|
||||
var pairing = new PushPairingServiceTests.Harness();
|
||||
pairing.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
return new PushPairingFunctions(pairing.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
}
|
||||
|
||||
private static async Task AssertForbiddenAsync(HttpResponseData response, string expectedMessage)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
var envelope = (await JsonSerializer.DeserializeAsync<ApiErrorResponse>(response.Body, ApiJsonOptions.Read))!;
|
||||
Assert.Equal(Constants.ApiErrorCodes.PushNotEligible, envelope.Code);
|
||||
Assert.Equal(expectedMessage, envelope.Error);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,9 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// The pairing flow end to end against the in-memory repository and a fake push service:
|
||||
/// create → begin → redeem (Pending) → confirm (Active, confirmation push), the one-shot redeem,
|
||||
/// the five-failure burn, expiry, the device token, re-subscribe re-arming a Stale row, the
|
||||
/// role/channel gates, grant ownership (a grant answers only to its creator) and the
|
||||
/// one-per-host-and-hour ops event for a refused subscription endpoint.
|
||||
/// role/channel gates (the channel also silences the device test, never the device list), the
|
||||
/// pairing of a member known only through an Entra app role, grant ownership (a grant answers
|
||||
/// only to its creator) and the one-per-host-and-hour ops event for a refused subscription endpoint.
|
||||
/// </summary>
|
||||
public class PushPairingServiceTests
|
||||
{
|
||||
@@ -304,6 +305,124 @@ public class PushPairingServiceTests
|
||||
Assert.Equal(PushOpError.NotEligible, (await h.Sut.CreateAsync(Tenant, Admin)).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_pairing_names_the_needed_role_only_on_the_platform()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
var tenant = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var platform = await h.Sut.CreateAsync(NotificationScope.Platform, Admin);
|
||||
|
||||
// The tenant refusal carries the function's default text ("…in this tenant's member list.").
|
||||
Assert.Equal(PushOpError.NotEligible, tenant.Error);
|
||||
Assert.Null(tenant.Message);
|
||||
Assert.Equal(PushOpError.NotEligible, platform.Error);
|
||||
Assert.Equal("Push notifications on the platform need the Global Administrator role.", platform.Message);
|
||||
Assert.Empty(h.Repo.Grants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_member_known_only_through_an_entra_app_role_can_pair_at_once()
|
||||
{
|
||||
var host = new AppRoleHost(appRolesEnabled: true);
|
||||
var member = new PushCaller("member@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000004", TenantId, IsScopeAdmin: true,
|
||||
AppRoles: new[] { Constants.TenantRoles.Admin });
|
||||
|
||||
var created = await host.Sut.CreateAsync(Tenant, member);
|
||||
|
||||
// No TenantAdmins row and no earlier sign-in stamp: the pairing recorded the live claim first.
|
||||
Assert.True(created.Ok);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, host.Repo.Owners[(TenantId, member.ObjectId)].ClaimRole);
|
||||
Assert.Single(host.Repo.Grants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_the_app_role_or_with_app_roles_off_the_pairing_is_refused()
|
||||
{
|
||||
var withoutRole = new AppRoleHost(appRolesEnabled: true);
|
||||
var plainUser = new PushCaller("user@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000005", TenantId, IsScopeAdmin: false);
|
||||
Assert.Equal(PushOpError.NotEligible, (await withoutRole.Sut.CreateAsync(Tenant, plainUser)).Error);
|
||||
Assert.Null(withoutRole.Repo.Owners[(TenantId, plainUser.ObjectId)].ClaimRole);
|
||||
|
||||
var appRolesOff = new AppRoleHost(appRolesEnabled: false);
|
||||
var claimAdmin = plainUser with { AppRoles = new[] { Constants.TenantRoles.Admin } };
|
||||
Assert.Equal(PushOpError.NotEligible, (await appRolesOff.Sut.CreateAsync(Tenant, claimAdmin)).Error);
|
||||
Assert.Null(appRolesOff.Repo.Owners[(TenantId, plainUser.ObjectId)].ClaimRole);
|
||||
Assert.Empty(appRolesOff.Repo.Grants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_test_with_the_channel_off_reports_it_and_sends_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
var deviceId = await PairAsync(h, Admin);
|
||||
var sentBefore = h.Http.Requests.Count;
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Tenant)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.TestAsync(Tenant, deviceId, Admin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.False(result.Value!.Success);
|
||||
Assert.Equal("The Push channel is off — paired devices receive nothing.", result.Value.Message);
|
||||
Assert.Equal(sentBefore, h.Http.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Devices_stay_listable_and_removable_with_the_channel_off()
|
||||
{
|
||||
var h = new Harness();
|
||||
var deviceId = await PairAsync(h, Admin);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
|
||||
Assert.Equal(deviceId, Assert.Single((await h.Sut.ListAsync(Tenant, Admin)).Devices).DeviceId);
|
||||
Assert.Equal(PushOpError.None, await h.Sut.DeleteAsync(Tenant, deviceId, Admin));
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
}
|
||||
|
||||
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
|
||||
private static async Task<string> PairAsync(Harness h, PushCaller owner)
|
||||
{
|
||||
var created = await h.Sut.CreateAsync(Tenant, owner);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, owner)).Ok);
|
||||
return h.Repo.Devices.Values.Single(d => d.OwnerObjectId == owner.ObjectId).DeviceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pairing service over the REAL <see cref="PushEligibility"/>: no TenantAdmins row for anyone,
|
||||
/// a tenant configuration with an enabled Push channel and the given app-role switch.
|
||||
/// </summary>
|
||||
private sealed class AppRoleHost
|
||||
{
|
||||
public InMemoryPushDeviceRepository Repo { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
|
||||
public AppRoleHost(bool appRolesEnabled)
|
||||
{
|
||||
var settings = PushSettings.ForKeys(new VapidKeyRing(VapidKey.Generate()));
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var pushChannel = new AutopilotMonitor.Shared.Models.Notifications.NotificationChannel
|
||||
{
|
||||
Id = "phones", Name = "Phones", ProviderType = (int)AutopilotMonitor.Shared.Models.Notifications.WebhookProviderType.Push, Enabled = true,
|
||||
};
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(null!, NullLogger<TenantConfigurationService>.Instance, cache, null!) { CallBase = false };
|
||||
tenantConfig.Setup(t => t.TryGetConfigurationAsync(TenantId)).ReturnsAsync((new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
EntraAppRolesEnabled = appRolesEnabled,
|
||||
NotificationChannelsJson = AutopilotMonitor.Shared.Models.Notifications.NotificationChannel.SerializeList(new[] { pushChannel }),
|
||||
}, true));
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
var eligibility = new PushEligibility(new Mock<IAdminRepository>().Object, Repo, tenantConfig.Object, adminConfig.Object, settings);
|
||||
var opsEvents = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
var delivery = new PushDeliveryService(Repo, eligibility, settings, new WebPushSender(new HttpClient(new RecordingHandler())),
|
||||
new Lazy<OpsEventService>(() => opsEvents.Object), cache, NullLogger<PushDeliveryService>.Instance);
|
||||
Sut = new PushPairingService(Repo, eligibility, settings, delivery, opsEvents.Object,
|
||||
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, cache, NullLogger<PushPairingService>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Codes_normalise_typed_variants_and_refuse_everything_else()
|
||||
{
|
||||
|
||||
@@ -362,7 +362,7 @@ public class PushSessionWatchServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_without_watches_neither_writes_nor_sends()
|
||||
public async Task Notify_without_watches_neither_reads_a_channel_nor_writes_nor_sends()
|
||||
{
|
||||
var repo = new Mock<IPushDeviceRepository>();
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List<PushSessionWatch>());
|
||||
@@ -372,9 +372,70 @@ public class PushSessionWatchServiceTests
|
||||
|
||||
repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once);
|
||||
repo.VerifyNoOtherCalls();
|
||||
h.Eligibility.Verify(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>()), Times.Never);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_into_a_scope_whose_channel_was_switched_off_sends_nothing_and_still_consumes_the_watch()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(HomeTenantId, "d1")].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_pushes_only_into_the_watch_scopes_whose_channel_is_on()
|
||||
{
|
||||
var h = new Harness();
|
||||
var platformDevice = h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, $"{Constants.Push.PlatformScope};{HomeTenantId}");
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed, "DESKTOP-4711", null);
|
||||
|
||||
Assert.Equal(platformDevice.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString());
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_with_empty_owner_scopes_asks_the_session_tenants_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row: the session tenant
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", null);
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
h.Eligibility.Verify(e => e.HasPushChannelAsync(Home), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_asks_each_scopes_channel_once_for_every_watcher()
|
||||
{
|
||||
var h = new Harness();
|
||||
var d1 = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
var d2 = h.AddDevice("d2", Operator, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded, "DESKTOP-4711", null);
|
||||
|
||||
Assert.Equal(Endpoints(d1, d2), SentTo(h));
|
||||
h.Eligibility.Verify(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>()), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionWatchOutcome.Succeeded)]
|
||||
[InlineData(SessionWatchOutcome.Failed)]
|
||||
|
||||
+84
-5
@@ -8,11 +8,11 @@ using Azure.Data.Tables;
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Store → Map round trip of <see cref="TablePushRepository"/> for the three models it projects
|
||||
/// (<see cref="PushDevice"/>, <see cref="PushPairingGrant"/>, <see cref="PushSessionWatch"/>;
|
||||
/// <see cref="PushOwner"/> is written inline, not through a Store/Map pair). Every row is a
|
||||
/// credential or binds one, so a column dropped on either side is a silently unpaired phone or a
|
||||
/// reusable code. The comparisons run by reflection over the model's public properties: a property
|
||||
/// Store → Map round trip of <see cref="TablePushRepository"/> for the four models it projects
|
||||
/// (<see cref="PushDevice"/>, <see cref="PushPairingGrant"/>, <see cref="PushOwner"/>,
|
||||
/// <see cref="PushSessionWatch"/>). Every row is a credential, binds one or decides who receives
|
||||
/// pushes, so a column dropped on either side is a silently unpaired phone, a reusable code or a
|
||||
/// lost role. The comparisons run by reflection over the model's public properties: a property
|
||||
/// added later is covered without touching this file, and the fixture guard fails until the fixture
|
||||
/// carries a distinct non-default value for it.
|
||||
/// </summary>
|
||||
@@ -28,6 +28,7 @@ public class TablePushRepositoryMappingTests
|
||||
// Documented key mappings: model property → entity key, where the value does not live in a column of its own name.
|
||||
private static readonly Dictionary<string, string> DeviceKeys = new() { ["Scope"] = "PartitionKey", ["DeviceId"] = "RowKey" };
|
||||
private static readonly Dictionary<string, string> GrantKeys = new() { ["CodeHash"] = "RowKey" };
|
||||
private static readonly Dictionary<string, string> OwnerKeys = new() { ["HomeTenantId"] = "PartitionKey", ["ObjectId"] = "RowKey" };
|
||||
private static readonly Dictionary<string, string> WatchKeys = new() { ["TenantId"] = "PartitionKey" };
|
||||
|
||||
// ── Devices ─────────────────────────────────────────────────────────────
|
||||
@@ -216,6 +217,75 @@ public class TablePushRepositoryMappingTests
|
||||
AssertDatesStoredAsUtc(input, entity, mapped);
|
||||
}
|
||||
|
||||
// ── Owners ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Owner_round_trip_preserves_every_property()
|
||||
{
|
||||
var owner = FullOwner();
|
||||
|
||||
var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner));
|
||||
|
||||
AssertSameProperties(owner, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_fixture_sets_every_property_to_a_distinct_non_default_value()
|
||||
=> AssertFixtureIsDistinctAndNonDefault(FullOwner());
|
||||
|
||||
[Fact]
|
||||
public void Owner_store_writes_every_property_as_a_column_or_key()
|
||||
{
|
||||
var owner = FullOwner();
|
||||
|
||||
var entity = TablePushRepository.StoreOwner(owner);
|
||||
|
||||
Assert.Equal(owner.HomeTenantId, entity.PartitionKey);
|
||||
Assert.Equal(owner.ObjectId, entity.RowKey);
|
||||
AssertEveryPropertyIsStored<PushOwner>(entity, OwnerKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_store_lower_cases_keys_and_upn_but_keeps_the_role_verbatim()
|
||||
{
|
||||
var owner = FullOwner();
|
||||
owner.HomeTenantId = "22222222-AAAA-2222-2222-222222222222";
|
||||
owner.ObjectId = "AAAAAAAA-0000-0000-0000-000000000001";
|
||||
owner.Upn = "Owner@Contoso.invalid";
|
||||
|
||||
var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner));
|
||||
|
||||
Assert.Equal("22222222-aaaa-2222-2222-222222222222", mapped.HomeTenantId);
|
||||
Assert.Equal(OwnerObjectId, mapped.ObjectId);
|
||||
Assert.Equal("owner@contoso.invalid", mapped.Upn);
|
||||
Assert.Equal(Constants.TenantRoles.Operator, mapped.ClaimRole);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void Owner_without_a_claim_role_maps_to_null(string? stored)
|
||||
{
|
||||
var owner = FullOwner();
|
||||
owner.ClaimRole = null;
|
||||
var entity = TablePushRepository.StoreOwner(owner);
|
||||
entity["ClaimRole"] = stored;
|
||||
|
||||
Assert.Null(TablePushRepository.MapOwner(entity).ClaimRole);
|
||||
Assert.Null(TablePushRepository.MapOwner(new TableEntity(OwnerHomeTenantId, OwnerObjectId)).ClaimRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_store_labels_the_sign_in_date_utc_without_moving_it()
|
||||
{
|
||||
var input = WithUnspecifiedKinds(FullOwner());
|
||||
|
||||
var entity = TablePushRepository.StoreOwner(input);
|
||||
var mapped = TablePushRepository.MapOwner(entity);
|
||||
|
||||
AssertDatesStoredAsUtc(input, entity, mapped);
|
||||
}
|
||||
|
||||
// ── Watches ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -350,6 +420,15 @@ public class TablePushRepositoryMappingTests
|
||||
ETag = "W/\"datetime'2026-10-07T08%3A30%3A00.0000000Z'\"",
|
||||
};
|
||||
|
||||
private static PushOwner FullOwner() => new()
|
||||
{
|
||||
HomeTenantId = OwnerHomeTenantId,
|
||||
ObjectId = OwnerObjectId,
|
||||
Upn = "owner@contoso.invalid",
|
||||
LastSignInUtc = Anchor,
|
||||
ClaimRole = Constants.TenantRoles.Operator,
|
||||
};
|
||||
|
||||
private static PushSessionWatch FullWatch() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
|
||||
@@ -1138,4 +1138,48 @@ public class SlaTenantStatusLifecycleTests
|
||||
Assert.Equal("consecutive_failures", send.Alert.EventType);
|
||||
Assert.Equal(NotificationScope.Tenant(TenantId), send.Scope);
|
||||
}
|
||||
|
||||
// ── Tenant name in the alert texts ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BreachAndResolvedSends_NameTheTenantByItsCompanyName()
|
||||
{
|
||||
var h = new Harness();
|
||||
var config = WithSlaChannel(CreateConfig(successRate: true));
|
||||
config.CompanyName = "Contoso";
|
||||
config.DomainName = "contoso.com";
|
||||
h.SetTenants(config);
|
||||
h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4));
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
|
||||
var breach = Assert.Single(h.ChannelSends).Alert;
|
||||
Assert.Contains(breach.Facts, f => f.Name == "Tenant" && f.Value == "Contoso");
|
||||
Assert.Contains(breach.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId);
|
||||
h.ChannelSends.Clear();
|
||||
|
||||
h.SetTerminalSessions(TenantId, Sessions(succeeded: 12, failed: 0));
|
||||
await h.Service.EvaluateAllTenantsAsync();
|
||||
|
||||
var resolved = Assert.Single(h.ChannelSends).Alert;
|
||||
Assert.Equal("SLA breach resolved for Contoso: Success Rate is back within target.", resolved.Summary);
|
||||
Assert.Contains(resolved.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsecutiveFailures_InlinePath_NamesTheTenantByItsDomainWithoutACompanyName()
|
||||
{
|
||||
var h = new Harness();
|
||||
var sends = new List<ChannelSend>();
|
||||
var config = WithSlaChannel(CreateConfig(consecutive: true, consecThreshold: 3));
|
||||
config.DomainName = "contoso.com";
|
||||
var (svc, _) = BuildInlineService(h.StatusRepo, config, FailedPage(3), sends);
|
||||
var failedSession = new SessionSummary { TenantId = TenantId, SessionId = "s3", Status = SessionStatus.Failed, DeviceName = "DEV-01", FailureReason = "timeout" };
|
||||
|
||||
await svc.EvaluateSessionCompletionAsync(TenantId, failedSession);
|
||||
|
||||
var alert = Assert.Single(sends).Alert;
|
||||
Assert.Equal("Alert: 3 enrollments failed in a row for contoso.com", alert.Summary);
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Tenant" && f.Value == "contoso.com");
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Tenant ID" && f.Value == TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-12
@@ -199,13 +199,7 @@ public class TablePushRepository : IPushDeviceRepository
|
||||
try
|
||||
{
|
||||
var entity = await _owners.GetEntityAsync<TableEntity>(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()).ConfigureAwait(false);
|
||||
return new PushOwner
|
||||
{
|
||||
HomeTenantId = entity.Value.PartitionKey,
|
||||
ObjectId = entity.Value.RowKey,
|
||||
Upn = entity.Value.GetString("Upn") ?? string.Empty,
|
||||
LastSignInUtc = entity.Value.GetDateTime("LastSignInUtc") ?? DateTime.MinValue,
|
||||
};
|
||||
return MapOwner(entity.Value);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -213,12 +207,17 @@ public class TablePushRepository : IPushDeviceRepository
|
||||
}
|
||||
}
|
||||
|
||||
public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc)
|
||||
=> _owners.UpsertEntityAsync(new TableEntity(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant())
|
||||
// Replace, not Merge: the stamp is the whole row, and a claim role the person no longer holds
|
||||
// must disappear — a Merge cannot clear a column.
|
||||
public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole)
|
||||
=> _owners.UpsertEntityAsync(StoreOwner(new PushOwner
|
||||
{
|
||||
["Upn"] = upn.ToLowerInvariant(),
|
||||
["LastSignInUtc"] = DateTime.SpecifyKind(signInUtc, DateTimeKind.Utc),
|
||||
}, TableUpdateMode.Merge);
|
||||
HomeTenantId = homeTenantId,
|
||||
ObjectId = objectId,
|
||||
Upn = upn,
|
||||
LastSignInUtc = signInUtc,
|
||||
ClaimRole = claimRole,
|
||||
}), TableUpdateMode.Replace);
|
||||
|
||||
// ── Watches ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -366,6 +365,23 @@ public class TablePushRepository : IPushDeviceRepository
|
||||
ETag = e.ETag.ToString(),
|
||||
};
|
||||
|
||||
internal static TableEntity StoreOwner(PushOwner o) =>
|
||||
new(o.HomeTenantId.ToLowerInvariant(), o.ObjectId.ToLowerInvariant())
|
||||
{
|
||||
["Upn"] = o.Upn.ToLowerInvariant(),
|
||||
["LastSignInUtc"] = DateTime.SpecifyKind(o.LastSignInUtc, DateTimeKind.Utc),
|
||||
["ClaimRole"] = o.ClaimRole,
|
||||
};
|
||||
|
||||
internal static PushOwner MapOwner(TableEntity e) => new()
|
||||
{
|
||||
HomeTenantId = e.PartitionKey,
|
||||
ObjectId = e.RowKey,
|
||||
Upn = e.GetString("Upn") ?? string.Empty,
|
||||
LastSignInUtc = e.GetDateTime("LastSignInUtc") ?? DateTime.MinValue,
|
||||
ClaimRole = string.IsNullOrEmpty(e.GetString("ClaimRole")) ? null : e.GetString("ClaimRole"),
|
||||
};
|
||||
|
||||
internal static TableEntity StoreWatch(PushSessionWatch w) =>
|
||||
new(w.TenantId.ToLowerInvariant(), WatchRowKey(w.SessionId, w.OwnerObjectId))
|
||||
{
|
||||
|
||||
+4
-3
@@ -211,9 +211,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
draft.OpsNotificationChannelsJson, storedChannels);
|
||||
|
||||
// Same structural gate the tenant channel list passes. No GA gate on Telegram here:
|
||||
// this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson);
|
||||
// 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.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson)
|
||||
?? TenantConfigValidation.ValidateSinglePushChannel(draft.OpsNotificationChannelsJson, storedChannels);
|
||||
if (channelsError != null)
|
||||
return $"Invalid ops notification channels: {channelsError}";
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public class AuthFunction
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
private readonly IPushDeviceRepository? _pushRepo;
|
||||
private readonly Services.Push.PushOwnerSignInService? _pushSignIn;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
|
||||
public AuthFunction(
|
||||
@@ -60,11 +60,11 @@ public class AuthFunction
|
||||
ISignalRNotificationService signalRService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo,
|
||||
IPushDeviceRepository? pushRepo = null,
|
||||
Services.Push.PushOwnerSignInService? pushSignIn = null,
|
||||
OpsEventService? opsEvents = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_pushRepo = pushRepo;
|
||||
_pushSignIn = pushSignIn;
|
||||
_opsEvents = opsEvents;
|
||||
_offboardingRepo = offboardingRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -224,7 +224,10 @@ public class AuthFunction
|
||||
}
|
||||
|
||||
// --- Post-decision side-effects ---
|
||||
await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId);
|
||||
// The push stamp records the app-role claim's role only where no TenantAdmins row decides.
|
||||
await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId,
|
||||
pushClaimRole: tableState == TableMemberState.NotPresent ? memberRole?.Role : null,
|
||||
isGlobalAdmin: isGlobalAdmin);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(decision.Body);
|
||||
@@ -688,12 +691,13 @@ public class AuthFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes post-decision side-effects: auto-admin assignment (awaited)
|
||||
/// and metrics recording (fire-and-forget).
|
||||
/// Executes post-decision side-effects: auto-admin assignment (awaited), metrics recording
|
||||
/// (fire-and-forget) and the push sign-in (awaited, fail-soft).
|
||||
/// </summary>
|
||||
internal async Task HandlePostDecisionSideEffectsAsync(
|
||||
AuthDecisionResult decision, string tenantId, string upn,
|
||||
string? displayName, string? objectId)
|
||||
string? displayName, string? objectId,
|
||||
string? pushClaimRole = null, bool isGlobalAdmin = false)
|
||||
{
|
||||
if (decision.NeedsAutoAdmin)
|
||||
{
|
||||
@@ -706,20 +710,12 @@ public class AuthFunction
|
||||
"Fire-and-forget RecordUserLoginAsync failed"),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
// Push freshness stamp (K3): one upsert per portal sign-in, keyed by the person (home
|
||||
// tenant + oid), so paired devices of someone who stopped signing in pause after the
|
||||
// inactivity window. Awaited — a lost stamp is an early pause; fail-soft — never a 500.
|
||||
if (_pushRepo != null && !string.IsNullOrEmpty(objectId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pushRepo.StampOwnerSignInAsync(tenantId, objectId, upn, DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Push owner sign-in stamp failed for tenant {TenantId}", tenantId);
|
||||
}
|
||||
}
|
||||
// Push sign-in (K3): one stamp per portal sign-in, keyed by the person (home tenant + oid),
|
||||
// so paired devices of someone who stopped signing in pause after the inactivity window; it
|
||||
// also carries the app-role claim's role, revokes when that role is gone and lifts an
|
||||
// inactivity pause. Awaited — a lost stamp is an early pause; the service never throws.
|
||||
if (_pushSignIn != null && !string.IsNullOrEmpty(objectId))
|
||||
await _pushSignIn.OnPortalSignInAsync(tenantId, objectId, upn, pushClaimRole, isGlobalAdmin, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
@@ -16,15 +17,16 @@ internal static class PushFunctionSupport
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
|
||||
/// the TenantAdminOrOperator tier by bypass, so the service re-checks the table-backed tenant
|
||||
/// role (K2/K5) — a GA without a member row is told to use the platform routes.
|
||||
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
|
||||
/// refuses a Global Admin without one in this tenant like any other caller.
|
||||
/// </summary>
|
||||
public static (NotificationScope Scope, PushCaller Caller) TenantContext(HttpRequestData req)
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var scope = NotificationScope.Tenant(ctx.TargetTenantId);
|
||||
var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId,
|
||||
IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin);
|
||||
IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin,
|
||||
AppRoles: req.FunctionContext.GetUser()?.GetAppRoles());
|
||||
return (scope, caller);
|
||||
}
|
||||
|
||||
@@ -33,7 +35,8 @@ internal static class PushFunctionSupport
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId,
|
||||
IsScopeAdmin: ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin);
|
||||
IsScopeAdmin: ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin,
|
||||
AppRoles: req.FunctionContext.GetUser()?.GetAppRoles());
|
||||
return (NotificationScope.Platform, caller);
|
||||
}
|
||||
|
||||
@@ -43,7 +46,7 @@ internal static class PushFunctionSupport
|
||||
PushOpError.ChannelRequired => req.ErrorAsync(HttpStatusCode.Conflict, Constants.ApiErrorCodes.PushChannelRequired,
|
||||
message ?? "A Push notification channel must be enabled in this scope first."),
|
||||
PushOpError.NotEligible => req.ErrorAsync(HttpStatusCode.Forbidden, Constants.ApiErrorCodes.PushNotEligible,
|
||||
message ?? "Pairing needs a table-backed Admin or Operator role in this scope."),
|
||||
message ?? "Push notifications need an Admin or Operator role in this tenant's member list."),
|
||||
PushOpError.NotFound => req.NotFoundAsync(message ?? "Not found."),
|
||||
PushOpError.CodeInvalid => req.ErrorAsync(HttpStatusCode.NotFound, Constants.ApiErrorCodes.PairingCodeInvalid,
|
||||
"This pairing code is unknown, expired or already used."),
|
||||
|
||||
@@ -186,6 +186,14 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
&& !Shared.Models.Notifications.NotificationChannel.IsSupportedProviderType(candidate.WebhookProviderType))
|
||||
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.
|
||||
if (candidate.WebhookProviderType != existing.WebhookProviderType
|
||||
&& candidate.WebhookProviderType != 0
|
||||
&& !Shared.Models.Notifications.NotificationChannel.IsWebhookUrlProvider(candidate.WebhookProviderType))
|
||||
return "The legacy webhook supports webhook providers only; use a notification channel for the others.";
|
||||
|
||||
var headersError = ValidateWebhookCustomHeaders(candidate.WebhookCustomHeadersJson);
|
||||
if (headersError != null)
|
||||
return $"Invalid custom headers: {headersError}";
|
||||
@@ -199,6 +207,10 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (telegramGateError != null)
|
||||
return telegramGateError;
|
||||
|
||||
var pushCountError = ValidateSinglePushChannel(candidate.NotificationChannelsJson, existing.NotificationChannelsJson);
|
||||
if (pushCountError != null)
|
||||
return $"Invalid notification channels: {pushCountError}";
|
||||
|
||||
var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl);
|
||||
if (brandingUrlError != null)
|
||||
return $"Invalid enrollment summary branding image URL: {brandingUrlError}";
|
||||
@@ -456,9 +468,8 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
/// 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), Push through the platform's push sender and VAPID key, E-mail through the
|
||||
/// platform's sender identity and provider key — GA-only until the customer release. A tenant admin must not be able to create or retarget one — hiding
|
||||
/// the provider in the UI is not a control.
|
||||
/// 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.
|
||||
/// <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
|
||||
@@ -492,7 +503,6 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
var provider = channel.ProviderType switch
|
||||
{
|
||||
(int)Shared.Models.Notifications.WebhookProviderType.Push => "Push",
|
||||
(int)Shared.Models.Notifications.WebhookProviderType.Email => "E-mail",
|
||||
_ => "Telegram",
|
||||
};
|
||||
@@ -503,6 +513,25 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At most one Push channel per scope, enabled or not: one already reaches every paired device
|
||||
/// of the scope, so a second would only deliver each alert twice. Compared with the stored list,
|
||||
/// so a pair saved before this rule stays saveable until it is reduced — only a save that raises
|
||||
/// the count above one is refused, for every caller (a Global Admin too). The tenant and the ops
|
||||
/// channel lists both pass it. Returns an error message, or null.
|
||||
/// </summary>
|
||||
internal static string? ValidateSinglePushChannel(string? candidateJson, string? existingJson)
|
||||
{
|
||||
var count = CountPushChannels(candidateJson);
|
||||
return count > 1 && count > CountPushChannels(existingJson)
|
||||
? "only one Push channel is allowed — a single channel already reaches every paired device."
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int CountPushChannels(string? json)
|
||||
=> Shared.Models.Notifications.NotificationChannel.ParseList(json)
|
||||
.Count(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when
|
||||
/// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// How a message names a tenant to people: the company name, else the domain, else the tenant id.
|
||||
/// One chain for the push surfaces and the SLA alerts, so a tenant reads the same on every channel.
|
||||
/// </summary>
|
||||
public static class TenantDisplayName
|
||||
{
|
||||
public static string Of(TenantConfiguration? config, string tenantId)
|
||||
{
|
||||
if (config != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(config.CompanyName)) return config.CompanyName;
|
||||
if (!string.IsNullOrWhiteSpace(config.DomainName)) return config.DomainName;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Notifications.
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushDeviceRevoker>(
|
||||
sp => sp.GetRequiredService<AutopilotMonitor.Functions.Services.Push.PushDeliveryService>());
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushPairingService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushOwnerSignInService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushMaintenanceService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushSessionWatchService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSessionWatchNotifier>(
|
||||
|
||||
+25
-22
@@ -351,7 +351,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// Unknown types fall back to a neutral template rather than silently rendering as Duration.
|
||||
/// </summary>
|
||||
public static NotificationAlert BuildSlaBreachAlert(
|
||||
string tenantId, double currentRate, double targetRate,
|
||||
string tenantId, string tenantName, double currentRate, double targetRate,
|
||||
int totalSessions, int failedSessions, string breachType, string? dashboardUrl = null,
|
||||
string? extraContext = null)
|
||||
{
|
||||
@@ -360,10 +360,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
string summary;
|
||||
string period;
|
||||
string label;
|
||||
var facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Tenant", Value = tenantId },
|
||||
};
|
||||
var facts = TenantFacts(tenantId, tenantName);
|
||||
|
||||
switch (breachType)
|
||||
{
|
||||
@@ -435,7 +432,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// Emitted once when a previously-active breach is no longer detected on the next evaluation.
|
||||
/// </summary>
|
||||
public static NotificationAlert BuildSlaResolvedAlert(
|
||||
string tenantId, string breachType, double? currentValue, double? targetValue,
|
||||
string tenantId, string tenantName, string breachType, double? currentValue, double? targetValue,
|
||||
DateTime? firstBreachAt, DateTime resolvedAt, string? dashboardUrl = null)
|
||||
{
|
||||
var label = breachType switch
|
||||
@@ -451,12 +448,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
? FormatDuration(resolvedAt - firstBreachAt.Value)
|
||||
: "–";
|
||||
|
||||
var facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Tenant", Value = tenantId },
|
||||
new() { Name = "Breach Type", Value = label },
|
||||
new() { Name = "Active For", Value = durationText },
|
||||
};
|
||||
var facts = TenantFacts(tenantId, tenantName);
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
facts.Add(new NotificationFact { Name = "Active For", Value = durationText });
|
||||
|
||||
if (currentValue.HasValue)
|
||||
facts.Add(new NotificationFact { Name = "Current", Value = $"{currentValue.Value:F1}" });
|
||||
@@ -467,7 +461,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
EventType = "sla_resolved",
|
||||
Title = $"🟢 SLA Breach Resolved: {label}",
|
||||
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
|
||||
Summary = $"SLA breach resolved for {tenantName}: {label} is back within target.",
|
||||
Severity = NotificationSeverity.Success,
|
||||
ThemeColor = "00B050",
|
||||
Facts = facts,
|
||||
@@ -490,23 +484,22 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// Builds a notification alert for consecutive enrollment failures.
|
||||
/// </summary>
|
||||
public static NotificationAlert BuildConsecutiveFailuresAlert(
|
||||
string tenantId, int consecutiveFailures,
|
||||
string tenantId, string tenantName, int consecutiveFailures,
|
||||
string? lastDeviceName, string? lastFailureReason, string? dashboardUrl = null)
|
||||
{
|
||||
var facts = TenantFacts(tenantId, tenantName);
|
||||
facts.Add(new NotificationFact { Name = "Consecutive Failures", Value = consecutiveFailures.ToString() });
|
||||
facts.Add(new NotificationFact { Name = "Last Device", Value = lastDeviceName ?? "\u2013" });
|
||||
facts.Add(new NotificationFact { Name = "Last Failure", Value = lastFailureReason ?? "\u2013" });
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "consecutive_failures",
|
||||
Title = $"\ud83d\udd34 {consecutiveFailures} Consecutive Enrollment Failures",
|
||||
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
|
||||
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for {tenantName}",
|
||||
Severity = NotificationSeverity.Error,
|
||||
ThemeColor = "FF0000",
|
||||
Facts = new List<NotificationFact>
|
||||
{
|
||||
new() { Name = "Tenant", Value = tenantId },
|
||||
new() { Name = "Consecutive Failures", Value = consecutiveFailures.ToString() },
|
||||
new() { Name = "Last Device", Value = lastDeviceName ?? "\u2013" },
|
||||
new() { Name = "Last Failure", Value = lastFailureReason ?? "\u2013" },
|
||||
},
|
||||
Facts = facts,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(dashboardUrl))
|
||||
@@ -515,6 +508,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
return alert;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How an SLA alert names its tenant: "Tenant" carries the display name people read,
|
||||
/// "Tenant ID" the id an integration keys on. The push projection allow-lists only the first.
|
||||
/// </summary>
|
||||
private static List<NotificationFact> TenantFacts(string tenantId, string tenantName) => new()
|
||||
{
|
||||
new() { Name = "Tenant", Value = tenantName },
|
||||
new() { Name = "Tenant ID", Value = tenantId },
|
||||
};
|
||||
|
||||
private static string BuildHardwareText(string? manufacturer, string? model)
|
||||
{
|
||||
var parts = new[]
|
||||
|
||||
+8
-1
@@ -81,6 +81,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
public virtual async Task SendToChannelsAsync(
|
||||
IEnumerable<NotificationChannel> channels, NotificationAlert alert, NotificationScope scope)
|
||||
{
|
||||
// One Push channel already reaches every paired device of the scope: a second one in the
|
||||
// same send (a pair stored before validation allowed only one) would deliver it twice.
|
||||
var pushSent = false;
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (channel == null || !channel.HasDestination())
|
||||
@@ -88,7 +91,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
if (channel.ProviderType == (int)WebhookProviderType.Push)
|
||||
{
|
||||
await _push.SendAsync(scope, alert);
|
||||
if (!pushSent)
|
||||
{
|
||||
pushSent = true;
|
||||
await _push.SendAsync(scope, alert);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
/// <summary>
|
||||
/// A system push to every Active device ONE person owns in a scope, through the same role and
|
||||
/// freshness filter as an alert (D-327: resolved at every send) — the session-watch fan-out.
|
||||
/// Returns the number of deliveries.
|
||||
/// The caller checks the scope's Push channel (K4). Returns the number of deliveries.
|
||||
/// </summary>
|
||||
public async Task<int> SendSystemToOwnerAsync(NotificationScope scope, string ownerObjectId, PushPayload payload, TimeSpan? ttl = null)
|
||||
{
|
||||
@@ -337,7 +337,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
Track("PushPairing", new Dictionary<string, string> { ["action"] = "paused", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform });
|
||||
}
|
||||
|
||||
/// <summary>An owner-inactivity pause lifts itself once the owner signs in again (maintenance) — no re-pairing.</summary>
|
||||
/// <summary>An owner-inactivity pause lifts itself once the owner signs in again (at the sign-in, the maintenance run as fallback) — no re-pairing.</summary>
|
||||
public async Task ResumeAsync(PushDevice device, DateTime now)
|
||||
{
|
||||
var scope = NotificationScope.FromKey(device.Scope);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -7,10 +8,12 @@ namespace AutopilotMonitor.Functions.Services.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE answer to "may this person pair and receive push in this scope", asked at pairing
|
||||
/// time and again at every send (Korrektur K2). Table-backed only — at send time there is no
|
||||
/// token, so a role that exists solely as an Entra app-role claim cannot be re-checked and is
|
||||
/// excluded fail-closed. Tenant scope: an enabled TenantAdmins row with role Admin or Operator
|
||||
/// (Viewer may not pair, F10). Platform scope: an enabled GlobalAdmins row with role GlobalAdmin
|
||||
/// time and again at every send (Korrektur K2). Tenant scope: the TenantAdmins row first — enabled
|
||||
/// with role Admin or Operator (Viewer may not pair, F10), a disabled row is a no. Without a row,
|
||||
/// the member role the Entra app-role claim granted at the person's last portal sign-in
|
||||
/// (<see cref="PushOwner.ClaimRole"/> — a send has no token to re-read), only in the person's home
|
||||
/// tenant and only while that tenant keeps app roles enabled; the sign-in that loses the claim
|
||||
/// role revokes the devices. Platform scope: an enabled GlobalAdmins row with role GlobalAdmin
|
||||
/// (Global Reader may not, F6) whose identity binding matches the owner's home tenant and object
|
||||
/// id — the same corroboration <c>GlobalAdminService</c> performs for a live caller. Reads the
|
||||
/// repositories, not the per-process role caches, so a revocation takes effect at the next send.
|
||||
@@ -82,18 +85,48 @@ public class PushEligibility
|
||||
}
|
||||
|
||||
var member = await _adminRepo.GetTenantMemberAsync(scope.Key, upn);
|
||||
if (member == null || !member.IsEnabled)
|
||||
return false;
|
||||
if (member != null)
|
||||
return member.IsEnabled && IsAlertingRole(string.IsNullOrEmpty(member.Role) ? Constants.TenantRoles.Admin : member.Role);
|
||||
|
||||
var role = string.IsNullOrEmpty(member.Role) ? Constants.TenantRoles.Admin : member.Role;
|
||||
return role == Constants.TenantRoles.Admin || role == Constants.TenantRoles.Operator;
|
||||
if (string.IsNullOrEmpty(objectId) || !string.Equals(scope.Key, homeTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
var owner = await _pushRepo.GetOwnerAsync(homeTenantId, objectId);
|
||||
if (owner == null || !IsAlertingRole(owner.ClaimRole))
|
||||
return false;
|
||||
var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key);
|
||||
return exists && config.EntraAppRolesEnabled;
|
||||
}
|
||||
|
||||
/// <summary>Admin and Operator receive push; Viewer never does (F10).</summary>
|
||||
public static bool IsAlertingRole(string? role)
|
||||
=> role == Constants.TenantRoles.Admin || role == Constants.TenantRoles.Operator;
|
||||
|
||||
/// <summary>
|
||||
/// The member role a live caller's Entra app-role claim grants in their home tenant — what a
|
||||
/// pairing records as <see cref="PushOwner.ClaimRole"/>, the same rule a portal sign-in follows:
|
||||
/// only without a TenantAdmins row (a row, enabled or disabled, decides alone) and only while the
|
||||
/// tenant has app roles enabled. Null otherwise.
|
||||
/// </summary>
|
||||
public virtual async Task<string?> ClaimRoleAsync(string homeTenantId, string upn, IReadOnlyList<string>? appRoles)
|
||||
{
|
||||
if (appRoles == null || appRoles.Count == 0 || string.IsNullOrWhiteSpace(homeTenantId)
|
||||
|| string.IsNullOrWhiteSpace(upn) || Constants.PrincipalKeys.IsApplication(upn))
|
||||
return null;
|
||||
|
||||
var home = NotificationScope.Tenant(homeTenantId).Key;
|
||||
if (await _adminRepo.GetTenantMemberAsync(home, upn.ToLowerInvariant()) != null)
|
||||
return null;
|
||||
|
||||
var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(home);
|
||||
return EntraAppRoleResolver.Resolve(TableMemberState.NotPresent, null, appRoles, exists && config.EntraAppRolesEnabled)?.Role;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness (K3): the owner signed in to the portal within the inactivity window. The stamp
|
||||
/// is written at pairing and on every portal sign-in, so an absent row means "never" — not
|
||||
/// fresh. A device of an owner who stopped signing in pauses; roles are app-internal and a
|
||||
/// deactivated Entra account would otherwise keep receiving alerts.
|
||||
/// deactivated Entra account would otherwise keep receiving alerts. The window also bounds
|
||||
/// the age of a recorded claim role.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> IsOwnerFreshAsync(string homeTenantId, string objectId, DateTime nowUtc)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Push;
|
||||
|
||||
/// <summary>
|
||||
/// What a portal sign-in means for the person's paired devices. It writes the freshness stamp with
|
||||
/// the member role the Entra app-role claim grants (<see cref="PushOwner.ClaimRole"/>, recorded only
|
||||
/// without a TenantAdmins row); it revokes the home-tenant devices once that claim role fell below
|
||||
/// Operator and no row took its place — the claim's counterpart of the TenantAdmins role-loss hook
|
||||
/// (K6); and it lifts an inactivity pause at once instead of at the next maintenance run. Never
|
||||
/// throws: a push failure must not fail the sign-in.
|
||||
/// </summary>
|
||||
public class PushOwnerSignInService
|
||||
{
|
||||
private readonly IPushDeviceRepository _repo;
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
private readonly PushSettings _settings;
|
||||
private readonly PushDeliveryService _delivery;
|
||||
private readonly ILogger<PushOwnerSignInService> _logger;
|
||||
|
||||
public PushOwnerSignInService(
|
||||
IPushDeviceRepository repo,
|
||||
IAdminRepository adminRepo,
|
||||
PushSettings settings,
|
||||
PushDeliveryService delivery,
|
||||
ILogger<PushOwnerSignInService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_adminRepo = adminRepo;
|
||||
_settings = settings;
|
||||
_delivery = delivery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <param name="claimRole">The member role the token's app-role claim grants, only when the person has no TenantAdmins row; else null.</param>
|
||||
/// <param name="isGlobalAdmin">The person's platform devices resume too.</param>
|
||||
public async Task OnPortalSignInAsync(string homeTenantId, string objectId, string upn, string? claimRole, bool isGlobalAdmin, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(homeTenantId) || string.IsNullOrWhiteSpace(objectId) || string.IsNullOrWhiteSpace(upn))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var previous = await _repo.GetOwnerAsync(homeTenantId, objectId).ConfigureAwait(false);
|
||||
await _repo.StampOwnerSignInAsync(homeTenantId, objectId, upn, nowUtc, claimRole).ConfigureAwait(false);
|
||||
|
||||
var home = NotificationScope.Tenant(homeTenantId);
|
||||
if (PushEligibility.IsAlertingRole(previous?.ClaimRole) && !PushEligibility.IsAlertingRole(claimRole)
|
||||
&& await _adminRepo.GetTenantMemberAsync(home.Key, upn.ToLowerInvariant()).ConfigureAwait(false) == null)
|
||||
{
|
||||
await _delivery.RevokeOwnerAsync(home, upn, "app_role_lost").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (previous == null || previous.LastSignInUtc < nowUtc.AddDays(-_settings.OwnerInactivityDays))
|
||||
{
|
||||
await ResumeInactivityPausesAsync(home, homeTenantId, objectId, nowUtc).ConfigureAwait(false);
|
||||
if (isGlobalAdmin)
|
||||
await ResumeInactivityPausesAsync(NotificationScope.Platform, homeTenantId, objectId, nowUtc).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Push sign-in handling failed for tenant {TenantId}", homeTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResumeInactivityPausesAsync(NotificationScope scope, string homeTenantId, string objectId, DateTime nowUtc)
|
||||
{
|
||||
foreach (var device in await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false))
|
||||
{
|
||||
if (device.Status == Constants.Push.DeviceStatus.Paused
|
||||
&& device.StatusReason == "owner_inactive"
|
||||
&& string.Equals(device.OwnerObjectId, objectId, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(device.OwnerHomeTenantId, homeTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await _delivery.ResumeAsync(device, nowUtc).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -14,9 +15,11 @@ namespace AutopilotMonitor.Functions.Services.Push;
|
||||
/// <summary>
|
||||
/// Who is pairing or managing devices: the authenticated caller as the middleware resolved it.
|
||||
/// <paramref name="IsScopeAdmin"/> = sees and manages every device of the scope; <paramref name="IsGlobalAdmin"/>
|
||||
/// = the caller's own devices may live in the platform partition (session watches fan out there).
|
||||
/// = the caller's own devices may live in the platform partition (session watches fan out there);
|
||||
/// <paramref name="AppRoles"/> = the token's raw Entra app-role values (pairing records the member role they grant).
|
||||
/// </summary>
|
||||
public sealed record PushCaller(string Upn, string ObjectId, string HomeTenantId, bool IsScopeAdmin, bool IsGlobalAdmin = false);
|
||||
public sealed record PushCaller(string Upn, string ObjectId, string HomeTenantId, bool IsScopeAdmin, bool IsGlobalAdmin = false,
|
||||
IReadOnlyList<string>? AppRoles = null);
|
||||
|
||||
/// <summary>Outcome of a pairing/device operation; the function maps it to a status + error code.</summary>
|
||||
public enum PushOpError
|
||||
@@ -93,10 +96,22 @@ public class PushPairingService
|
||||
return PushOpResult<CreatePairingResponse>.Fail(PushOpError.NotConfigured, "The push channel is not configured on the platform.");
|
||||
if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false))
|
||||
return PushOpResult<CreatePairingResponse>.Fail(PushOpError.ChannelRequired, "Add and enable a Push notification channel first.");
|
||||
if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false))
|
||||
return PushOpResult<CreatePairingResponse>.Fail(PushOpError.NotEligible, "Pairing needs an Admin or Operator role held in the member list of this tenant.");
|
||||
|
||||
// Pairing is a portal action from a signed-in session: it counts as a sign-in for freshness,
|
||||
// and it records the live app-role claim BEFORE the role check, so a member known only
|
||||
// through Entra app roles can pair without waiting for the next recorded sign-in.
|
||||
var now = DateTime.UtcNow;
|
||||
if (!string.IsNullOrEmpty(caller.HomeTenantId) && !string.IsNullOrEmpty(caller.ObjectId))
|
||||
{
|
||||
var claimRole = await _eligibility.ClaimRoleAsync(caller.HomeTenantId, caller.Upn, caller.AppRoles).ConfigureAwait(false);
|
||||
await _repo.StampOwnerSignInAsync(caller.HomeTenantId.ToLowerInvariant(), caller.ObjectId.ToLowerInvariant(),
|
||||
caller.Upn.ToLowerInvariant(), now, claimRole).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false))
|
||||
return PushOpResult<CreatePairingResponse>.Fail(PushOpError.NotEligible,
|
||||
scope.IsPlatform ? "Push notifications on the platform need the Global Administrator role." : null);
|
||||
|
||||
var code = GenerateCode();
|
||||
var grant = new PushPairingGrant
|
||||
{
|
||||
@@ -110,9 +125,6 @@ public class PushPairingService
|
||||
ExpiresUtc = now.AddMinutes(Constants.Push.PairingGrantMinutes),
|
||||
};
|
||||
await _repo.AddGrantAsync(grant).ConfigureAwait(false);
|
||||
|
||||
// Pairing is a portal action from a signed-in session: it counts as a sign-in for freshness.
|
||||
await _repo.StampOwnerSignInAsync(grant.OwnerHomeTenantId, grant.OwnerObjectId, grant.OwnerUpn, now).ConfigureAwait(false);
|
||||
Track("created", scope);
|
||||
|
||||
return PushOpResult<CreatePairingResponse>.Success(new CreatePairingResponse
|
||||
@@ -445,6 +457,9 @@ public class PushPairingService
|
||||
var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false);
|
||||
if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller)))
|
||||
return PushOpResult<TestWebhookNotificationResponse>.Fail(PushOpError.NotFound);
|
||||
// The channel is the one switch (K4); a switched-off channel is a state the panel shows inline.
|
||||
if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false))
|
||||
return PushOpResult<TestWebhookNotificationResponse>.Success(new TestWebhookNotificationResponse { Success = false, Message = "The Push channel is off — paired devices receive nothing." });
|
||||
if (device.Status != Constants.Push.DeviceStatus.Active)
|
||||
return PushOpResult<TestWebhookNotificationResponse>.Success(new TestWebhookNotificationResponse { Success = false, Message = $"The device is {device.Status.ToLowerInvariant()} — only active devices receive pushes." });
|
||||
|
||||
@@ -470,11 +485,7 @@ public class PushPairingService
|
||||
if (scope.IsPlatform)
|
||||
return "Platform operator";
|
||||
var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key).ConfigureAwait(false);
|
||||
if (!exists)
|
||||
return scope.Key;
|
||||
if (!string.IsNullOrWhiteSpace(config.CompanyName)) return config.CompanyName;
|
||||
if (!string.IsNullOrWhiteSpace(config.DomainName)) return config.DomainName;
|
||||
return scope.Key;
|
||||
return TenantDisplayName.Of(exists ? config : null, scope.Key);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,10 +28,11 @@ public interface IPushSessionWatchNotifier
|
||||
/// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's
|
||||
/// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can
|
||||
/// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in
|
||||
/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and
|
||||
/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a
|
||||
/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed
|
||||
/// out), and die with the session's tenant.
|
||||
/// a scope with an enabled Push channel (K4) — checked when the watch is set and again when it
|
||||
/// fires, so switching the channel off silences a pending watch (it is consumed all the same) —
|
||||
/// and the fan-out runs through the same role and freshness filter as any alert. Watches expire
|
||||
/// after 48 hours (the agent's emergency break), a watch on a sealed session after 30 days (the
|
||||
/// user phase starts whenever the device is handed out), and die with the session's tenant.
|
||||
/// </summary>
|
||||
public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
{
|
||||
@@ -161,6 +162,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber);
|
||||
var channelOn = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var watch in watches)
|
||||
{
|
||||
@@ -172,6 +174,11 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
|
||||
foreach (var scope in ScopesOf(watch, tenantId))
|
||||
{
|
||||
if (!channelOn.TryGetValue(scope.Key, out var on))
|
||||
channelOn[scope.Key] = on = await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false);
|
||||
if (!on)
|
||||
continue;
|
||||
|
||||
var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now,
|
||||
Constants.PortalSessionUrl(sessionId, tenantId));
|
||||
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
|
||||
|
||||
@@ -541,7 +541,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? extraContext = null)
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaBreachAlert(
|
||||
config.TenantId, currentValue, targetValue,
|
||||
config.TenantId, TenantDisplayName.Of(config, config.TenantId), currentValue, targetValue,
|
||||
totalSessions, failedSessions, breachType, DashboardUrl, extraContext);
|
||||
|
||||
var slaChannels = GetSlaChannels(config);
|
||||
@@ -584,7 +584,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
double? currentValue, double? targetValue, DateTime? firstBreachAt, DateTime resolvedAt)
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaResolvedAlert(
|
||||
config.TenantId, breachType, currentValue, targetValue,
|
||||
config.TenantId, TenantDisplayName.Of(config, config.TenantId), breachType, currentValue, targetValue,
|
||||
firstBreachAt, resolvedAt, DashboardUrl);
|
||||
|
||||
var slaChannels = GetSlaChannels(config);
|
||||
@@ -673,7 +673,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var lastReason = failedSession.FailureReason;
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildConsecutiveFailuresAlert(
|
||||
tenantId, threshold, lastDevice, lastReason, DashboardUrl);
|
||||
tenantId, TenantDisplayName.Of(config, tenantId), threshold, lastDevice, lastReason, DashboardUrl);
|
||||
|
||||
var slaChannels = GetSlaChannels(config);
|
||||
if (slaChannels.Count > 0)
|
||||
|
||||
@@ -1731,7 +1731,8 @@ namespace AutopilotMonitor.Shared
|
||||
// PushOwners PK = owner's HOME tenant, RK = oid. LastSignInUtc stamped once per
|
||||
// portal sign-in — the freshness source that retires devices of
|
||||
// people who stopped signing in (roles are app-internal, never
|
||||
// Entra-synchronized). Never pruned on its own.
|
||||
// Entra-synchronized) — with ClaimRole, the Entra app-role member
|
||||
// role of a person without a TenantAdmins row. Never pruned on its own.
|
||||
// PushPairingGrants PK = "grant", RK = SHA-256(code). Ten-minute one-shot pairing
|
||||
// codes; TenantId property for the offboarding property wipe;
|
||||
// expired lazily on redeem and by the maintenance sweep.
|
||||
|
||||
@@ -35,8 +35,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
|
||||
// --- Owners (PK = home tenant, RK = oid) ---
|
||||
Task<PushOwner?> GetOwnerAsync(string homeTenantId, string objectId);
|
||||
/// <summary>Upsert of the sign-in stamp — one write per portal sign-in.</summary>
|
||||
Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc);
|
||||
/// <summary>
|
||||
/// Upsert of the whole sign-in stamp — one write per portal sign-in: the time and
|
||||
/// <paramref name="claimRole"/> (<see cref="PushOwner.ClaimRole"/>; null clears it).
|
||||
/// </summary>
|
||||
Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc, string? claimRole);
|
||||
|
||||
// --- Session watches (PK = tenantId, RK = "{sessionId}_{oid}") ---
|
||||
Task UpsertWatchAsync(PushSessionWatch watch);
|
||||
@@ -118,6 +121,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public string ObjectId { get; set; } = string.Empty;
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
public DateTime LastSignInUtc { get; set; }
|
||||
/// <summary>
|
||||
/// The member role (Constants.TenantRoles) the Entra app-role claim granted in the home tenant at
|
||||
/// the last portal sign-in, recorded only while the person has no TenantAdmins row there; null
|
||||
/// otherwise. A send has no token, so this is the role a claim-only member is checked against.
|
||||
/// </summary>
|
||||
public string? ClaimRole { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>"Notify my devices when this session ends" — one row per (session, person), stored in the SESSION's tenant.</summary>
|
||||
|
||||
@@ -45,7 +45,8 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// Provider type (int form of <see cref="WebhookProviderType"/>):
|
||||
/// 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord,
|
||||
/// 40=Telegram (Global-Admin only — see the enum member), 50=Push (paired devices, no
|
||||
/// destination field; Global-Admin only until the customer release).
|
||||
/// destination field; open to tenant Admins, at most one Push channel per scope),
|
||||
/// 60=Email (Global-Admin only).
|
||||
/// </summary>
|
||||
public int ProviderType { get; set; }
|
||||
|
||||
@@ -70,16 +71,28 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
=> ProviderType == (int)WebhookProviderType.Push || !string.IsNullOrWhiteSpace(Url);
|
||||
|
||||
/// <summary>
|
||||
/// Providers a tenant admin may not create or retarget: they send through PLATFORM-owned
|
||||
/// infrastructure (the Telegram bot; the push sender and its VAPID key; the platform's e-mail
|
||||
/// sender). Enforced server-side in TenantConfigValidation, mirrored by the <c>gaOnly</c> flag
|
||||
/// in the web ChannelEditor.
|
||||
/// Providers a tenant admin may not create or retarget: they deliver to a destination the
|
||||
/// caller names (a chat, mail recipients) through PLATFORM-owned infrastructure (the Telegram
|
||||
/// bot; the platform's e-mail sender and identity). Enforced server-side in
|
||||
/// TenantConfigValidation, mirrored by the <c>gaOnly</c> flag in the web ChannelEditor. Push
|
||||
/// sends through the platform too, but is open to tenant Admins: its only recipients are the
|
||||
/// scope's own paired devices, and a scope holds at most one Push channel.
|
||||
/// </summary>
|
||||
public static bool IsGlobalAdminOnlyProvider(int providerType)
|
||||
=> providerType == (int)WebhookProviderType.Telegram
|
||||
|| providerType == (int)WebhookProviderType.Push
|
||||
|| providerType == (int)WebhookProviderType.Email;
|
||||
|
||||
/// <summary>
|
||||
/// True for a provider whose destination is a caller-supplied webhook URL, rendered and posted
|
||||
/// through the SSRF-guarded webhook path: every supported provider except Telegram, Push and
|
||||
/// E-mail. The legacy single-webhook field accepts only these.
|
||||
/// </summary>
|
||||
public static bool IsWebhookUrlProvider(int providerType)
|
||||
=> IsSupportedProviderType(providerType)
|
||||
&& providerType != (int)WebhookProviderType.Telegram
|
||||
&& providerType != (int)WebhookProviderType.Push
|
||||
&& providerType != (int)WebhookProviderType.Email;
|
||||
|
||||
/// <summary>
|
||||
/// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }), applied
|
||||
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
|
||||
|
||||
@@ -46,9 +46,9 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// end-to-end encrypted per device). Not a webhook: the channel has NO destination of its
|
||||
/// own — <c>Url</c> stays empty — because the recipients are resolved at send time from
|
||||
/// the paired devices of the scope's Admin/Operator members (tenant channel) or of the
|
||||
/// Global Admins (platform channel). Global-Admin only while the customer release is
|
||||
/// pending (same gate as Telegram, enforced in TenantConfigValidation). Value 50 stays
|
||||
/// reserved for this provider even if it were ever retired.
|
||||
/// Global Admins (platform channel). Open to tenant Admins; at most one Push channel per
|
||||
/// scope (enforced in TenantConfigValidation), since one already reaches every paired
|
||||
/// device. Value 50 stays reserved for this provider even if it were ever retired.
|
||||
/// </summary>
|
||||
Push = 50,
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// E-mail through the platform's own sender (the same provider path as the welcome and
|
||||
/// farewell mails, <c>noreply@autopilotmonitor.com</c>). Not a webhook: <c>Url</c> carries
|
||||
/// the recipients — one to five addresses, ';' or ',' separated — and the message is rendered
|
||||
/// by the backend. Global-Admin only (same gate as Telegram and Push, enforced in
|
||||
/// by the backend. Global-Admin only (same gate as Telegram, enforced in
|
||||
/// TenantConfigValidation) because it sends under the platform's identity and API key.
|
||||
/// </summary>
|
||||
Email = 60,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "Can I follow a Windows Autopilot enrollment live?",
|
||||
answer:
|
||||
"Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and pushes them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
"Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
},
|
||||
{
|
||||
question: "Why did my Autopilot enrollment fail, and how do I find out without touching the device?",
|
||||
@@ -42,7 +42,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "How do I get alerted when an Autopilot enrollment fails?",
|
||||
answer:
|
||||
"Configure a notification channel for Microsoft Teams, Slack, Discord, or a generic JSON webhook and choose which triggers fire: enrollment start, success, or failure. The same channel carries SLA breach and resolution alerts, consecutive-failure alerts, and hardware rejection notices. Configuration and hardware alerts also appear as bell notifications in the portal.",
|
||||
"Configure a notification channel for Microsoft Teams, Slack, Discord, a generic JSON webhook, or push notifications to the phones and browsers your admins and operators pair, and choose which triggers fire: enrollment start, success, or failure. The same channel carries SLA breach and resolution alerts, consecutive-failure alerts, and hardware rejection notices. Admins and Operators can also click Notify me when done on a running enrollment to get a push when it finishes. Configuration and hardware alerts also appear as bell notifications in the portal.",
|
||||
},
|
||||
{
|
||||
question: "Is there reporting on Autopilot deployments, such as success rate, duration, and failing apps?",
|
||||
|
||||
@@ -20,9 +20,9 @@ const FEATURES = [
|
||||
{
|
||||
title: "Real-Time Enrollment Monitoring",
|
||||
description:
|
||||
"Track every Windows Autopilot enrollment phase as it happens. Live push updates surface device registration, ESP progress, app installs, and user phase transitions without manual refreshing.",
|
||||
"Track every Windows Autopilot enrollment phase as it happens. Live updates surface device registration, ESP progress, app installs, and user phase transitions without manual refreshing.",
|
||||
color: "blue",
|
||||
bullets: ["Live phase-by-phase tracking", "Near real-time push updates via SignalR", "Per-device event stream with timestamps"],
|
||||
bullets: ["Live phase-by-phase tracking", "Near real-time updates via SignalR", "Per-device event stream with timestamps"],
|
||||
},
|
||||
{
|
||||
title: "Intelligent Analyze Rules",
|
||||
@@ -242,7 +242,7 @@ export default function AboutPage() {
|
||||
"Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Table Storage — high-throughput event ingestion",
|
||||
"Azure Blob Storage — diagnostics and log bundle storage",
|
||||
"Azure SignalR Service — real-time push to the portal",
|
||||
"Azure SignalR Service — live updates in the portal",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -269,6 +269,7 @@ export default function AboutPage() {
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams, Slack, Discord & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
|
||||
"Push notifications — end-to-end encrypted alerts and Notify me when done on paired phones and browsers",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
"Model Context Protocol (MCP) server — AI assistants query sessions, fleet metrics and diagnostics (read-only)",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } fro
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { filterOpsEventCatalog } from "./opsAlertRuleSearch";
|
||||
import { hasOtherPushChannel } from "@/lib/pushPortal";
|
||||
import type { ChannelHealthView } from "@/lib/channelHealth";
|
||||
|
||||
/** Destination sentence of a platform Push channel (ChannelEditor shows it in place of the URL field). */
|
||||
@@ -639,6 +640,7 @@ export function OpsAlertRulesSection({
|
||||
showEventToggles={false}
|
||||
pushDestinationHint={PUSH_DESTINATION_HINT_OPS}
|
||||
health={channelHealth?.get(channel.id) ?? null}
|
||||
pushTaken={hasOtherPushChannel(channels, channel.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -58,10 +58,13 @@ export function SectionAlerts() {
|
||||
testChannelResult={testOpsChannelResult}
|
||||
channelHealth={channelHealth}
|
||||
/>
|
||||
{/* Platform Push devices (plan push-relay K4): only once the SAVED ops channel list carries an
|
||||
enabled Push channel — opsNotificationChannels mirrors the stored config, the editor above
|
||||
keeps its own draft. */}
|
||||
{hasEnabledPushChannel(opsNotificationChannels) && <PushDevicesPanel scope="platform" />}
|
||||
{/* Platform Push devices (plan push-relay K4), once the config is loaded: the SAVED ops channel
|
||||
list says whether the channel is on — opsNotificationChannels mirrors the stored config, the
|
||||
editor above keeps its own draft. With the channel off the panel shows only while devices
|
||||
are left to remove. */}
|
||||
{adminConfig != null && (
|
||||
<PushDevicesPanel scope="platform" channelEnabled={hasEnabledPushChannel(opsNotificationChannels)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const tips = [
|
||||
"The Software Analyzer detects installed software across enrollments \u2014 find open vulnerabilities fast.",
|
||||
"Use the Local Admin Analyzer to spot legitimate vs. unexpected local admin accounts.",
|
||||
"Connect Teams or Slack under Notifications to get real-time enrollment alerts in your channel.",
|
||||
"Get a push on your phone when an enrollment finishes: add a Push channel under Settings → Notifications, pair your phone, then click Notify me when done on a running session.",
|
||||
"Share the Progress Portal link with your team \u2014 anyone can check enrollment status by serial number.",
|
||||
"Missing an event in your sessions? Use Gather Rules to collect it.",
|
||||
"Analyze Rules are continuously refined \u2014 analysis results may shift as rules improve. This is normal.",
|
||||
|
||||
@@ -155,14 +155,14 @@ const jsonLd = {
|
||||
softwareHelp: { "@type": "CreativeWork", url: DOCS_URL },
|
||||
sameAs: [GITHUB_REPO],
|
||||
featureList: [
|
||||
"Real-time enrollment monitoring with live push updates",
|
||||
"Real-time enrollment monitoring with live updates",
|
||||
"Analyze rules that detect enrollment failure patterns automatically",
|
||||
"Per-session event timeline with ESP phases, app installs, errors, and performance snapshots",
|
||||
"Diagnostics collection (agent, IME, and device information bundle) without touching the device",
|
||||
"Fleet health dashboard with success rates, failure trends, and enrollment duration",
|
||||
"Delegated read-only administration across customer tenants for MSPs",
|
||||
"Model Context Protocol (MCP) server for AI assistants",
|
||||
"Notifications to Microsoft Teams, Slack, Discord, and generic JSON webhooks",
|
||||
"Notifications to Microsoft Teams, Slack, Discord, generic JSON webhooks and end-to-end encrypted push notifications on paired phones and browsers",
|
||||
],
|
||||
keywords:
|
||||
"Windows Autopilot, Microsoft Intune, enrollment monitoring, Autopilot troubleshooting, Enrollment Status Page, Windows deployment",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
const LAST_UPDATED = "14 October 2026";
|
||||
const LAST_UPDATED = "20 October 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
|
||||
@@ -80,13 +80,15 @@ export default function PrivacyPage() {
|
||||
<li>Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID</li>
|
||||
<li>Audit records of administrative actions, including who performed them and when</li>
|
||||
<li>Content you choose to enter about a session — report comments and annotations (a verdict on the analysis plus notes) — stored with your tenant's data and attributed to the author</li>
|
||||
<li>If you pair a device for push notifications: its name, platform, push endpoint and encryption keys, the receiver app version, delivery times, and your user principal name and object ID — kept until the device is unpaired, and removed automatically when you stop signing in to the portal. The notification history stays on the device.</li>
|
||||
<li>Operational request telemetry used to run and support the service</li>
|
||||
<li>A <strong>contact address</strong> and <strong>company name</strong> for your tenant, if provided</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
The contact address and company name are used <strong>only to reach and identify you about this service</strong> — a
|
||||
technical problem affecting your tenant, a security matter, a support request, or a change that needs an
|
||||
administrator's attention. They are never used for marketing and never shared. Both are optional on the
|
||||
administrator's attention — and the company name also names your tenant in the alerts your own notification
|
||||
channels and paired devices receive. They are never used for marketing and never shared. Both are optional on the
|
||||
Community plan and required before a Pro trial. Your administrators set and change them under Settings →
|
||||
Tenant → Contact, and clearing a field removes it. Where an organization gave a notification address during sign-up
|
||||
(tenant activation), that address is copied once as the initial contact and is yours to change from then on.
|
||||
@@ -157,8 +159,11 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
|
||||
notification channels such as Teams, Slack, Discord, or a generic webhook, and your own AI assistant if a user connects
|
||||
one through the MCP integration. The platform itself makes no calls to any AI or LLM provider.{" "}
|
||||
notification channels such as Teams, Slack, Discord, a generic webhook, or push notifications to devices your
|
||||
admins and operators pair, and your own AI assistant if a user connects one through the MCP integration. A push notification
|
||||
is encrypted end to end for the receiving device and travels through the push service of its platform (Apple,
|
||||
Google, Microsoft or Mozilla), which sees only ciphertext and delivery metadata. The platform itself makes no
|
||||
calls to any AI or LLM provider.{" "}
|
||||
<DocsLink href={DOCS_DATA_FLOWS}>Data Flows & External Services</DocsLink> maps every outbound connection
|
||||
and what it carries. The <DocsLink href={DOCS_DPA}>data processing agreement</DocsLink> is the authoritative
|
||||
document for the parties engaged and the terms of their engagement; its list of sub-processors names them.
|
||||
@@ -213,7 +218,7 @@ export default function PrivacyPage() {
|
||||
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
|
||||
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
|
||||
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
|
||||
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys</li>
|
||||
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys; push notifications are encrypted end to end for the paired device (RFC 8291)</li>
|
||||
<li><strong>Managed identity instead of storage keys</strong>, and secret-less OIDC deployment pipelines</li>
|
||||
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user, and an event limit per enrollment session that pauses a device's uploads when it sends far more than an enrollment needs</li>
|
||||
<li><strong>Verifiable agent binaries</strong> — Authenticode-signed scripts and binaries, third-party libraries only with their vendor's own signature, a catalog signed by glueckkanja AG that lists every file of the agent package and is checked on the device before anything runs, Sigstore build attestation, and SHA-256 verification at download, through an independent backend cross-check, and at runtime</li>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
|
||||
import { ChannelEditor } from "@/components/notifications/ChannelEditor";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { hasOtherPushChannel } from "@/lib/pushPortal";
|
||||
import type { ChannelHealthView } from "@/lib/channelHealth";
|
||||
|
||||
interface NotificationsSectionProps {
|
||||
@@ -61,7 +62,7 @@ export default function NotificationsSection({
|
||||
tone="sky"
|
||||
iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
title="Notifications"
|
||||
subtitle="Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives, including What's new product updates."
|
||||
subtitle="Send notifications to one or more channels — Teams, Slack, Discord, push to paired devices, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives, including What's new product updates."
|
||||
docsPath={DOCS_PATHS.notifications}
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
@@ -84,6 +85,7 @@ export default function NotificationsSection({
|
||||
testResult={testChannelResult?.channelId === channel.id ? testChannelResult : null}
|
||||
showTelegramProvider={showTelegramProvider}
|
||||
health={health?.get(channel.id) ?? null}
|
||||
pushTaken={hasOtherPushChannel(channels, channel.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -37,13 +37,13 @@ export function SectionNotifications() {
|
||||
testChannelResult,
|
||||
);
|
||||
|
||||
// Push devices (plan push-relay K4): the SAVED config decides, not the draft — a Push channel
|
||||
// added but not yet saved has nothing to pair against on the server. Pairing is open to
|
||||
// Push devices (plan push-relay K4): the SAVED config says whether the channel is on, not the
|
||||
// draft — a Push channel added but not yet saved has nothing to pair against on the server. With
|
||||
// the channel off the panel shows only while devices are left to remove. Pairing is open to
|
||||
// Admins and Operators of the tenant (a Global Admin without a member row is refused by the
|
||||
// server with a hint), so the panel sits outside the read-only fieldset above.
|
||||
const canPairDevices =
|
||||
user?.isTenantAdmin === true || user?.role === "Admin" || user?.role === "Operator" || user?.isGlobalAdmin === true;
|
||||
const showPushDevices = canPairDevices && hasEnabledPushChannel(config?.notificationChannelsJson);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -61,7 +61,9 @@ export function SectionNotifications() {
|
||||
showTelegramProvider={showTelegramProvider}
|
||||
health={channelHealth}
|
||||
/>
|
||||
{showPushDevices && <PushDevicesPanel scope="tenant" />}
|
||||
{canPairDevices && config != null && (
|
||||
<PushDevicesPanel scope="tenant" channelEnabled={hasEnabledPushChannel(config.notificationChannelsJson)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const TENANT_NAV_SECTIONS = [
|
||||
{ id: "delegations", label: "Delegated Access", description: "Who can read your tenant, and the tenants you manage (MSP)" },
|
||||
{ id: "autopilot", label: "Autopilot Validation", description: "Autopilot device and corporate identifier validation" },
|
||||
{ id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" },
|
||||
{ id: "notifications", label: "Notifications", description: "Webhook notification configuration" },
|
||||
{ id: "notifications", label: "Notifications", description: "Notification channels and paired push devices" },
|
||||
{ id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" },
|
||||
{ id: "ai-integration", label: "AI Integration", description: "Connect AI assistants and self-hosted AI clients through MCP" },
|
||||
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
|
||||
|
||||
@@ -46,6 +46,8 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
const internalHrefs = (html: string) => [...html.matchAll(/<a\b[^>]*?\shref="(\/[^"]*)"/g)].map(m => m[1]);
|
||||
const inPageTargets = (html: string) => [...html.matchAll(/<a\b[^>]*?\shref="#([^"]+)"/g)].map(m => m[1]);
|
||||
const elementIds = (html: string) => new Set([...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]));
|
||||
|
||||
/** A page path without its trailing slash; the root and files such as /llms.txt are exempt. */
|
||||
const missesSlash = (href: string) => {
|
||||
@@ -60,6 +62,13 @@ describe("marketing page links", () => {
|
||||
expect(hrefs.length).toBeGreaterThan(0);
|
||||
expect(hrefs.filter(missesSlash)).toEqual([]);
|
||||
});
|
||||
|
||||
// A renamed section id leaves its #anchor jumping nowhere, without any error.
|
||||
it(`${name}: every in-page #anchor has its target on the page`, () => {
|
||||
const html = renderToStaticMarkup(createElement(Page));
|
||||
const ids = elementIds(html);
|
||||
expect(inPageTargets(html).filter(target => !ids.has(target))).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("treats a bare page path as missing its slash, the root and files not", () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [
|
||||
{
|
||||
name: "landing",
|
||||
Page: Landing,
|
||||
sections: ["hero", "stats", "evidence_loop", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
|
||||
sections: ["hero", "stats", "evidence_loop", "just_ask", "story", "push", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
|
||||
},
|
||||
{ name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] },
|
||||
{ name: "ai", Page: AiPage, sections: ["hero", "structured", "questions", "rules", "architecture", "connect", "closing"] },
|
||||
@@ -66,6 +66,13 @@ describe("marketing page tracking", () => {
|
||||
expect(ids).toEqual(expect.arrayContaining(["nav_ai", "evidence_loop_ai", "just_ask_ai", "footer_ai"]));
|
||||
});
|
||||
|
||||
it("the landing announces push in the hero pill, which jumps to the push section, and links its docs", () => {
|
||||
const html = render(Landing);
|
||||
expect(trackIds(html)).toEqual(expect.arrayContaining(["hero_announcement:push", "push_docs"]));
|
||||
expect(html).toMatch(/<a\b[^>]*\shref="#push"[^>]*\sdata-track="hero_announcement:push"/);
|
||||
expect(html).toMatch(/<section\b[^>]*\sid="push"[^>]*\sdata-track-section="push"/);
|
||||
});
|
||||
|
||||
it("the AI page carries every question text in its static HTML", () => {
|
||||
const html = render(AiPage);
|
||||
const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FRAMES, STILL_FRAME } from "@/components/landing/PushWatchDemo";
|
||||
|
||||
/** The landing's Notify me when done demo is a frame table; these pin its pacing and its end states. */
|
||||
describe("PushWatchDemo frames", () => {
|
||||
it("one loop lasts about ten seconds", () => {
|
||||
const total = FRAMES.reduce((sum, frame) => sum + frame.ms, 0);
|
||||
expect(total).toBeGreaterThanOrEqual(9000);
|
||||
expect(total).toBeLessThanOrEqual(11000);
|
||||
});
|
||||
|
||||
it("the still frame tells the whole story: watched, finished, pushed, not fading", () => {
|
||||
expect(FRAMES[STILL_FRAME]).toMatchObject({ watching: true, done: true, notified: true, fade: false });
|
||||
});
|
||||
|
||||
it("every loop starts from the untouched enrollment", () => {
|
||||
expect(FRAMES[0]).toMatchObject({ watching: false, done: false, notified: false, fade: false });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Stroke icons for the /ai page (24-unit grid, currentColor). */
|
||||
/** Stroke icons for the /ai page and the landing (24-unit grid, currentColor). */
|
||||
|
||||
type IconProps = { className?: string };
|
||||
|
||||
@@ -91,6 +91,18 @@ export const CopyIcon = (p: IconProps) => (
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</Icon>
|
||||
);
|
||||
export const LockIcon = (p: IconProps) => (
|
||||
<Icon {...p}>
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</Icon>
|
||||
);
|
||||
/** The bell of the portal's Notify me when done button (SessionWatchButton). */
|
||||
export const BellIcon = (p: IconProps) => (
|
||||
<Icon {...p}>
|
||||
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
/** The check chip used in landing lists (CapabilitiesStrip). */
|
||||
export function CheckChip() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Reveal } from "./Reveal";
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
title: "Real-time event stream",
|
||||
detail: "Live phase tracking with push updates, from first boot to desktop.",
|
||||
detail: "Phase tracking with live updates, from first boot to desktop.",
|
||||
},
|
||||
{
|
||||
title: "Analyze rules",
|
||||
@@ -27,7 +27,7 @@ const CAPABILITIES = [
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
detail: "Teams and webhook alerts the moment an enrollment fails or stalls.",
|
||||
detail: "Teams, Slack, webhooks, or encrypted push to your phone — the moment an enrollment fails or stalls.",
|
||||
},
|
||||
{
|
||||
title: "MCP server for AI",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Reveal } from "./Reveal";
|
||||
const ROWS = [
|
||||
{
|
||||
label: "Deployment visibility",
|
||||
monitor: "Real-time phase tracking with live push updates",
|
||||
monitor: "Real-time phase tracking with live updates",
|
||||
standard: "None — black box until it finishes or fails",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,27 +11,23 @@ const SCENARIOS = [
|
||||
"Entra ID & Hybrid join",
|
||||
];
|
||||
|
||||
const DEVICE_ASSOCIATION_DOCS_URL = `${DOCS_URL}/getting-started/autopilot-device-preparation#device-association`;
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<header data-track-section="hero" className="pt-16 sm:pt-20 pb-0 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Release-day banner: Microsoft shipped Autopilot device association on 2026-08-25;
|
||||
the validator is live in the same week — the pill exists to make that visible. */}
|
||||
{/* Release banner: push notifications opened to every tenant on 2026-10-20; the pill
|
||||
jumps to the push section further down this page. */}
|
||||
<a
|
||||
href={DEVICE_ASSOCIATION_DOCS_URL}
|
||||
data-track="hero_announcement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="#push"
|
||||
data-track="hero_announcement:push"
|
||||
className="group inline-flex items-center gap-2.5 mb-6 pl-1.5 pr-3.5 py-1.5 rounded-full border border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)] text-[13px] text-[var(--lp-ink)] hover:border-[var(--lp-accent)] transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-[var(--lp-accent)] text-white text-[10px] font-bold uppercase tracking-[0.14em]">
|
||||
New
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">Windows Autopilot device association</span> is generally available
|
||||
<span className="hidden sm:inline"> — Autopilot Monitor validates associated devices from day one.</span>
|
||||
<span className="font-semibold">Encrypted push notifications</span>
|
||||
<span className="hidden sm:inline">{" — get pinged when an enrollment finishes"}</span>
|
||||
</span>
|
||||
<svg className="w-3.5 h-3.5 text-[var(--lp-accent-ink)] shrink-0 transition-transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatsBand } from "./StatsBand";
|
||||
import { EvidenceLoop } from "./EvidenceLoop";
|
||||
import { JustAsk } from "./JustAsk";
|
||||
import { Story } from "./Story";
|
||||
import { PushSection } from "./PushSection";
|
||||
import { CapabilitiesStrip } from "./CapabilitiesStrip";
|
||||
import { Comparison } from "./Comparison";
|
||||
import { HowItWorks } from "./HowItWorks";
|
||||
@@ -36,6 +37,7 @@ export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSna
|
||||
<EvidenceLoop />
|
||||
<JustAsk />
|
||||
<Story />
|
||||
<PushSection />
|
||||
<CapabilitiesStrip />
|
||||
<Comparison />
|
||||
<HowItWorks />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { LockIcon } from "../ai/icons";
|
||||
|
||||
/**
|
||||
* A phone at its lock screen for the landing demos. Clock and day are static strings (no Date in
|
||||
* render); `children` sit where notifications appear. The caller crops the frame and animates
|
||||
* what goes inside.
|
||||
*/
|
||||
export function PhoneFrame({ children }: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="w-[232px] rounded-[2.6rem] bg-[var(--lp-ink)] p-[7px] shadow-2xl shadow-black/25">
|
||||
<div className="h-[470px] rounded-[2.17rem] bg-[var(--lp-term-bg)] px-2.5 pt-2.5">
|
||||
<div className="mx-auto h-[22px] w-[76px] rounded-full bg-[var(--lp-ink)]" />
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<LockIcon className="h-3.5 w-3.5 text-[var(--lp-term-faint)]" />
|
||||
<p className="mt-1.5 text-[12px] font-medium leading-4 text-[var(--lp-term-faint)]">Tuesday</p>
|
||||
<p className="text-[54px] font-semibold leading-none tracking-tight tabular-nums text-[var(--lp-term-ink)]">10:07</p>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Reveal } from "./Reveal";
|
||||
import { PushWatchDemo } from "./PushWatchDemo";
|
||||
import { ArrowRightIcon, CheckChip } from "../ai/icons";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
|
||||
/**
|
||||
* Customer-facing claims: each one holds against the push channel's code (per-device RFC 8291
|
||||
* encryption, sign-in-free receiver paired from the portal, lock-screen-only payload).
|
||||
*/
|
||||
const POINTS = [
|
||||
{
|
||||
title: "Encrypted end to end",
|
||||
text: "Every alert is encrypted for exactly one paired device. Apple, Google, Microsoft and Mozilla only pass the ciphertext along.",
|
||||
},
|
||||
{
|
||||
title: "Works on locked-down phones",
|
||||
text: "You pair the phone once by QR code from your signed-in portal. The phone itself never signs in, so it gets your alerts even where Conditional Access keeps it out of the portal.",
|
||||
},
|
||||
{
|
||||
title: "Only what fits a lock screen",
|
||||
text: "What happened and on which device, with the serial masked. One tap opens the details, behind your sign-in.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Push notifications: the Story ends with "no dashboard-watching required", this section hands the
|
||||
* enrollment to the admin's phone. Wide: text left, the demo spanning both rows on the right.
|
||||
*/
|
||||
export function PushSection() {
|
||||
return (
|
||||
<section
|
||||
id="push"
|
||||
data-track-section="push"
|
||||
className="py-20 sm:py-24 px-6 scroll-mt-20 border-t border-[var(--lp-line-soft)]"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto grid gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:grid-rows-[auto_1fr] lg:gap-x-14 lg:gap-y-8">
|
||||
<Reveal className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Push notifications</p>
|
||||
<h2 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)] text-balance">
|
||||
Watch it live. Or walk away.
|
||||
</h2>
|
||||
<p className="mt-4 text-[16px] text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
|
||||
Click{" "}
|
||||
<span className="font-semibold text-[var(--lp-ink)]">Notify me when done</span>{" "}
|
||||
on a running enrollment and close the laptop. When it finishes, your phone tells you — encrypted end to end,
|
||||
no app store app, no Teams or Slack setup.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delayMs={120} className="min-w-0 lg:col-start-2 lg:row-start-1 lg:row-span-2 lg:self-center">
|
||||
<PushWatchDemo />
|
||||
</Reveal>
|
||||
|
||||
<Reveal className="min-w-0 lg:col-start-1 lg:row-start-2">
|
||||
<ul className="space-y-5">
|
||||
{POINTS.map(point => (
|
||||
<li key={point.title} className="flex items-start gap-3">
|
||||
<CheckChip />
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{point.title}</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{point.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={`${DOCS_URL}${DOCS_PATHS.pushNotifications}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-track="push_docs"
|
||||
className="mt-6 inline-flex items-center gap-1.5 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
How push notifications work
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInViewActive } from "@/hooks/useInViewActive";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { BrandMark } from "../BrandMark";
|
||||
import { BellIcon, LockIcon } from "../ai/icons";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
|
||||
type Cursor = "away" | "over" | "press" | "gone";
|
||||
|
||||
interface Frame {
|
||||
ms: number;
|
||||
/** Apps installed, out of TOTAL_APPS. */
|
||||
apps: number;
|
||||
cursor: Cursor;
|
||||
watching: boolean;
|
||||
done: boolean;
|
||||
notified: boolean;
|
||||
/** The stage fades out before the loop starts over. */
|
||||
fade: boolean;
|
||||
}
|
||||
|
||||
const TOTAL_APPS = 12;
|
||||
const DEVICE = "CONTOSO-4711";
|
||||
// The real watch push (PushSessionWatchService): title plus device name with the masked serial.
|
||||
const ALERT_TITLE = "Enrollment finished";
|
||||
const ALERT_BODY = `${DEVICE} (…8F2K) finished enrollment.`;
|
||||
const CAPTION = `Animation: in the portal, a click on Notify me when done switches the running enrollment ${DEVICE} to Watching. When it completes, a locked phone shows a notification from AM Alerts: ${ALERT_TITLE}. ${ALERT_BODY} End-to-end encrypted.`;
|
||||
const MAX_LOOPS = 3;
|
||||
|
||||
/** One loop of about ten seconds: watch a running enrollment, let it finish, get the push. */
|
||||
export const FRAMES: readonly Frame[] = [
|
||||
{ ms: 1200, apps: 9, cursor: "away", watching: false, done: false, notified: false, fade: false },
|
||||
{ ms: 900, apps: 9, cursor: "over", watching: false, done: false, notified: false, fade: false },
|
||||
{ ms: 250, apps: 9, cursor: "press", watching: false, done: false, notified: false, fade: false },
|
||||
{ ms: 900, apps: 9, cursor: "gone", watching: true, done: false, notified: false, fade: false },
|
||||
{ ms: 700, apps: 10, cursor: "gone", watching: true, done: false, notified: false, fade: false },
|
||||
{ ms: 700, apps: 11, cursor: "gone", watching: true, done: false, notified: false, fade: false },
|
||||
{ ms: 900, apps: 12, cursor: "gone", watching: true, done: true, notified: false, fade: false },
|
||||
{ ms: 3800, apps: 12, cursor: "gone", watching: true, done: true, notified: true, fade: false },
|
||||
{ ms: 500, apps: 12, cursor: "gone", watching: true, done: true, notified: true, fade: true },
|
||||
];
|
||||
|
||||
/** Reduced motion shows this frame for good, and the last loop comes to rest on it. */
|
||||
export const STILL_FRAME = 7;
|
||||
|
||||
const CURSOR_CLASS: Record<Cursor, string> = {
|
||||
away: "translate-x-10 translate-y-8 opacity-0 duration-700",
|
||||
over: "opacity-100 duration-700",
|
||||
press: "scale-90 opacity-100 duration-150",
|
||||
gone: "opacity-0 duration-300",
|
||||
};
|
||||
|
||||
const FADE = "transition-opacity duration-300 motion-reduce:transition-none";
|
||||
const PILL =
|
||||
"col-start-1 row-start-1 inline-flex items-center gap-1.5 justify-self-start whitespace-nowrap rounded-full border px-3 py-1 text-[12px] font-medium transition duration-300 motion-reduce:transition-none";
|
||||
|
||||
/** The portal's Notify me when done button: idle, pressed, Watching, gone once the session ends. */
|
||||
function WatchPill({ frame }: { frame: Frame }) {
|
||||
const idle = !frame.watching && !frame.done;
|
||||
const watching = frame.watching && !frame.done;
|
||||
const pressed = frame.cursor === "press" ? "scale-[0.97] ring-2 ring-[var(--lp-accent-line)]" : "";
|
||||
return (
|
||||
<span className="relative inline-grid">
|
||||
<span className={`${PILL} border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink-soft)] ${idle ? "opacity-100" : "opacity-0"} ${pressed}`}>
|
||||
<BellIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
Notify me when done
|
||||
</span>
|
||||
<span className={`${PILL} border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)] ${watching ? "opacity-100" : "opacity-0"}`}>
|
||||
<BellIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
Watching
|
||||
</span>
|
||||
<svg
|
||||
className={`absolute left-[70%] top-[55%] h-5 w-5 origin-top-left text-[var(--lp-ink)] transition ease-out motion-reduce:transition-none ${CURSOR_CLASS[frame.cursor]}`}
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 1.5v14l3.8-3.5 2.5 5.6 2.3-1-2.5-5.4h5.1z"
|
||||
fill="currentColor"
|
||||
className="stroke-[var(--lp-surface)]"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({ frame }: { frame: Frame }) {
|
||||
const percent = Math.round((frame.apps / TOTAL_APPS) * 100);
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-4 sm:self-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-4 w-4 shrink-0 place-items-center">
|
||||
<span className={`col-start-1 row-start-1 h-2 w-2 rounded-full bg-[var(--lp-accent)] ${FADE} ${frame.done ? "opacity-0" : "lp-live-dot"}`} />
|
||||
<span
|
||||
className={`col-start-1 row-start-1 grid h-4 w-4 place-items-center rounded-full bg-[var(--lp-accent)] text-white transition duration-300 motion-reduce:transition-none ${frame.done ? "opacity-100" : "scale-50 opacity-0"}`}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--lp-ink)]">{DEVICE}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[11px] text-[var(--lp-ink-faint)]">ThinkPad X1 Carbon · User-driven</p>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-[12px]">
|
||||
<span className="grid min-w-0">
|
||||
<span className={`col-start-1 row-start-1 truncate tabular-nums text-[var(--lp-ink-soft)] ${FADE} ${frame.done ? "opacity-0" : "opacity-100"}`}>
|
||||
{`Installing apps ${frame.apps}/${TOTAL_APPS}`}
|
||||
</span>
|
||||
<span className={`col-start-1 row-start-1 font-semibold text-[var(--lp-accent-ink)] ${FADE} ${frame.done ? "opacity-100" : "opacity-0"}`}>
|
||||
Completed
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums text-[var(--lp-ink)]">{`${percent}%`}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--lp-surface-2)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--lp-accent)] transition-[width] duration-500 ease-out motion-reduce:transition-none"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<WatchPill frame={frame} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The push as the lock screen shows it. Mounting it plays the slide-in. */
|
||||
function LockScreenAlert() {
|
||||
return (
|
||||
<div className="lp-event-in flex items-start gap-2 rounded-2xl bg-[var(--lp-surface)] p-2.5 shadow-lg shadow-black/30">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
|
||||
<BrandMark className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-baseline justify-between gap-2 text-[10px] leading-tight text-[var(--lp-ink-faint)]">
|
||||
<span className="font-medium">AM Alerts</span>
|
||||
<span>now</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-semibold leading-snug text-[var(--lp-ink)]">{ALERT_TITLE}</p>
|
||||
<p className="text-[11px] leading-snug text-[var(--lp-ink-soft)]">{ALERT_BODY}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Next to the phone, never inside the push: the real notification shows no such badge. Below sm it
|
||||
* sits between card and phone; from sm it hangs left of the phone, level with the notification.
|
||||
*/
|
||||
function EncryptedBadge({ shown }: { shown: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`mb-4 inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--lp-accent-ink)] transition-opacity duration-500 motion-reduce:transition-none sm:absolute sm:right-full sm:top-[192px] sm:mb-0 sm:mr-4 ${shown ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<LockIcon className="h-3 w-3 shrink-0" />
|
||||
end-to-end encrypted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing demo of Notify me when done: a running enrollment gets watched, finishes, and the
|
||||
* phone gets the push. A setTimeout state machine like McpTerminalDemo, at most MAX_LOOPS loops.
|
||||
*/
|
||||
export function PushWatchDemo() {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
// Starts once half the stage is visible; no timers while it is off screen or the tab is hidden.
|
||||
const active = useInViewActive(stageRef, { startThreshold: 0.5, disabled: reduced });
|
||||
const [index, setIndex] = useState(0);
|
||||
const [loop, setLoop] = useState(1);
|
||||
const resting = loop >= MAX_LOOPS && index === STILL_FRAME;
|
||||
|
||||
useEffect(() => {
|
||||
// `active` stays true when reduced motion is switched on mid-loop, hence the extra check.
|
||||
if (!active || reduced || resting) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const next = (index + 1) % FRAMES.length;
|
||||
if (next === 0) setLoop(n => n + 1);
|
||||
setIndex(next);
|
||||
}, FRAMES[index].ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [active, reduced, resting, index]);
|
||||
|
||||
const shown = reduced ? STILL_FRAME : index;
|
||||
const frame = FRAMES[shown];
|
||||
// Frame 0 restarts the loop: everything snaps back without transitions while the stage fades in.
|
||||
const restart = shown === 0 ? "[&_*]:transition-none" : "";
|
||||
|
||||
return (
|
||||
<figure>
|
||||
<div
|
||||
ref={stageRef}
|
||||
aria-hidden="true"
|
||||
className="select-none overflow-hidden rounded-3xl border border-[var(--lp-line)] bg-[var(--lp-surface)]"
|
||||
>
|
||||
<div
|
||||
className={`grid gap-5 px-4 pt-4 sm:grid-cols-[minmax(0,1fr)_232px] sm:gap-6 sm:px-8 sm:pt-8 ${FADE} ${frame.fade ? "opacity-0" : "opacity-100"} ${restart}`}
|
||||
>
|
||||
<SessionCard frame={frame} />
|
||||
<div className="relative flex flex-col items-center">
|
||||
<EncryptedBadge shown={frame.notified && !frame.fade} />
|
||||
<div className="h-[300px] overflow-hidden sm:h-[360px]">
|
||||
<PhoneFrame>{frame.notified && <LockScreenAlert />}</PhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="sr-only">{CAPTION}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -112,8 +112,8 @@ export function Story() {
|
||||
text: (
|
||||
<>
|
||||
<p>
|
||||
The moment the rule fired, it pinged your team — Teams or webhook, your choice. No
|
||||
dashboard-watching required.
|
||||
The moment the rule fired, it pinged your team — Teams, webhook or your phone, your choice.
|
||||
No dashboard-watching required.
|
||||
</p>
|
||||
<p>
|
||||
And instead of asking the user to hunt for logs, one click pulls the complete
|
||||
|
||||
@@ -26,13 +26,13 @@ const EMAIL_PROVIDER = 60;
|
||||
|
||||
/** Default destination sentence of a Push channel (tenant scope); the ops card passes its own. */
|
||||
export const PUSH_DESTINATION_HINT_TENANT =
|
||||
"Delivers to the devices that Admins and Operators of this tenant pair under Notifications › Push devices. Preview: Global Administrators only.";
|
||||
"Delivers to the devices that Admins and Operators of this tenant pair under Notifications › Push devices.";
|
||||
|
||||
export const PROVIDERS: {
|
||||
value: number;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
/** Offered only to Global Admins (platform-owned transport: Telegram, Push). */
|
||||
/** Offered only to Global Admins (platform-owned transport: Telegram, e-mail). */
|
||||
gaOnly?: boolean;
|
||||
}[] = [
|
||||
{ value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/..." },
|
||||
@@ -45,11 +45,10 @@ export const PROVIDERS: {
|
||||
// way, so saving unrelated changes cannot silently drop it.
|
||||
{ value: TELEGRAM_PROVIDER, label: "Telegram (mobile push)", placeholder: "-1003785642894", gaOnly: true },
|
||||
// Web Push to the devices paired in this scope (plan push-relay). No destination: the
|
||||
// recipients are the scope's paired devices, so the URL field gives way to one sentence.
|
||||
// GA-only like Telegram until the customer release (K4: one gate until then).
|
||||
{ value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "", gaOnly: true },
|
||||
// recipients are the scope's paired devices, so the URL field gives way to a short description.
|
||||
{ value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "" },
|
||||
// E-mail through the platform's own sender (noreply@autopilotmonitor.com) and provider key —
|
||||
// GA-only like Telegram and Push; the destination is one to five recipient addresses.
|
||||
// GA-only like Telegram; the destination is one to five recipient addresses.
|
||||
{ value: EMAIL_PROVIDER, label: "E-mail (from noreply@autopilotmonitor.com)", placeholder: "ops@example.com; second@example.com", gaOnly: true },
|
||||
];
|
||||
|
||||
@@ -172,6 +171,7 @@ export function ChannelEditor({
|
||||
showEventToggles = true,
|
||||
pushDestinationHint = PUSH_DESTINATION_HINT_TENANT,
|
||||
health = null,
|
||||
pushTaken = false,
|
||||
}: {
|
||||
channel: NotificationChannel;
|
||||
onChange: (next: NotificationChannel) => void;
|
||||
@@ -179,7 +179,7 @@ export function ChannelEditor({
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
testResult: { success: boolean; message: string } | null;
|
||||
/** Global Admin: may pick the platform-owned providers (Telegram bot, Web Push, e-mail). */
|
||||
/** Global Admin: may pick the platform-owned providers (Telegram bot, e-mail). */
|
||||
showTelegramProvider?: boolean;
|
||||
/** Tenant channels subscribe to event kinds here; ops channels are targeted by rules instead. */
|
||||
showEventToggles?: boolean;
|
||||
@@ -187,6 +187,8 @@ export function ChannelEditor({
|
||||
pushDestinationHint?: string;
|
||||
/** Delivery status of the SAVED channel; null for an unsaved channel, a Push channel or a reader without access. */
|
||||
health?: ChannelHealthView | null;
|
||||
/** Another channel of the host's list already is Push, so Push is not offered here. */
|
||||
pushTaken?: boolean;
|
||||
}) {
|
||||
const placeholder = PROVIDERS.find((p) => p.value === channel.providerType)?.placeholder;
|
||||
const isTelegram = channel.providerType === TELEGRAM_PROVIDER;
|
||||
@@ -197,7 +199,9 @@ export function ChannelEditor({
|
||||
// Keep the channel's own provider listed even when gated, so a GA-created Telegram channel
|
||||
// does not silently render as a different provider for a tenant admin.
|
||||
const providerOptions = PROVIDERS.filter(
|
||||
(p) => !p.gaOnly || showTelegramProvider || p.value === channel.providerType,
|
||||
(p) =>
|
||||
p.value === channel.providerType ||
|
||||
((!p.gaOnly || showTelegramProvider) && !(p.value === PUSH_PROVIDER && pushTaken)),
|
||||
);
|
||||
// A disabled channel sends nothing, so its last status would only be history.
|
||||
const status = channel.enabled ? health : null;
|
||||
@@ -281,10 +285,13 @@ export function ChannelEditor({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Destination: a Push channel has none — one sentence says where it delivers. */}
|
||||
{/* Destination: a Push channel has none — it says where it delivers and what a push carries. */}
|
||||
{isPush && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-gray-500 flex-1 min-w-0">{pushDestinationHint}</p>
|
||||
<div className="flex-1 min-w-0 space-y-1 text-xs text-gray-500">
|
||||
<p>{pushDestinationHint}</p>
|
||||
<p>Paired devices never sign in, so Conditional Access does not apply to them. Pushes carry only lock-screen details; the portal holds the rest.</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
|
||||
Active
|
||||
|
||||
@@ -7,7 +7,14 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useNotifications } from "@/contexts/NotificationContext";
|
||||
import { apiErrorText } from "@/lib/apiClient";
|
||||
import { formatRelativeTime } from "@/lib/push/pushFormat";
|
||||
import { describePortalDeviceStatus, platformLabel, statusTone, type PushScope, type StatusTone } from "@/lib/pushPortal";
|
||||
import {
|
||||
describePortalDeviceStatus,
|
||||
platformLabel,
|
||||
showPushDevicesPanel,
|
||||
statusTone,
|
||||
type PushScope,
|
||||
type StatusTone,
|
||||
} from "@/lib/pushPortal";
|
||||
import { listPushDevices, removePushDevice, testPushDevice } from "@/lib/pushPortalApi";
|
||||
import type { PairingDeviceDto, PushDeviceDto } from "@/lib/generated/wire-types.generated";
|
||||
|
||||
@@ -26,15 +33,18 @@ const TOAST_KEY = "push-devices";
|
||||
interface PushDevicesPanelProps {
|
||||
/** `tenant` talks to push/…, `platform` to global/push/… — and picks the host's card styling. */
|
||||
scope: PushScope;
|
||||
/** The SAVED channel list carries an enabled Push channel (K4: the one switch for pairing and delivery). */
|
||||
channelEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paired devices of a scope (plan push-relay): everyone sees their own, scope admins every
|
||||
* device. Hosts render it only when the SAVED channel list carries an enabled Push channel (K4)
|
||||
* and the caller may pair (Admin/Operator of the tenant, or a Global Admin on the platform card),
|
||||
* and deliberately OUTSIDE any read-only fieldset: an Operator cannot edit channels but pairs.
|
||||
* device. Hosts render it for everyone who may pair (Admin/Operator of the tenant, or a Global
|
||||
* Admin on the platform card), deliberately OUTSIDE any read-only fieldset: an Operator cannot
|
||||
* edit channels but pairs. With the channel off the panel stays only while a device of this
|
||||
* scope is left, so it can be removed; pairing and tests wait for the channel.
|
||||
*/
|
||||
export function PushDevicesPanel({ scope }: PushDevicesPanelProps) {
|
||||
export function PushDevicesPanel({ scope, channelEnabled }: PushDevicesPanelProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification, notifyError } = useNotifications();
|
||||
const [devices, setDevices] = useState<PushDeviceDto[] | null>(null);
|
||||
@@ -82,6 +92,8 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) {
|
||||
setPairingOpen(false);
|
||||
}, []);
|
||||
|
||||
if (!showPushDevicesPanel(channelEnabled, devices, scope)) return null;
|
||||
|
||||
const sendTest = async (device: PushDeviceDto) => {
|
||||
setTestingId(device.deviceId);
|
||||
try {
|
||||
@@ -116,12 +128,13 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) {
|
||||
? "bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
: "bg-sky-600 hover:bg-sky-700";
|
||||
|
||||
const pairBlocked = channelRequired || !channelEnabled;
|
||||
const pairButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPairingOpen(true)}
|
||||
disabled={channelRequired}
|
||||
title={channelRequired ? "Enable a Push channel above first." : undefined}
|
||||
disabled={pairBlocked}
|
||||
title={pairBlocked ? "Enable a Push channel above first." : undefined}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${pairButtonClass}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -145,8 +158,12 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) {
|
||||
trailing={pairButton}
|
||||
/>
|
||||
<div className="p-6 space-y-3">
|
||||
{channelRequired && (
|
||||
<p className="text-sm text-orange-700">Enable a Push channel above first.</p>
|
||||
{!channelEnabled ? (
|
||||
<p className="text-sm text-orange-700">
|
||||
The Push channel is off — paired devices receive nothing. Remove devices here or switch the channel back on.
|
||||
</p>
|
||||
) : (
|
||||
channelRequired && <p className="text-sm text-orange-700">Enable a Push channel above first.</p>
|
||||
)}
|
||||
{loadError && (
|
||||
<p className="text-sm text-red-700">
|
||||
@@ -224,8 +241,14 @@ export function PushDevicesPanel({ scope }: PushDevicesPanelProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendTest(device)}
|
||||
disabled={busy || !canTest}
|
||||
title={canTest ? "Sends a test alert to this device." : "Only an active device can receive a test."}
|
||||
disabled={busy || !canTest || !channelEnabled}
|
||||
title={
|
||||
!channelEnabled
|
||||
? "The Push channel is off."
|
||||
: canTest
|
||||
? "Sends a test alert to this device."
|
||||
: "Only an active device can receive a test."
|
||||
}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testingId === device.deviceId ? "Sending…" : "Send test"}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
formatCountdown,
|
||||
formatPairingCode,
|
||||
hasEnabledPushChannel,
|
||||
hasOtherPushChannel,
|
||||
isPushProvider,
|
||||
PAIRING_GRANT_SECONDS,
|
||||
pairPageUrl,
|
||||
pairingSecondsLeft,
|
||||
platformLabel,
|
||||
PUSH_PROVIDER,
|
||||
showPushDevicesPanel,
|
||||
statusTone,
|
||||
} from "../pushPortal";
|
||||
|
||||
@@ -99,6 +101,59 @@ describe("hasEnabledPushChannel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOtherPushChannel", () => {
|
||||
it("is true only when ANOTHER channel of the list is Push", () => {
|
||||
const list = [
|
||||
{ id: "a", providerType: 2 },
|
||||
{ id: "b", providerType: PUSH_PROVIDER },
|
||||
];
|
||||
expect(hasOtherPushChannel(list, "a")).toBe(true);
|
||||
expect(hasOtherPushChannel(list, "b")).toBe(false);
|
||||
expect(hasOtherPushChannel([{ id: "a", providerType: 2 }, { id: "c", providerType: 40 }], "a")).toBe(false);
|
||||
expect(hasOtherPushChannel([], "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a disabled Push channel as taken (the saved list may carry it switched off)", () => {
|
||||
const channels = [
|
||||
{ id: "a", name: "Slack", providerType: 10, enabled: true },
|
||||
{ id: "b", name: "Phones", providerType: 50, enabled: false },
|
||||
];
|
||||
expect(hasOtherPushChannel(channels, "a")).toBe(true);
|
||||
});
|
||||
|
||||
it("counts every Push channel when the id is not in the list", () => {
|
||||
expect(hasOtherPushChannel([{ id: "b", providerType: 50 }], "new")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showPushDevicesPanel", () => {
|
||||
const tenantDevice = { scope: "tenant" };
|
||||
const platformDevice = { scope: "platform" };
|
||||
|
||||
it("always renders while the channel is enabled, even before the devices are known", () => {
|
||||
expect(showPushDevicesPanel(true, null, "tenant")).toBe(true);
|
||||
expect(showPushDevicesPanel(true, undefined, "platform")).toBe(true);
|
||||
expect(showPushDevicesPanel(true, [], "tenant")).toBe(true);
|
||||
});
|
||||
|
||||
it("with the channel off renders only while a device of the panel's own scope is left", () => {
|
||||
expect(showPushDevicesPanel(false, [tenantDevice], "tenant")).toBe(true);
|
||||
expect(showPushDevicesPanel(false, [platformDevice, tenantDevice], "tenant")).toBe(true);
|
||||
expect(showPushDevicesPanel(false, [platformDevice], "platform")).toBe(true);
|
||||
expect(showPushDevicesPanel(false, [], "tenant")).toBe(false);
|
||||
expect(showPushDevicesPanel(false, [], "platform")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count a Global Administrator's platform device listed in the home tenant", () => {
|
||||
expect(showPushDevicesPanel(false, [platformDevice], "tenant")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders nothing with the channel off while the devices are unknown (loading or failed)", () => {
|
||||
expect(showPushDevicesPanel(false, null, "tenant")).toBe(false);
|
||||
expect(showPushDevicesPanel(false, undefined, "platform")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing countdown helpers", () => {
|
||||
const received = Date.parse("2026-10-07T10:00:00Z");
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const DOCS_PATHS = {
|
||||
enrollmentDeviceValidation: "/reference/settings#enrollment-device-validation",
|
||||
hardwareWhitelist: "/reference/settings#hardware-whitelist",
|
||||
notifications: "/integrations/notifications",
|
||||
pushNotifications: "/integrations/push-notifications",
|
||||
accessManagement: "/concepts/roles-and-permissions",
|
||||
delegatedAccess: "/concepts/roles-and-permissions#msp-fleet-access",
|
||||
slaTargets: "/portal-guide/sla-compliance#configuring-targets",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Pure helpers of the portal side of the Web Push channel (plan push-relay): the provider
|
||||
* constant, the pairing-code grouping shown next to the QR code, the human names of the wire's
|
||||
* closed platform and status lists, and the "is a Push channel enabled" gate the hosts use to
|
||||
* decide whether the devices panel renders at all (K4: the channel is the one switch).
|
||||
* closed platform and status lists, and the channel gates — whether the saved Push channel is
|
||||
* enabled (K4: the channel is the one switch for pairing and delivery), whether another channel
|
||||
* of a list already is Push, and whether a devices panel renders.
|
||||
*
|
||||
* No Tailwind class strings live here — lib/ is outside the Tailwind content globs, so the chip
|
||||
* classes stay in the component and key off {@link statusTone}.
|
||||
@@ -97,13 +98,36 @@ export interface PushChannelLike {
|
||||
* True when the SAVED channel list carries an enabled Push channel. Accepts the parsed list or
|
||||
* the stored JSON string (TenantConfiguration.notificationChannelsJson /
|
||||
* AdminConfiguration.opsNotificationChannelsJson); a blank, malformed or redacted string is
|
||||
* "no channel", never an exception — the panel simply does not render.
|
||||
* "no channel", never an exception.
|
||||
*/
|
||||
export function hasEnabledPushChannel(channels: readonly PushChannelLike[] | string | null | undefined): boolean {
|
||||
const list = typeof channels === "string" || channels == null ? parseChannelList(channels) : channels;
|
||||
return list.some((c) => c && c.enabled === true && isPushProvider(c.providerType));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a channel OTHER than `channelId` in the (unsaved) list carries the Push provider,
|
||||
* enabled or not. Every Push channel delivers to the same paired devices of the scope, so a
|
||||
* second one would only repeat each alert; the provider picker offers Push to one channel.
|
||||
*/
|
||||
export function hasOtherPushChannel(channels: readonly { id: string; providerType: number }[], channelId: string): boolean {
|
||||
return channels.some((c) => c.id !== channelId && isPushProvider(c.providerType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a devices panel renders: always while the scope's Push channel is enabled; with the
|
||||
* channel off only while a device paired in this very scope is left to remove (a Global
|
||||
* Administrator's platform device listed in the home tenant is managed on the platform side).
|
||||
* Unknown devices — still loading or failed to load — count as none.
|
||||
*/
|
||||
export function showPushDevicesPanel(
|
||||
channelEnabled: boolean,
|
||||
devices: readonly { scope: string }[] | null | undefined,
|
||||
scope: PushScope,
|
||||
): boolean {
|
||||
return channelEnabled || (devices ?? []).some((d) => d.scope === scope);
|
||||
}
|
||||
|
||||
function parseChannelList(json: string | null | undefined): PushChannelLike[] {
|
||||
if (!json || !json.trim()) return [];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user