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
+