mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-01 09:41:22 +00:00
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:
co-authored by
Copilot Autofix powered by AI
parent
69afe7723f
commit
a205717171
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user