diff --git a/test/Notifications.Test/Notifications.Test.csproj b/test/Notifications.Test/Notifications.Test.csproj
index a4bab9df983a..c588914d56cc 100644
--- a/test/Notifications.Test/Notifications.Test.csproj
+++ b/test/Notifications.Test/Notifications.Test.csproj
@@ -8,6 +8,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitiveall
+
+
diff --git a/test/Notifications.Test/NotificationsApplicationFactory.cs b/test/Notifications.Test/NotificationsApplicationFactory.cs
new file mode 100644
index 000000000000..1c296664e1e4
--- /dev/null
+++ b/test/Notifications.Test/NotificationsApplicationFactory.cs
@@ -0,0 +1,146 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Bit.IntegrationTestCommon.Factories;
+using Bit.Notifications;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+
+namespace Notifications.Test;
+
+///
+/// Wraps a for the Notifications service alongside
+/// an in-memory Identity server that issues real JWT tokens. Tests interact with the service through
+/// only.
+///
+public sealed class NotificationsApplicationFactory : IAsyncDisposable
+{
+ // Shared key that the Identity test server uses to authenticate internal clients.
+ // Must match the value configured on the Identity factory so that InternalClientProvider
+ // accepts client_credentials requests for the "internal" scope.
+ private const string InternalIdentityKey = "test-internal-identity-key-notifications";
+
+ private readonly IdentityApplicationFactory _identityFactory;
+ private readonly WebApplicationFactory _notificationsFactory;
+ private readonly Lazy> _cachedToken;
+
+ ///
+ /// The mock wired into . Use this to
+ /// assert that POST /send routed a notification to the expected user or group.
+ ///
+ public IHubClients NotificationsHubClients { get; }
+
+ public NotificationsApplicationFactory()
+ {
+ _identityFactory = new IdentityApplicationFactory();
+ // InternalClientProvider requires SelfHosted = true and a non-empty InternalIdentityKey.
+ // A non-empty InstallationId is also required when SelfHosted = true (AddPush validation).
+ _identityFactory.UpdateConfiguration(config =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "globalSettings:selfHosted", "true" },
+ { "globalSettings:internalIdentityKey", InternalIdentityKey },
+ { "globalSettings:installation:id", "10000000-0000-0000-0000-000000000000" },
+ });
+ });
+
+ var (notificationsHubContext, notificationsClients) = BuildHubContext();
+ NotificationsHubClients = notificationsClients;
+ var (anonymousHubContext, _) = BuildHubContext();
+
+ _notificationsFactory = new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "OpenTelemetry:Enabled", "false" },
+ // SelfHosted = true activates the [SelfHosted(SelfHostedOnly = true)] filter on
+ // SendController, and skips cloud-only background services at startup.
+ { "globalSettings:selfHosted", "true" },
+ // The host portion of this URI is irrelevant; all backchannel requests (OIDC discovery,
+ // JWKS) are routed directly to the Identity test server via BackchannelHttpHandler.
+ { "globalSettings:baseServiceUri:internalIdentity", "http://localhost" },
+ });
+ });
+ builder.ConfigureTestServices(services =>
+ {
+ // Route JWT validation to the in-memory Identity test server so tokens issued by
+ // _identityFactory are trusted without needing a running external identity service.
+ services.Configure(JwtBearerDefaults.AuthenticationScheme, options =>
+ {
+ options.BackchannelHttpHandler = _identityFactory.Server.CreateHandler();
+ });
+ // Replace the real SignalR hub contexts with substitutes so tests can assert
+ // which user or group each notification was routed to.
+ services.AddSingleton(notificationsHubContext);
+ services.AddSingleton(anonymousHubContext);
+ });
+ });
+
+ _cachedToken = new Lazy>(FetchInternalAccessTokenAsync);
+ }
+
+ ///
+ /// Returns a Bearer token with scope=internal, satisfying the Notifications service
+ /// "Internal" authorization policy. The result is cached for the lifetime of the factory.
+ ///
+ public Task GetInternalAccessTokenAsync() => _cachedToken.Value;
+
+ ///
+ /// Creates an pre-configured with a valid Bearer token that satisfies
+ /// the "Internal" authorization policy required by POST /send.
+ ///
+ public async Task CreateAuthenticatedClientAsync()
+ {
+ var token = await GetInternalAccessTokenAsync();
+ var client = _notificationsFactory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ public HttpClient CreateClient() => _notificationsFactory.CreateClient();
+
+ public async ValueTask DisposeAsync()
+ {
+ await _notificationsFactory.DisposeAsync();
+ _identityFactory.Dispose();
+ }
+
+ private async Task FetchInternalAccessTokenAsync()
+ {
+ using var client = _identityFactory.CreateClient();
+ var response = await client.PostAsync("/connect/token", new FormUrlEncodedContent(
+ new Dictionary
+ {
+ { "grant_type", "client_credentials" },
+ { "client_id", "internal.notifications" },
+ { "client_secret", InternalIdentityKey },
+ { "scope", "internal" },
+ }));
+ response.EnsureSuccessStatusCode();
+ using var doc = await response.Content.ReadFromJsonAsync();
+ return doc!.RootElement.GetProperty("access_token").GetString()!;
+ }
+
+ // Builds a substitute IHubContext whose Clients property captures routing calls so
+ // tests can assert on which user or group received a notification.
+ private static (IHubContext Context, IHubClients Clients) BuildHubContext()
+ where THub : Hub
+ {
+ var proxy = Substitute.For();
+ var clients = Substitute.For();
+ clients.User(Arg.Any()).Returns(proxy);
+ clients.Group(Arg.Any()).Returns(proxy);
+
+ var context = Substitute.For>();
+ context.Clients.Returns(clients);
+ return (context, clients);
+ }
+}
diff --git a/test/Notifications.Test/PostSendEndpointTests.cs b/test/Notifications.Test/PostSendEndpointTests.cs
new file mode 100644
index 000000000000..717ae904ca41
--- /dev/null
+++ b/test/Notifications.Test/PostSendEndpointTests.cs
@@ -0,0 +1,344 @@
+using System.Buffers.Text;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Bit.Core.Context;
+using Bit.Core.Enums;
+using Bit.Core.Models;
+using Bit.Core.Platform.Push;
+using Bit.Core.Platform.Push.Internal;
+using Bit.Core.Settings;
+using Bit.Notifications;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RichardSzalay.MockHttp;
+
+namespace Notifications.Test;
+
+///
+/// Integration tests for POST /send on the Notifications service.
+///
+/// The endpoint is the internal ingress for push notifications from other services.
+/// Two contracts are enforced here:
+///
+/// Every format in must be accepted by the endpoint and
+/// routed to the correct SignalR hub group.
+/// Whatever currently produces must
+/// be one of those formats, so any wire-format change is caught immediately.
+///
+/// When PushAsync is updated to produce a new shape, add the new format to
+/// and update if needed.
+///
+/// Not every push type is covered intentionally. The long-term goal is for
+/// POST /send to be a dumb proxy: routing decisions should be driven entirely by
+/// envelope-level fields (Type, ContextId, and a future target/clientType on the
+/// envelope) rather than by inspecting the inner Payload. Once that migration is complete,
+/// the payload becomes opaque to the endpoint and exhaustive per-type coverage here would add
+/// noise without value. The representative sample in is
+/// sufficient to guard the contract until then.
+///
+public sealed class PostSendEndpointTests : IAsyncDisposable
+{
+ // Fixed IDs used in all payload literals below — changing these requires updating SupportedPayloads.
+ private static readonly Guid _userId = Guid.Parse("d2ea5b72-6d47-4d20-b5a3-b7a6e89d8e7c");
+ private static readonly Guid _orgId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _installationId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _notifId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private const string TestContextId = "test-device-id";
+
+ ///
+ /// Every JSON format that POST /send must accept. When PushAsync changes its
+ /// wire format, add the new shape here. Old shapes must be kept for at least one release to
+ /// support rolling upgrades where the sender (e.g. Api) may still be on the previous version
+ /// while the Notifications service has already been updated.
+ ///
+ /// Do not add a new entry here in the same commit that updates
+ /// POST /send to handle it. A new entry proves the endpoint accepts the new
+ /// format, but the point of keeping old entries is to prove the endpoint still accepts the
+ /// previous format after it has been updated. If both changes land together the old
+ /// entry is never tested against a Notifications build that lacks the new handling code, so
+ /// you cannot tell from CI alone whether the deployment order matters.
+ ///
+ private static readonly string[] SupportedPayloads =
+ [
+ // User — LogOut, no context exclusion
+ """{"Type":11,"Payload":{"UserId":"d2ea5b72-6d47-4d20-b5a3-b7a6e89d8e7c","Reason":null},"ContextId":null}""",
+ // User — LogOut, with context exclusion
+ """{"Type":11,"Payload":{"UserId":"d2ea5b72-6d47-4d20-b5a3-b7a6e89d8e7c","Reason":null},"ContextId":"test-device-id"}""",
+ // Organization — SyncOrganizationStatusChanged, no context exclusion
+ """{"Type":18,"Payload":{"OrganizationId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","Enabled":true},"ContextId":null}""",
+ // Organization — SyncOrganizationStatusChanged, with context exclusion
+ """{"Type":18,"Payload":{"OrganizationId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","Enabled":true},"ContextId":"test-device-id"}""",
+ // Installation — Notification (ClientType.All), no context exclusion
+ """{"Type":20,"Payload":{"Id":"cccccccc-cccc-cccc-cccc-cccccccccccc","Priority":0,"Global":false,"ClientType":0,"UserId":null,"OrganizationId":null,"InstallationId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","TaskId":null,"Title":null,"Body":null,"CreationDate":"0001-01-01T00:00:00","RevisionDate":"0001-01-01T00:00:00","ReadDate":null,"DeletedDate":null},"ContextId":null}""",
+ // Installation — Notification (ClientType.All), with context exclusion
+ """{"Type":20,"Payload":{"Id":"cccccccc-cccc-cccc-cccc-cccccccccccc","Priority":0,"Global":false,"ClientType":0,"UserId":null,"OrganizationId":null,"InstallationId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","TaskId":null,"Title":null,"Body":null,"CreationDate":"0001-01-01T00:00:00","RevisionDate":"0001-01-01T00:00:00","ReadDate":null,"DeletedDate":null},"ContextId":"test-device-id"}""",
+ // User — Notification (ClientType.Mobile), routes to client-type-specific group
+ """{"Type":20,"Payload":{"Id":"cccccccc-cccc-cccc-cccc-cccccccccccc","Priority":0,"Global":false,"ClientType":4,"UserId":"d2ea5b72-6d47-4d20-b5a3-b7a6e89d8e7c","OrganizationId":null,"InstallationId":null,"TaskId":null,"Title":null,"Body":null,"CreationDate":"0001-01-01T00:00:00","RevisionDate":"0001-01-01T00:00:00","ReadDate":null,"DeletedDate":null},"ContextId":null}""",
+ // Organization — Notification (ClientType.Mobile), routes to client-type-specific group
+ """{"Type":20,"Payload":{"Id":"cccccccc-cccc-cccc-cccc-cccccccccccc","Priority":0,"Global":false,"ClientType":4,"UserId":null,"OrganizationId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","InstallationId":null,"TaskId":null,"Title":null,"Body":null,"CreationDate":"0001-01-01T00:00:00","RevisionDate":"0001-01-01T00:00:00","ReadDate":null,"DeletedDate":null},"ContextId":null}""",
+ // Installation — Notification (ClientType.Mobile), routes to client-type-specific group
+ """{"Type":20,"Payload":{"Id":"cccccccc-cccc-cccc-cccc-cccccccccccc","Priority":0,"Global":false,"ClientType":4,"UserId":null,"OrganizationId":null,"InstallationId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","TaskId":null,"Title":null,"Body":null,"CreationDate":"0001-01-01T00:00:00","RevisionDate":"0001-01-01T00:00:00","ReadDate":null,"DeletedDate":null},"ContextId":null}""",
+ ];
+
+ // Each supported payload paired with the SignalR routing call it must trigger.
+ // Real payload types are used here because HubHelpers inspects the inner payload to determine
+ // which SignalR group to route to. Once that routing information moves into the envelope itself,
+ // these can be replaced with a simple mock payload type.
+ private sealed record RoutingCase(string Json, string? ExpectedUserId, string? ExpectedGroup);
+
+ private static readonly RoutingCase[] RoutingCases =
+ [
+ new(SupportedPayloads[0], _userId.ToString(), null),
+ new(SupportedPayloads[1], _userId.ToString(), null),
+ new(SupportedPayloads[2], null, NotificationsHub.GetOrganizationGroup(_orgId)),
+ new(SupportedPayloads[3], null, NotificationsHub.GetOrganizationGroup(_orgId)),
+ new(SupportedPayloads[4], null, NotificationsHub.GetInstallationGroup(_installationId, ClientType.All)),
+ new(SupportedPayloads[5], null, NotificationsHub.GetInstallationGroup(_installationId, ClientType.All)),
+ new(SupportedPayloads[6], null, NotificationsHub.GetUserGroup(_userId, ClientType.Mobile)),
+ new(SupportedPayloads[7], null, NotificationsHub.GetOrganizationGroup(_orgId, ClientType.Mobile)),
+ new(SupportedPayloads[8], null, NotificationsHub.GetInstallationGroup(_installationId, ClientType.Mobile)),
+ ];
+
+ private readonly NotificationsApplicationFactory _factory = new();
+
+ ///
+ /// All (target, excludeCurrentContext) combinations exercised against the engine.
+ ///
+ public static IEnumerable