diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c9a3f05..a734261 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -54,6 +54,15 @@ build_backend-staticfiles: DOCKERFILE_PATH: "Dockerfile.staticfiles" IMAGE_NAME: "$CI_REGISTRY_IMAGE/backend-staticfiles" +build_docs: + <<: *build-template + rules: + - if: '$CI_COMMIT_REF_NAME == "master"' + variables: + CONTEXT: "${CI_PROJECT_DIR}/docs" + DOCKERFILE_PATH: "Dockerfile" + IMAGE_NAME: "$CI_REGISTRY_IMAGE/docs" + deploy: image: kroniak/ssh-client:3.19 stage: deploy diff --git a/compose.yaml b/compose.yaml index 21501ef..9aac6d8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -356,6 +356,20 @@ services: source: prometheus_data target: /prometheus + docs: + image: gitlab.prodcontest.ru:5050/team-15/project/docs:latest + build: + context: ./docs + dockerfile: Dockerfile + ports: + - name: web + target: 3000 + published: 8008 + host_ip: 0.0.0.0 + protocol: tcp + restart: unless-stopped + shm_size: 4mb + proxy: image: docker.io/nginx:1.27-alpine3.21 configs: diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000..ee2e828 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Base image +FROM node:lts AS base + +ENV FORCE_COLOR=0 + +RUN corepack enable + +WORKDIR /opt/docusaurus + +# Stage 2: Production build mode +FROM base AS prod + +WORKDIR /opt/docusaurus + +COPY . /opt/docusaurus/ + +RUN npm ci + +RUN npm run build + +# Stage 3: Serve with docusaurus serve +FROM prod AS serve + +EXPOSE 3000 + +CMD ["npm", "run", "serve", "--", "--host", "0.0.0.0", "--no-open"] diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index bb23c46..758ca67 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -7,10 +7,10 @@ import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { title: 'DataRush', tagline: 'Изучите основы анализа данных здесь!', - favicon: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/logo.svg', + favicon: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg', url: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru', - baseUrl: '/', + baseUrl: '/docs/', organizationName: 'megazord', projectName: 'megazord', @@ -36,12 +36,12 @@ const config: Config = { ], themeConfig: { - image: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/logo.svg', + image: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg', navbar: { title: 'DataRush', logo: { - alt: 'My Site Logo', - src: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/logo.svg', + alt: 'DataRush', + src: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg', }, items: [ { @@ -60,7 +60,7 @@ const config: Config = { items: [ { label: 'Начало', - to: '/docs/intro', + to: '/docs/docs/intro', }, ], }, diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 6e338eb..dff0f61 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -110,6 +110,24 @@ http { proxy_read_timeout 600s; } + location /docs { + proxy_pass http://docs:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + proxy_hide_header X-Powered-By; + + proxy_connect_timeout 75s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + location /static { rewrite ^/static/(.*)$ /$1 break; proxy_pass http://backend-staticfiles:80; diff --git a/services/backend/api/v1/auth.py b/services/backend/api/v1/auth.py index 1d36ce9..4284e49 100644 --- a/services/backend/api/v1/auth.py +++ b/services/backend/api/v1/auth.py @@ -19,7 +19,10 @@ class BearerAuth(HttpBearer): except Exception: raise AuthenticationError - user = User.objects.get(id=data["id"]) + try: + user = User.objects.get(id=data["id"]) + except User.DoesNotExist: + raise AuthenticationError return user @staticmethod diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py new file mode 100644 index 0000000..f9508a2 --- /dev/null +++ b/services/backend/apps/competition/tests.py @@ -0,0 +1,108 @@ +import uuid + +from django.contrib.auth.hashers import make_password +from django.test import TestCase + +from apps.user.models import User +from apps.competition.models import Competition + + +class CompetitionEndpointTests(TestCase): + def setUp(self): + self.user = User.objects.create( + email="user@example.com", + password=make_password("password123"), + username="t1wk4" + ) + + self.competition = Competition.objects.create( + title="AI Challenge", + description="Machine Learning Competition", + type="solo", + participation_type="edu" + ) + + resp = self.client.post( + "/api/v1/sign-in", + data={"email": self.user.email, "password": "password123"}, + content_type="application/json", + ).json() + token = resp["token"] + + self.valid_headers = { + "HTTP_AUTHORIZATION": f"Bearer {token}" + } + + # --- Helper methods --- + def get_url(self, competition_id): + return f"/api/v1/competition/{competition_id}" + + # --- Test Cases --- + def test_get_competition_success(self): + """Authenticated user gets competition details (200 OK)""" + response = self.client.get( + self.get_url(self.competition.id), + **self.valid_headers + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + + # Validate required fields + self.assertEqual(data["id"], str(self.competition.id)) + self.assertEqual(data["title"], "AI Challenge") + self.assertEqual(data["type"], "solo") + + # Validate optional null fields + self.assertIsNone(data["image_url"]) + self.assertIsNone(data["start_date"]) + self.assertIsNone(data["end_date"]) + + def test_invalid_uuid_format(self): + """Invalid UUID format returns 400 Bad Request""" + response = self.client.get( + self.get_url("invalid-id"), + **self.valid_headers + ) + self.assertEqual(response.status_code, 400) + + def test_unauthenticated_access(self): + """Missing auth token returns 401 Unauthorized""" + response = self.client.get(self.get_url(self.competition.id)) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + def test_nonexistent_competition(self): + """Valid UUID but missing competition returns 404""" + new_uuid = uuid.uuid4() + response = self.client.get( + self.get_url(new_uuid), + **self.valid_headers + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Not Found") + + def test_invalid_auth_token(self): + """Invalid token returns 401 Unauthorized""" + response = self.client.get( + self.get_url(self.competition.id), + HTTP_AUTHORIZATION="Bearer invalid_token" + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + def test_malformed_auth_header(self): + """Malformed Authorization header returns 401""" + cases = [ + ("InvalidScheme valid_token_123", 401), + ("Bearer", 401), # Missing token + ("", 401), # No header + ] + + for header, expected_status in cases: + with self.subTest(header=header): + response = self.client.get( + self.get_url(self.competition.id), + HTTP_AUTHORIZATION=header + ) + self.assertEqual(response.status_code, expected_status) diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index 5a246db..f525c29 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -14,10 +14,11 @@ class User(BaseModel): username = models.SlugField(unique=True, verbose_name="юзернейм") password = models.TextField(verbose_name="пароль") - def make_password(self): - return make_password(self.password) + @staticmethod + def make_password(password: str): + return make_password(password) - def check_password(self, password): + def check_password(self, password: str): return check_password(self.password, password) status = models.CharField( diff --git a/services/backend/apps/user/test.py b/services/backend/apps/user/test.py index cad7709..33bbc41 100644 --- a/services/backend/apps/user/test.py +++ b/services/backend/apps/user/test.py @@ -1,29 +1,217 @@ -from django.core.exceptions import ValidationError +import json +import uuid + from django.test import TestCase +from django.contrib.auth.hashers import make_password from apps.user.models import User -class TestSignUp(TestCase): - def test_correct_signup(self): - user = User( - email="123123@timka.su", - password="1321312", - username="123123", +class SignUpAPITestCase(TestCase): + def test_successful_sign_up(self): + payload = { + "email": "user@example.com", + "password": "securepassword123", + "username": "123", + } + response = self.client.post( + "/api/v1/sign-up", + data=json.dumps(payload), + content_type="application/json", ) - user.full_clean() - user.save() + self.assertEqual(response.status_code, 201) + self.assertIn("token", response.json()) + self.assertEqual(User.objects.count(), 1) - def test_incorrect_mail(self): - user = User( - email="123123", - password="1321312", - username="123123123", + def test_missing_required_fields(self): + payload = {"password": "testpass123", "username": "sffsdf"} + response = self.client.post( + "/api/v1/sign-up", + data=json.dumps(payload), + content_type="application/json", ) - with self.assertRaises(ValidationError): - user.full_clean() + self.assertEqual(response.status_code, 400) - def test_missing_params(self): - user = User(password="123123", username="132131232131") - with self.assertRaises(ValidationError): - user.full_clean() + def test_invalid_email_format(self): + payload = { + "email": "ervtb uktr bym", + "password": "securepassword123", + "username": "123", + } + response = self.client.post( + "/api/v1/sign-up", + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_existing_user_conflict(self): + User.objects.create( + email="existing@example.com", password="existingpass123", username="testing" + ) + payload = { + "email": "existing@example.com", + "password": "sfsad", + "username": "testing", + } + response = self.client.post( + "/api/v1/sign-up", + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 409) + self.assertIn("detail", response.json()) + +class SignInAPITestCase(TestCase): + def setUp(self): + self.user = User.objects.create( + email="valid@example.com", + password=make_password("securepassword123"), + username="testuser" + ) + self.valid_payload = { + "email": "valid@example.com", + "password": "securepassword123" + } + + def test_successful_sign_in(self): + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps(self.valid_payload), + content_type="application/json" + ) + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.json()) + + def test_missing_credentials(self): + # Test missing email + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps({"password": "pass"}), + content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + + # Test missing password + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps({"email": "test@example.com"}), + content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + + def test_invalid_email_format(self): + payload = { + "email": "invalid-email", + "password": "password123" + } + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps(payload), + content_type="application/json" + ) + self.assertEqual(response.status_code, 401) + + def test_incorrect_password(self): + payload = { + "email": "valid@example.com", + "password": "wrongpassword" + } + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps(payload), + content_type="application/json" + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + def test_nonexistent_user(self): + payload = { + "email": "notexist@example.com", + "password": "password123" + } + response = self.client.post( + "/api/v1/sign-in", + data=json.dumps(payload), + content_type="application/json" + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + +class UserMeEndpointTestCase(TestCase): + def setUp(self): + # Create test user and token + self.user = User.objects.create( + email="johndoe@example.com", + username="johndoe", + password=make_password("securepassword123") + ) + resp = self.client.post( + "/api/v1/sign-in", + data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}), + content_type="application/json" + ).json() + self.token = resp['token'] + self.url = "/api/v1/me" + + def test_get_authenticated_user_data(self): + """Test authenticated user can retrieve their profile (200 OK)""" + response = self.client.get( + self.url, + HTTP_AUTHORIZATION=f"Bearer {self.token}" + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + + # Validate UserSchema structure + self.assertIn("id", data) + self.assertIn("email", data) + self.assertIn("username", data) + + # Validate UUID format if ID is present + if data["id"] is not None: + try: + uuid.UUID(data["id"]) + except ValueError: + self.fail("ID is not a valid UUID") + + # Validate response content + self.assertEqual(data["email"], "johndoe@example.com") + self.assertEqual(data["username"], "johndoe") + + def test_unauthenticated_access(self): + """Test unauthorized access returns 401 Unauthorized""" + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + def test_invalid_auth_scheme(self): + """Test invalid authentication scheme returns 401""" + response = self.client.get( + self.url, + HTTP_AUTHORIZATION=f"InvalidScheme {self.token}" + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") + + def test_malformed_token(self): + """Test malformed token returns 401""" + test_cases = [ + "invalid.token.123", + "Bearer", + "", + "123456" + ] + + for token in test_cases: + with self.subTest(token=token): + response = self.client.get( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token}" + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Unauthorized") diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 66332c8..45b28ed 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -485,6 +485,7 @@ LANGUAGE_COOKIE_AGE = 31449600 PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.ScryptPasswordHasher", ] LANGUAGE_COOKIE_DOMAIN = None