From b7375ee1e8a59c5e1a167daca919e40ef0416663 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 8 Mar 2025 00:30:28 +0300 Subject: [PATCH 1/4] feat: Create a user app for registration and authentication. - Implement proper separation of 400/401/409 status codes for registration errors - Implement proper separation of 400/401 status codes for authentication errors - Add some tests for registration and authentication --- .flake8 | 2 +- .github/workflows/test.yaml | 79 +++++++++++++ .isort.cfg | 2 +- promo_code/promo_code/settings.py | 58 +++++++++- promo_code/promo_code/urls.py | 1 + promo_code/user/__init__.py | 0 promo_code/user/apps.py | 6 + promo_code/user/authentication.py | 20 ++++ promo_code/user/migrations/0001_initial.py | 77 +++++++++++++ promo_code/user/migrations/__init__.py | 0 promo_code/user/models.py | 65 +++++++++++ promo_code/user/serializers.py | 101 +++++++++++++++++ promo_code/user/tests.py | 123 +++++++++++++++++++++ promo_code/user/tokens.py | 9 ++ promo_code/user/urls.py | 19 ++++ promo_code/user/views.py | 78 +++++++++++++ 16 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 promo_code/user/__init__.py create mode 100644 promo_code/user/apps.py create mode 100644 promo_code/user/authentication.py create mode 100644 promo_code/user/migrations/0001_initial.py create mode 100644 promo_code/user/migrations/__init__.py create mode 100644 promo_code/user/models.py create mode 100644 promo_code/user/serializers.py create mode 100644 promo_code/user/tests.py create mode 100644 promo_code/user/tokens.py create mode 100644 promo_code/user/urls.py create mode 100644 promo_code/user/views.py diff --git a/.flake8 b/.flake8 index b2d6c81..370d558 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 79 -application_import_names = promo_code +application_import_names = promo_code, user import-order-style = google exclude = */migrations/, venv/, verdict.py, .venv/, env/, venv, .git, __pycache__ max-complexity = 10 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..f9ae4da --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,79 @@ +name: Run tests + +on: deployment + +permissions: + contents: read + packages: write + attestations: write + id-token: write + + +jobs: + build: + name: Build image + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.action != 'github-classroom[bot]' + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Save image name (lowercased) + run: echo "IMAGE_NAME=$(echo 'ghcr.io/${{ github.repository }}:run-${{ github.run_id }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Build and push image + uses: docker/build-push-action@v4 + with: + context: ./solution + file: ./solution/Dockerfile + tags: ${{ env.IMAGE_NAME }} + push: true + + tests: + needs: build + name: Run tests + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.action != 'github-classroom[bot]' + steps: + - name: Setup checker environment + uses: Central-University-IT/setup-test-2025-backend@v1 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run tests + run: | + export IMAGE_SOLUTION=$(echo 'ghcr.io/${{ github.repository }}:run-${{ github.run_id }}' | tr '[:upper:]' '[:lower:]') + export IMAGE_ANTIFRAUD=docker.io/lodthe/prod-backend-antifraud + /usr/local/bin/checker + continue-on-error: true + + - uses: actions/upload-artifact@v4.0.0 + with: + name: result + path: ./result.json + if-no-files-found: error + compression-level: 0 + + - uses: bots-house/ghcr-delete-image-action@v1.1.0 + continue-on-error: true + with: + owner: ${{ github.repository_owner }} + name: ${{ github.event.repository.name }} + token: ${{ secrets.GITHUB_TOKEN }} + tag: run-${{ github.run_id }} diff --git a/.isort.cfg b/.isort.cfg index 8d3cdbe..4d10ca4 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,7 +1,7 @@ [settings] profile = black skip = migrations, venv/, venv -known_first_party = promo_code +known_first_party = promo_code, user default_section = THIRDPARTY force_sort_within_sections = true line_length = 79 \ No newline at end of file diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index ef31e28..cd4b375 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -1,3 +1,4 @@ +import datetime import os import pathlib @@ -26,19 +27,70 @@ def load_bool(name, default): DEBUG = load_bool('DJANGO_DEBUG', False) -ALLOWED_HOSTS = [] - +ALLOWED_HOSTS = ['*'] INSTALLED_APPS = [ - 'core.apps.CoreConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + # + 'rest_framework', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', + # + 'core.apps.CoreConfig', + 'user.apps.UserConfig', ] +AUTH_USER_MODEL = 'user.User' + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'user.authentication.CustomJWTAuthentication', + ], +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': datetime.timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + # + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + 'JSON_ENCODER': None, + 'JWK_URL': None, + 'LEEWAY': 0, + # + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': ( + 'rest_framework_simplejwt.authentication' + '.default_user_authentication_rule', + ), + # + 'TOKEN_TYPE_CLAIM': 'token_type', + 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', + # + 'JTI_CLAIM': 'jti', + # + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': datetime.timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': datetime.timedelta(days=1), + # + 'ACCESS_TOKEN_CLASS': 'user.tokens.CustomAccessToken', +} + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/promo_code/promo_code/urls.py b/promo_code/promo_code/urls.py index 738ad7b..8628dbf 100644 --- a/promo_code/promo_code/urls.py +++ b/promo_code/promo_code/urls.py @@ -3,5 +3,6 @@ urlpatterns = [ django.urls.path('api/ping/', django.urls.include('core.urls')), + django.urls.path('api/user/', django.urls.include('user.urls')), django.urls.path('admin/', django.contrib.admin.site.urls), ] diff --git a/promo_code/user/__init__.py b/promo_code/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/apps.py b/promo_code/user/apps.py new file mode 100644 index 0000000..5c0320b --- /dev/null +++ b/promo_code/user/apps.py @@ -0,0 +1,6 @@ +import django.apps + + +class UserConfig(django.apps.AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/promo_code/user/authentication.py b/promo_code/user/authentication.py new file mode 100644 index 0000000..5294ea6 --- /dev/null +++ b/promo_code/user/authentication.py @@ -0,0 +1,20 @@ +import datetime + +import rest_framework.exceptions +import rest_framework_simplejwt.authentication + + +class CustomJWTAuthentication( + rest_framework_simplejwt.authentication.JWTAuthentication, +): + def get_user(self, validated_token): + user = super().get_user(validated_token) + last_login_str = validated_token.get('last_login') + if last_login_str: + last_login = datetime.datetime.fromisoformat(last_login_str) + if user.last_login and user.last_login > last_login: + raise rest_framework.exceptions.AuthenticationFailed( + 'Token has been invalidated', + ) + + return user diff --git a/promo_code/user/migrations/0001_initial.py b/promo_code/user/migrations/0001_initial.py new file mode 100644 index 0000000..92150f1 --- /dev/null +++ b/promo_code/user/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2b1 on 2025-02-28 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'password', + models.CharField(max_length=128, verbose_name='password'), + ), + ( + 'is_superuser', + models.BooleanField( + default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status', + ), + ), + ('email', models.EmailField(max_length=120, unique=True)), + ('name', models.CharField(max_length=100)), + ('surname', models.CharField(max_length=120)), + ( + 'avatar_url', + models.URLField(blank=True, max_length=350, null=True), + ), + ('other', models.JSONField(default=dict)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('last_login', models.DateTimeField(blank=True, null=True)), + ( + 'groups', + models.ManyToManyField( + blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name='user_set', + related_query_name='user', + to='auth.group', + verbose_name='groups', + ), + ), + ( + 'user_permissions', + models.ManyToManyField( + blank=True, + help_text='Specific permissions for this user.', + related_name='user_set', + related_query_name='user', + to='auth.permission', + verbose_name='user permissions', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/promo_code/user/migrations/__init__.py b/promo_code/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/models.py b/promo_code/user/models.py new file mode 100644 index 0000000..225fb3b --- /dev/null +++ b/promo_code/user/models.py @@ -0,0 +1,65 @@ +import django.contrib.auth.models +import django.db.models +import django.utils.timezone + + +class UserManager(django.contrib.auth.models.BaseUserManager): + def create_user(self, email, name, surname, password=None, **extra_fields): + if not email: + raise ValueError('The Email must be set') + + email = self.normalize_email(email) + user = self.model( + email=email, + name=name, + surname=surname, + **extra_fields, + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser( + self, + email, + name, + surname, + password=None, + **extra_fields, + ): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + return self.create_user(email, name, surname, password, **extra_fields) + + +class User( + django.contrib.auth.models.AbstractBaseUser, + django.contrib.auth.models.PermissionsMixin, +): + email = django.db.models.EmailField(unique=True, max_length=120) + name = django.db.models.CharField(max_length=100) + surname = django.db.models.CharField(max_length=120) + avatar_url = django.db.models.URLField( + blank=True, + null=True, + max_length=350, + ) + other = django.db.models.JSONField(default=dict) + + is_active = django.db.models.BooleanField(default=True) + is_staff = django.db.models.BooleanField(default=False) + last_login = django.db.models.DateTimeField(null=True, blank=True) + + objects = UserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['name', 'surname'] + + def __str__(self): + return self.email + + def save(self, *args, **kwargs): + if not self.pk: + self.last_login = django.utils.timezone.now() + + super().save(*args, **kwargs) diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py new file mode 100644 index 0000000..477bfa7 --- /dev/null +++ b/promo_code/user/serializers.py @@ -0,0 +1,101 @@ +import django.contrib.auth.password_validation +import django.core.exceptions +import rest_framework.serializers +import rest_framework_simplejwt.tokens + +import user.models + + +class SignUpSerializer(rest_framework.serializers.ModelSerializer): + password = rest_framework.serializers.CharField( + write_only=True, + required=True, + validators=[django.contrib.auth.password_validation.validate_password], + max_length=60, + min_length=8, + style={'input_type': 'password'}, + ) + name = rest_framework.serializers.CharField( + required=True, + ) + surname = rest_framework.serializers.CharField( + required=True, + ) + email = rest_framework.serializers.EmailField( + required=True, + ) + other = rest_framework.serializers.JSONField( + required=True, + ) + + class Meta: + model = user.models.User + fields = [ + 'name', + 'surname', + 'email', + 'avatar_url', + 'other', + 'password', + ] + extra_kwargs = {'avatar_url': {'required': False}} + + def validate_email(self, value): + if user.models.User.objects.filter(email=value).exists(): + raise rest_framework.serializers.ValidationError( + 'User with this email already exists.', + code='email_conflict', + ) + + return value + + def create(self, validated_data): + try: + user_ = user.models.User.objects.create_user( + email=validated_data['email'], + name=validated_data['name'], + surname=validated_data['surname'], + avatar_url=validated_data.get('avatar_url'), + other=validated_data['other'], + password=validated_data['password'], + ) + return user_ + except django.core.exceptions.ValidationError as e: + raise rest_framework.serializers.ValidationError(e.messages) + + +class SignInSerializer(rest_framework.serializers.Serializer): + email = rest_framework.serializers.EmailField(required=True) + password = rest_framework.serializers.CharField( + required=True, + write_only=True, + ) + + def validate(self, data): + email = data.get('email') + password = data.get('password') + + if not email or not password: + raise rest_framework.serializers.ValidationError( + {'status': 'error', 'message': 'Both fields are required.'}, + code='required', + ) + + user = django.contrib.auth.authenticate( + request=self.context.get('request'), + email=email, + password=password, + ) + if not user: + raise rest_framework.serializers.ValidationError( + {'status': 'error', 'message': 'Invalid email or password.'}, + code='authorization', + ) + + data['user'] = user + return data + + def get_token(self): + user = self.validated_data['user'] + refresh = rest_framework_simplejwt.tokens.RefreshToken.for_user(user) + return {'token': str(refresh.access_token)} diff --git a/promo_code/user/tests.py b/promo_code/user/tests.py new file mode 100644 index 0000000..5b61606 --- /dev/null +++ b/promo_code/user/tests.py @@ -0,0 +1,123 @@ +import django.test +import django.urls +import rest_framework.status +import rest_framework.test + +import user.models + + +class AuthTestCase(django.test.TestCase): + def setUp(self): + self.client = rest_framework.test.APIClient() + + super(AuthTestCase, self).setUp() + + def tearDown(self): + user.models.User.objects.all().delete() + + super(AuthTestCase, self).tearDown() + + def test_signup_success(self): + url = django.urls.reverse('api-user:sign-up') + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'emmat1@example.com', + 'avatar_url': 'https://cdn2.thecatapi.com/images/3lo.jpg', + 'other': {'age': 25, 'country': 'US'}, + 'password': 'HardPa$$w0rd!iamthewinner', + } + response = self.client.post(url, data, format='json') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_200_OK, + ) + self.assertIn('token', response.data) + self.assertTrue( + user.models.User.objects.filter( + email='emmat1@example.com', + ).exists(), + ) + + def test_signup_duplicate_email(self): + user.models.User.objects.create_user( + email='emmat1@example.com', + name='Emma', + surname='Thompson', + password='HardPa$$w0rd!iamthewinner', + other={'age': 25, 'country': 'US'}, + ) + url = django.urls.reverse('api-user:sign-up') + data = { + 'name': 'Liam', + 'surname': 'Wilson', + 'email': 'emmat1@example.com', + 'password': 'HardPa$$w0rd!iamthewinner', + 'other': {'age': 30, 'country': 'GB'}, + } + response = self.client.post(url, data, format='json') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_409_CONFLICT, + ) + self.assertEqual( + response.data['message'], + 'This email address is already registered.', + ) + self.assertEqual(user.models.User.objects.count(), 1) + + def test_signup_invalid_data(self): + url = django.urls.reverse('api-user:sign-up') + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'invalid-email', + 'password': 'short', + } + response = self.client.post(url, data, format='json') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual(response.data['message'], 'Error in request data.') + + def test_signin_success(self): + user.models.User.objects.create_user( + email='emmat1@example.com', + name='Emma', + surname='Thompson', + password='HardPa$$w0rd!iamthewinner', + other={'age': 23, 'country': 'US'}, + ) + url = django.urls.reverse('api-user:sign-in') + data = { + 'email': 'emmat1@example.com', + 'password': 'HardPa$$w0rd!iamthewinner', + } + response = self.client.post(url, data, format='json') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_200_OK, + ) + self.assertIn('token', response.data) + + def test_signin_invalid_credentials(self): + user.models.User.objects.create_user( + email='emmat1@example.com', + name='Emma', + surname='Thompson', + password='HardPa$$w0rd!iamthewinner', + other={'age': 23, 'country': 'ru'}, + ) + + url = django.urls.reverse('api-user:sign-in') + data = {'email': 'emmat1@example.com', 'password': 'WrongPass123!'} + response = self.client.post(url, data, format='json') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_401_UNAUTHORIZED, + ) + self.assertEqual( + response.data['message'], + 'Invalid email or password.', + ) diff --git a/promo_code/user/tokens.py b/promo_code/user/tokens.py new file mode 100644 index 0000000..fda698e --- /dev/null +++ b/promo_code/user/tokens.py @@ -0,0 +1,9 @@ +import rest_framework_simplejwt.tokens + + +class CustomAccessToken(rest_framework_simplejwt.tokens.AccessToken): + @classmethod + def for_user(cls, user): + token = super().for_user(user) + token['last_login'] = user.last_login.isoformat() + return token diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py new file mode 100644 index 0000000..36ece70 --- /dev/null +++ b/promo_code/user/urls.py @@ -0,0 +1,19 @@ +import django.urls + +import user.views + +app_name = 'api-user' + + +urlpatterns = [ + django.urls.path( + 'user/auth/sign-up', + user.views.SignUpView.as_view(), + name='sign-up', + ), + django.urls.path( + 'user/auth/sign-in', + user.views.SignInView.as_view(), + name='sign-in', + ), +] diff --git a/promo_code/user/views.py b/promo_code/user/views.py new file mode 100644 index 0000000..19c401d --- /dev/null +++ b/promo_code/user/views.py @@ -0,0 +1,78 @@ +import rest_framework.exceptions +import rest_framework.generics +import rest_framework.response +import rest_framework.serializers +import rest_framework.status +import rest_framework_simplejwt.tokens +import rest_framework_simplejwt.views + +import user.serializers + + +class SignUpView(rest_framework.generics.CreateAPIView): + serializer_class = user.serializers.SignUpSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except rest_framework.exceptions.ValidationError as e: + if hasattr(e, 'get_codes'): + codes = e.get_codes() + if 'email' in codes and 'email_conflict' in codes['email']: + return rest_framework.response.Response( + { + 'status': 'error', + 'message': ( + 'This email address is already registered.' + ), + }, + status=rest_framework.status.HTTP_409_CONFLICT, + ) + + return rest_framework.response.Response( + {'status': 'error', 'message': 'Error in request data.'}, + status=rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + user = serializer.save() + refresh = rest_framework_simplejwt.tokens.RefreshToken.for_user(user) + return rest_framework.response.Response( + {'token': str(refresh.access_token)}, + status=rest_framework.status.HTTP_200_OK, + ) + + +class SignInView(rest_framework_simplejwt.views.TokenViewBase): + serializer_class = user.serializers.SignInSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except rest_framework.serializers.ValidationError as e: + code = ( + next(iter(e.get_codes().values()))[0] + if e.get_codes() + else None + ) + if code == 'authorization': + return rest_framework.response.Response( + { + 'status': 'error', + 'message': 'Invalid email or password.', + }, + status=rest_framework.status.HTTP_401_UNAUTHORIZED, + ) + + return rest_framework.response.Response( + {'status': 'error', 'message': 'Error in request data.'}, + status=rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + return rest_framework.response.Response( + serializer.get_token(), + status=rest_framework.status.HTTP_200_OK, + ) From 2aef431d0ca866995f41f6e09284820fd467fdab Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 8 Mar 2025 00:42:59 +0300 Subject: [PATCH 2/4] ci: Update ci files. --- .github/workflows/{lint.yml => linting.yml} | 5 +++-- .github/workflows/test.yaml | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) rename .github/workflows/{lint.yml => linting.yml} (91%) diff --git a/.github/workflows/lint.yml b/.github/workflows/linting.yml similarity index 91% rename from .github/workflows/lint.yml rename to .github/workflows/linting.yml index 1f28d3d..d5221cb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/linting.yml @@ -1,9 +1,10 @@ name: Linting on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] + workflow_dispatch: jobs: lint: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f9ae4da..63a7b98 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,11 @@ name: Run tests -on: deployment +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: permissions: contents: read @@ -14,7 +19,6 @@ jobs: name: Build image runs-on: ubuntu-latest timeout-minutes: 10 - if: github.action != 'github-classroom[bot]' steps: - uses: actions/checkout@v4 From b74e43d7995a11961f6d29ccf2df406cb32331f8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 8 Mar 2025 00:48:09 +0300 Subject: [PATCH 3/4] ci: Fix test.yaml. --- .github/workflows/test.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 63a7b98..718a6d6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -38,8 +38,8 @@ jobs: - name: Build and push image uses: docker/build-push-action@v4 with: - context: ./solution - file: ./solution/Dockerfile + context: . + file: ./Dockerfile tags: ${{ env.IMAGE_NAME }} push: true @@ -48,7 +48,7 @@ jobs: name: Run tests runs-on: ubuntu-latest timeout-minutes: 20 - if: github.action != 'github-classroom[bot]' + steps: - name: Setup checker environment uses: Central-University-IT/setup-test-2025-backend@v1 From f98b4d26214492dbf8a5ab4cd0ea60e224a6de99 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 8 Mar 2025 21:33:24 +0300 Subject: [PATCH 4/4] test: Add more registration and authentication tests, optimize code in some files. - Add more tests that check validation of input data - Add some custom password validators - Add some validators for serializers.py --- .github/workflows/test.yaml | 83 ------ promo_code/promo_code/settings.py | 30 ++- promo_code/promo_code/validators.py | 201 ++++++++++++++ promo_code/user/serializers.py | 51 ++-- promo_code/user/tests.py | 392 +++++++++++++++++++++++----- promo_code/user/validators.py | 71 +++++ promo_code/user/views.py | 61 ++--- requirements/prod.txt | 1 + requirements/test.txt | 3 +- 9 files changed, 676 insertions(+), 217 deletions(-) delete mode 100644 .github/workflows/test.yaml create mode 100644 promo_code/promo_code/validators.py create mode 100644 promo_code/user/validators.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 718a6d6..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,83 +0,0 @@ -name: Run tests - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - workflow_dispatch: - -permissions: - contents: read - packages: write - attestations: write - id-token: write - - -jobs: - build: - name: Build image - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Save image name (lowercased) - run: echo "IMAGE_NAME=$(echo 'ghcr.io/${{ github.repository }}:run-${{ github.run_id }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - - name: Build and push image - uses: docker/build-push-action@v4 - with: - context: . - file: ./Dockerfile - tags: ${{ env.IMAGE_NAME }} - push: true - - tests: - needs: build - name: Run tests - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Setup checker environment - uses: Central-University-IT/setup-test-2025-backend@v1 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Run tests - run: | - export IMAGE_SOLUTION=$(echo 'ghcr.io/${{ github.repository }}:run-${{ github.run_id }}' | tr '[:upper:]' '[:lower:]') - export IMAGE_ANTIFRAUD=docker.io/lodthe/prod-backend-antifraud - /usr/local/bin/checker - continue-on-error: true - - - uses: actions/upload-artifact@v4.0.0 - with: - name: result - path: ./result.json - if-no-files-found: error - compression-level: 0 - - - uses: bots-house/ghcr-delete-image-action@v1.1.0 - continue-on-error: true - with: - owner: ${{ github.repository_owner }} - name: ${{ github.event.repository.name }} - token: ${{ secrets.GITHUB_TOKEN }} - tag: run-${{ github.run_id }} diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index cd4b375..aaaad76 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -59,7 +59,7 @@ def load_bool(name, default): 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, - 'UPDATE_LAST_LOGIN': False, + 'UPDATE_LAST_LOGIN': False, # ! # 'ALGORITHM': 'HS256', 'SIGNING_KEY': SECRET_KEY, @@ -135,20 +135,32 @@ def load_bool(name, default): AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.' - 'UserAttributeSimilarityValidator', + 'NAME': 'django.contrib.auth.password_validation' + '.UserAttributeSimilarityValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'MinimumLengthValidator', + 'NAME': 'django.contrib.auth.password_validation' + '.MinimumLengthValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'CommonPasswordValidator', + 'NAME': 'django.contrib.auth.password_validation' + '.CommonPasswordValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'NumericPasswordValidator', + 'NAME': 'django.contrib.auth.password_validation' + '.NumericPasswordValidator', + }, + { + 'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator', + }, + { + 'NAME': 'promo_code.validators.NumericPasswordValidator', + }, + { + 'NAME': 'promo_code.validators.LatinLetterPasswordValidator', + }, + { + 'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator', }, ] diff --git a/promo_code/promo_code/validators.py b/promo_code/promo_code/validators.py new file mode 100644 index 0000000..664c3fb --- /dev/null +++ b/promo_code/promo_code/validators.py @@ -0,0 +1,201 @@ +import abc +import re +import unicodedata + +import django.core.exceptions +from django.utils.translation import gettext as _ + + +class BaseCountPasswordValidator(abc.ABC): + """ + Abstract base class for password validators checking + character count requirements. + + Attributes: + min_count (int): Minimum required character count (>=1) + + Raises: + ValueError: If min_count is less than 1 during initialization + """ + + def __init__(self, min_count=1): + if min_count < 1: + raise ValueError('min_count must be at least 1') + + self.min_count = min_count + + @abc.abstractmethod + def get_help_text(self) -> str: + """Abstract method to return user-friendly help text""" + pass + + def validate(self, password, user=None): + """ + Validate password meets the character count requirement + + Args: + password (str): Password to validate + user (User): Optional user object (not used) + + Raises: + ValidationError: If validation fails + """ + count = sum(1 for char in password if self.validate_char(char)) + if count < self.min_count: + raise django.core.exceptions.ValidationError( + self.get_error_message(), + code=self.get_code(), + ) + + def validate_char(self, char) -> bool: + """ + Check if character meets validation criteria + + Args: + char (str): Single character to check + + Returns: + bool: Validation result + """ + raise NotImplementedError + + def get_code(self) -> str: + """Get error code identifier""" + return getattr(self, 'code', 'base_code') + + def get_error_message(self) -> str: + """Get localized error message""" + raise NotImplementedError + + +class SpecialCharacterPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required special characters + + Args: + special_chars (str): Regex pattern for valid special characters + min_count (int): Minimum required count (default: 1) + + Example: + SpecialCharacterValidator(r'[!@#$%^&*]', min_count=2) + """ + + def __init__( + self, + special_chars=r'[!@#$%^&*()_+\-=\[\]{};\':",./<>?`~\\]', + min_count=1, + ): + super().__init__(min_count) + self.pattern = re.compile(special_chars) + self.code = 'password_no_special_char' + + def validate_char(self, char) -> bool: + """Check if character matches special characters pattern""" + return bool(self.pattern.match(char)) + + def get_help_text(self) -> str: + return _( + ( + f'Your password must contain at least {self.min_count} ' + 'special character(s).' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'special character(s).' + ), + ) + + +class NumericPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required numeric digits + + Args: + min_count (int): Minimum required digits (default: 1) + """ + + def __init__(self, min_count=1): + super().__init__(min_count) + self.code = 'password_no_number' + + def validate_char(self, char) -> bool: + """Check if character is a digit""" + return char.isdigit() + + def get_help_text(self) -> str: + return _( + f'Your password must contain at least {self.min_count} digit(s).', + ) + + def get_error_message(self) -> str: + return _(f'Password must contain at least {self.min_count} digit(s).') + + +class LatinLetterPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required Latin letters (ASCII) + + Args: + min_count (int): Minimum required letters (default: 1) + """ + + def __init__(self, min_count=1): + super().__init__(min_count) + self.code = 'password_no_latin_letter' + + def validate_char(self, char) -> bool: + """Check if character is a Latin ASCII letter""" + return unicodedata.category(char).startswith('L') and char.isascii() + + def get_help_text(self) -> str: + return _( + ( + f'Your password must contain at least {self.min_count} ' + 'Latin letter(s).' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'Latin letter(s).' + ), + ) + + +class UppercaseLatinLetterPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required uppercase Latin letters + + Args: + min_count (int): Minimum required uppercase letters (default: 1) + """ + + def __init__(self, min_count=1): + super().__init__(min_count) + self.code = 'password_no_uppercase_latin' + + def validate_char(self, char) -> bool: + """Check if character is uppercase Latin letter""" + return char.isupper() and char.isascii() + + def get_help_text(self) -> str: + return _( + ( + f'Your password must contain at least {self.min_count} ' + 'uppercase Latin letter(s).' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'uppercase Latin letter(s).' + ), + ) diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index 477bfa7..6debfff 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -1,9 +1,13 @@ import django.contrib.auth.password_validation import django.core.exceptions +import django.core.validators +import rest_framework.exceptions import rest_framework.serializers +import rest_framework.status import rest_framework_simplejwt.tokens -import user.models +import user.models as user_models +import user.validators class SignUpSerializer(rest_framework.serializers.ModelSerializer): @@ -15,43 +19,44 @@ class SignUpSerializer(rest_framework.serializers.ModelSerializer): min_length=8, style={'input_type': 'password'}, ) - name = rest_framework.serializers.CharField( - required=True, - ) - surname = rest_framework.serializers.CharField( - required=True, - ) + name = rest_framework.serializers.CharField(required=True, min_length=1) + surname = rest_framework.serializers.CharField(required=True, min_length=1) email = rest_framework.serializers.EmailField( required=True, + min_length=8, + validators=[ + user.validators.UniqueEmailValidator( + 'This email address is already registered.', + 'email_conflict', + ), + ], + ) + avatar_url = rest_framework.serializers.CharField( + required=False, + max_length=350, + validators=[ + django.core.validators.URLValidator(schemes=['http', 'https']), + ], ) other = rest_framework.serializers.JSONField( required=True, + validators=[user.validators.OtherFieldValidator()], ) class Meta: - model = user.models.User - fields = [ + model = user_models.User + fields = ( 'name', 'surname', 'email', 'avatar_url', 'other', 'password', - ] - extra_kwargs = {'avatar_url': {'required': False}} - - def validate_email(self, value): - if user.models.User.objects.filter(email=value).exists(): - raise rest_framework.serializers.ValidationError( - 'User with this email already exists.', - code='email_conflict', - ) - - return value + ) def create(self, validated_data): try: - user_ = user.models.User.objects.create_user( + user = user_models.User.objects.create_user( email=validated_data['email'], name=validated_data['name'], surname=validated_data['surname'], @@ -59,7 +64,7 @@ def create(self, validated_data): other=validated_data['other'], password=validated_data['password'], ) - return user_ + return user except django.core.exceptions.ValidationError as e: raise rest_framework.serializers.ValidationError(e.messages) @@ -87,7 +92,7 @@ def validate(self, data): password=password, ) if not user: - raise rest_framework.serializers.ValidationError( + raise rest_framework.exceptions.AuthenticationFailed( {'status': 'error', 'message': 'Invalid email or password.'}, code='authorization', ) diff --git a/promo_code/user/tests.py b/promo_code/user/tests.py index 5b61606..93800b9 100644 --- a/promo_code/user/tests.py +++ b/promo_code/user/tests.py @@ -1,123 +1,391 @@ import django.test import django.urls +import parameterized import rest_framework.status import rest_framework.test import user.models +import user.tokens -class AuthTestCase(django.test.TestCase): +class AuthTestCase(rest_framework.test.APITestCase): def setUp(self): self.client = rest_framework.test.APIClient() - super(AuthTestCase, self).setUp() def tearDown(self): user.models.User.objects.all().delete() - super(AuthTestCase, self).tearDown() - def test_signup_success(self): - url = django.urls.reverse('api-user:sign-up') - data = { + def test_valid_registration_and_email_duplication(self): + # Successful registration + valid_data = { 'name': 'Emma', 'surname': 'Thompson', - 'email': 'emmat1@example.com', - 'avatar_url': 'https://cdn2.thecatapi.com/images/3lo.jpg', - 'other': {'age': 25, 'country': 'US'}, - 'password': 'HardPa$$w0rd!iamthewinner', + 'email': 'emmat1@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, } - response = self.client.post(url, data, format='json') + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + valid_data, + format='json', + ) self.assertEqual( response.status_code, rest_framework.status.HTTP_200_OK, ) - self.assertIn('token', response.data) - self.assertTrue( - user.models.User.objects.filter( - email='emmat1@example.com', - ).exists(), + + # Duplicate email registration attempt + duplicate_data = { + 'name': 'Lui', + 'surname': 'Jomalone', + 'email': 'emmat1@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 14, 'country': 'fr'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + duplicate_data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_409_CONFLICT, ) - def test_signup_duplicate_email(self): - user.models.User.objects.create_user( - email='emmat1@example.com', - name='Emma', - surname='Thompson', - password='HardPa$$w0rd!iamthewinner', - other={'age': 25, 'country': 'US'}, + def test_invalid_email_format(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.fan', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', ) - url = django.urls.reverse('api-user:sign-up') + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_weak_password_common_phrase(self): data = { - 'name': 'Liam', - 'surname': 'Wilson', - 'email': 'emmat1@example.com', - 'password': 'HardPa$$w0rd!iamthewinner', - 'other': {'age': 30, 'country': 'GB'}, + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'whereismymoney777', + 'other': {'age': 23, 'country': 'us'}, } - response = self.client.post(url, data, format='json') + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) self.assertEqual( response.status_code, - rest_framework.status.HTTP_409_CONFLICT, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_weak_password_missing_special_char(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'fioejifojfieoAAAA9299', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', ) self.assertEqual( - response.data['message'], - 'This email address is already registered.', + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, ) - self.assertEqual(user.models.User.objects.count(), 1) - def test_signup_invalid_data(self): - url = django.urls.reverse('api-user:sign-up') + def test_weak_password_too_short(self): data = { 'name': 'Emma', 'surname': 'Thompson', - 'email': 'invalid-email', - 'password': 'short', + 'email': 'dota.for.fan@gmail.com', + 'password': 'Aa7$b!', + 'other': {'age': 23, 'country': 'us'}, } - response = self.client.post(url, data, format='json') + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) self.assertEqual( response.status_code, rest_framework.status.HTTP_400_BAD_REQUEST, ) - self.assertEqual(response.data['message'], 'Error in request data.') - def test_signin_success(self): - user.models.User.objects.create_user( - email='emmat1@example.com', - name='Emma', - surname='Thompson', - password='HardPa$$w0rd!iamthewinner', - other={'age': 23, 'country': 'US'}, + def generate_test_cases(): + invalid_urls = [ + 'itsnotalink', + 'itsnotalinkjpeg', + 'https://', + 'grpc://', + '', + ] + return [ + (f'url_{i}', url, f'{i}dota.for.fan@gmail.com') + for i, url in enumerate(invalid_urls) + ] + + @parameterized.parameterized.expand(generate_test_cases()) + def test_invalid_avatar_urls(self, name, url, email): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': email, + 'password': 'SuperStrongPassword2000!', + 'avatar_url': url, + 'other': {'age': 23, 'country': 'us'}, + } + + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + msg=f'Failed for URL: {url}', + ) + + def test_missing_country_field(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, ) - url = django.urls.reverse('api-user:sign-in') + + def test_invalid_age_type(self): data = { - 'email': 'emmat1@example.com', - 'password': 'HardPa$$w0rd!iamthewinner', + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': '23aaaaaa', 'country': 'us'}, } - response = self.client.post(url, data, format='json') + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_missing_age_field(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_negative_age_value(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': -20, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + @parameterized.parameterized.expand( + [ + ('empty_domain', '@'), + ('no_at_symbol', 'dota'), + ('missing_username', '@gmail.com'), + ('missing_domain_part', 'gmail.com'), + ], + ) + def test_invalid_email_formats(self, name, email): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': email, + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + msg=f'Failed for email: {email}', + ) + + def test_empty_name_field(self): + data = { + 'name': '', + 'surname': 'Thompson', + 'email': 'gogogonow@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_empty_surname_field(self): + data = { + 'name': 'Emma', + 'surname': '', + 'email': 'gogogonow@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + +class AuthFlowTestCase(rest_framework.test.APITestCase): + def setUp(self): + self.client = rest_framework.test.APIClient() + super(AuthFlowTestCase, self).setUp() + + def tearDown(self): + user.models.User.objects.all().delete() + super(AuthFlowTestCase, self).tearDown() + + def test_valid_registration(self): + data = { + 'name': 'Steve', + 'surname': 'Jobs', + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'gb'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) self.assertEqual( response.status_code, rest_framework.status.HTTP_200_OK, ) self.assertIn('token', response.data) + self.assertTrue( + user.models.User.objects.filter( + email='minecraft.digger@gmail.com', + ).exists(), + ) - def test_signin_invalid_credentials(self): + def test_signin_missing_fields(self): + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + {}, # Empty data + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_signin_invalid_password(self): user.models.User.objects.create_user( - email='emmat1@example.com', - name='Emma', - surname='Thompson', - password='HardPa$$w0rd!iamthewinner', - other={'age': 23, 'country': 'ru'}, + email='minecraft.digger@gmail.com', + name='Steve', + surname='Jobs', + password='SuperStrongPassword2000!', + other={'age': 23, 'country': 'gb'}, ) - url = django.urls.reverse('api-user:sign-in') - data = {'email': 'emmat1@example.com', 'password': 'WrongPass123!'} - response = self.client.post(url, data, format='json') + data = { + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperInvalidPassword2000!', + } + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + data, + format='json', + ) self.assertEqual( response.status_code, rest_framework.status.HTTP_401_UNAUTHORIZED, ) + + def test_signin_success(self): + user.models.User.objects.create_user( + email='minecraft.digger@gmail.com', + name='Steve', + surname='Jobs', + password='SuperStrongPassword2000!', + other={'age': 23, 'country': 'gb'}, + ) + + data = { + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperStrongPassword2000!', + } + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + data, + format='json', + ) self.assertEqual( - response.data['message'], - 'Invalid email or password.', + response.status_code, + rest_framework.status.HTTP_200_OK, ) diff --git a/promo_code/user/validators.py b/promo_code/user/validators.py new file mode 100644 index 0000000..feda79a --- /dev/null +++ b/promo_code/user/validators.py @@ -0,0 +1,71 @@ +import pycountry +import rest_framework.exceptions + +import user.models + + +class UniqueEmailValidator: + def __init__(self, default_detail=None, default_code=None): + self.status_code = 409 + self.default_detail = ( + default_detail or 'This email address is already registered.' + ) + self.default_code = default_code or 'email_conflict' + + def __call__(self, value): + if user.models.User.objects.filter(email=value).exists(): + exc = rest_framework.exceptions.APIException( + detail={ + 'status': 'error', + 'message': self.default_detail, + 'code': self.default_code, + }, + ) + exc.status_code = self.status_code + raise exc + + +class OtherFieldValidator: + """ + Validator for JSON fields containing: + - age (required, integer between 0 and 100) + - country (required, string with an ISO 3166-1 alpha-2 country code) + """ + + error_messages = { + 'invalid_type': 'Must be a JSON object.', + 'missing_field': 'This field is required.', + 'age_type': 'Must be an integer.', + 'age_range': 'Must be between 0 and 100.', + 'country_format': 'Must be a 2-letter ISO code.', + 'country_invalid': 'Invalid ISO 3166-1 alpha-2 country code.', + } + + def __call__(self, value): + if not isinstance(value, dict): + raise rest_framework.exceptions.ValidationError( + self.error_messages['invalid_type'], + ) + + errors = {} + + # Validate the 'age' field + age = value.get('age') + if age is None: + errors['age'] = self.error_messages['missing_field'] + elif not isinstance(age, int): + errors['age'] = self.error_messages['age_type'] + elif not (0 <= age <= 100): + errors['age'] = self.error_messages['age_range'] + + # Validate the 'country' field + country_code = value.get('country') + if country_code is None: + errors['country'] = self.error_messages['missing_field'] + elif not (isinstance(country_code, str) and len(country_code) == 2): + errors['country'] = self.error_messages['country_format'] + elif not pycountry.countries.get(alpha_2=country_code.upper()): + errors['country'] = self.error_messages['country_invalid'] + + if errors: + raise rest_framework.exceptions.ValidationError(errors) diff --git a/promo_code/user/views.py b/promo_code/user/views.py index 19c401d..b8d65ec 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -9,7 +9,20 @@ import user.serializers -class SignUpView(rest_framework.generics.CreateAPIView): +class BaseCustomResponseMixin: + error_response = {'status': 'error', 'message': 'Error in request data.'} + + def handle_validation_error(self): + return rest_framework.response.Response( + self.error_response, + status=rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + +class SignUpView( + BaseCustomResponseMixin, + rest_framework.generics.CreateAPIView, +): serializer_class = user.serializers.SignUpSerializer def create(self, request, *args, **kwargs): @@ -17,24 +30,8 @@ def create(self, request, *args, **kwargs): try: serializer.is_valid(raise_exception=True) - except rest_framework.exceptions.ValidationError as e: - if hasattr(e, 'get_codes'): - codes = e.get_codes() - if 'email' in codes and 'email_conflict' in codes['email']: - return rest_framework.response.Response( - { - 'status': 'error', - 'message': ( - 'This email address is already registered.' - ), - }, - status=rest_framework.status.HTTP_409_CONFLICT, - ) - - return rest_framework.response.Response( - {'status': 'error', 'message': 'Error in request data.'}, - status=rest_framework.status.HTTP_400_BAD_REQUEST, - ) + except rest_framework.exceptions.ValidationError: + return self.handle_validation_error() user = serializer.save() refresh = rest_framework_simplejwt.tokens.RefreshToken.for_user(user) @@ -44,7 +41,10 @@ def create(self, request, *args, **kwargs): ) -class SignInView(rest_framework_simplejwt.views.TokenViewBase): +class SignInView( + BaseCustomResponseMixin, + rest_framework_simplejwt.views.TokenViewBase, +): serializer_class = user.serializers.SignInSerializer def post(self, request, *args, **kwargs): @@ -52,25 +52,8 @@ def post(self, request, *args, **kwargs): try: serializer.is_valid(raise_exception=True) - except rest_framework.serializers.ValidationError as e: - code = ( - next(iter(e.get_codes().values()))[0] - if e.get_codes() - else None - ) - if code == 'authorization': - return rest_framework.response.Response( - { - 'status': 'error', - 'message': 'Invalid email or password.', - }, - status=rest_framework.status.HTTP_401_UNAUTHORIZED, - ) - - return rest_framework.response.Response( - {'status': 'error', 'message': 'Error in request data.'}, - status=rest_framework.status.HTTP_400_BAD_REQUEST, - ) + except rest_framework.serializers.ValidationError: + return self.handle_validation_error() return rest_framework.response.Response( serializer.get_token(), diff --git a/requirements/prod.txt b/requirements/prod.txt index 0ab1727..9e2c2a1 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -2,5 +2,6 @@ django==5.2b1 djangorestframework==3.15.2 djangorestframework-simplejwt==5.4.0 gunicorn==23.0.0 +pycountry==24.6.1 python-dotenv==1.0.1 psycopg2-binary==2.9.10 diff --git a/requirements/test.txt b/requirements/test.txt index 234f255..49553db 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,2 +1,3 @@ -r flake8-plugins.txt -django-debug-toolbar==4.4.6 \ No newline at end of file +django-debug-toolbar==4.4.6 +parameterized==0.9.0