diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerExternalCallbackPrivilegedControlsSeatTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerExternalCallbackPrivilegedControlsSeatTests.cs new file mode 100644 index 000000000000..c31cf448b3c2 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerExternalCallbackPrivilegedControlsSeatTests.cs @@ -0,0 +1,689 @@ +using System.Net; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Mail.Mailer; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Sso.IntegrationTest.Utilities; +using Microsoft.AspNetCore.Mvc.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; + +namespace Bit.Sso.IntegrationTest.Controllers; + +/// +/// When an SSO sign-in moves a Staged member that has Privileged Controls access to Invited, the member starts +/// to use a Privileged Controls seat. The SSO callback must reserve that seat first. When no seat can be reserved, +/// the member joins without Privileged Controls access, and on cloud the organization's Owners get an email that +/// the member needs a seat. +/// +/// +/// Each test does the SSO callback for one of two sign-ins against a Staged member: a person with no Bitwarden +/// account, whom SSO provisions just in time, or a person with an existing Bitwarden account, who gets an invite +/// email. Password Manager seats are unlimited unless a test sets them, so the Password Manager seat check passes. +/// Plans come from a substituted pricing client, so no test calls the pricing service. +/// +public class AccountControllerExternalCallbackPrivilegedControlsSeatTests +{ + /* + * SUCCESS PATH: A just-in-time provisioned user claims a Staged member with Privileged Controls access, and + * the organization has a free Privileged Controls seat. The member keeps access, no seat is bought, and the + * Owners get no email. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_WithFreeSeat_KeepsPrivilegedControls() + { + // Arrange — one Privileged Controls seat, none taken. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.NotNull(orgUser.UserId); + Assert.True(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * SUCCESS PATH: A just-in-time provisioned user claims a Staged member with Privileged Controls access. All + * Privileged Controls seats are taken, but the organization has no autoscale limit. The SSO callback adds one + * seat and the member keeps access. + * + * The organization has no subscription id, so the autoscale treats it as a sales-initiated trial and raises + * the seat count without a Stripe call. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_WhenAutoscaleCanGrow_AddsSeatAndKeepsPrivilegedControls() + { + // Arrange — one Privileged Controls seat, already taken, no autoscale limit. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.True(orgUser.AccessPam); + + Assert.Equal(2, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * DEGRADED PATH: A just-in-time provisioned user claims a Staged member with Privileged Controls access, and + * the organization is at its Privileged Controls autoscale limit. The sign-in still succeeds. The member joins + * without Privileged Controls access, and the Owners get an email that names the member. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_AtSeatLimit_JoinsWithoutPrivilegedControlsAndEmailsOwners() + { + // Arrange — one Privileged Controls seat, already taken, autoscale limit of one. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedMailer() + .BuildAsync(); + var ownerEmail = await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.NotNull(orgUser.UserId); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailSentAsync(testData, ownerEmail, testData.OrganizationUser!.Email!); + } + + /* + * DEGRADED PATH: A just-in-time provisioned user claims a Staged member with Privileged Controls access, and + * the organization has zero Privileged Controls seats. Autoscale cannot add seats to an organization that has + * none, so the member joins without Privileged Controls access and the Owners get an email. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_WithZeroSeats_JoinsWithoutPrivilegedControlsAndEmailsOwners() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 0, maxAutoscalePamSeats: null)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedMailer() + .BuildAsync(); + var ownerEmail = await SeedOwnerAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(0, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailSentAsync(testData, ownerEmail, testData.OrganizationUser!.Email!); + } + + /* + * DEGRADED PATH: On a self-hosted instance, a just-in-time provisioned user claims a Staged member with + * Privileged Controls access, and no Privileged Controls seat is free. Self-hosted instances cannot autoscale, + * so the member joins without Privileged Controls access. Self-hosted instances do not send the seat-needed + * email. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_OnSelfHostedWithNoFreeSeat_JoinsWithoutPrivilegedControlsAndDoesNotEmail() + { + // Arrange — one Privileged Controls seat, already taken. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .AsSelfHosted() + .WithSelfHostedEnvironment() + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * SUCCESS PATH: A just-in-time provisioned user claims a Staged member without Privileged Controls access. + * The member does not start to use a Privileged Controls seat, so the SSO callback reserves none, even when + * the organization is at its Privileged Controls seat limit. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithoutPrivilegedControls_AtSeatLimit_DoesNotReserveSeatOrEmail() + { + // Arrange — one Privileged Controls seat, already taken, autoscale limit of one. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = false) + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * FAILURE PATH: A just-in-time provisioned user claims a Staged member with Privileged Controls access, but + * the organization has no Password Manager seat and cannot autoscale one. The Password Manager seat check runs + * first and stops the sign-in, so the SSO callback must not buy a Privileged Controls seat for a member who + * never joins. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_WhenNoPasswordManagerSeat_DoesNotReservePrivilegedControlsSeat() + { + // Arrange — Password Manager seats at cap and at the autoscale limit. Privileged Controls autoscale would + // succeed (no limit), so a reservation would show as a raised PamSeats value. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null); + }) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedMailer() + .BuildAsync(); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Staged, orgUser.Status); + Assert.Null(orgUser.UserId); + Assert.True(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * SUCCESS PATH: A person with an existing Bitwarden account signs in with SSO against a Staged member with + * Privileged Controls access, and the organization has a free Privileged Controls seat. The member moves to + * Invited with access, gets an invite email, and the Owners get no seat-needed email. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_WithFreeSeat_InvitesWithPrivilegedControls() + { + // Arrange — one Privileged Controls seat, none taken. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertStagedInviteAcceptanceRequiredRedirect(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Null(orgUser.UserId); + Assert.True(orgUser.AccessPam); + + var inviteCommand = testData.Factory.Services.GetRequiredService(); + await inviteCommand.Received(1).SendInvitesAsync(Arg.Is(r => + r.Users.Length == 1 && + r.Users[0].Id == testData.OrganizationUser!.Id && + r.Users[0].AccessPam)); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * DEGRADED PATH: A person with an existing Bitwarden account signs in with SSO against a Staged member with + * Privileged Controls access, and the organization is at its Privileged Controls autoscale limit. The member + * moves to Invited without access and gets an invite email. The Owners get an email that names the member. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_AtSeatLimit_InvitesWithoutPrivilegedControlsAndEmailsOwners() + { + // Arrange — one Privileged Controls seat, already taken, autoscale limit of one. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedMailer() + .BuildAsync(); + var ownerEmail = await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertStagedInviteAcceptanceRequiredRedirect(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + var inviteCommand = testData.Factory.Services.GetRequiredService(); + await inviteCommand.Received(1).SendInvitesAsync(Arg.Is(r => + r.Users.Length == 1 && + r.Users[0].Id == testData.OrganizationUser!.Id && + !r.Users[0].AccessPam)); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailSentAsync(testData, ownerEmail, testData.OrganizationUser!.Email!); + } + + /* + * FAILURE PATH: A person with an existing Bitwarden account signs in with SSO against a Staged member with + * Privileged Controls access at the seat limit, and the invite email fails. The SSO callback rolls the member + * back to Staged and gives back Privileged Controls access, so the next sign-in starts again from the same + * state. The Owners get no seat-needed email, because the member did not join. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_AtSeatLimit_WhenInviteSendFails_RestoresPrivilegedControlsAndDoesNotEmail() + { + // Arrange — one Privileged Controls seat, already taken, autoscale limit of one. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + var inviteCommand = testData.Factory.Services.GetRequiredService(); + inviteCommand + .When(x => x.SendInvitesAsync(Arg.Any())) + .Do(_ => throw new Exception("simulated invite send failure")); + + // Act + var response = await testData.Factory.CreateClient().GetAsync("/Account/ExternalCallback"); + + // Assert — the untyped exception surfaces as a 500, after the invite step was reached. + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + await inviteCommand.Received(1).SendInvitesAsync(Arg.Any()); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Staged, orgUser.Status); + Assert.Null(orgUser.UserId); + Assert.True(orgUser.AccessPam); + + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * DEGRADED PATH: On cloud, a just-in-time provisioned user claims a Staged member with Privileged Controls + * access, and the seat reservation fails for a reason other than the seat limit. The member joins without + * Privileged Controls access. The Owners get no seat-needed email, because more seats would not fix the + * failure. + */ + [Fact] + public async Task ExternalCallback_JitAgainstStagedOrgUserWithPrivilegedControls_WhenReservationFailsUnexpectedly_JoinsWithoutPrivilegedControlsAndDoesNotEmail() + { + // Arrange — one Privileged Controls seat, already taken, no autoscale limit. The autoscale must read the + // Stripe subscription, and that read fails. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => + { + WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null); + org.GatewaySubscriptionId = _subscriptionId; + }) + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedStripeAdapter(FailSubscriptionLookup) + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertSsoSucceeded(response); + await AssertSubscriptionLookupAttemptedAsync(testData); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * SUCCESS PATH: A person with an existing Bitwarden account signs in with SSO against a Staged member without + * Privileged Controls access. The member does not start to use a Privileged Controls seat, so the SSO callback + * reserves none, even when the organization is at its Privileged Controls seat limit. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithoutPrivilegedControls_AtSeatLimit_DoesNotReserveSeatOrEmail() + { + // Arrange — one Privileged Controls seat, already taken, autoscale limit of one. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1)) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = false) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertStagedInviteAcceptanceRequiredRedirect(response); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * FAILURE PATH: A person with an existing Bitwarden account signs in with SSO against a Staged member with + * Privileged Controls access, but the organization has no Password Manager seat and cannot autoscale one. The + * Password Manager seat check runs first and stops the sign-in, so the SSO callback must not buy a Privileged + * Controls seat or send an invite. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_WhenNoPasswordManagerSeat_DoesNotReservePrivilegedControlsSeat() + { + // Arrange — Password Manager seats at cap and at the autoscale limit. Privileged Controls autoscale would + // succeed (no limit), so a reservation would show as a raised PamSeats value. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null); + }) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedMailer() + .BuildAsync(); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); + + var inviteCommand = testData.Factory.Services.GetRequiredService(); + await inviteCommand.DidNotReceive().SendInvitesAsync(Arg.Any()); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Staged, orgUser.Status); + Assert.True(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + /* + * DEGRADED PATH: On cloud, a person with an existing Bitwarden account signs in with SSO against a Staged + * member with Privileged Controls access, and the seat reservation fails for a reason other than the seat + * limit. The member is invited without Privileged Controls access. The Owners get no seat-needed email, + * because more seats would not fix the failure. + */ + [Fact] + public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_WhenReservationFailsUnexpectedly_InvitesWithoutPrivilegedControlsAndDoesNotEmail() + { + // Arrange — one Privileged Controls seat, already taken, no autoscale limit. The autoscale must read the + // Stripe subscription, and that read fails. + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithMockedPricingClient() + .WithOrganization(org => + { + WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: null); + org.GatewaySubscriptionId = _subscriptionId; + }) + .WithUser() + .WithStagedOrganizationUser(orgUser => orgUser.AccessPam = true) + .WithMockedSendOrganizationInvitesCommand() + .WithMockedStripeAdapter(FailSubscriptionLookup) + .WithMockedMailer() + .BuildAsync(); + await SeedOwnerAsync(testData); + await SeedPrivilegedControlsSeatHolderAsync(testData); + + // Act + var response = await CreateNonRedirectingClient(testData).GetAsync("/Account/ExternalCallback"); + + // Assert + AssertStagedInviteAcceptanceRequiredRedirect(response); + await AssertSubscriptionLookupAttemptedAsync(testData); + + var orgUser = await GetOrgUserAsync(testData); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.False(orgUser.AccessPam); + + Assert.Equal(1, (await GetOrganizationAsync(testData)).PamSeats); + await AssertSeatNeededEmailNotSentAsync(testData); + } + + private const string _subscriptionId = "sub_integration_test"; + + private static void FailSubscriptionLookup(IStripeAdapter stripeAdapter) => + stripeAdapter.GetSubscriptionAsync(_subscriptionId, Arg.Any()) + .ThrowsAsync(new Exception("simulated Stripe outage")); + + /// + /// Proves the reservation reached the Stripe read, so the failure came from the substituted outage and not + /// from an earlier step. + /// + private static async Task AssertSubscriptionLookupAttemptedAsync(SsoTestData testData) + { + var stripeAdapter = testData.Factory.Services.GetRequiredService(); + await stripeAdapter.Received(1).GetSubscriptionAsync(_subscriptionId, Arg.Any()); + } + + private static void WithPrivilegedControlsSeats(Organization organization, + int pamSeats, int? maxAutoscalePamSeats) + { + organization.PlanType = PlanType.EnterpriseAnnually; + organization.UsePam = true; + organization.PamSeats = pamSeats; + organization.MaxAutoscalePamSeats = maxAutoscalePamSeats; + } + + private static HttpClient CreateNonRedirectingClient(SsoTestData testData) => + testData.Factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + /// + /// Seeds a Confirmed Owner, who gets the organization's seat-needed emails. + /// + /// The Owner's email address. + private static async Task SeedOwnerAsync(SsoTestData testData) + { + var owner = await SeedConfirmedMemberAsync(testData, OrganizationUserType.Owner, accessPam: false); + return owner.Email; + } + + /// + /// Seeds a Confirmed member with Privileged Controls access, who takes one Privileged Controls seat. + /// + private static Task SeedPrivilegedControlsSeatHolderAsync(SsoTestData testData) => + SeedConfirmedMemberAsync(testData, OrganizationUserType.User, accessPam: true); + + private static async Task SeedConfirmedMemberAsync(SsoTestData testData, OrganizationUserType type, + bool accessPam) + { + var userRepository = testData.Factory.Services.GetRequiredService(); + var user = await userRepository.CreateAsync(new User + { + Email = $"member_{Guid.NewGuid()}@test.dev", + Name = "Member", + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }); + + var organizationUserRepository = testData.Factory.Services.GetRequiredService(); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = testData.Organization!.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = type, + AccessPam = accessPam + }); + + return user; + } + + private static async Task GetOrgUserAsync(SsoTestData testData) + { + var organizationUserRepository = testData.Factory.Services.GetRequiredService(); + var orgUser = await organizationUserRepository.GetByIdAsync(testData.OrganizationUser!.Id); + Assert.NotNull(orgUser); + return orgUser; + } + + private static async Task GetOrganizationAsync(SsoTestData testData) + { + var organizationRepository = testData.Factory.Services.GetRequiredService(); + var organization = await organizationRepository.GetByIdAsync(testData.Organization!.Id); + Assert.NotNull(organization); + return organization; + } + + private static async Task AssertSeatNeededEmailSentAsync(SsoTestData testData, string ownerEmail, + string memberEmail) + { + var mailer = testData.Factory.Services.GetRequiredService(); + await mailer.Received(1).SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(ownerEmail) && + mail.View.OrganizationId == testData.Organization!.Id && + mail.View.MemberEmail == CoreHelpers.PreventEmailAutoLinking(memberEmail))); + } + + private static async Task AssertSeatNeededEmailNotSentAsync(SsoTestData testData) + { + var mailer = testData.Factory.Services.GetRequiredService(); + await mailer.DidNotReceive().SendEmail(Arg.Any()); + } + + /// + /// Asserts the response is the standard SSO success redirect, not a redirect back to the web client's + /// /login or /sso-login-failed page. + /// + private static void AssertSsoSucceeded(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.NotNull(response.Headers.Location); + var location = response.Headers.Location.ToString(); + Assert.DoesNotContain("/login?", location); + Assert.DoesNotContain("/sso-login-failed?", location); + } + + private static void AssertStagedInviteAcceptanceRequiredRedirect(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.NotNull(response.Headers.Location); + var location = response.Headers.Location.ToString(); + Assert.Contains("/login?", location); + Assert.Contains("error=ssoStagedOrgUserInviteAcceptanceRequired", location); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs index 425d7332a400..987aa9c2ae49 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -419,7 +419,7 @@ public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_RedirectsTo // Act var response = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); } /* @@ -448,7 +448,7 @@ public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_Red // Act var response = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); } /* @@ -961,7 +961,7 @@ public async Task ExternalCallback_WithAutoscalePartialFailure_RollsBackSeatsAnd // Act var response = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); // Assert — rollback ran with the exact delta before the redirect fired. var orgService = testData.Factory.Services.GetRequiredService(); @@ -998,7 +998,7 @@ public async Task ExternalCallback_WithJitProvisioning_AgainstStagedOrgUser_OnSe var response = await client.GetAsync("/Account/ExternalCallback"); // Promoting the Staged row would exceed the seat cap and self-hosted cannot autoscale. - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); } /* @@ -1032,7 +1032,7 @@ public async Task ExternalCallback_WithJitProvisioning_AgainstStagedOrgUser_OnCl // Promoting the Staged row would exceed the seat cap and autoscale cannot grow past // MaxAutoscaleSeats. - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); } /* @@ -1115,7 +1115,7 @@ public async Task ExternalCallback_JitAgainstStagedOrgUser_WhenSeatCheckThrows_R // Phase 1 act — first SSO attempt should be rejected. var response1 = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response1); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response1); // Phase 1 assert — no BW User row was persisted. This is the fix's contract: // the seat check runs before RegisterSSOAutoProvisionedUserAsync so a rejection @@ -1248,7 +1248,7 @@ public async Task ExternalCallback_WithExistingUser_AndStagedOrgUser_OnSelfHoste // Act var response = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); // Assert — no invite was sent (seat check must run before the email step). var inviteCommand = testData.Factory.Services.GetRequiredService(); @@ -1290,7 +1290,7 @@ public async Task ExternalCallback_WithExistingUser_AndStagedOrgUser_OnCloudAuto // Act var response = await client.GetAsync("/Account/ExternalCallback"); - AssertNoSeatsAvailableRedirect(response); + ExternalCallbackAssertions.AssertNoSeatsAvailableRedirect(response); // Assert — no invite was sent. var inviteCommand = testData.Factory.Services.GetRequiredService(); @@ -1453,20 +1453,4 @@ public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status() var content = await response.Content.ReadAsStringAsync(); Assert.NotEmpty(content); // View content should be present } - - /// - /// Asserts the response is a 302 redirect to the web client's - /// /sso-login-failed terminal page carrying the - /// no-seats-available kind. Shared by every SSO no-seats test path - /// (self-hosted, cloud autoscale-fail, autoscale partial-failure rollback, - /// fresh-JIT, and Staged-promotion variants). - /// - private static void AssertNoSeatsAvailableRedirect(HttpResponseMessage response) - { - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.NotNull(response.Headers.Location); - var location = response.Headers.Location!.ToString(); - Assert.Contains("/sso-login-failed?", location); - Assert.Contains("kind=no-seats-available", location); - } } diff --git a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj index 38ce6ac4445d..ef003cca014c 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj +++ b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj @@ -27,6 +27,7 @@ + diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/ExternalCallbackAssertions.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/ExternalCallbackAssertions.cs new file mode 100644 index 000000000000..53973adf2ba0 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/ExternalCallbackAssertions.cs @@ -0,0 +1,23 @@ +using System.Net; +using Xunit; + +namespace Bit.Sso.IntegrationTest.Utilities; + +/// +/// Assertions on the responses that /Account/ExternalCallback returns. +/// +public static class ExternalCallbackAssertions +{ + /// + /// Asserts the response is a 302 redirect to the web client's /sso-login-failed terminal page + /// with the no-seats-available kind. + /// + public static void AssertNoSeatsAvailableRedirect(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.NotNull(response.Headers.Location); + var location = response.Headers.Location.ToString(); + Assert.Contains("/sso-login-failed?", location); + Assert.Contains("kind=no-seats-available", location); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs index 9a5c508cf445..0e31d9db5323 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs @@ -5,6 +5,9 @@ using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Mail.Delivery; @@ -12,8 +15,10 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.Billing.Mocks; using Bit.Sso.Models; using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Bitwarden.Server.Sdk.Environment; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; @@ -59,12 +64,16 @@ public class SsoTestDataBuilder private bool _successfulAuth = true; private bool _withNullEmail = false; private bool _isSelfHosted = false; + private bool _isSelfHostedEnvironment = false; private bool _includeProviderUserId = true; private bool _useNonExistentOrgInAuth = false; private bool _isNativeClient = false; private bool _mockAutoscaleSuccess = false; private bool _mockAutoscalePartialFailure = false; private bool _mockSendOrganizationInvitesCommand = false; + private bool _mockStripeAdapter = false; + private bool _mockPricingClient = false; + private Action? _stripeAdapterConfig; private X509Certificate2? _samlSigningCertificate; private bool? _wantAssertionsSignedFlagEnabled; private int _rsa15DeprecationEmailCooldownInDays = 7; @@ -161,6 +170,17 @@ public SsoTestDataBuilder AsSelfHosted() return this; } + /// + /// Substitutes so that it reports a self-hosted instance. + /// Billing commands read self-hosted from , not from + /// , so alone does not reach them. + /// + public SsoTestDataBuilder WithSelfHostedEnvironment() + { + _isSelfHostedEnvironment = true; + return this; + } + /// /// Causes the auth result to use a different (non-existent) organization ID than what is seeded /// in the database. This simulates the "organization not found" scenario. @@ -237,6 +257,27 @@ public SsoTestDataBuilder WithMockedSendOrganizationInvitesCommand() return this; } + /// + /// Substitutes so that it returns plans from , + /// and no test calls the pricing service. + /// + public SsoTestDataBuilder WithMockedPricingClient() + { + _mockPricingClient = true; + return this; + } + + /// + /// Substitutes so no test calls Stripe. The optional action + /// configures the substitute, for example to make a subscription lookup throw. + /// + public SsoTestDataBuilder WithMockedStripeAdapter(Action? configure = null) + { + _mockStripeAdapter = true; + _stripeAdapterConfig = configure; + return this; + } + /// /// Substitutes so AutoAddSeatsAsync succeeds /// without hitting the real billing gateway. Use to exercise the "at cap, autoscale @@ -325,6 +366,15 @@ public async Task BuildAsync() factory.UpdateConfiguration(key, value); } + // 1.a.ii Configure IBitwardenEnvironment, if the test requests a self-hosted environment + if (_isSelfHostedEnvironment) + { + factory.SubstituteService(environment => + { + environment.SelfHosted.Returns(true); + }); + } + // 1.b Replace SamlEnvironment with a version that has a test SP signing certificate, if the test requests it if (_samlSigningCertificate != null) { @@ -358,6 +408,25 @@ public async Task BuildAsync() factory.SubstituteService(_ => { }); } + // 1.d.i Configure IStripeAdapter, if the test requests it + if (_mockStripeAdapter) + { + factory.SubstituteService(stripeAdapter => _stripeAdapterConfig?.Invoke(stripeAdapter)); + } + + // 1.d.ii Configure IPricingClient, if the test requests it + if (_mockPricingClient) + { + factory.SubstituteService(pricingClient => + { + pricingClient.GetPlan(Arg.Any()) + .Returns(callInfo => MockPlans.Get(callInfo.Arg())); + pricingClient.GetPlanOrThrow(Arg.Any()) + .Returns(callInfo => MockPlans.Get(callInfo.Arg())); + pricingClient.ListPlans().Returns(MockPlans.Plans); + }); + } + // 1.e Configure IOrganizationService for autoscale-branch tests if (_mockAutoscaleSuccess) { diff --git a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json index 3735f89ae539..02317a8a78ec 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json +++ b/bitwarden_license/test/Sso.IntegrationTest/packages.lock.json @@ -752,6 +752,11 @@ "StackExchange.Redis": "2.7.27" } }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.8.0", + "contentHash": "7I0DXMTCqpXpXg+SLLDlfedtv6DN5Lgiv+PR3MclzqKPOHNB17YP42KMNI61ckSrffh0xsuIqQyCIjvWc6vUuw==" + }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", "resolved": "10.0.10", @@ -767,6 +772,14 @@ "resolved": "10.0.10", "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" }, + "Microsoft.Extensions.Diagnostics.Testing": { + "type": "Transitive", + "resolved": "10.8.0", + "contentHash": "juYWZdATg/uODFWdYWrlo9a/0KRHGycsVinxI+/IpiBSDW5JZNZa2Fs/Os9N9Q2WHBHnhwANSPo2zljbzFRkRA==", + "dependencies": { + "Microsoft.Extensions.Telemetry.Abstractions": "10.8.0" + } + }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", "resolved": "10.0.8", @@ -777,6 +790,14 @@ "resolved": "10.0.10", "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==" }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.8.0", + "contentHash": "XHlOerNKjVrPJqbuFi196tyFhS7eNMoPUC800xJmCGsSiruE/goGoKyBKzS1HeelAGnoFPFrS+LgNitXNeEYRQ==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.8.0" + } + }, "Microsoft.Extensions.TimeProvider.Testing": { "type": "Transitive", "resolved": "10.8.0", @@ -1452,6 +1473,20 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "core.test": { + "type": "Project", + "dependencies": { + "AutoFixture.AutoNSubstitute": "[4.18.1, )", + "AutoFixture.Xunit2": "[4.18.1, )", + "Common": "[2026.10.1, )", + "Core": "[2026.10.1, )", + "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", + "Microsoft.Extensions.Diagnostics.Testing": "[10.8.0, 10.8.0]", + "Microsoft.NET.Test.Sdk": "[18.0.1, )", + "NSubstitute": "[5.1.0, )", + "xunit": "[2.6.6, )" + } + }, "currentuser": { "type": "Project" },