SSO e2e testing (#12739)

* Extend SSO auth workflows

* SSO e2e testing workflow

* Remove orphaned code

* Add unit tests for SSO

* improved error messaging

* Add test for SSO registration disabled

* Fix gating on LOGIN_ENABLE_SSO

* Adjust UI state management

* Add playwright test for SSO disabled

* Add backend unit tests

* Adjust API version

* Additional playwright tests

* Retain desired page state on login failure

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Adjust test code

* Stricter matcher checking

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2026-08-30 10:32:54 +10:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 69afe7723f
commit a205717171
23 changed files with 893 additions and 72 deletions
+2
View File
@@ -230,6 +230,7 @@ class InfoApiSerializer(serializers.Serializer):
class SettingsSerializer(serializers.Serializer):
"""Serializer for InfoApiSerializer."""
sso_enabled = serializers.BooleanField()
sso_registration = serializers.BooleanField()
registration_enabled = serializers.BooleanField()
password_forgotten_enabled = serializers.BooleanField()
@@ -329,6 +330,7 @@ class InfoView(APIView):
if (is_staff and settings.INVENTREE_ADMIN_ENABLED)
else None,
'settings': {
'sso_enabled': get_global_setting('LOGIN_ENABLE_SSO'),
'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'),
'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': get_global_setting(
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 534
INVENTREE_API_VERSION = 535
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v535 -> 2026-08-29 : https://github.com/inventree/InvenTree/pull/12739
- Adds SSO registration API endpoint for creating new users via SSO
v534 -> 2026-08-21 : https://github.com/inventree/InvenTree/pull/12672
- rename 'tags' filter to 'tag_name' to avoid name clash with the 'tags' field on various API endpoints
@@ -199,6 +199,16 @@ class CustomSocialAccountAdapter(RegistrationMixin, DefaultSocialAccountAdapter)
REGISTRATION_SETTING = 'LOGIN_ENABLE_SSO_REG'
def pre_social_login(self, request, sociallogin):
"""Reject SSO logins outright while SSO is disabled via settings.
This runs for every social login attempt (new or existing account),
not just self-registration - LOGIN_ENABLE_SSO_REG only gates signup.
"""
if not get_global_setting('LOGIN_ENABLE_SSO'):
raise PermissionDenied('SSO is disabled')
super().pre_social_login(request, sociallogin)
def is_auto_signup_allowed(self, request, sociallogin):
"""Check if auto signup is enabled in settings."""
if get_global_setting('LOGIN_SIGNUP_SSO_AUTO', True):
-54
View File
@@ -14,60 +14,6 @@ from common.settings import get_global_setting
logger = structlog.get_logger('inventree')
def get_provider_app(provider):
"""Return the SocialApp object for the given provider."""
from allauth.socialaccount.models import SocialApp
try:
apps = SocialApp.objects.filter(provider__iexact=provider.id)
except SocialApp.DoesNotExist:
logger.warning("SSO SocialApp not found for provider '%s'", provider.id)
return None
if apps.count() > 1:
logger.warning("Multiple SocialApps found for provider '%s'", provider.id)
if apps.count() == 0:
logger.warning("SSO SocialApp not found for provider '%s'", provider.id)
return apps.first()
def check_provider(provider):
"""Check if the given provider is correctly configured.
To be correctly configured, the following must be true:
- Provider must either have a registered SocialApp
- Must have at least one site enabled
"""
import allauth.app_settings
# First, check that the provider is enabled
app = get_provider_app(provider)
if not app:
return False
if allauth.app_settings.SITES_ENABLED:
# At least one matching site must be specified
if not app.sites.exists():
logger.error('SocialApp %s has no sites configured', app)
return False
# At this point, we assume that the provider is correctly configured
return True
def provider_display_name(provider):
"""Return the 'display name' for the given provider."""
if app := get_provider_app(provider):
return app.name
# Fallback value if app not found
return provider.name
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
"""Sync groups from IdP each time a SSO user logs on.
+71 -3
View File
@@ -1,8 +1,9 @@
"""Test the sso and auth module functionality."""
from django.conf import settings as django_settings
from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.test import override_settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.test import RequestFactory, override_settings
from django.test.testcases import TransactionTestCase
from django.urls import reverse
@@ -10,7 +11,7 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.models import InvenTreeSetting
from InvenTree import sso
from InvenTree.auth_overrides import RegistrationMixin
from InvenTree.auth_overrides import CustomSocialAccountAdapter, RegistrationMixin
from InvenTree.unit_test import InvenTreeAPITestCase
@@ -124,6 +125,61 @@ class TestSsoGroupSync(TransactionTestCase):
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)
class TestSocialAccountAdapter(TransactionTestCase):
"""Tests for CustomSocialAccountAdapter, used for all SSO logins."""
def setUp(self):
"""Construct a fresh adapter for each test."""
self.adapter = CustomSocialAccountAdapter()
def test_pre_social_login_blocked_when_sso_disabled(self):
"""SSO logins (new or existing accounts) must be rejected outright when SSO is disabled."""
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', False)
with self.assertRaises(PermissionDenied):
self.adapter.pre_social_login(None, None)
def test_pre_social_login_allowed_when_sso_enabled(self):
"""A normal SSO login attempt should pass through untouched when SSO is enabled."""
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', True)
# Should not raise - the default super() implementation is a no-op
self.adapter.pre_social_login(None, None)
def test_is_auto_signup_allowed(self):
"""Auto-signup must be blockable independently of whether SSO registration is open."""
InvenTreeSetting.set_setting('LOGIN_SIGNUP_SSO_AUTO', False)
self.assertFalse(self.adapter.is_auto_signup_allowed(None, None))
# When enabled, defers to allauth's own default (SOCIALACCOUNT_AUTO_SIGNUP)
InvenTreeSetting.set_setting('LOGIN_SIGNUP_SSO_AUTO', True)
self.assertTrue(self.adapter.is_auto_signup_allowed(None, None))
def test_is_open_for_signup(self):
"""SSO self-registration is gated by LOGIN_ENABLE_SSO_REG (and a configured mail backend)."""
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_REG', False)
self.assertFalse(self.adapter.is_open_for_signup(None, None))
with self.settings(EMAIL_HOST='localhost', TESTING_BYPASS_MAILCHECK=True):
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_REG', True)
self.assertTrue(self.adapter.is_open_for_signup(None, None))
def test_get_connect_redirect_url(self):
"""Connecting an SSO account should redirect back to the frontend root."""
request = RequestFactory().get('/')
url = self.adapter.get_connect_redirect_url(request, None)
self.assertTrue(url.endswith(f'/{django_settings.FRONTEND_URL_BASE}/'))
def test_authentication_error_does_not_raise(self):
"""A provider-side authentication error should be logged, not raised further."""
request = RequestFactory().get(
'/', data={'error': 'access_denied', 'error_description': 'Cancelled'}
)
# Should not raise, regardless of whether error/exception are passed explicitly
self.adapter.authentication_error(request, 'mock')
self.adapter.authentication_error(
request, 'mock', error='denied', exception='User cancelled'
)
class EmailSettingsContext:
"""Context manager to enable email settings for tests."""
@@ -244,3 +300,15 @@ class TestAuth(InvenTreeAPITestCase):
# Logged out user
self.client.logout()
self.get(url, expected_code=401)
def test_server_info_sso_enabled(self):
"""The server info endpoint should reflect the LOGIN_ENABLE_SSO setting."""
url = reverse('api-inventree-info')
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', True)
resp = self.get(url, expected_code=200)
self.assertTrue(resp.json()['settings']['sso_enabled'])
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', False)
resp = self.get(url, expected_code=200)
self.assertFalse(resp.json()['settings']['sso_enabled'])