From d9c3377a57b0966a12117a5ff06173d12505a376 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 04:39:22 +0300 Subject: [PATCH 1/9] Add rate limiting --- .DS_Store | Bin 8196 -> 0 bytes main.py | 52 +++++++++++++++++++++++++++++++++-------------- requirements.txt | 3 ++- 3 files changed, 39 insertions(+), 16 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d3314d842230fe30e0be8e5a32409e79497a8706..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&5P4O6o1nXn_7j!?m}+{ycTV}?24Cctw%wuD56r^O?MkiGo?u>QVQ{^2Vrk| z63<>%Jcxh5UcGr;!Gj=r5dQ(+d~D4m&F-zp?o65aP3OIr_wswmq%Q>k(iq!yfC>N@ z*cp~fI4o#P&U&I{{uc)Dna!G!^4u3v zJ!%Ft1E-P!wm+EI8CnuUfqd)0ftLV?Ib7xq=VdlQG$mRRLxCv4MuaJ%FeS3YAi^Bi zO{r%|3_^@We6Y|I^61wA8+y=DJj05uN~693LigtZ_Z_hx z(0|?+YbgGKzoIBX8$UyM3R}>@-p5FAMU7?iCB~AXsm2ALzl*q1wWBc}r>(|&i}6*! z7%7Zl1U^RUcPIW(X5?dc=sJE-t9>DvY%YJMU=)m!@u=0OlUC3P#(twSdceNjq15dj z&O6<^PH)mKuU@BK&~d!ph#NR|4^Sv%bDD ztD4iRS87#reWNy;6^*rvm#^J!KXC^hea^zh%~)>Qa9(_$k1uc*J1dp_+DcMGWj!>1 z2-aiAiCqyXS_@dt;T7p1!|L6T`ST1Tnag4^+ZfPVf*Dlz^1=U3tB8~y?%=>yLI diff --git a/main.py b/main.py index 73360cc..b42e326 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,9 @@ import re import logging from datetime import datetime +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded from grading import ( LabGrader, @@ -79,6 +82,11 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") SECRET_KEY = os.getenv("SECRET_KEY", "super-secret-key") +# Rate limiting configuration +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # Проверка обязательных переменных окружения if not ADMIN_LOGIN or not ADMIN_PASSWORD: raise RuntimeError( @@ -228,11 +236,13 @@ class StudentRegistration(BaseModel): @app.get("/") -async def read_index(): +@limiter.limit("100/minute") +async def read_index(request: Request): return FileResponse("dist/index.html") @app.post("/admin/login") -def admin_login(data: AuthRequest, response: Response): +@limiter.limit("5/minute") +def admin_login(request: Request, data: AuthRequest, response: Response): if data.login == ADMIN_LOGIN and data.password == ADMIN_PASSWORD: token = signer.sign(data.login.encode()).decode() response.set_cookie( @@ -247,6 +257,7 @@ def admin_login(data: AuthRequest, response: Response): raise HTTPException(status_code=401, detail="Неверный логин или пароль") @app.get("/admin/check-auth") +@limiter.limit("30/minute") def check_auth(request: Request): cookie = request.cookies.get("admin_session") if not cookie: @@ -263,13 +274,15 @@ def check_auth(request: Request): return {"authenticated": True} @app.post("/admin/logout") -def logout(response: Response): +@limiter.limit("30/minute") +def logout(request: Request, response: Response): response.delete_cookie("admin_session", path="/") return {"message": "Logged out"} @app.get("/courses") -def get_courses(status: str = "active"): +@limiter.limit("100/minute") +def get_courses(request: Request, status: str = "active"): """ Get courses filtered by status @@ -329,7 +342,8 @@ def parse_lab_id(lab_id: str) -> int: return int(match.group(0)) @app.get("/courses/{course_id}") -def get_course(course_id: str): +@limiter.limit("100/minute") +def get_course(request: Request, course_id: str): course_info = get_course_by_id(course_id) return { @@ -346,7 +360,8 @@ def get_course(course_id: str): } @app.delete("/courses/{course_id}") -def delete_course(course_id: str): +@limiter.limit("20/minute") +def delete_course(request: Request, course_id: str): """ Mark course as hidden in index (soft delete) The course file is preserved in repository @@ -376,7 +391,8 @@ class EditCourseRequest(BaseModel): @app.get("/courses/{course_id}/edit") -def edit_course_get(course_id: str): +@limiter.limit("30/minute") +def edit_course_get(request: Request, course_id: str): """Получить YAML содержимое курса для редактирования""" course_info = get_course_by_id(course_id) filename = course_info["_meta"]["filename"] @@ -392,7 +408,8 @@ def edit_course_get(course_id: str): @app.put("/courses/{course_id}/edit") -def edit_course_put(course_id: str, data: EditCourseRequest): +@limiter.limit("20/minute") +def edit_course_put(request: Request, course_id: str, data: EditCourseRequest): """Сохранить изменения в YAML файле курса""" course_info = get_course_by_id(course_id) filename = course_info["_meta"]["filename"] @@ -411,7 +428,8 @@ def edit_course_put(course_id: str, data: EditCourseRequest): @app.get("/courses/{course_id}/groups") -def get_course_groups(course_id: str): +@limiter.limit("10/minute") +def get_course_groups(request: Request, course_id: str): course_info = get_course_by_id(course_id) spreadsheet_id = course_info.get("google", {}).get("spreadsheet") info_sheet = course_info.get("google", {}).get("info-sheet") @@ -434,7 +452,8 @@ def get_course_groups(course_id: str): @app.get("/courses/{course_id}/groups/{group_id}/labs") -def get_course_labs(course_id: str, group_id: str): +@limiter.limit("10/minute") +def get_course_labs(request: Request, course_id: str, group_id: str): course_info = get_course_by_id(course_id) spreadsheet_id = course_info.get("google", {}).get("spreadsheet") labs = [lab["short-name"] for lab in course_info.get("labs", {}).values() if "short-name" in lab] @@ -461,7 +480,8 @@ def get_course_labs(course_id: str, group_id: str): @app.post("/courses/{course_id}/groups/{group_id}/register") -def register_student(course_id: str, group_id: str, student: StudentRegistration): +@limiter.limit("10/minute") +def register_student(request: Request, course_id: str, group_id: str, student: StudentRegistration): # Build full name first for consistent logging full_name = f"{student.surname} {student.name} {student.patronymic}".strip() @@ -568,7 +588,8 @@ class GradeRequest(BaseModel): github: str = Field(..., min_length=1) @app.post("/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade") -def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest): +@limiter.limit("10/minute") +def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grade_request: GradeRequest): """ Grade a lab submission by checking GitHub repository and CI status. @@ -581,7 +602,7 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) 3. Return early for errors/pending (no Sheets connection needed) 4. Connect to Sheets only when we have a result to write """ - logger.info(f"Grading attempt - Course: {course_id}, Group: {group_id}, Lab: {lab_id}, GitHub: {request.github}") + logger.info(f"Grading attempt - Course: {course_id}, Group: {group_id}, Lab: {lab_id}, GitHub: {grade_request.github}") try: # Load course and lab configuration @@ -604,7 +625,7 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) github_client = GitHubClient(GITHUB_TOKEN) grader = LabGrader(github_client) - username = request.github + username = grade_request.github repo_name = f"{repo_prefix}-{username}" logger.info(f"Checking repository: {org}/{repo_name}") @@ -775,7 +796,8 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) @app.post("/courses/upload") -async def upload_course(file: UploadFile = File(...)): +@limiter.limit("10/minute") +async def upload_course(request: Request, file: UploadFile = File(...)): """ Upload a new course file and add it to index diff --git a/requirements.txt b/requirements.txt index e9d6b98..2f50d77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ pyyaml requests python-multipart python-dotenv -itsdangerous \ No newline at end of file +itsdangerous +slowapi \ No newline at end of file From 4f5deb0a05a81c470de760e68781da56d07d0eb3 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 05:19:08 +0300 Subject: [PATCH 2/9] Update tests --- tests/conftest.py | 22 +++++++ tests/test_grade_lab_characterization.py | 76 ++++++++++++++---------- 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 060700e..2b4b539 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,6 +90,28 @@ def mock_service_account_creds(): yield mock_creds +@pytest.fixture +def mock_request(): + """Mock FastAPI Request object for rate limiting.""" + from starlette.requests import Request + + # Create a real Request object with minimal scope + scope = { + "type": "http", + "method": "POST", + "path": "/test", + "headers": [], + "client": ("127.0.0.1", 12345), + } + + async def receive(): + return {"type": "http.request"} + + # Create a real Request instance + request = Request(scope, receive) + return request + + @pytest.fixture def github_api_success_responses(): """Standard successful GitHub API responses.""" diff --git a/tests/test_grade_lab_characterization.py b/tests/test_grade_lab_characterization.py index 81779fa..91efe38 100644 --- a/tests/test_grade_lab_characterization.py +++ b/tests/test_grade_lab_characterization.py @@ -37,7 +37,8 @@ def test_success_all_checks_pass( mock_get_course_by_id, mock_gspread, mock_service_account_creds, - sample_course_config + sample_course_config, + mock_request ): """Test successful grading when all CI checks pass.""" org = sample_course_config["github"]["organization"] @@ -85,8 +86,8 @@ def test_success_all_checks_pass( # Import and call from main import grade_lab, GradeRequest - request = GradeRequest(github="testuser") - result = grade_lab("test-course", "group1", "ЛР1", request) + grade_request = GradeRequest(github="testuser") + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert result["status"] == "updated" assert result["result"] == "v" @@ -99,7 +100,8 @@ def test_failure_some_checks_fail( mock_get_course_by_id, mock_gspread, mock_service_account_creds, - sample_course_config + sample_course_config, + mock_request ): """Test grading when some CI checks fail.""" org = sample_course_config["github"]["organization"] @@ -147,8 +149,8 @@ def test_failure_some_checks_fail( mock_gspread['worksheet'].col_values.return_value = ["", "", "testuser"] from main import grade_lab, GradeRequest - request = GradeRequest(github="testuser") - result = grade_lab("test-course", "group1", "ЛР1", request) + grade_request = GradeRequest(github="testuser") + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert result["status"] == "updated" assert result["result"] == "x" @@ -158,7 +160,8 @@ def test_failure_some_checks_fail( def test_missing_test_main_py( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test error when test_main.py is missing.""" org = sample_course_config["github"]["organization"] @@ -174,9 +177,9 @@ def test_missing_test_main_py( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 400 assert "test_main.py" in exc_info.value.detail @@ -185,7 +188,8 @@ def test_missing_test_main_py( def test_missing_workflows( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test error when .github/workflows is missing.""" org = sample_course_config["github"]["organization"] @@ -207,9 +211,9 @@ def test_missing_workflows( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 400 assert "workflows" in exc_info.value.detail.lower() @@ -218,7 +222,8 @@ def test_missing_workflows( def test_no_commits( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test error when repository has no commits.""" org = sample_course_config["github"]["organization"] @@ -246,9 +251,9 @@ def test_no_commits( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 404 assert "коммит" in exc_info.value.detail.lower() @@ -257,7 +262,8 @@ def test_no_commits( def test_forbidden_test_file_modification( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test error when student modifies test_main.py.""" org = sample_course_config["github"]["organization"] @@ -294,9 +300,9 @@ def test_forbidden_test_file_modification( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 403 assert "test_main.py" in exc_info.value.detail @@ -305,7 +311,8 @@ def test_forbidden_test_file_modification( def test_forbidden_tests_folder_modification( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test error when student modifies tests/ folder.""" org = sample_course_config["github"]["organization"] @@ -342,9 +349,9 @@ def test_forbidden_tests_folder_modification( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 403 assert "tests" in exc_info.value.detail.lower() @@ -353,7 +360,8 @@ def test_forbidden_tests_folder_modification( def test_no_ci_checks_pending( self, mock_get_course_by_id, - sample_course_config + sample_course_config, + mock_request ): """Test that empty check_runs returns pending status.""" org = sample_course_config["github"]["organization"] @@ -391,8 +399,8 @@ def test_no_ci_checks_pending( ) from main import grade_lab, GradeRequest - request = GradeRequest(github="testuser") - result = grade_lab("test-course", "group1", "ЛР1", request) + grade_request = GradeRequest(github="testuser") + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert result["status"] == "pending" assert "⏳" in result["message"] @@ -403,7 +411,8 @@ def test_github_user_not_in_spreadsheet( mock_get_course_by_id, mock_gspread, mock_service_account_creds, - sample_course_config + sample_course_config, + mock_request ): """Test error when GitHub user not found in spreadsheet.""" org = sample_course_config["github"]["organization"] @@ -448,14 +457,14 @@ def test_github_user_not_in_spreadsheet( from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="unknownuser") + grade_request = GradeRequest(github="unknownuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 404 assert "не найден" in exc_info.value.detail.lower() - def test_missing_course_configuration(self): + def test_missing_course_configuration(self, mock_request): """Test error when course configuration is incomplete.""" with patch('main.get_course_by_id') as mock: mock.return_value = { @@ -468,9 +477,9 @@ def test_missing_course_configuration(self): from main import grade_lab, GradeRequest from fastapi import HTTPException - request = GradeRequest(github="testuser") + grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab("test-course", "group1", "ЛР1", request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) assert exc_info.value.status_code == 400 @@ -487,7 +496,8 @@ def test_success_response_format( self, mock_gspread, mock_service_account_creds, - sample_course_config + sample_course_config, + mock_request ): """Verify success response has all expected fields.""" with patch('main.get_course_by_id', return_value=sample_course_config): @@ -506,8 +516,8 @@ def test_success_response_format( mock_gspread['worksheet'].col_values.return_value = ["", "", "testuser"] from main import grade_lab, GradeRequest - request = GradeRequest(github="testuser") - result = grade_lab("test-course", "group1", "ЛР1", request) + grade_request = GradeRequest(github="testuser") + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) # Verify response structure assert "status" in result From 5e9181acb168c3d892a24af371220b3949a0f006 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 05:31:12 +0300 Subject: [PATCH 3/9] Disable rate limiting during tests --- tests/conftest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 2b4b539..e73d7a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,17 @@ def mock_env_vars(monkeypatch): monkeypatch.setenv("SECRET_KEY", "test_secret_key") +@pytest.fixture(autouse=True) +def disable_rate_limiting(): + """Disable rate limiting in tests by patching the limiter.""" + # Patch limiter after main module is imported + # Use patch which works even if the object doesn't exist yet + patcher = patch('main.limiter._check_request_limit', lambda self, request, func, sync: None) + patcher.start() + yield + patcher.stop() + + @pytest.fixture def sample_course_config(): """Sample course configuration for testing.""" From ff88ae1e1c1f00fa5805a295f89025d19406f14e Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 05:34:41 +0300 Subject: [PATCH 4/9] Fix lambda --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index e73d7a5..f9bd753 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,12 @@ def disable_rate_limiting(): """Disable rate limiting in tests by patching the limiter.""" # Patch limiter after main module is imported # Use patch which works even if the object doesn't exist yet - patcher = patch('main.limiter._check_request_limit', lambda self, request, func, sync: None) + # Accept all arguments to match the original method signature + def noop_check(*args, **kwargs): + """No-op function to disable rate limiting in tests.""" + pass + + patcher = patch('main.limiter._check_request_limit', noop_check) patcher.start() yield patcher.stop() From d7da7a22e1b9d694bf45d8ea49aeec9c6785055f Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 05:43:43 +0300 Subject: [PATCH 5/9] Add rate limit test --- tests/conftest.py | 38 +++++++++++++++--- tests/test_rate_limit.py | 87 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 tests/test_rate_limit.py diff --git a/tests/conftest.py b/tests/conftest.py index f9bd753..4748efc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,14 +20,27 @@ def mock_env_vars(monkeypatch): @pytest.fixture(autouse=True) -def disable_rate_limiting(): - """Disable rate limiting in tests by patching the limiter.""" +def disable_rate_limiting(request): + """Disable rate limiting in tests by patching the limiter. + + By default, rate limiting is disabled for all tests to avoid interference. + To test rate limiting functionality, mark your test with @pytest.mark.rate_limit. + """ + # Check if test is marked to test rate limiting + if request.node.get_closest_marker("rate_limit"): + # Don't disable rate limiting for this test + yield + return + # Patch limiter after main module is imported - # Use patch which works even if the object doesn't exist yet - # Accept all arguments to match the original method signature - def noop_check(*args, **kwargs): + # We need to patch _check_request_limit to do nothing + # and also ensure view_rate_limit is set to avoid AttributeError + + def noop_check(self, request, func, sync): """No-op function to disable rate limiting in tests.""" - pass + # Set view_rate_limit to avoid AttributeError in wrapper + if hasattr(request, 'state') and not hasattr(request.state, 'view_rate_limit'): + request.state.view_rate_limit = None patcher = patch('main.limiter._check_request_limit', noop_check) patcher.start() @@ -35,6 +48,13 @@ def noop_check(*args, **kwargs): patcher.stop() +def pytest_configure(config): + """Register custom pytest markers.""" + config.addinivalue_line( + "markers", "rate_limit: mark test to enable rate limiting (for testing rate limit functionality)" + ) + + @pytest.fixture def sample_course_config(): """Sample course configuration for testing.""" @@ -125,6 +145,12 @@ async def receive(): # Create a real Request instance request = Request(scope, receive) + + # Initialize state attribute that slowapi expects + # This is needed even when rate limiting is disabled + if not hasattr(request.state, 'view_rate_limit'): + request.state.view_rate_limit = None + return request diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..ac327c5 --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,87 @@ +""" +Tests for rate limiting functionality. + +These tests verify that rate limiting works correctly. +Note: These tests use @pytest.mark.rate_limit to enable rate limiting. +""" +import pytest +from fastapi import Request +from fastapi.testclient import TestClient +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from main import app + + +@pytest.mark.rate_limit +class TestRateLimiting: + """Tests for rate limiting functionality.""" + + @pytest.fixture + def client(self): + """Create a test client.""" + return TestClient(app) + + # Uses mock_env_vars from conftest.py + + def test_rate_limit_on_grade_endpoint(self, client): + """Test that rate limiting works on grade endpoint.""" + # Make 10 requests (within limit) + for i in range(10): + response = client.post( + "/courses/test-course/groups/group1/labs/ЛР1/grade", + json={"github": "testuser"} + ) + # Should not be rate limited yet + assert response.status_code != 429 + + # 11th request should be rate limited + response = client.post( + "/courses/test-course/groups/group1/labs/ЛР1/grade", + json={"github": "testuser"} + ) + assert response.status_code == 429 + assert "rate limit" in response.json()["detail"].lower() or "429" in str(response.status_code) + + def test_rate_limit_on_register_endpoint(self, client): + """Test that rate limiting works on register endpoint.""" + # Make 10 requests (within limit) + for i in range(10): + response = client.post( + "/courses/test-course/groups/group1/register", + json={ + "name": "Test", + "surname": "User", + "github": "testuser" + } + ) + # Should not be rate limited yet + assert response.status_code != 429 + + # 11th request should be rate limited + response = client.post( + "/courses/test-course/groups/group1/register", + json={ + "name": "Test", + "surname": "User", + "github": "testuser" + } + ) + assert response.status_code == 429 + + def test_rate_limit_resets_after_time_window(self, client): + """Test that rate limit resets after time window.""" + # This test would require waiting for the time window to expire + # For now, we just verify the limit exists + # In a real scenario, you might use time mocking + pass + + def test_different_ips_have_separate_limits(self, client): + """Test that different IP addresses have separate rate limits.""" + # This would require simulating different client IPs + # TestClient doesn't easily support this, but in production + # different IPs would have separate limits + pass + From b4324e97d4fd40473864b5e0e35bb1c18d7cfb02 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 05:54:39 +0300 Subject: [PATCH 6/9] Fix GitHub Actions tests setup --- .github/workflows/tests.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1ee2a2b..7923c6f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,8 +27,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-cov pytest-mock responses + pip install -r requirements-dev.txt - name: Run tests run: | From 2646c76feb9ddf6f6a3be6714072ac460792439d Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 06:02:20 +0300 Subject: [PATCH 7/9] Update tests --- tests/conftest.py | 14 +++++++++----- tests/test_rate_limit.py | 8 +++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4748efc..575f73d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,13 +36,17 @@ def disable_rate_limiting(request): # We need to patch _check_request_limit to do nothing # and also ensure view_rate_limit is set to avoid AttributeError - def noop_check(self, request, func, sync): + def noop_check(*args, **kwargs): """No-op function to disable rate limiting in tests.""" - # Set view_rate_limit to avoid AttributeError in wrapper - if hasattr(request, 'state') and not hasattr(request.state, 'view_rate_limit'): - request.state.view_rate_limit = None + # Extract request from args (it's the second argument: self, request, func, sync) + if len(args) >= 2: + request = args[1] + # Set view_rate_limit to avoid AttributeError in wrapper + if hasattr(request, 'state') and not hasattr(request.state, 'view_rate_limit'): + request.state.view_rate_limit = None - patcher = patch('main.limiter._check_request_limit', noop_check) + # Patch using the full path to the method + patcher = patch('main.limiter._check_request_limit', noop_check, create=False) patcher.start() yield patcher.stop() diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index ac327c5..85c1f5a 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -43,7 +43,13 @@ def test_rate_limit_on_grade_endpoint(self, client): json={"github": "testuser"} ) assert response.status_code == 429 - assert "rate limit" in response.json()["detail"].lower() or "429" in str(response.status_code) + # Check response content - slowapi may return different formats + response_data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {} + if "detail" in response_data: + assert "rate limit" in response_data["detail"].lower() or "429" in str(response.status_code) + else: + # If no detail, just verify status code + assert response.status_code == 429 def test_rate_limit_on_register_endpoint(self, client): """Test that rate limiting works on register endpoint.""" From bac4fc371629d63c72099c744ce690f0b332009e Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 07:00:59 +0300 Subject: [PATCH 8/9] Show rate-limit error messages to user --- frontend/courses-front/src/api/index.js | 64 +++++++++++++++++++ .../src/components/course-list/index.jsx | 2 +- .../src/components/group-list/index.jsx | 18 +++++- .../src/components/lab-list/index.jsx | 18 +++++- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/frontend/courses-front/src/api/index.js b/frontend/courses-front/src/api/index.js index 2b34655..9889885 100644 --- a/frontend/courses-front/src/api/index.js +++ b/frontend/courses-front/src/api/index.js @@ -27,16 +27,46 @@ function formatValidationError(err) { export const fetchCourses = async () => { const response = await fetch(`${API_BASE_URL}/courses`); + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } return response.json(); }; export const fetchCourseDetails = async (courseId) => { const response = await fetch(`${API_BASE_URL}/courses/${courseId}`); + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } return response.json(); }; export const fetchGroups = async (courseId) => { const response = await fetch(`${API_BASE_URL}/courses/${courseId}/groups`); + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } return response.json(); }; @@ -44,6 +74,16 @@ export const fetchLabs = async (courseId, groupId) => { const response = await fetch( `${API_BASE_URL}/courses/${courseId}/groups/${groupId}/labs` ); + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } return response.json(); }; @@ -59,6 +99,18 @@ export const registerAndCheck = async (courseId, groupId, formData) => { } ); + // Обрабатываем 429 Rate Limit как специальный случай (до парсинга JSON) + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } + const data = await response.json(); // Обрабатываем 409 Conflict как специальный случай @@ -99,6 +151,18 @@ export async function gradeLab(courseId, groupId, labId, github) { } ); + // Обрабатываем 429 Rate Limit как специальный случай (до парсинга JSON) + if (response.status === 429) { + let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; + try { + const data = await response.json(); + errorMessage = data.detail || data.message || errorMessage; + } catch (e) { + // Если не удалось распарсить JSON, используем сообщение по умолчанию + } + throw new Error(errorMessage); + } + const data = await response.json(); // Если ответ не успешный, выбрасываем ошибку с сообщением от сервера diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index 4dcc0d5..9fa7aba 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -107,7 +107,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { setExpandedCourse(courseId); } catch (error) { console.error(error); - showSnackbar(t("errorLoadingCourseDetails"), "error"); + showSnackbar(error.message || t("errorLoadingCourseDetails"), "error"); } }; diff --git a/frontend/courses-front/src/components/group-list/index.jsx b/frontend/courses-front/src/components/group-list/index.jsx index e82ad1e..0967b06 100644 --- a/frontend/courses-front/src/components/group-list/index.jsx +++ b/frontend/courses-front/src/components/group-list/index.jsx @@ -5,19 +5,25 @@ import { CardContainer, DescriptionContainer, Number, Title } from "./styled"; import { CardTitle, MainContainer } from "../../../theme"; import { ButtonBack } from "../course-list/styled"; import { Breadcrumb } from "../breadcrumb"; +import { Snackbar, Alert } from "@mui/material"; export const GroupList = ({ courseId, onSelectGroup, onBack }) => { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { setLoading(true); + setError(null); fetchGroups(courseId) .then((data) => { setGroups(data); setLoading(false); }) - .catch(() => setLoading(false)); + .catch((err) => { + setLoading(false); + setError(err.message || "Ошибка при загрузке групп"); + }); }, [courseId]); return ( @@ -37,6 +43,16 @@ export const GroupList = ({ courseId, onSelectGroup, onBack }) => { ))} )} + setError(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setError(null)} severity="error" sx={{ width: "100%" }}> + {error} + + ); }; diff --git a/frontend/courses-front/src/components/lab-list/index.jsx b/frontend/courses-front/src/components/lab-list/index.jsx index f211f42..8ed0076 100644 --- a/frontend/courses-front/src/components/lab-list/index.jsx +++ b/frontend/courses-front/src/components/lab-list/index.jsx @@ -10,19 +10,25 @@ import { import { CardTitle, MainContainer } from "../../../theme"; import { ButtonBack } from "../course-list/styled"; import { Breadcrumb } from "../breadcrumb"; +import { Snackbar, Alert } from "@mui/material"; export const LabList = ({ courseId, groupId, onSelectLab, onBack }) => { const [labs, setLabs] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { setLoading(true); + setError(null); fetchLabs(courseId, groupId) .then((data) => { setLabs(data); setLoading(false); }) - .catch(() => setLoading(false)); + .catch((err) => { + setLoading(false); + setError(err.message || "Ошибка при загрузке лабораторных работ"); + }); }, [courseId, groupId]); return ( @@ -42,6 +48,16 @@ export const LabList = ({ courseId, groupId, onSelectLab, onBack }) => { ))} )} + setError(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setError(null)} severity="error" sx={{ width: "100%" }}> + {error} + + ); }; From bbdb14f7fc703efda916c3b45abe4234c17f2032 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 1 Dec 2025 14:41:13 +0300 Subject: [PATCH 9/9] Add job names in lab 3 --- courses/operating-systems-2025.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/courses/operating-systems-2025.yaml b/courses/operating-systems-2025.yaml index 4b355fc..f9f10be 100644 --- a/courses/operating-systems-2025.yaml +++ b/courses/operating-systems-2025.yaml @@ -142,7 +142,10 @@ course: taskid-max: 20 penalty-max: 7 ci: - - workflows + workflows: + - run-autograding-tests + - build (MINGW64, MinGW Makefiles) + - build (MSVC, Visual Studio 17 2022) files: - lab3.cpp moss: