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"
},