Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions test/Notifications.Test/Notifications.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="[10.0.8]" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio"
Expand All @@ -20,5 +21,6 @@
<ProjectReference Include="..\..\src\Notifications\Notifications.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
</Project>
146 changes: 146 additions & 0 deletions test/Notifications.Test/NotificationsApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Wraps a <see cref="WebApplicationFactory{TEntryPoint}"/> for the Notifications service alongside
/// an in-memory Identity server that issues real JWT tokens. Tests interact with the service through
/// <see cref="HttpClient"/> only.
/// </summary>
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<Bit.Notifications.Program> _notificationsFactory;
private readonly Lazy<Task<string>> _cachedToken;

/// <summary>
/// The mock <see cref="IHubClients"/> wired into <see cref="NotificationsHub"/>. Use this to
/// assert that <c>POST /send</c> routed a notification to the expected user or group.
/// </summary>
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<string, string?>
{
{ "globalSettings:selfHosted", "true" },
{ "globalSettings:internalIdentityKey", InternalIdentityKey },
{ "globalSettings:installation:id", "10000000-0000-0000-0000-000000000000" },
});
});

var (notificationsHubContext, notificationsClients) = BuildHubContext<NotificationsHub>();
NotificationsHubClients = notificationsClients;
var (anonymousHubContext, _) = BuildHubContext<AnonymousNotificationsHub>();

_notificationsFactory = new WebApplicationFactory<Bit.Notifications.Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "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<JwtBearerOptions>(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<Task<string>>(FetchInternalAccessTokenAsync);
}

/// <summary>
/// Returns a Bearer token with <c>scope=internal</c>, satisfying the Notifications service
/// "Internal" authorization policy. The result is cached for the lifetime of the factory.
/// </summary>
public Task<string> GetInternalAccessTokenAsync() => _cachedToken.Value;

/// <summary>
/// Creates an <see cref="HttpClient"/> pre-configured with a valid Bearer token that satisfies
/// the "Internal" authorization policy required by <c>POST /send</c>.
/// </summary>
public async Task<HttpClient> 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<string> FetchInternalAccessTokenAsync()
{
using var client = _identityFactory.CreateClient();
var response = await client.PostAsync("/connect/token", new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "grant_type", "client_credentials" },
{ "client_id", "internal.notifications" },
{ "client_secret", InternalIdentityKey },
{ "scope", "internal" },
}));
Comment on lines +119 to +126
response.EnsureSuccessStatusCode();
using var doc = await response.Content.ReadFromJsonAsync<JsonDocument>();
return doc!.RootElement.GetProperty("access_token").GetString()!;
}

// Builds a substitute IHubContext<THub> whose Clients property captures routing calls so
// tests can assert on which user or group received a notification.
private static (IHubContext<THub> Context, IHubClients Clients) BuildHubContext<THub>()
where THub : Hub
{
var proxy = Substitute.For<IClientProxy>();
var clients = Substitute.For<IHubClients>();
clients.User(Arg.Any<string>()).Returns(proxy);
clients.Group(Arg.Any<string>()).Returns(proxy);

var context = Substitute.For<IHubContext<THub>>();
context.Clients.Returns(clients);
return (context, clients);
}
}
Loading