Skip to content

Commit ddc3a9a

Browse files
test(auth): Cover organization SSO flows (#123240)
Restores the reverted Auth V2 organization SSO acceptance coverage. The organization-switch test now enables the Auth V2 rollout feature. Without it, loading the first organization clears the React login cookie and the subsequent SSO redirect renders the legacy login page, causing the test to time out on React-specific copy. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent d8a8378 commit ddc3a9a

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

tests/acceptance/test_auth_react.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sentry.auth.authenticators.recovery_code import RecoveryCodeInterface
66
from sentry.auth.authenticators.totp import TotpInterface
77
from sentry.testutils.cases import AcceptanceTestCase
8+
from sentry.testutils.helpers.features import with_feature
89
from sentry.testutils.silo import no_silo_test
910
from sentry.users.models.user import User
1011

@@ -71,6 +72,55 @@ def submit_second_factor(self, code: str) -> None:
7172
code
7273
)
7374

75+
def locate_organization_sso(self, organization_slug: str) -> None:
76+
self.open_login()
77+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='Organization SSO']")
78+
self.browser.element('[aria-label="Organization Slug"]').send_keys(organization_slug)
79+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='Locate']")
80+
81+
def select_organization_sso(self, organization_slug: str) -> None:
82+
self.locate_organization_sso(organization_slug)
83+
self.browser.wait_until(
84+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
85+
)
86+
87+
def begin_organization_sso(self, organization_slug: str) -> None:
88+
self.select_organization_sso(organization_slug)
89+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
90+
91+
# The dummy provider renders its email challenge inline as the initiation response.
92+
# A real provider redirects to its identity service before returning to Sentry.
93+
self.browser.wait_until('form > input[type="email"][name="email"]:only-child')
94+
95+
def complete_dummy_sso(self, email: str) -> None:
96+
csrf_cookie = self.browser.driver.get_cookie(settings.CSRF_COOKIE_NAME)
97+
assert csrf_cookie is not None
98+
99+
# Simulate the provider callback by posting its identity to the shared SSO continuation
100+
# endpoint. Include the browser's CSRF token because this remains a browser-driven POST.
101+
self.browser.driver.execute_script(
102+
"""
103+
const form = document.createElement('form');
104+
form.method = 'POST';
105+
form.action = '/auth/sso/';
106+
107+
const input = document.createElement('input');
108+
input.name = 'email';
109+
input.value = arguments[0];
110+
form.appendChild(input);
111+
112+
const csrfInput = document.createElement('input');
113+
csrfInput.name = 'csrfmiddlewaretoken';
114+
csrfInput.value = arguments[1];
115+
form.appendChild(csrfInput);
116+
117+
document.body.appendChild(form);
118+
form.submit();
119+
""",
120+
email,
121+
csrf_cookie["value"],
122+
)
123+
74124
def wait_for_authenticated_organization(self, organization_slug: str) -> None:
75125
expected_path = f"/organizations/{organization_slug}/issues/"
76126
self.browser.wait_until_script_execution(
@@ -169,3 +219,176 @@ def test_multi_organization_login(self) -> None:
169219
self.clear_session_authentication()
170220
self.submit_credentials(user.email, PASSWORD, org_b.slug)
171221
self.wait_for_authenticated_organization(org_b.slug)
222+
223+
def test_organization_sso(self) -> None:
224+
user = self.create_login_user("sso-org")
225+
auth_provider = self.create_auth_provider(
226+
organization_id=self.organization.id, provider="dummy"
227+
)
228+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
229+
230+
# The organization lookup shows that SSO is required and starts its provider flow.
231+
self.begin_organization_sso(self.organization.slug)
232+
233+
# The dummy provider callback authenticates the linked user.
234+
self.complete_dummy_sso(user.email)
235+
self.wait_for_authenticated_organization(self.organization.slug)
236+
237+
def test_organization_sso_with_totp(self) -> None:
238+
user = self.create_login_user("sso-totp-org")
239+
totp = TotpInterface()
240+
totp.enroll(user)
241+
auth_provider = self.create_auth_provider(
242+
organization_id=self.organization.id, provider="dummy"
243+
)
244+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
245+
246+
# SSO identifies the user but leaves authentication pending on their second factor.
247+
self.begin_organization_sso(self.organization.slug)
248+
self.complete_dummy_sso(user.email)
249+
self.browser.wait_until_script_execution(
250+
"return window.location.pathname === '/auth/login/'"
251+
)
252+
self.browser.wait_until('[aria-label="One-time password"]')
253+
254+
assert not self.browser.element_exists('[aria-label="Email"]')
255+
assert not self.browser.element_exists('[aria-label="Password"]')
256+
self.submit_second_factor(totp.make_otp().generate_otp())
257+
self.wait_for_authenticated_organization(self.organization.slug)
258+
259+
def test_organization_without_sso(self) -> None:
260+
user = self.create_login_user("password-only-org")
261+
262+
# The organization lookup explains that members use password authentication.
263+
self.locate_organization_sso(self.organization.slug)
264+
self.browser.wait_until(
265+
xpath="//*[contains(normalize-space(.), 'Members sign in with email and password')]"
266+
)
267+
sso_button = self.browser.element(xpath="//button[normalize-space(.)='SSO']")
268+
assert not sso_button.is_enabled()
269+
270+
# Password authentication remains available for the selected organization.
271+
self.submit_visible_credentials(user.email, PASSWORD)
272+
self.wait_for_authenticated_organization(self.organization.slug)
273+
274+
def test_password_authentication_with_optional_organization_sso(self) -> None:
275+
user = self.create_login_user("optional-sso-org")
276+
auth_provider = self.create_auth_provider(
277+
organization_id=self.organization.id, provider="dummy"
278+
)
279+
auth_provider.flags.allow_unlinked = True
280+
auth_provider.save()
281+
282+
self.locate_organization_sso(self.organization.slug)
283+
self.browser.wait_until(
284+
xpath="//*[contains(normalize-space(.), 'Members sign in with Dummy')]"
285+
)
286+
assert self.browser.element_exists('[aria-label="Email"]')
287+
assert self.browser.element_exists('[aria-label="Password"]')
288+
self.submit_visible_credentials(user.email, PASSWORD)
289+
290+
self.wait_for_authenticated_organization(self.organization.slug)
291+
292+
def test_password_login_cannot_access_sso_required_organization(self) -> None:
293+
user = self.create_user(email="sso-password@example.com")
294+
user.set_password(PASSWORD)
295+
user.save()
296+
organization = self.create_organization(slug="sso-password-org")
297+
self.create_member(organization=organization, user=user)
298+
self.create_auth_provider(organization_id=organization.id, provider="dummy")
299+
300+
# Selecting an SSO-required organization does not force the user to start SSO.
301+
self.select_organization_sso(organization.slug)
302+
self.submit_visible_credentials(user.email, PASSWORD)
303+
304+
# Password authentication succeeds, but it does not grant access to the organization.
305+
self.browser.wait_until_script_execution(
306+
"return window.location.pathname === '/settings/account/'"
307+
)
308+
309+
def test_password_login_uses_organization_without_sso(self) -> None:
310+
user = self.create_user(email="multi-org-password@example.com")
311+
user.set_password(PASSWORD)
312+
user.save()
313+
sso_organization = self.create_organization(slug="sso-required-org")
314+
self.create_member(organization=sso_organization, user=user)
315+
self.create_auth_provider(organization_id=sso_organization.id, provider="dummy")
316+
password_organization = self.create_organization(owner=user, slug="password-org")
317+
318+
# Password login cannot enter the selected organization because it requires SSO.
319+
self.select_organization_sso(sso_organization.slug)
320+
self.submit_visible_credentials(user.email, PASSWORD)
321+
322+
# The authenticated user lands in an accessible organization instead.
323+
self.wait_for_authenticated_organization(password_organization.slug)
324+
325+
def test_password_login_preserves_sso_organization_destination(self) -> None:
326+
user = self.create_user(email="preserved-sso-destination@example.com")
327+
user.set_password(PASSWORD)
328+
user.save()
329+
sso_organization = self.create_organization(slug="preserved-sso-org")
330+
self.create_member(organization=sso_organization, user=user)
331+
auth_provider = self.create_auth_provider(
332+
organization_id=sso_organization.id,
333+
provider="dummy",
334+
)
335+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
336+
self.create_organization(owner=user, slug="password-fallback-org")
337+
338+
self.save_cookie(
339+
name="sentry_react_auth",
340+
value="1",
341+
expires="Tue, 20 Jun 2035 19:07:44 GMT",
342+
)
343+
self.browser.get(f"/organizations/{sso_organization.slug}/issues/")
344+
self.browser.wait_until('[aria-label="Email"]')
345+
346+
# Password authentication establishes the account session, but the protected
347+
# destination takes precedence over the password-capable fallback organization.
348+
self.submit_visible_credentials(user.email, PASSWORD)
349+
self.browser.wait_until_script_execution(
350+
f"return window.location.pathname === '/auth/login/{sso_organization.slug}/'"
351+
)
352+
self.browser.wait_until(
353+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
354+
)
355+
356+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
357+
self.browser.wait_until('form > input[type="email"][name="email"]:only-child')
358+
self.complete_dummy_sso(user.email)
359+
self.wait_for_authenticated_organization(sso_organization.slug)
360+
361+
@with_feature("organizations:authv2-rollout")
362+
def test_switch_to_sso_required_organization(self) -> None:
363+
user = self.create_login_user("org-a")
364+
password_organization = self.organization
365+
sso_organization = self.create_organization(owner=user, slug="org-b")
366+
auth_provider = self.create_auth_provider(
367+
organization_id=sso_organization.id, provider="dummy"
368+
)
369+
self.create_auth_identity(auth_provider=auth_provider, user_id=user.id, ident=user.email)
370+
371+
# The user first authenticates into an organization that accepts password login.
372+
self.submit_credentials(user.email, PASSWORD)
373+
self.wait_for_authenticated_organization(password_organization.slug)
374+
375+
# Navigating to an organization that requires SSO starts an organization-scoped login.
376+
self.browser.get(f"/organizations/{sso_organization.slug}/issues/")
377+
self.browser.wait_until_script_execution(
378+
f"return window.location.pathname === '/auth/login/{sso_organization.slug}/'"
379+
)
380+
381+
# The current session cannot access the selected organization until SSO completes, so
382+
# the login page keeps the organization in focus and offers no alternative auth method.
383+
self.browser.wait_until(
384+
xpath="//*[contains(normalize-space(.), 'Requires sign in with Dummy')]"
385+
)
386+
assert not self.browser.element_exists('[aria-label="Email"]')
387+
assert not self.browser.element_exists('[aria-label="Password"]')
388+
assert not self.browser.element_exists('[aria-label="Clear organization login context"]')
389+
390+
# SSO authenticates the linked identity and returns the user to the protected org.
391+
self.browser.click_when_visible(xpath="//button[normalize-space(.)='SSO']")
392+
self.browser.wait_until('input[name="email"]')
393+
self.complete_dummy_sso(user.email)
394+
self.wait_for_authenticated_organization(sso_organization.slug)

0 commit comments

Comments
 (0)