diff --git a/django/contrib/auth/management/commands/createsuperuser.py b/django/contrib/auth/management/commands/createsuperuser.py index d5d5d193c845..5fecb4c0ed8b 100644 --- a/django/contrib/auth/management/commands/createsuperuser.py +++ b/django/contrib/auth/management/commands/createsuperuser.py @@ -298,9 +298,13 @@ def username_is_unique(self): for unique_constraint in self.UserModel._meta.total_unique_constraints ) + @cached_property + def natural_key_defined(self): + return hasattr(self.UserModel._default_manager, "get_by_natural_key") + def _validate_username(self, username, verbose_field_name, database): """Validate username. If invalid, return a string error message.""" - if self.username_is_unique: + if self.username_is_unique and self.natural_key_defined: try: self.UserModel._default_manager.db_manager(database).get_by_natural_key( username diff --git a/package.json b/package.json index 2c79999414ce..2a0c8726f3b6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ }, "devDependencies": { "@biomejs/biome": "2.4.15", - "puppeteer": "^25.0.4", "grunt": "^1.6.2", "grunt-cli": "^1.5.0", "grunt-contrib-qunit": "^10.2.0", diff --git a/tests/auth_tests/models/__init__.py b/tests/auth_tests/models/__init__.py index 185b34d85716..7d3c27522215 100644 --- a/tests/auth_tests/models/__init__.py +++ b/tests/auth_tests/models/__init__.py @@ -8,6 +8,7 @@ from .invalid_models import CustomUserNonUniqueUsername from .is_active import IsActiveTestUser1 from .minimal import MinimalUser +from .no_natural_key import CustomUserNoNaturalKey from .no_password import NoPasswordUser from .proxy import Proxy, UserProxy from .uuid_pk import UUIDUser @@ -23,6 +24,7 @@ "CustomPermissionsUser", "CustomUser", "CustomUserCompositePrimaryKey", + "CustomUserNoNaturalKey", "CustomUserNonUniqueUsername", "CustomUserWithFK", "CustomUserWithM2M", diff --git a/tests/auth_tests/models/no_natural_key.py b/tests/auth_tests/models/no_natural_key.py new file mode 100644 index 000000000000..e7082b441b87 --- /dev/null +++ b/tests/auth_tests/models/no_natural_key.py @@ -0,0 +1,22 @@ +from django.contrib.auth.hashers import make_password +from django.db import models + + +class CustomUserNoNaturalKeyManager(models.Manager): + def create_superuser(self, email, password=None, **extra_fields): + user = self.model(email=email, is_superuser=True, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + +class CustomUserNoNaturalKey(models.Model): + email = models.EmailField(max_length=255, unique=True) + password = models.CharField(max_length=128) + is_superuser = models.BooleanField(default=False) + is_staff = models.BooleanField(default=False) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = CustomUserNoNaturalKeyManager() diff --git a/tests/auth_tests/test_management.py b/tests/auth_tests/test_management.py index 985dfd79f8e7..6d009e32d9ab 100644 --- a/tests/auth_tests/test_management.py +++ b/tests/auth_tests/test_management.py @@ -19,12 +19,14 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db import migrations +from django.db.utils import IntegrityError from django.test import TestCase, override_settings from django.test.testcases import TransactionTestCase from django.utils.translation import gettext_lazy as _ from .models import ( CustomUser, + CustomUserNoNaturalKey, CustomUserNonUniqueUsername, CustomUserWithFK, CustomUserWithM2M, @@ -470,6 +472,66 @@ def createsuperuser(): users = CustomUserNonUniqueUsername.objects.filter(username="joe") self.assertEqual(users.count(), 2) + @override_settings(AUTH_USER_MODEL="auth_tests.CustomUserNoNaturalKey") + def test_swappable_user_no_natural_key_non_interactive(self): + new_io = StringIO() + call_command( + "createsuperuser", + interactive=False, + email="joe@somewhere.org", + stdout=new_io, + ) + command_output = new_io.getvalue().strip() + self.assertEqual(command_output, "Superuser created successfully.") + + user = CustomUserNoNaturalKey._default_manager.get(email="joe@somewhere.org") + self.assertIsNotNone(user) + + @override_settings(AUTH_USER_MODEL="auth_tests.CustomUserNoNaturalKey") + def test_swappable_user_no_natural_key_interactive(self): + @mock_inputs( + { + "Email: ": "joe@somewhere.org", + "password": "nopasswd", + } + ) + def createsuperuser(): + new_io = StringIO() + call_command( + "createsuperuser", + interactive=True, + stdout=new_io, + stdin=MockTTY(), + ) + command_output = new_io.getvalue().strip() + self.assertEqual(command_output, "Superuser created successfully.") + + createsuperuser() + user = CustomUserNoNaturalKey._default_manager.get(email="joe@somewhere.org") + self.assertIsNotNone(user) + + @override_settings(AUTH_USER_MODEL="auth_tests.CustomUserNoNaturalKey") + def test_swappable_user_no_natural_key_duplicate_allowed(self): + """Without get_by_natural_key, + duplicate username validation is skipped.""" + new_io = StringIO() + call_command( + "createsuperuser", + interactive=False, + email="joe@somewhere.org", + stdout=new_io, + ) + # Creating a second user with the same email won't be caught by + # _validate_username since there's no get_by_natural_key; it will + # fail at the database level instead due to the unique constraint. + with self.assertRaises(IntegrityError): + call_command( + "createsuperuser", + interactive=False, + email="joe@somewhere.org", + stdout=new_io, + ) + def test_skip_if_not_in_TTY(self): """ If the command is not called from a TTY, it should be skipped and a