Skip to content

Add SSO ExternalCallback integration tests for Privileged Controls seats - #8822

Open
JaredSnider-Bitwarden wants to merge 1 commit into
billing/PM-45107/autoscale-pam-seats-on-access-grantfrom
auth/sso-pam-seat-integration-tests
Open

JaredSnider-Bitwarden wants to merge 1 commit into
billing/PM-45107/autoscale-pam-seats-on-access-grantfrom
auth/sso-pam-seat-integration-tests

Conversation

@JaredSnider-Bitwarden

@JaredSnider-Bitwarden JaredSnider-Bitwarden commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🎟️ Tracking

No ticket. Stacked on #8812. Merge this PR into that branch.

📔 Objective

Add integration tests for the Privileged Controls seat reservation that #8812 adds to the SSO AccountController. The reservation runs when an SSO sign-in moves a Staged member with AccessPam to Invited.

Please confirm the expected results

I wrote these tests from the code, not from a spec, so each Expected result below is what the code does today. Please flag any row that is not what you intend. These rows are the least obvious:

  1. A member at the seat limit still joins or is invited, but without access.
  2. Owners get the seat-needed email only at the seat limit on cloud. Other failures and self-hosted send no email.
  3. Zero seats counts as being at the limit.
  4. An organization with no GatewaySubscriptionId gets a seat added without a Stripe call.

What the tests cover

New file AccountControllerExternalCallbackPrivilegedControlsSeatTests (14 tests). "JIT" is a new user that SSO provisions just in time. "Existing" is a user with a Bitwarden account, who gets an invite email.

Scenario Expected result JIT Existing
A seat is free Keeps access. Nothing bought or emailed. ✅ ✅
All seats taken, no autoscale limit One seat added. Keeps access. ✅
At the autoscale limit Joins without access. Owners emailed. ✅ ✅
Zero seats Joins without access. Owners emailed. ✅
Self-hosted, no free seat Joins without access. No email. ✅
Reservation fails for another reason Joins without access. No email. ✅ ✅
Staged member has no AccessPam No reservation, even at the limit. ✅ ✅
No Password Manager seat Sign-in fails. No Privileged Controls seat bought. ✅ ✅
Invite send fails at the limit Back to Staged with AccessPam restored. No email. ✅

Test infrastructure

  1. New SsoTestDataBuilder opt-ins: WithSelfHostedEnvironment(), WithMockedStripeAdapter(...), and WithMockedPricingClient(). No test calls Stripe or the pricing service.
  2. Sso.IntegrationTest now references Core.Test for MockPlans, as Identity.IntegrationTest does.
  3. AssertNoSeatsAvailableRedirect moved to a shared ExternalCallbackAssertions helper.

Verification

  1. SSO integration suite: 101 passed, 2 skipped (unchanged). SSO unit suite: 177 passed.
  2. Mutation check: 15 mutations of the new AccountController lines, 14 killed. The survivor is equivalent (possibleOrgUser.Email ?? newUser.Email; the values are always equal in the test data).

📸 Screenshots

N/A

Cover the Privileged Controls seat reservation that #8812 adds to the
SSO AccountController, for both the just-in-time and existing-user
Staged promotion paths.

Add SsoTestDataBuilder opt-ins for a self-hosted environment, a
substituted Stripe adapter, and a substituted pricing client backed by
MockPlans, so no test calls an external service. Move
AssertNoSeatsAvailableRedirect to a shared ExternalCallbackAssertions
helper.
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR only adds tests. It adds 14 SSO ExternalCallback integration tests for the Privileged Controls seat reservation in the base branch, covering both JIT and existing-user sign-ins. The scenarios are: a free seat, autoscale growth, the autoscale limit, zero seats, self-hosted, an unexpected reservation failure, no AccessPam, no Password Manager seat, and a failed invite send. It also adds three opt-in SsoTestDataBuilder substitutes (IBitwardenEnvironment, IStripeAdapter, IPricingClient), moves AssertNoSeatsAvailableRedirect into a shared ExternalCallbackAssertions helper, and adds a Core.Test project reference for MockPlans. The packages.lock.json changes are only the transitive entries that Core.Test brings in, so no new third-party dependency enters the codebase.

@ike-kottlowski
ike-kottlowski self-requested a review November 5, 2026 20:47
@JaredSnider-Bitwarden JaredSnider-Bitwarden added ai-review Request a Claude code review t:tech-debt Change Type - Tech debt labels Nov 5, 2026
@JaredSnider-Bitwarden
JaredSnider-Bitwarden requested review from kdenney and removed request for cyprain-okeke November 5, 2026 20:48
@codecov

codecov Bot commented Nov 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (9b4182c) to head (2d9bc89).

Additional details and impacted files
@@                                   Coverage Diff                                    @@
##           billing/PM-45107/autoscale-pam-seats-on-access-grant   #8822       +/-   ##
========================================================================================
- Coverage                                                 68.63%       0   -68.64%     
========================================================================================
  Files                                                      2765       0     -2765     
  Lines                                                    118581       0   -118581     
  Branches                                                  10826       0    -10826     
========================================================================================
- Hits                                                      81385       0    -81385     
+ Misses                                                    34643       0    -34643     
+ Partials                                                   2553       0     -2553     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rr-bw
rr-bw removed their request for review November 6, 2026 17:09

@kdenney kdenney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks again for these. Only required change is the feature flag mock.

Everything from the pr description's expected results table looks correct to me except for the one technicality nitpick comment below.

* the organization has a free Privileged Controls seat. The member keeps access, no seat is bought, and the
* Owners get no email.
*/
[Fact]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: #8812 now has a PM39883_Pam_Billing_Support check in ReservePrivilegedControlsSeatsCommand. On cloud with the flag off, the reservation succeeds right away: the member keeps access, and nothing is bought or emailed. Self-hosted is unaffected, because that branch runs before the flag check.

var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithMockedPricingClient()
.WithOrganization(org => WithPrivilegedControlsSeats(org, pamSeats: 1, maxAutoscalePamSeats: 1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a technicality nitpick, but FYI: with one seat bought, none in use and a limit of 1, this member brings the total to exactly the autoscale limit. In that case AutoscalePrivilegedControlsSeatsCommand.RunAsync sends the Owners PrivilegedControlsSeatLimitReachedMail. That's intended: Owners are told when seats in use land exactly on the limit, whether or not a seat was bought but that email was added in a previous branch. So the "Nothing bought or emailed" wording in the description table for this row isn't exactly accurate.

If you want, you could increase coverage by asserting that the limit-reached email is sent here. If you'd rather keep this test about the free-seat case alone, raising maxAutoscalePamSeats to 2 (or null) would also make "nothing emailed" true.

The same applies to the existing-user version at line 292.

* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this is the only test that adds a seat, and it goes down the no-subscription path, so Stripe is never called. Is it worth it to add another test that uses the WithMockedStripeAdapter helper so that's covered?

* state. The Owners get no seat-needed email, because the member did not join.
*/
[Fact]
public async Task ExternalCallback_ExistingUserAgainstStagedOrgUserWithPrivilegedControls_AtSeatLimit_WhenInviteSendFails_RestoresPrivilegedControlsAndDoesNotEmail()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: might be worth adding another test here to increase coverage where the invite send fails after autoscale already bought a seat. The expected result would be that the member goes back to Staged and keeps AccessPam, and the bought Privileged Controls seat stays bought, which matches how this path already keeps the Password Manager seat.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:tech-debt Change Type - Tech debt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants