diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d3314d8..0000000 Binary files a/.DS_Store and /dev/null differ 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: | 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: 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} + + ); }; 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 diff --git a/tests/conftest.py b/tests/conftest.py index 060700e..575f73d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,46 @@ def mock_env_vars(monkeypatch): monkeypatch.setenv("SECRET_KEY", "test_secret_key") +@pytest.fixture(autouse=True) +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 + # We need to patch _check_request_limit to do nothing + # and also ensure view_rate_limit is set to avoid AttributeError + + def noop_check(*args, **kwargs): + """No-op function to disable rate limiting in tests.""" + # 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 + + # Patch using the full path to the method + patcher = patch('main.limiter._check_request_limit', noop_check, create=False) + patcher.start() + yield + 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.""" @@ -90,6 +130,34 @@ 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) + + # 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 + + @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 diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..85c1f5a --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,93 @@ +""" +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 + # 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.""" + # 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 +