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; buildtransitive all + + 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 EngineInputArgs() => + from target in Enum.GetValues() + from excludeCurrentContext in new[] { false, true } + select new object[] { target, excludeCurrentContext }; + + /// + /// All (target, clientType) combinations for the Notification push type. ClientType on the + /// Notification payload controls which client-type-scoped SignalR group receives the message — + /// the same filtering the Azure Notification Hub engine applies via tags on the mobile path. + /// + public static IEnumerable NotificationClientTypeArgs() => + from target in Enum.GetValues() + select new object[] { target, ClientType.Mobile }; + + public static IEnumerable RoutingCaseArgs() => + RoutingCases.Select(c => new object?[] { c.Json, c.ExpectedUserId, c.ExpectedGroup }); + + /// + /// Verifies that the JSON currently produced by + /// for every (target, context) combination is represented in . + /// Fails when PushAsync changes its wire format without a corresponding update. + /// + [Theory] + [MemberData(nameof(EngineInputArgs))] + public async Task PushAsync_ProducesASupportedPayload(NotificationTarget target, bool excludeCurrentContext) + { + var captured = await CapturePayloadAsync(excludeCurrentContext, + engine => PushForTargetAsync(engine, target, excludeCurrentContext)); + var capturedNode = JsonNode.Parse(captured); + + Assert.True( + SupportedPayloads.Any(s => JsonNode.DeepEquals(capturedNode, JsonNode.Parse(s))), + $"NotificationsApiPushEngine.PushAsync produced a payload not listed in {nameof(SupportedPayloads)}.\n" + + $"Captured:\n {captured}\n" + + $"Supported:\n {string.Join("\n ", SupportedPayloads)}"); + } + + /// + /// Verifies that every format in is accepted by + /// POST /send and routed to the correct SignalR user or group. + /// + [Theory] + [MemberData(nameof(RoutingCaseArgs))] + public async Task PostSend_RoutesPayloadToCorrectHubGroup( + string json, string? expectedUserId, string? expectedGroup) + { + using var client = await _factory.CreateAuthenticatedClientAsync(); + using var response = await client.PostAsync("/send", + new StringContent(json, Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + if (expectedUserId is not null) + { + _factory.NotificationsHubClients.Received(1).User(expectedUserId); + } + else + { + _factory.NotificationsHubClients.Received(1).Group(expectedGroup!); + } + } + + /// + /// Verifies the full chain: a call with a + /// payload carrying a specific + /// produces a wire format that the endpoint accepts and routes to the correct client-type-scoped + /// SignalR group — matching the filtering the Azure Notification Hub engine applies via tags. + /// + [Theory] + [MemberData(nameof(NotificationClientTypeArgs))] + public async Task PushAsync_Notification_RoutesToClientTypeGroup( + NotificationTarget target, ClientType clientType) + { + var captured = await CapturePayloadAsync(false, + engine => engine.PushAsync(new PushNotification + { + Type = PushType.Notification, + Target = target, + TargetId = target switch + { + NotificationTarget.User => _userId, + NotificationTarget.Organization => _orgId, + NotificationTarget.Installation => _installationId, + _ => throw new ArgumentOutOfRangeException(nameof(target)), + }, + Payload = new NotificationPushNotification + { + Id = _notifId, + UserId = target == NotificationTarget.User ? _userId : null, + OrganizationId = target == NotificationTarget.Organization ? _orgId : null, + InstallationId = target == NotificationTarget.Installation ? _installationId : null, + ClientType = clientType, + }, + ExcludeCurrentContext = false, + })); + + Assert.True( + SupportedPayloads.Any(s => JsonNode.DeepEquals(JsonNode.Parse(captured), JsonNode.Parse(s))), + $"Notification payload with {nameof(ClientType)}.{clientType} not listed in {nameof(SupportedPayloads)}.\n" + + $"Captured:\n {captured}\n" + + $"Supported:\n {string.Join("\n ", SupportedPayloads)}"); + + using var client = await _factory.CreateAuthenticatedClientAsync(); + using var response = await client.PostAsync("/send", + new StringContent(captured, Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var expectedGroup = target switch + { + NotificationTarget.User => NotificationsHub.GetUserGroup(_userId, clientType), + NotificationTarget.Organization => NotificationsHub.GetOrganizationGroup(_orgId, clientType), + NotificationTarget.Installation => NotificationsHub.GetInstallationGroup(_installationId, clientType), + _ => throw new ArgumentOutOfRangeException(nameof(target)), + }; + _factory.NotificationsHubClients.Received(1).Group(expectedGroup); + } + + public ValueTask DisposeAsync() => _factory.DisposeAsync(); + + // Runs a PushAsync invocation against mock HTTP handlers and returns the JSON body the engine + // posted to /send, preserving the real JsonContent.Create serialization path. + private static async Task CapturePayloadAsync( + bool excludeCurrentContext, Func invoke) + { + const string notificationsBase = "http://localhost/"; + const string identityBase = "http://localhost/"; + + var mockClient = new MockHttpMessageHandler(); + var mockIdentityClient = new MockHttpMessageHandler(); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient("client").Returns(new HttpClient(mockClient)); + httpClientFactory.CreateClient("identity").Returns(new HttpClient(mockIdentityClient)); + + var globalSettings = new GlobalSettings + { + BaseServiceUri = + { + InternalNotifications = notificationsBase, + InternalIdentity = identityBase, + }, + InternalIdentityKey = "test-key", + ProjectName = "test", + }; + + mockIdentityClient + .Expect(HttpMethod.Post, $"{identityBase}connect/token") + .Respond(HttpStatusCode.OK, JsonContent.Create(new { access_token = BuildTestToken() })); + + string? capturedJson = null; + mockClient + .Expect(HttpMethod.Post, $"{notificationsBase}send") + .With(request => + { + if (request.Content is JsonContent jsonContent) + { + capturedJson = JsonSerializer.Serialize(jsonContent.Value); + } + return true; + }) + .Respond(HttpStatusCode.OK); + + var httpContextAccessor = Substitute.For(); + if (excludeCurrentContext) + { + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier.Returns(TestContextId); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(ICurrentContext)).Returns(currentContext); + var httpContext = Substitute.For(); + httpContext.RequestServices.Returns(serviceProvider); + httpContextAccessor.HttpContext.Returns(httpContext); + } + + var engine = new NotificationsApiPushEngine( + httpClientFactory, + globalSettings, + httpContextAccessor, + NullLogger.Instance); + + await invoke(engine); + + return capturedJson ?? throw new InvalidOperationException("Engine did not POST to /send."); + } + + private static Task PushForTargetAsync( + NotificationsApiPushEngine engine, NotificationTarget target, bool excludeCurrentContext) => + target switch + { + NotificationTarget.User => engine.PushAsync(new PushNotification + { + Type = PushType.LogOut, + Target = target, + TargetId = _userId, + Payload = new LogOutPushNotification { UserId = _userId }, + ExcludeCurrentContext = excludeCurrentContext, + }), + NotificationTarget.Organization => engine.PushAsync(new PushNotification + { + Type = PushType.SyncOrganizationStatusChanged, + Target = target, + TargetId = _orgId, + Payload = new OrganizationStatusPushNotification { OrganizationId = _orgId, Enabled = true }, + ExcludeCurrentContext = excludeCurrentContext, + }), + NotificationTarget.Installation => engine.PushAsync(new PushNotification + { + Type = PushType.Notification, + Target = target, + TargetId = _installationId, + Payload = new NotificationPushNotification + { + Id = _notifId, + InstallationId = _installationId, + ClientType = ClientType.All, + }, + ExcludeCurrentContext = excludeCurrentContext, + }), + _ => throw new ArgumentOutOfRangeException(nameof(target)), + }; + + // Builds a minimal unsigned JWT with a far-future exp so BaseIdentityClientService's + // token-refresh check passes without requiring a real signing key. + private static string BuildTestToken() + { + static string Encode(string json) => + Base64Url.EncodeToString(Encoding.UTF8.GetBytes(json)); + + return $"{Encode("""{"alg":"none","typ":"JWT"}""")}" + + $".{Encode("""{"exp":9999999999}""")}."; + } +} diff --git a/test/Notifications.Test/packages.lock.json b/test/Notifications.Test/packages.lock.json index 23c7c170d6f7..443bc1ebaf51 100644 --- a/test/Notifications.Test/packages.lock.json +++ b/test/Notifications.Test/packages.lock.json @@ -8,6 +8,17 @@ "resolved": "6.0.0", "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.8, 10.0.8]", + "resolved": "10.0.8", + "contentHash": "C9kMpUciPgx7ObqoO6W+eXEf3zHFWb7XpQgFJBzdO8GsmmVYrgcErTLMuki6e3EihycGpHbcJECYHDgM7XRMkg==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Hosting": "10.0.8" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.0.1, )", @@ -249,6 +260,11 @@ "Bitwarden.Server.Sdk.Environment": "0.1.0" } }, + "Bogus": { + "type": "Transitive", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, "BouncyCastle.Cryptography": { "type": "Transitive", "resolved": "2.6.2", @@ -281,6 +297,23 @@ "resolved": "2.1.66", "contentHash": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==" }, + "dbup-core": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "dbup-sqlserver": { + "type": "Transitive", + "resolved": "7.2.0", + "contentHash": "1xhdu2ZoQEi2nNrirBfkkfn+AbHWQvy8CGilb+5dIjghFJrsMKFM17DiI5Nz+ofWg9N1lqz5WorujzuGvc/+fQ==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.4", + "dbup-core": "6.1.1" + } + }, "DnsClient": { "type": "Transitive", "resolved": "1.8.0", @@ -546,6 +579,11 @@ "StackExchange.Redis": "2.7.27" } }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HRH/XAke90wkHv9ykCsrvpVqvKOUt53jQzvHHIXrPIPZWAjyPq6B5/InCmPYWvme+WKMXD10rplMAitzNMtC3w==" + }, "Microsoft.Azure.Amqp": { "type": "Transitive", "resolved": "2.7.0", @@ -857,6 +895,15 @@ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" } }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", "resolved": "10.0.8", @@ -915,8 +962,8 @@ }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "5Ou6varcxLBzQ+Agfm0k0pnH7vrEITYlXMDuE6s7ZHlZHz6/G8XJ3iISZDr5rfwfge6RnXJ1+Wc479mMn52vjA==" + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", @@ -975,6 +1022,35 @@ "resolved": "10.0.8", "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", + "Microsoft.Extensions.Configuration.Json": "10.0.8", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Logging.Console": "10.0.8", + "Microsoft.Extensions.Logging.Debug": "10.0.8", + "Microsoft.Extensions.Logging.EventLog": "10.0.8", + "Microsoft.Extensions.Logging.EventSource": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", "resolved": "10.0.9", @@ -1041,17 +1117,63 @@ }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "resolved": "10.0.8", + "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "System.Diagnostics.EventLog": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" } }, "Microsoft.Extensions.ObjectPool": { @@ -1588,8 +1710,8 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.13", - "contentHash": "675Rk4RwaVrWo09wrR2rpDVKixtgtnhd5NhPrn6O21uj92JvE61KTGupn76M2N6Ff/xJjY3SHSfSg0MIanEzGw==" + "resolved": "10.0.8", + "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw==" }, "System.Formats.Cbor": { "type": "Transitive", @@ -1812,6 +1934,23 @@ "httpextensions": { "type": "Project" }, + "identity": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.4.0, )", + "Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )", + "Core": "[2026.7.2, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", + "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )", + "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", + "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", + "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", + "SharedWeb": "[2026.7.2, )" + } + }, "infrastructure.dapper": { "type": "Project", "dependencies": { @@ -1833,6 +1972,24 @@ "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" } }, + "integrationtestcommon": { + "type": "Project", + "dependencies": { + "Common": "[2026.7.2, )", + "Identity": "[2026.7.2, )", + "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", + "Migrator": "[2026.7.2, )", + "Seeder": "[2026.7.2, )" + } + }, + "migrator": { + "type": "Project", + "dependencies": { + "Core": "[2026.7.2, )", + "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", + "dbup-sqlserver": "[7.2.0, 7.2.0]" + } + }, "notifications": { "type": "Project", "dependencies": { @@ -1853,6 +2010,19 @@ "SharedWeb": "[2026.7.2, )" } }, + "rustsdk": { + "type": "Project" + }, + "seeder": { + "type": "Project", + "dependencies": { + "Bogus": "[35.6.5, 35.6.5]", + "Core": "[2026.7.2, )", + "Infrastructure.EntityFramework": "[2026.7.2, )", + "RustSdk": "[2026.7.2, )", + "SharedWeb": "[2026.7.2, )" + } + }, "sharedweb": { "type": "Project", "dependencies": {