diff --git a/.env.example b/.env.example index 38080cd..3b990a7 100644 --- a/.env.example +++ b/.env.example @@ -8,12 +8,32 @@ # Required scopes: repo, read:org GITHUB_TOKEN=ghp_your_github_token_here +# GitHub OAuth App используется только для подтверждения аккаунта студента. +# OAuth App создаётся в аккаунте преподавателя. Для Client Secret нельзя +# использовать префикс VITE_: такие переменные Vite помещает в браузерный bundle. +GITHUB_OAUTH_CLIENT_ID=your_oauth_client_id +GITHUB_OAUTH_CLIENT_SECRET=your_oauth_client_secret + +# Точный URL, указанный как "Authorization callback URL" в настройках OAuth App. +# При Caddy из примера production-адрес содержит публичный префикс /api/v1. +GITHUB_OAUTH_CALLBACK_URL=https://labgrader.example.edu/api/v1/join/callback + +# Публичный origin frontend для возврата после OAuth. Необязателен при общем +# origin frontend/backend; нужен при локальной разработке на разных портах. +FRONTEND_BASE_URL=https://labgrader.example.edu + +# Uvicorn должен доверять X-Forwarded-For только от известного reverse proxy. +# Для Docker/Caddy укажите точный IP контейнера Caddy или его минимальный CIDR; +# не используйте "*", если backend доступен кому-либо в обход proxy. +FORWARDED_ALLOW_IPS=SET_EXACT_CADDY_PROXY_IP_OR_CIDR + # Admin credentials for the web interface ADMIN_LOGIN=your_admin_username ADMIN_PASSWORD=your_secure_password_here # Secret key for cookie signing # Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +# Для OAuth state обязательно задайте собственное случайное значение. SECRET_KEY=your_random_secret_key_here # Google Sheets credentials file path (inside container) diff --git a/docker-compose.example.yaml b/docker-compose.example.yaml index b3a75b2..94f2060 100644 --- a/docker-compose.example.yaml +++ b/docker-compose.example.yaml @@ -29,6 +29,13 @@ services: # Credentials and secrets CREDENTIALS_FILE: /app/google-credentials/credentials.json GITHUB_TOKEN: ${GITHUB_TOKEN} + GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID} + GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET} + GITHUB_OAUTH_CALLBACK_URL: ${GITHUB_OAUTH_CALLBACK_URL} + FRONTEND_BASE_URL: ${FRONTEND_BASE_URL} + # Обязательное доверие только к реальному Caddy позволяет Slowapi видеть + # адрес студента и не даёт произвольному X-Forwarded-For обходить лимит. + FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:?Set trusted Caddy IP or CIDR} ADMIN_LOGIN: ${ADMIN_LOGIN} ADMIN_PASSWORD: ${ADMIN_PASSWORD} SECRET_KEY: ${SECRET_KEY} @@ -38,6 +45,10 @@ services: labels: caddy: labgrader.markpolyak.ru # API endpoints + # Внешний Caddy в этом файле не включает access log. Если он включён в + # глобальной конфигурации proxy, параметры code/state callback необходимо + # редактировать там отдельно: приложение не может изменить уже сделанную + # reverse proxy запись. caddy.handle_path: /api/v1* caddy.handle_path.0_reverse_proxy: "{{upstreams 8000}}" # Course logos (served by backend) diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md index b7e6f26..c252d4e 100644 --- a/docs/COURSE_CONFIG.md +++ b/docs/COURSE_CONFIG.md @@ -199,6 +199,28 @@ labs: short-name: ЛР1 ``` +### `template-repo` (обязательно для автоматического создания репозитория) +**Тип:** `string` +**Формат:** `owner/repo` без суффикса `.git` +**Описание:** GitHub-репозиторий, из которого создаётся приватный репозиторий +студента при переходе по `/join/{course_id}/{lab_id}`. Исходный репозиторий +должен быть отмечен в GitHub как **Template repository**, а серверный +`GITHUB_TOKEN` должен иметь к нему доступ и право создавать репозитории в +организации курса. + +Если поле отсутствует, обычная регистрация и проверка лабораторной продолжают +работать, но ссылка `/join/...` для этой лабораторной вернёт понятную ошибку +конфигурации. + +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 + short-name: ЛР1 + template-repo: suai-os-2025/os-task1-template +``` + --- ## CI/CD опции diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 20ba038..cb92fed 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -66,6 +66,10 @@ services: environment: CREDENTIALS_FILE: /app/google-credentials/credentials.json GITHUB_TOKEN: ${GITHUB_TOKEN} + GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID} + GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET} + GITHUB_OAUTH_CALLBACK_URL: ${GITHUB_OAUTH_CALLBACK_URL} + FRONTEND_BASE_URL: ${FRONTEND_BASE_URL} ADMIN_LOGIN: ${ADMIN_LOGIN} ADMIN_PASSWORD: ${ADMIN_PASSWORD} SECRET_KEY: ${SECRET_KEY} @@ -86,11 +90,40 @@ networks: ```bash # /opt/labgrader/.env GITHUB_TOKEN=your_github_token +GITHUB_OAUTH_CLIENT_ID=your_oauth_client_id +GITHUB_OAUTH_CLIENT_SECRET=your_oauth_client_secret +# Caddy из примера удаляет /api/v1 перед передачей запроса в /join/callback. +GITHUB_OAUTH_CALLBACK_URL=https://labgrader.markpolyak.ru/api/v1/join/callback +FRONTEND_BASE_URL=https://labgrader.markpolyak.ru ADMIN_LOGIN=your_admin_login ADMIN_PASSWORD=your_secure_password SECRET_KEY=your_secret_key ``` +### GitHub setup for automatic repository creation + +1. Under the **teacher's** GitHub account, create an OAuth App in + `Settings → Developer settings → OAuth Apps`. Set its callback URL exactly to + `GITHUB_OAUTH_CALLBACK_URL`. With the Caddy `/api/v1` rule above the public + callback is `https:///api/v1/join/callback`, while FastAPI receives it + as the internal `/join/callback` route. +2. Store the issued Client ID and Client Secret only in the server `.env` file. + The secret must never use a `VITE_` prefix because Vite embeds such values in + the browser bundle. + Set a unique random `SECRET_KEY` as well: the `/join` flow rejects the public + development default because this key signs OAuth `state` values. +3. Create one GitHub template repository per lab and enable + **Settings → Template repository**. Add its `owner/repo` value to the lab's + `template-repo` field described in `docs/COURSE_CONFIG.md`. +4. Ensure `GITHUB_TOKEN` can read the template, create private repositories in + the target course organization, and manage repository collaborators. A + classic PAT normally needs `repo` and organization access. A fine-grained + token needs repository **Administration: write** and **Contents: read**, plus + access to the template and target organization. +5. Open `/join/{course_id}/{lab_id}` with a test student account and verify the + complete flow: OAuth approval, private repository creation, invitation, and + a repeated visit that does not recreate or modify the repository. + ## Switching Between Branches ### Method 1: Using the Script (Recommended) diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 3a6f66d..81fb7a8 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -11,6 +11,7 @@ Платформа решает следующие задачи: - **Регистрация студентов** — связывание ФИО студента с GitHub аккаунтом +- **Создание репозиториев** — OAuth-подтверждение GitHub аккаунта и создание приватного репозитория из шаблона - **Автоматическая проверка работ** — валидация кода через GitHub Actions/CI - **Управление курсами** — поддержка множественных курсов с отдельными настройками - **Интеграция с Google Sheets** — централизованное хранение данных о студентах и оценках @@ -82,6 +83,7 @@ 2. **Регистрация** — ввод ФИО и GitHub никнейма с валидацией 3. **Отправка работы на проверку** — автоматический запуск процесса оценивания 4. **Получение результата** — мгновенная обратная связь о статусе проверки +5. **Получение репозитория по общей ссылке** — безопасный вход через GitHub, создание репозитория и восстановление истёкшего приглашения ### Для администраторов @@ -136,6 +138,9 @@ lab_grader_web/ │ └── README.md ├── tests/ # Модульные тесты │ └── test_lab_column_lookup.py +├── grading/ +│ ├── github_oauth.py # Серверная идентификация студента через GitHub OAuth +│ └── repository_provisioner.py # Создание репозитория и управление приглашениями ├── docs/ # Документация │ ├── PROJECT_DESCRIPTION.md │ ├── DEPLOYMENT.md @@ -176,6 +181,9 @@ lab_grader_web/ | GET | `/courses/{course_id}/groups/{group_id}/labs` | Список лабораторных работ | | POST | `/courses/{course_id}/groups/{group_id}/register` | Регистрация студента | | POST | `/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade` | Проверка лабораторной работы | +| GET | `/join/{course_id}/{lab_id}` | Публичная информация для страницы получения репозитория | +| GET | `/join/{course_id}/{lab_id}/start` | Начало GitHub OAuth авторизации | +| GET | `/join/callback` | OAuth callback, создание репозитория и проверка приглашения | ### Административные маршруты diff --git a/frontend/courses-front/package-lock.json b/frontend/courses-front/package-lock.json index 051900d..5906cc6 100644 --- a/frontend/courses-front/package-lock.json +++ b/frontend/courses-front/package-lock.json @@ -15,8 +15,10 @@ "@uiw/react-codemirror": "^4.23.12", "antd": "^5.24.4", "axios": "^1.9.0", + "i18next": "^23.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^13.0.1", "react-router-dom": "^6.23.0", "rollup": "^4.34.7", "styled-components": "^6.1.16" @@ -2166,6 +2168,7 @@ "version": "19.1.4", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4266,6 +4269,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4283,6 +4295,29 @@ "node": ">= 0.8" } }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6151,6 +6186,28 @@ "react": "^19.1.0" } }, + "node_modules/react-i18next": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", + "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", @@ -7233,6 +7290,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -7368,21 +7434,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/courses-front/package.json b/frontend/courses-front/package.json index 8635d64..c732133 100644 --- a/frontend/courses-front/package.json +++ b/frontend/courses-front/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/components/JoinLab/state.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/courses-front/src/App.jsx b/frontend/courses-front/src/App.jsx index 7e830e6..ee65131 100644 --- a/frontend/courses-front/src/App.jsx +++ b/frontend/courses-front/src/App.jsx @@ -6,6 +6,7 @@ import { CourseListWrapper } from "./components/course-list/courseListWrapper"; import { GroupListWrapper } from "./components/group-list/groupListWrapper"; import { LabListWrapper } from "./components/lab-list/labListWrapper"; import { RegistrationFormWrapper } from "./components/registration-form/registrationFormWrapper"; +import { JoinLab } from "./components/JoinLab"; function App() { return ( @@ -21,6 +22,8 @@ function App() { } /> } /> + } /> + } /> } /> { + const response = await fetch( + `${API_BASE_URL}/join/${encodeURIComponent(courseId)}/${encodeURIComponent(labId)}` + ); + + if (!response.ok) { + const error = new Error("Unable to load repository-generation settings"); + // Стабильные коды позволяют компоненту переводить ожидаемые ошибки, не + // показывая русскоязычный detail backend во всех поддерживаемых языках UI. + if (response.status === 404) error.code = "join_not_found"; + else if (response.status === 409) error.code = "join_not_configured"; + else if (response.status === 429) error.code = "rate_limit"; + else error.code = "unknown"; + throw error; + } + + return response.json(); +}; + +export const getJoinStartUrl = (courseId, labId) => + `${API_BASE_URL}/join/${encodeURIComponent(courseId)}/${encodeURIComponent(labId)}/start`; + // Маппинг полей на русские названия для сообщений об ошибках const fieldLabels = { name: "Имя", @@ -182,4 +207,3 @@ export async function gradeLab(courseId, groupId, labId, github) { return data; } - diff --git a/frontend/courses-front/src/components/JoinLab/index.jsx b/frontend/courses-front/src/components/JoinLab/index.jsx new file mode 100644 index 0000000..c13f25c --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/index.jsx @@ -0,0 +1,152 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams, useSearchParams } from "react-router-dom"; + +import { fetchJoinLab, getJoinStartUrl } from "../../api"; +import { + ActionButton, + Description, + Details, + ErrorPanel, + JoinCard, + JoinPage, + Label, + RepositoryLink, + Spinner, + SuccessPanel, + Title, + Value, +} from "./styled"; +import { ERROR_TRANSLATION_KEYS, getSafeRepositoryUrl } from "./state"; + + +export function JoinLab() { + const { courseId, labId } = useParams(); + const [searchParams] = useSearchParams(); + const { t } = useTranslation(); + const [lab, setLab] = useState(null); + const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRedirecting, setIsRedirecting] = useState(false); + + const callbackStatus = searchParams.get("status"); + const callbackError = searchParams.get("error"); + const hasLabContext = Boolean(courseId && labId); + const isStandaloneError = !hasLabContext && callbackStatus === "error"; + const repositoryUrl = useMemo( + () => getSafeRepositoryUrl(searchParams.get("repository")), + [searchParams] + ); + + useEffect(() => { + let isCurrentRequest = true; + setIsLoading(true); + setLoadError(null); + + if (!hasLabContext) { + setLab(null); + setLoadError(isStandaloneError ? null : "join_not_found"); + setIsLoading(false); + return () => { + isCurrentRequest = false; + }; + } + + fetchJoinLab(courseId, labId) + .then((data) => { + if (isCurrentRequest) setLab(data); + }) + .catch((error) => { + if (isCurrentRequest) setLoadError(error.code || "unknown"); + }) + .finally(() => { + if (isCurrentRequest) setIsLoading(false); + }); + + // React может размонтировать route до завершения запроса. Флаг исключает + // обновление state, относящегося к предыдущей странице курса или лабы. + return () => { + isCurrentRequest = false; + }; + }, [courseId, labId, hasLabContext, isStandaloneError]); + + const beginOAuth = () => { + setIsRedirecting(true); + window.location.assign(getJoinStartUrl(courseId, labId)); + }; + + const translatedError = (code) => + t(ERROR_TRANSLATION_KEYS[code] || "join.errors.unknown"); + + return ( + + + {t("join.title")} + + {isLoading && ( + + + )} + + {!isLoading && loadError && ( + + {t("join.errorTitle")} + {translatedError(loadError)} + + )} + + {!isLoading && isStandaloneError && ( + + {t("join.errorTitle")} + {translatedError(callbackError)} + + )} + + {!isLoading && lab && ( + <> +
+
+ + {lab.course_name} +
+
+ + {lab.lab_name} +
+
+ + {callbackStatus === "success" && repositoryUrl ? ( + + {t("join.successTitle")} + {t("join.successDescription")} + + {t("join.openRepository")} + + + ) : callbackStatus === "success" ? ( + + {t("join.errorTitle")} + {t("join.errors.invalidRepositoryLink")} + + ) : callbackStatus === "error" ? ( + + {t("join.errorTitle")} + {translatedError(callbackError)} + + ) : ( + {t("join.description")} + )} + + {callbackStatus !== "success" && ( + + {isRedirecting ? t("join.redirecting") : t("join.signIn")} + + )} + + )} +
+
+ ); +} diff --git a/frontend/courses-front/src/components/JoinLab/state.js b/frontend/courses-front/src/components/JoinLab/state.js new file mode 100644 index 0000000..8a1da17 --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/state.js @@ -0,0 +1,43 @@ +export const ERROR_TRANSLATION_KEYS = { + oauth_denied: "join.errors.oauthDenied", + oauth_failed: "join.errors.oauthFailed", + oauth_unavailable: "join.errors.oauthUnavailable", + oauth_not_configured: "join.errors.oauthNotConfigured", + oauth_state_missing: "join.errors.oauthStateMissing", + oauth_state_invalid: "join.errors.oauthStateInvalid", + oauth_state_expired: "join.errors.oauthStateExpired", + oauth_state_mismatch: "join.errors.oauthStateMismatch", + join_not_found: "join.errors.notFound", + join_not_configured: "join.errors.notConfigured", + template_unavailable: "join.errors.templateUnavailable", + repository_lookup_failed: "join.errors.repositoryFailed", + repository_create_failed: "join.errors.repositoryFailed", + access_check_failed: "join.errors.accessFailed", + invitation_lookup_failed: "join.errors.accessFailed", + invitation_delete_failed: "join.errors.accessFailed", + invitation_create_failed: "join.errors.invitationFailed", + github_rate_limit: "join.errors.rateLimit", + github_unavailable: "join.errors.githubUnavailable", + rate_limit: "join.errors.rateLimit", +}; + + +export function getSafeRepositoryUrl(rawUrl) { + try { + const url = new URL(rawUrl); + const pathParts = url.pathname.split("/").filter(Boolean); + // Параметры результата остаются изменяемым вводом из адресной строки. + // Разрешаем только обычный URL репозитория github.com из двух сегментов, + // чтобы подделанный query string не превратил страницу в открытый redirect. + if ( + url.protocol !== "https:" || + url.hostname !== "github.com" || + pathParts.length !== 2 + ) { + return null; + } + return url.toString(); + } catch { + return null; + } +} diff --git a/frontend/courses-front/src/components/JoinLab/state.test.js b/frontend/courses-front/src/components/JoinLab/state.test.js new file mode 100644 index 0000000..8c925c8 --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/state.test.js @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ERROR_TRANSLATION_KEYS, getSafeRepositoryUrl } from "./state.js"; + + +test("ошибки OAuth state имеют отдельные ключи локализации", () => { + assert.deepEqual( + [ + "oauth_state_missing", + "oauth_state_invalid", + "oauth_state_expired", + "oauth_state_mismatch", + ].map((code) => ERROR_TRANSLATION_KEYS[code]), + [ + "join.errors.oauthStateMissing", + "join.errors.oauthStateInvalid", + "join.errors.oauthStateExpired", + "join.errors.oauthStateMismatch", + ] + ); +}); + + +test("страница принимает только обычную ссылку репозитория github.com", () => { + assert.equal( + getSafeRepositoryUrl("https://github.com/test-org/test-repository"), + "https://github.com/test-org/test-repository" + ); + assert.equal(getSafeRepositoryUrl("https://example.com/test-org/repository"), null); + assert.equal(getSafeRepositoryUrl("javascript:alert(1)"), null); + assert.equal(getSafeRepositoryUrl("https://github.com/test-org/repository/issues"), null); +}); diff --git a/frontend/courses-front/src/components/JoinLab/styled.js b/frontend/courses-front/src/components/JoinLab/styled.js new file mode 100644 index 0000000..75c3999 --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/styled.js @@ -0,0 +1,121 @@ +import styled, { keyframes } from "styled-components"; + +import { buttonStyles, colors, textStyles } from "../../../theme"; + + +const rotate = keyframes` + to { transform: rotate(360deg); } +`; + +export const JoinPage = styled.main` + width: min(1200px, calc(100vw - 2em)); + min-height: calc(100vh - 2em); + box-sizing: border-box; + display: grid; + place-items: center; + padding: 24px; + background: #f6f7f9; +`; + +export const JoinCard = styled.section` + ${textStyles} + width: min(520px, 100%); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; + padding: 32px; + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 36px rgba(24, 24, 24, 0.1); +`; + +export const Title = styled.h1` + margin: 0; + color: ${colors.textPrimary}; + font-size: 24px; + line-height: 1.35; +`; + +export const Description = styled.p` + display: flex; + align-items: center; + gap: 10px; + margin: 0; + color: ${colors.textSecondary}; + font-size: 14px; + line-height: 1.6; +`; + +export const Details = styled.div` + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid ${colors.buttonHover}; + border-radius: 12px; +`; + +export const Label = styled.div` + margin-bottom: 4px; + color: ${colors.textSecondary}; + font-size: 12px; +`; + +export const Value = styled.div` + color: ${colors.textPrimary}; + font-size: 15px; + font-weight: 600; +`; + +export const ActionButton = styled.button` + ${buttonStyles} + width: 100%; + align-items: center; + padding: 12px 18px; + background: ${colors.buttonBackground}; + color: ${colors.buttonText}; + font-size: 14px; + cursor: pointer; + + &:disabled { + cursor: wait; + opacity: 0.65; + } +`; + +const ResultPanel = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; +`; + +export const SuccessPanel = styled(ResultPanel)` + border: 1px solid ${colors.save}; + background: rgba(34, 195, 142, 0.08); +`; + +export const ErrorPanel = styled(ResultPanel)` + border: 1px solid ${colors.error}; + background: rgba(235, 87, 87, 0.08); +`; + +export const RepositoryLink = styled.a` + color: ${colors.textPrimary}; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +`; + +export const Spinner = styled.span` + width: 16px; + height: 16px; + flex: 0 0 16px; + border: 2px solid ${colors.buttonHover}; + border-top-color: ${colors.buttonBackground}; + border-radius: 50%; + animation: ${rotate} 0.8s linear infinite; +`; diff --git a/frontend/courses-front/src/locales/en/translation.json b/frontend/courses-front/src/locales/en/translation.json index f02296b..ce52ffc 100644 --- a/frontend/courses-front/src/locales/en/translation.json +++ b/frontend/courses-front/src/locales/en/translation.json @@ -23,5 +23,38 @@ "information": "Information", "activeCourses": "Active Courses", "archivedCourses": "Archived Courses", - "allCourses": "All Courses" + "allCourses": "All Courses", + "join": { + "title": "Get your lab repository", + "loading": "Loading lab settings…", + "course": "Course", + "lab": "Lab", + "description": "Sign in with GitHub. The service will create a private repository from the template or restore access to an existing repository.", + "signIn": "Sign in with GitHub", + "redirecting": "Redirecting to GitHub…", + "successTitle": "Your repository is ready", + "successDescription": "Open the repository. If the invitation is still pending, GitHub will offer to accept it on that page.", + "openRepository": "Open repository", + "errorTitle": "Could not prepare the repository", + "errors": { + "oauthDenied": "You cancelled GitHub authorization. You can try again.", + "oauthFailed": "GitHub could not confirm the sign-in. Start authorization again.", + "oauthUnavailable": "GitHub is temporarily unavailable. Please try again later.", + "oauthNotConfigured": "The teacher has not configured GitHub sign-in for this service yet.", + "oauthStateMissing": "GitHub returned without the original sign-in data. Return to the lab link and try again.", + "oauthStateInvalid": "The sign-in data is damaged or was changed. Return to the lab link and start again.", + "oauthStateExpired": "The GitHub sign-in has expired. Return to the lab link and try again.", + "oauthStateMismatch": "This GitHub response belongs to another or completed sign-in. Return to the lab link and start again.", + "notFound": "The course or lab was not found. Check the link.", + "notConfigured": "A template repository has not been configured for this lab.", + "templateUnavailable": "The template repository is not accessible to the server. Contact the teacher.", + "repositoryFailed": "The repository could not be created or checked. Please try again.", + "accessFailed": "The repository is ready, but its current invitation could not be checked. Please try again.", + "invitationFailed": "The repository invitation could not be sent. Try later or contact the teacher.", + "rateLimit": "GitHub temporarily limited requests or invitations. Please try again later.", + "githubUnavailable": "GitHub could not be reached. Check the connection and try again.", + "invalidRepositoryLink": "The server returned an invalid repository link. Start again.", + "unknown": "An unexpected error occurred. Try again or contact the teacher." + } + } } diff --git a/frontend/courses-front/src/locales/ru/translation.json b/frontend/courses-front/src/locales/ru/translation.json index bdc4e25..0952c1a 100644 --- a/frontend/courses-front/src/locales/ru/translation.json +++ b/frontend/courses-front/src/locales/ru/translation.json @@ -23,5 +23,38 @@ "information": "Информация", "activeCourses": "Активные курсы", "archivedCourses": "Архив курсов", - "allCourses": "Все курсы" + "allCourses": "Все курсы", + "join": { + "title": "Получение репозитория лабораторной", + "loading": "Загрузка настроек лабораторной…", + "course": "Курс", + "lab": "Лабораторная работа", + "description": "Войдите через GitHub. Система создаст приватный репозиторий из шаблона или восстановит приглашение к уже существующему репозиторию.", + "signIn": "Войти через GitHub", + "redirecting": "Переход на GitHub…", + "successTitle": "Репозиторий готов", + "successDescription": "Откройте репозиторий. Если приглашение ещё не принято, GitHub покажет предложение принять его прямо на странице.", + "openRepository": "Открыть репозиторий", + "errorTitle": "Не удалось подготовить репозиторий", + "errors": { + "oauthDenied": "Вы отменили вход на GitHub. Можно повторить попытку.", + "oauthFailed": "GitHub не подтвердил вход. Начните авторизацию заново.", + "oauthUnavailable": "GitHub временно недоступен. Попробуйте ещё раз позже.", + "oauthNotConfigured": "Преподаватель ещё не настроил вход через GitHub для этого сервиса.", + "oauthStateMissing": "GitHub вернул ответ без данных начатого входа. Вернитесь к исходной ссылке лабораторной и попробуйте снова.", + "oauthStateInvalid": "Данные входа повреждены или были изменены. Вернитесь к исходной ссылке лабораторной и начните вход заново.", + "oauthStateExpired": "Время входа через GitHub истекло. Вернитесь к исходной ссылке лабораторной и попробуйте снова.", + "oauthStateMismatch": "Этот ответ GitHub относится к другому или уже завершённому входу. Вернитесь к исходной ссылке лабораторной и начните заново.", + "notFound": "Курс или лабораторная работа не найдены. Проверьте ссылку.", + "notConfigured": "Для этой лабораторной ещё не настроен репозиторий-шаблон.", + "templateUnavailable": "Репозиторий-шаблон недоступен серверу. Сообщите преподавателю.", + "repositoryFailed": "Не удалось создать или проверить репозиторий. Попробуйте ещё раз.", + "accessFailed": "Репозиторий готов, но проверить текущее приглашение не удалось. Попробуйте ещё раз.", + "invitationFailed": "Не удалось отправить приглашение в репозиторий. Попробуйте позже или сообщите преподавателю.", + "rateLimit": "GitHub временно ограничил количество запросов или приглашений. Попробуйте позже.", + "githubUnavailable": "Не удалось связаться с GitHub. Проверьте соединение и повторите попытку.", + "invalidRepositoryLink": "Сервер вернул некорректную ссылку на репозиторий. Начните заново.", + "unknown": "Произошла непредвиденная ошибка. Попробуйте ещё раз или сообщите преподавателю." + } + } } diff --git a/frontend/courses-front/src/locales/zh/translation.json b/frontend/courses-front/src/locales/zh/translation.json index d33b3f9..0693b2c 100644 --- a/frontend/courses-front/src/locales/zh/translation.json +++ b/frontend/courses-front/src/locales/zh/translation.json @@ -26,5 +26,38 @@ "expand": "展开", "activeCourses": "活跃课程", "archivedCourses": "归档课程", - "allCourses": "所有课程" + "allCourses": "所有课程", + "join": { + "title": "获取实验仓库", + "loading": "正在加载实验设置…", + "course": "课程", + "lab": "实验", + "description": "使用 GitHub 登录。系统会从模板创建私有仓库,或恢复对现有仓库的访问权限。", + "signIn": "使用 GitHub 登录", + "redirecting": "正在跳转到 GitHub…", + "successTitle": "仓库已准备就绪", + "successDescription": "请打开仓库。如果邀请尚未接受,GitHub 会在仓库页面提示您接受。", + "openRepository": "打开仓库", + "errorTitle": "无法准备仓库", + "errors": { + "oauthDenied": "您取消了 GitHub 授权,可以重新尝试。", + "oauthFailed": "GitHub 无法确认登录,请重新开始授权。", + "oauthUnavailable": "GitHub 暂时不可用,请稍后重试。", + "oauthNotConfigured": "教师尚未为此服务配置 GitHub 登录。", + "oauthStateMissing": "GitHub 返回时缺少原始登录数据。请返回实验链接后重试。", + "oauthStateInvalid": "登录数据已损坏或被更改。请返回实验链接并重新开始。", + "oauthStateExpired": "GitHub 登录已过期。请返回实验链接后重试。", + "oauthStateMismatch": "此 GitHub 响应属于其他或已完成的登录。请返回实验链接并重新开始。", + "notFound": "未找到课程或实验,请检查链接。", + "notConfigured": "此实验尚未配置模板仓库。", + "templateUnavailable": "服务器无法访问模板仓库,请联系教师。", + "repositoryFailed": "无法创建或检查仓库,请重试。", + "accessFailed": "仓库已准备好,但无法检查当前邀请,请重试。", + "invitationFailed": "无法发送仓库邀请,请稍后重试或联系教师。", + "rateLimit": "GitHub 暂时限制了请求或邀请数量,请稍后重试。", + "githubUnavailable": "无法连接 GitHub,请检查网络后重试。", + "invalidRepositoryLink": "服务器返回了无效的仓库链接,请重新开始。", + "unknown": "发生未知错误,请重试或联系教师。" + } + } } diff --git a/grading/github_oauth.py b/grading/github_oauth.py new file mode 100644 index 0000000..f92d929 --- /dev/null +++ b/grading/github_oauth.py @@ -0,0 +1,173 @@ +"""Минимальный серверный GitHub OAuth flow для идентификации студента. + +Полученный здесь OAuth-токен намеренно не передаётся вызывающему коду. Он нужен +приложению ровно для одной операции: узнать у GitHub, какой аккаунт подтвердил +запрос. Репозиторий создаётся и доступ выдаётся отдельным серверным токеном, +указанным в ``GITHUB_TOKEN``. +""" + +from dataclasses import dataclass +from urllib.parse import urlencode + +import requests + + +class GitHubOAuthError(Exception): + """Классифицированная ошибка GitHub OAuth с безопасным публичным кодом.""" + + def __init__(self, code: str, log_message: str): + super().__init__(log_message) + self.code = code + self.log_message = log_message + + +@dataclass(frozen=True) +class GitHubOAuthConfig: + """Значения, необходимые для GitHub OAuth Web Application Flow.""" + + client_id: str + client_secret: str + callback_url: str + + +class GitHubOAuthClient: + """Идентифицировать GitHub-пользователя через OAuth authorization code.""" + + AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + CURRENT_USER_URL = "https://api.github.com/user" + REQUEST_TIMEOUT = (3.05, 10) + + def __init__(self, config: GitHubOAuthConfig): + self.config = config + + def build_authorization_url(self, state: str) -> str: + """Собрать redirect браузера, запросив только доступ к данным профиля. + + Web-слой создаёт и подписывает ``state``. Клиент передаёт значение без + изменений, чтобы callback связал подтверждённый GitHub-аккаунт именно с + тем курсом и лабораторной, для которых была начата авторизация. + """ + + query = urlencode( + { + "client_id": self.config.client_id, + "redirect_uri": self.config.callback_url, + "scope": "read:user", + "state": state, + } + ) + return f"{self.AUTHORIZE_URL}?{query}" + + def get_verified_username(self, code: str) -> str: + """Обменять ``code`` и вернуть login, полученный через ``GET /user``. + + Access token остаётся локальной переменной, не попадает в исключения и + не возвращается другому слою. Это исключает его случайное сохранение в + ответах приложения, redirect URL или обычных логах. + """ + + access_token = self._exchange_code(code) + try: + return self._get_username(access_token) + finally: + # Строку Python нельзя гарантированно стереть из памяти, однако + # удаление ссылки явно ограничивает время жизни токена и исключает + # его повторное использование при дальнейшей работе с репозиторием. + access_token = None + + def _exchange_code(self, code: str) -> str: + try: + response = requests.post( + self.ACCESS_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": self.config.client_id, + "client_secret": self.config.client_secret, + "code": code, + "redirect_uri": self.config.callback_url, + }, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub token exchange request failed: {exc.__class__.__name__}", + ) from exc + + # GitHub может вернуть OAuth-ошибку в JSON даже с HTTP 200, поэтому тело + # ответа необходимо проверять независимо от HTTP-кода. + try: + payload = response.json() + except ValueError as exc: + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub token exchange returned non-JSON response ({response.status_code})", + ) from exc + + if not isinstance(payload, dict): + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub token exchange returned an unexpected JSON value ({response.status_code})", + ) + + if response.status_code == 429 or response.status_code >= 500: + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub token exchange is temporarily unavailable ({response.status_code})", + ) + + access_token = payload.get("access_token") + if response.status_code != 200 or not isinstance(access_token, str) or not access_token: + github_error = payload.get("error", "missing_access_token") + raise GitHubOAuthError( + "oauth_failed", + f"GitHub rejected OAuth code exchange: {github_error} ({response.status_code})", + ) + + return access_token + + def _get_username(self, access_token: str) -> str: + try: + response = requests.get( + self.CURRENT_USER_URL, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {access_token}", + "X-GitHub-Api-Version": "2026-03-10", + }, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub current-user request failed: {exc.__class__.__name__}", + ) from exc + + if response.status_code == 429 or response.status_code >= 500: + raise GitHubOAuthError( + "oauth_unavailable", + f"GitHub current-user endpoint is temporarily unavailable ({response.status_code})", + ) + + if response.status_code != 200: + raise GitHubOAuthError( + "oauth_failed", + f"GitHub current-user request failed with status {response.status_code}", + ) + + try: + username = response.json().get("login") + except (ValueError, AttributeError) as exc: + raise GitHubOAuthError( + "oauth_failed", + "GitHub current-user response did not contain a JSON object", + ) from exc + + if not isinstance(username, str) or not username.strip(): + raise GitHubOAuthError( + "oauth_failed", + "GitHub current-user response did not contain a login", + ) + + return username.strip() diff --git a/grading/repository_provisioner.py b/grading/repository_provisioner.py new file mode 100644 index 0000000..7472849 --- /dev/null +++ b/grading/repository_provisioner.py @@ -0,0 +1,361 @@ +"""Создание репозитория студента и восстановление приглашений в коллабораторы. + +Модуль намеренно не зависит от FastAPI и OAuth. Он получает подтверждённый +GitHub login и выполняет операции с репозиторием только через серверный +``GITHUB_TOKEN``. Явная граница не позволяет случайно использовать OAuth-токен +студента для действий с правами организации. +""" + +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import requests + + +class RepositoryProvisionError(Exception): + """Ошибка GitHub со стабильным публичным кодом и закрытым описанием для логов.""" + + def __init__(self, code: str, log_message: str, status_code: int | None = None): + super().__init__(log_message) + self.code = code + self.log_message = log_message + self.status_code = status_code + + +@dataclass(frozen=True) +class ProvisionResult: + """Результат, возвращаемый callback после завершения обязательных операций.""" + + organization: str + repository: str + repository_url: str + created: bool + access_action: str + + +class GitHubRepositoryClient: + """GitHub REST-клиент только для подготовки студенческих репозиториев.""" + + BASE_URL = "https://api.github.com" + REQUEST_TIMEOUT = (3.05, 15) + + def __init__(self, token: str): + self.headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + } + + def repository_exists(self, organization: str, repository: str) -> bool: + response = self._request( + "get", + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}", + ) + if response.status_code == 200: + return True + if response.status_code == 404: + return False + raise self._api_error("repository_lookup_failed", "check repository", response) + + def generate_repository( + self, + template_owner: str, + template_repository: str, + organization: str, + repository: str, + ) -> None: + response = self._request( + "post", + ( + f"/repos/{quote(template_owner, safe='')}/" + f"{quote(template_repository, safe='')}/generate" + ), + json={ + "owner": organization, + "name": repository, + "private": True, + "include_all_branches": False, + }, + ) + if response.status_code == 201: + return + + code = "template_unavailable" if response.status_code == 404 else "repository_create_failed" + raise self._api_error(code, "generate repository from template", response) + + def is_direct_collaborator( + self, + organization: str, + repository: str, + username: str, + ) -> bool: + """Найти пользователя среди прямых коллабораторов на всех страницах ответа.""" + + page = 1 + expected_login = username.casefold() + collaborators_url = ( + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}/collaborators" + ) + + while True: + # Параметр affiliation поддерживается endpoint списка. Значение + # direct исключает доступ через команды и базовые права организации. + response = self._request( + "get", + collaborators_url, + params={"affiliation": "direct", "per_page": 100, "page": page}, + ) + if response.status_code != 200: + raise self._api_error( + "access_check_failed", + "list direct collaborators", + response, + ) + + try: + collaborators = response.json() + except ValueError as exc: + raise RepositoryProvisionError( + "access_check_failed", + "GitHub collaborator list was not valid JSON", + response.status_code, + ) from exc + + if not isinstance(collaborators, list): + raise RepositoryProvisionError( + "access_check_failed", + "GitHub collaborator list was not an array", + response.status_code, + ) + + for collaborator in collaborators: + login = ( + collaborator.get("login") + if isinstance(collaborator, dict) + else None + ) + if isinstance(login, str) and login.casefold() == expected_login: + return True + + # Неполная страница является последней. Это позволяет обработать + # репозитории с любым количеством прямых коллабораторов без Link-header. + if len(collaborators) < 100: + return False + page += 1 + + def find_pending_invitation( + self, + organization: str, + repository: str, + username: str, + ) -> int | None: + """Найти id открытого приглашения, просмотрев все страницы GitHub.""" + + page = 1 + expected_login = username.casefold() + + while True: + response = self._request( + "get", + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}/invitations", + params={"per_page": 100, "page": page}, + ) + if response.status_code != 200: + raise self._api_error("invitation_lookup_failed", "list invitations", response) + + try: + invitations = response.json() + except ValueError as exc: + raise RepositoryProvisionError( + "invitation_lookup_failed", + "GitHub invitation list was not valid JSON", + response.status_code, + ) from exc + + if not isinstance(invitations, list): + raise RepositoryProvisionError( + "invitation_lookup_failed", + "GitHub invitation list was not an array", + response.status_code, + ) + + for invitation in invitations: + if not isinstance(invitation, dict): + continue + invitee = invitation.get("invitee") + invitee_login = invitee.get("login") if isinstance(invitee, dict) else None + invitation_id = invitation.get("id") + if ( + isinstance(invitee_login, str) + and invitee_login.casefold() == expected_login + and isinstance(invitation_id, int) + ): + return invitation_id + + # Неполная страница является последней. Так обрабатываются и + # репозитории, где открыто больше 100 приглашений, без Link-header. + if len(invitations) < 100: + return None + page += 1 + + def delete_invitation( + self, + organization: str, + repository: str, + invitation_id: int, + ) -> bool: + response = self._request( + "delete", + ( + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}/" + f"invitations/{invitation_id}" + ), + ) + if response.status_code == 204: + return True + if response.status_code == 404: + # Параллельный callback мог уже принять или удалить приглашение. + # Верхний уровень повторно проверит доступ перед новой отправкой. + return False + raise self._api_error("invitation_delete_failed", "delete invitation", response) + + def invite_collaborator( + self, + organization: str, + repository: str, + username: str, + ) -> None: + response = self._request( + "put", + ( + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}/" + f"collaborators/{quote(username, safe='')}" + ), + json={"permission": "push"}, + ) + # 201 означает создание приглашения. GitHub возвращает 204, если доступ + # уже есть либо участник организации добавлен напрямую без приглашения. + if response.status_code in (201, 204): + return + raise self._api_error("invitation_create_failed", "invite collaborator", response) + + def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: + try: + return requests.request( + method, + f"{self.BASE_URL}{path}", + headers=self.headers, + timeout=self.REQUEST_TIMEOUT, + **kwargs, + ) + except requests.RequestException as exc: + raise RepositoryProvisionError( + "github_unavailable", + f"GitHub API request failed during {method.upper()} {path}: {exc.__class__.__name__}", + ) from exc + + @staticmethod + def _api_error( + default_code: str, + operation: str, + response: requests.Response, + ) -> RepositoryProvisionError: + # Обычное сообщение GitHub полезно для серверной диагностики и не + # содержит Authorization header. В браузер оно не передаётся: frontend + # получает только стабильный публичный код ошибки. + github_message = "unknown GitHub error" + try: + payload = response.json() + if isinstance(payload, dict) and isinstance(payload.get("message"), str): + github_message = payload["message"] + except ValueError: + pass + + code = default_code + is_rate_limited = response.status_code == 429 or ( + response.status_code == 403 + and ( + response.headers.get("X-RateLimit-Remaining") == "0" + or "Retry-After" in response.headers + or "rate limit" in github_message.casefold() + ) + ) + if is_rate_limited: + code = "github_rate_limit" + + return RepositoryProvisionError( + code, + f"Failed to {operation}: {response.status_code} {github_message}", + response.status_code, + ) + + +class RepositoryProvisioner: + """Идемпотентно создать приватный репозиторий и обеспечить доступ студента.""" + + def __init__(self, client: GitHubRepositoryClient): + self.client = client + + def provision( + self, + organization: str, + github_prefix: str, + template_owner: str, + template_repository: str, + join_key: str, + ) -> ProvisionResult: + repository = f"{github_prefix}-{join_key}" + created = False + + if not self.client.repository_exists(organization, repository): + try: + self.client.generate_repository( + template_owner, + template_repository, + organization, + repository, + ) + created = True + except RepositoryProvisionError as exc: + # Два callback могут одновременно получить 404 и начать + # создание. Ошибка 409/422 медленного запроса считается гонкой + # только после GET, подтвердившего появление репозитория. + if exc.status_code not in (409, 422) or not self.client.repository_exists( + organization, repository + ): + raise + + access_action = self._ensure_access(organization, repository, join_key) + repository_url = ( + f"https://github.com/{quote(organization, safe='')}/{quote(repository, safe='')}" + ) + return ProvisionResult( + organization=organization, + repository=repository, + repository_url=repository_url, + created=created, + access_action=access_action, + ) + + def _ensure_access(self, organization: str, repository: str, username: str) -> str: + if self.client.is_direct_collaborator(organization, repository, username): + return "already_has_access" + + invitation_id = self.client.find_pending_invitation( + organization, repository, username + ) + if invitation_id is None: + self.client.invite_collaborator(organization, repository, username) + return "invited" + + invitation_deleted = self.client.delete_invitation( + organization, repository, invitation_id + ) + if not invitation_deleted and self.client.is_direct_collaborator( + organization, repository, username + ): + return "already_has_access" + + self.client.invite_collaborator(organization, repository, username) + return "reinvited" diff --git a/main.py b/main.py index e5be819..a7d1b86 100644 --- a/main.py +++ b/main.py @@ -6,14 +6,22 @@ import requests from oauth2client.service_account import ServiceAccountCredentials from pydantic import BaseModel, Field -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from fastapi.middleware.cors import CORSMiddleware from fastapi import UploadFile, File from dotenv import load_dotenv -from itsdangerous import TimestampSigner, BadSignature +from itsdangerous import ( + BadSignature, + SignatureExpired, + TimestampSigner, + URLSafeTimedSerializer, +) import re import logging +import secrets from datetime import datetime +from dataclasses import dataclass +from urllib.parse import quote, urlencode, urlsplit from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded @@ -33,6 +41,49 @@ format_grade_with_score, format_score, ) +from grading.github_oauth import ( + GitHubOAuthClient, + GitHubOAuthConfig, + GitHubOAuthError, +) +from grading.repository_provisioner import ( + GitHubRepositoryClient, + RepositoryProvisionError, + RepositoryProvisioner, +) + + +SENSITIVE_CALLBACK_QUERY_PATTERN = re.compile( + r"(?i)(?P[?&](?:code|state|access_token|client_secret|" + r"authorization|token|nonce)=)[^&\s\"]*" +) + + +def redact_oauth_callback_query(value: object) -> object: + """Скрыть чувствительные параметры callback, сохранив путь и структуру запроса.""" + + if not isinstance(value, str) or "/join/callback?" not in value: + return value + return SENSITIVE_CALLBACK_QUERY_PATTERN.sub( + lambda match: f"{match.group('prefix')}[REDACTED]", + value, + ) + + +class OAuthCallbackAccessLogFilter(logging.Filter): + """Удалять OAuth-секреты из access log сервера и тестового HTTP-клиента.""" + + def filter(self, record: logging.LogRecord) -> bool: + # Uvicorn передаёт URL строкой, а httpx в тестах — объектом URL внутри + # аргументов шаблона. Очистка уже сформированного сообщения закрывает оба + # варианта и не зависит от позиции request target в конкретной версии. + rendered_message = record.getMessage() + redacted_message = redact_oauth_callback_query(rendered_message) + if redacted_message != rendered_message: + record.msg = redacted_message + record.args = () + return True + # Configure logging to both file and console LOG_DIR = os.getenv("LOG_DIR", "logs") @@ -55,12 +106,14 @@ # Console handler (for docker logs) console_handler = logging.StreamHandler() console_handler.setFormatter(log_formatter) +console_handler.addFilter(OAuthCallbackAccessLogFilter()) root_logger.addHandler(console_handler) # File handler (persistent logs) log_file = os.path.join(LOG_DIR, "labgrader.log") file_handler = logging.FileHandler(log_file, encoding='utf-8') file_handler.setFormatter(log_formatter) +file_handler.addFilter(OAuthCallbackAccessLogFilter()) root_logger.addHandler(file_handler) # Configure uvicorn loggers to use the same format @@ -81,9 +134,14 @@ COURSES_DIR = "courses" CREDENTIALS_FILE = os.getenv("CREDENTIALS_FILE", "credentials.json") # Файл с учетными данными Google API GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +GITHUB_OAUTH_CLIENT_ID = os.getenv("GITHUB_OAUTH_CLIENT_ID") +GITHUB_OAUTH_CLIENT_SECRET = os.getenv("GITHUB_OAUTH_CLIENT_SECRET") +GITHUB_OAUTH_CALLBACK_URL = os.getenv("GITHUB_OAUTH_CALLBACK_URL") +FRONTEND_BASE_URL = os.getenv("FRONTEND_BASE_URL") ADMIN_LOGIN = os.getenv("ADMIN_LOGIN") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") -SECRET_KEY = os.getenv("SECRET_KEY", "super-secret-key") +DEFAULT_SECRET_KEY = "super-secret-key" +SECRET_KEY = os.getenv("SECRET_KEY") # Rate limiting configuration limiter = Limiter(key_func=get_remote_address) @@ -102,6 +160,12 @@ "GITHUB_TOKEN должен быть установлен в переменных окружения. " "Приложение требует доступ к GitHub API." ) + +if not SECRET_KEY or SECRET_KEY == DEFAULT_SECRET_KEY: + raise RuntimeError( + "SECRET_KEY должен быть задан собственным безопасным значением в переменных " + "окружения. Приложение не запускается с отсутствующим или известным ключом." + ) app.add_middleware( CORSMiddleware, allow_origins=["*"], # Разрешить запросы с любых источников @@ -110,6 +174,14 @@ allow_headers=["*"], # Разрешить все заголовки ) signer = TimestampSigner(SECRET_KEY) +# Отдельная соль не позволяет использовать подпись административной сессии как +# OAuth state (и наоборот), хотя оба механизма используют общий SECRET_KEY. +oauth_state_serializer = URLSafeTimedSerializer( + SECRET_KEY, + salt="github-oauth-state-v1", +) +OAUTH_STATE_MAX_AGE_SECONDS = 600 +OAUTH_NONCE_COOKIE_NAME = "join_oauth_nonce" # Course index management INDEX_FILE = os.path.join(COURSES_DIR, "index.yaml") @@ -213,6 +285,166 @@ def get_course_by_id(course_id: str): return course_info + +@dataclass(frozen=True) +class JoinLabConfig: + """Проверенная часть конфигурации курса, необходимая сценарию `/join`.""" + + course_id: str + course_name: str + lab_id: str + lab_name: str + organization: str + github_prefix: str + template_owner: str + template_repository: str + + +class OAuthStateError(Exception): + """Ошибка проверки OAuth state с безопасным кодом для frontend.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _parse_template_repository(value: object) -> tuple[str, str]: + """Разобрать формат ``owner/repo``, не пытаясь исправить ошибочный ввод.""" + + if not isinstance(value, str): + raise HTTPException( + status_code=409, + detail="Для этой лабораторной не настроен репозиторий-шаблон", + ) + + parts = value.strip().split("/") + if ( + len(parts) != 2 + or not parts[0] + or not parts[1] + or parts[1].endswith(".git") + or any(part != part.strip() or any(char.isspace() for char in part) for part in parts) + ): + raise HTTPException( + status_code=409, + detail="Поле template-repo должно иметь формат owner/repo без .git", + ) + + return parts[0], parts[1] + + +def get_join_lab_config(course_id: str, lab_id: str) -> JoinLabConfig: + """Загрузить лабораторную по точному YAML-ключу и проверить поля для `/join`. + + Существующий helper ``parse_lab_id`` здесь намеренно не используется: в + конфигурациях есть самостоятельные идентификаторы наподобие ``01``. Их + преобразование в целое число могло бы незаметно выбрать другую лабораторную. + """ + + course_info = get_course_by_id(course_id) + labs = course_info.get("labs", {}) + lab_config = labs.get(lab_id) if isinstance(labs, dict) else None + if not isinstance(lab_config, dict): + raise HTTPException(status_code=404, detail="Лабораторная работа не найдена") + + github_config = course_info.get("github", {}) + organization = github_config.get("organization") if isinstance(github_config, dict) else None + github_prefix_value = lab_config.get("github-prefix") + template_owner, template_repository = _parse_template_repository( + lab_config.get("template-repo") + ) + + if not isinstance(organization, str) or not organization.strip(): + raise HTTPException( + status_code=409, + detail="В конфигурации курса не задана GitHub-организация", + ) + # В старых конфигурациях часть числовых префиксов записана без кавычек и + # загружается PyYAML как int. Поддерживаем этот существующий формат, но не + # принимаем bool и произвольные типы, которые скрыли бы ошибку настройки. + if isinstance(github_prefix_value, str): + github_prefix = github_prefix_value.strip() + elif isinstance(github_prefix_value, int) and not isinstance( + github_prefix_value, bool + ): + github_prefix = str(github_prefix_value) + else: + github_prefix = "" + + if not github_prefix: + raise HTTPException( + status_code=409, + detail="В конфигурации лабораторной не задан github-prefix", + ) + + return JoinLabConfig( + course_id=course_id, + course_name=str(course_info.get("name", "Курс")), + lab_id=lab_id, + lab_name=str(lab_config.get("short-name", f"Лабораторная {lab_id}")), + organization=organization.strip(), + github_prefix=github_prefix, + template_owner=template_owner, + template_repository=template_repository, + ) + + +def _validated_absolute_url(value: str | None, setting_name: str) -> str | None: + """Проверить URL развёртывания, используемый как адрес OAuth-перенаправления.""" + + # Docker Compose заменяет необъявленную необязательную переменную пустой + # строкой. Считаем её отсутствующей, чтобы значения по умолчанию работали + # локально и при размещении frontend/backend на одном host. + if value is None or not value.strip(): + return None + normalized = value.strip().rstrip("/") + parsed = urlsplit(normalized) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise HTTPException( + status_code=503, + detail=f"Переменная {setting_name} содержит некорректный URL", + ) + return normalized + + +def get_oauth_callback_url(request: Request) -> str: + """Вернуть точный публичный callback из настроек OAuth App преподавателя.""" + + configured = _validated_absolute_url( + GITHUB_OAUTH_CALLBACK_URL, + "GITHUB_OAUTH_CALLBACK_URL", + ) + if configured: + return configured + return str(request.url_for("github_oauth_callback")) + + +def get_frontend_base_url(request: Request) -> str: + """Определить публичный адрес frontend для итогового перенаправления браузера.""" + + configured = _validated_absolute_url(FRONTEND_BASE_URL, "FRONTEND_BASE_URL") + if configured: + return configured + + # В production frontend и API обычно находятся на одном origin. Получение + # origin из настроенного callback сохраняет правильный публичный host, даже + # если backend скрыт за префиксом обратного прокси `/api/v1`. + callback_parts = urlsplit(get_oauth_callback_url(request)) + return f"{callback_parts.scheme}://{callback_parts.netloc}" + + +def build_join_result_url( + request: Request, + course_id: str, + lab_id: str, + **query: str, +) -> str: + """Собрать URL результата из проверенных route-id и безопасных кодов состояния.""" + + base_url = get_frontend_base_url(request) + path = f"/join/{quote(course_id, safe='')}/{quote(lab_id, safe='')}" + return f"{base_url}{path}?{urlencode(query)}" + # Validate index on startup print("Validating course index...") if not validate_course_index(): @@ -243,6 +475,275 @@ class StudentRegistration(BaseModel): async def read_index(request: Request): return FileResponse("dist/index.html") + +def _get_github_oauth_client(request: Request) -> GitHubOAuthClient: + """Создать OAuth-клиент только при фактическом использовании `/join`. + + Приложение исторически запускается без OAuth-настроек. Отложенная проверка + сохраняет работу существующих endpoint проверки лабораторных, а новая + функция вместо падения всего приложения возвращает понятную ошибку настройки. + """ + + if not GITHUB_OAUTH_CLIENT_ID or not GITHUB_OAUTH_CLIENT_SECRET: + raise HTTPException( + status_code=503, + detail="Вход через GitHub OAuth пока не настроен преподавателем", + ) + return GitHubOAuthClient( + GitHubOAuthConfig( + client_id=GITHUB_OAUTH_CLIENT_ID, + client_secret=GITHUB_OAUTH_CLIENT_SECRET, + callback_url=get_oauth_callback_url(request), + ) + ) + + +def _load_oauth_state( + state: str | None, + expected_nonce: str | None, +) -> tuple[str, str]: + """Проверить подпись, возраст, структуру и привязку OAuth state к браузеру.""" + + if not state: + raise OAuthStateError("oauth_state_missing") + try: + payload = oauth_state_serializer.loads( + state, + max_age=OAUTH_STATE_MAX_AGE_SECONDS, + ) + except SignatureExpired as exc: + raise OAuthStateError("oauth_state_expired") from exc + except BadSignature as exc: + raise OAuthStateError("oauth_state_invalid") from exc + + if not isinstance(payload, dict): + raise OAuthStateError("oauth_state_invalid") + course_id = payload.get("course_id") + lab_id = payload.get("lab_id") + nonce = payload.get("nonce") + if ( + not isinstance(course_id, str) + or not course_id + or not isinstance(lab_id, str) + or not lab_id + ): + raise OAuthStateError("oauth_state_invalid") + if ( + not isinstance(nonce, str) + or not nonce + or not isinstance(expected_nonce, str) + or not secrets.compare_digest(nonce, expected_nonce) + ): + raise OAuthStateError("oauth_state_mismatch") + return course_id, lab_id + + +def _redirect_to_join_result( + request: Request, + course_id: str, + lab_id: str, + **query: str, +) -> RedirectResponse: + """Перенаправить на frontend и удалить одноразовую OAuth-cookie.""" + + response = RedirectResponse( + build_join_result_url(request, course_id, lab_id, **query), + status_code=303, + ) + response.delete_cookie(OAUTH_NONCE_COOKIE_NAME, path="/") + return response + + +def _redirect_to_oauth_state_error( + request: Request, + error_code: str, +) -> RedirectResponse: + """Вернуть браузер на общий экран ошибки, не доверяя данным из state.""" + + target = ( + f"{get_frontend_base_url(request)}/join/error?" + f"{urlencode({'status': 'error', 'error': error_code})}" + ) + response = RedirectResponse(target, status_code=303) + response.delete_cookie(OAUTH_NONCE_COOKIE_NAME, path="/") + return response + + +@app.get("/join/{course_id}/{lab_id}") +@limiter.limit("60/minute") +def get_join_lab(request: Request, course_id: str, lab_id: str): + """Вернуть только публичные поля курса и лабораторной для landing page.""" + + config = get_join_lab_config(course_id, lab_id) + return { + "course_id": config.course_id, + "course_name": config.course_name, + "lab_id": config.lab_id, + "lab_name": config.lab_name, + } + + +@app.get("/join/{course_id}/{lab_id}/start") +@limiter.limit("10/minute") +def start_github_oauth(request: Request, course_id: str, lab_id: str): + """Запустить OAuth после проверки настройки автоматического создания репозитория.""" + + get_join_lab_config(course_id, lab_id) + try: + oauth_client = _get_github_oauth_client(request) + except HTTPException: + # Браузер переходит на этот endpoint напрямую, поэтому возвращаем его на + # frontend с понятной ошибкой вместо показа необработанного JSON-ответа. + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error="oauth_not_configured", + ) + nonce = secrets.token_urlsafe(24) + state = oauth_state_serializer.dumps( + { + "course_id": course_id, + "lab_id": lab_id, + # Подпись не позволяет изменить контекст лабораторной, а случайный + # nonce делает state непредсказуемым и связывает его с этим браузером. + "nonce": nonce, + } + ) + response = RedirectResponse( + oauth_client.build_authorization_url(state), + status_code=302, + ) + response.set_cookie( + OAUTH_NONCE_COOKIE_NAME, + nonce, + max_age=OAUTH_STATE_MAX_AGE_SECONDS, + httponly=True, + secure=urlsplit(oauth_client.config.callback_url).scheme == "https", + samesite="lax", + path="/", + ) + return response + + +@app.get("/join/callback", name="github_oauth_callback") +@limiter.limit("20/minute") +def github_oauth_callback( + request: Request, + code: str | None = None, + state: str | None = None, + error: str | None = None, +): + """Завершить OAuth, подготовить репозиторий и перенаправить на frontend.""" + + # State проверяется для любого результата, включая access_denied. Иначе + # злоумышленник мог бы подделать callback для произвольного курса или лабы. + try: + course_id, lab_id = _load_oauth_state( + state, + request.cookies.get(OAUTH_NONCE_COOKIE_NAME), + ) + except OAuthStateError as exc: + # В лог попадает только фиксированный код причины. Сам state, code и + # nonce-cookie здесь не нужны ни для диагностики, ни для ответа браузеру. + logger.warning("OAuth state rejected: %s", exc.code) + return _redirect_to_oauth_state_error(request, exc.code) + + if error: + error_code = "oauth_denied" if error == "access_denied" else "oauth_failed" + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error=error_code, + ) + + if not code: + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error="oauth_failed", + ) + + try: + config = get_join_lab_config(course_id, lab_id) + except HTTPException as exc: + config_error = "join_not_found" if exc.status_code == 404 else "join_not_configured" + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error=config_error, + ) + + try: + oauth_client = _get_github_oauth_client(request) + except HTTPException: + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error="oauth_not_configured", + ) + + try: + username = oauth_client.get_verified_username(code) + except GitHubOAuthError as exc: + logger.warning("GitHub OAuth failed: %s", exc.log_message) + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error=exc.code, + ) + + try: + provisioner = RepositoryProvisioner(GitHubRepositoryClient(GITHUB_TOKEN)) + result = provisioner.provision( + organization=config.organization, + github_prefix=config.github_prefix, + template_owner=config.template_owner, + template_repository=config.template_repository, + join_key=username, + ) + except RepositoryProvisionError as exc: + logger.warning( + "Repository provisioning failed for %s/%s-%s: %s", + config.organization, + config.github_prefix, + username, + exc.log_message, + ) + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="error", + error=exc.code, + ) + + logger.info( + "Repository provisioning completed for %s/%s (created=%s, access=%s)", + result.organization, + result.repository, + result.created, + result.access_action, + ) + return _redirect_to_join_result( + request, + course_id, + lab_id, + status="success", + repository=result.repository_url, + ) + @app.post("/admin/login") @limiter.limit("5/minute") def admin_login(request: Request, data: AuthRequest, response: Response): @@ -891,4 +1392,4 @@ async def upload_course(request: Request, file: UploadFile = File(...)): "detail": "Курс успешно загружен и добавлен в индекс", "course_id": course_id, "filename": file.filename - } \ No newline at end of file + } diff --git a/tests/conftest.py b/tests/conftest.py index 575f73d..a702dd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,14 @@ # Add parent directory to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Pytest импортирует тестовые модули до создания fixture. Обязательные настройки +# задаются здесь заранее, чтобы импорт ``main`` не зависел от порядка файлов и от +# переменных окружения компьютера разработчика. +os.environ.setdefault("GITHUB_TOKEN", "test_github_token") +os.environ.setdefault("ADMIN_LOGIN", "admin") +os.environ.setdefault("ADMIN_PASSWORD", "test_password") +os.environ.setdefault("SECRET_KEY", "test_secret_key") + @pytest.fixture def mock_env_vars(monkeypatch): @@ -38,12 +46,12 @@ def disable_rate_limiting(request): 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 + # Версии Slowapi различаются наличием ``self`` среди позиционных + # аргументов подменённого метода. Ищем Request по атрибуту state, не + # полагаясь на позицию аргумента, зависящую от версии библиотеки. + for candidate in (*args, *kwargs.values()): + if hasattr(candidate, 'state') and not hasattr(candidate.state, 'view_rate_limit'): + candidate.state.view_rate_limit = None # Patch using the full path to the method patcher = patch('main.limiter._check_request_limit', noop_check, create=False) diff --git a/tests/test_github_oauth.py b/tests/test_github_oauth.py new file mode 100644 index 0000000..3488462 --- /dev/null +++ b/tests/test_github_oauth.py @@ -0,0 +1,250 @@ +"""Тесты изолированного серверного GitHub OAuth-клиента.""" + +from urllib.parse import parse_qs, urlsplit + +import pytest +import requests +import responses + +from grading.github_oauth import ( + GitHubOAuthClient, + GitHubOAuthConfig, + GitHubOAuthError, +) + + +@pytest.fixture +def oauth_client(): + return GitHubOAuthClient( + GitHubOAuthConfig( + client_id="client-id", + client_secret="client-secret", + callback_url="https://grader.example/api/v1/join/callback", + ) + ) + + +def test_authorization_url_contains_required_identity_parameters(oauth_client): + url = urlsplit(oauth_client.build_authorization_url("signed-state")) + query = parse_qs(url.query) + + assert f"{url.scheme}://{url.netloc}{url.path}" == GitHubOAuthClient.AUTHORIZE_URL + assert query == { + "client_id": ["client-id"], + "redirect_uri": ["https://grader.example/api/v1/join/callback"], + "scope": ["read:user"], + "state": ["signed-state"], + } + + +@responses.activate +def test_verified_username_uses_student_token_only_for_current_user(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"access_token": "student-secret-token", "token_type": "bearer"}, + status=200, + ) + responses.add( + responses.GET, + GitHubOAuthClient.CURRENT_USER_URL, + json={"login": "VerifiedStudent"}, + status=200, + ) + + assert oauth_client.get_verified_username("temporary-code") == "VerifiedStudent" + assert "student-secret-token" not in responses.calls[0].request.body + assert responses.calls[1].request.headers["Authorization"] == "Bearer student-secret-token" + + +@responses.activate +def test_oauth_json_error_is_reported_without_exposing_secrets(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"error": "bad_verification_code"}, + status=200, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("invalid-code") + + assert error.value.code == "oauth_failed" + assert "client-secret" not in error.value.log_message + assert "invalid-code" not in error.value.log_message + + +@responses.activate +def test_missing_access_token_is_rejected(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"token_type": "bearer"}, + status=200, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_failed" + + +@responses.activate +def test_non_json_token_response_is_retryable(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + body="temporarily unavailable", + status=502, + content_type="text/plain", + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_unavailable" + + +@pytest.mark.parametrize( + ("status_code", "expected_code"), + [ + (401, "oauth_failed"), + (403, "oauth_failed"), + (429, "oauth_unavailable"), + (500, "oauth_unavailable"), + (502, "oauth_unavailable"), + (503, "oauth_unavailable"), + ], +) +@responses.activate +def test_token_exchange_classifies_http_errors( + oauth_client, + status_code, + expected_code, +): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"error": "github-error"}, + status=status_code, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == expected_code + + +@responses.activate +def test_missing_login_is_rejected(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"access_token": "student-token"}, + status=200, + ) + responses.add( + responses.GET, + GitHubOAuthClient.CURRENT_USER_URL, + json={"id": 123}, + status=200, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_failed" + + +@pytest.mark.parametrize( + ("status_code", "expected_code"), + [ + (401, "oauth_failed"), + (403, "oauth_failed"), + (429, "oauth_unavailable"), + (500, "oauth_unavailable"), + (502, "oauth_unavailable"), + (503, "oauth_unavailable"), + ], +) +@responses.activate +def test_current_user_classifies_http_errors( + oauth_client, + status_code, + expected_code, +): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"access_token": "student-token"}, + status=200, + ) + responses.add( + responses.GET, + GitHubOAuthClient.CURRENT_USER_URL, + json={"message": "github-error"}, + status=status_code, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == expected_code + + +@responses.activate +def test_current_user_rejects_non_json_response(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"access_token": "student-token"}, + status=200, + ) + responses.add( + responses.GET, + GitHubOAuthClient.CURRENT_USER_URL, + body="not-json", + status=200, + content_type="text/plain", + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_failed" + + +@responses.activate +def test_unexpected_token_payload_cannot_escape_as_server_error(oauth_client): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + json=["unexpected", "payload"], + status=200, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_unavailable" + + +@pytest.mark.parametrize( + "network_error", + [ + requests.Timeout("timeout"), + requests.ConnectionError("offline"), + ], +) +@responses.activate +def test_network_failure_has_retryable_public_code(oauth_client, network_error): + responses.add( + responses.POST, + GitHubOAuthClient.ACCESS_TOKEN_URL, + body=network_error, + ) + + with pytest.raises(GitHubOAuthError) as error: + oauth_client.get_verified_username("temporary-code") + + assert error.value.code == "oauth_unavailable" diff --git a/tests/test_join_rate_limit.py b/tests/test_join_rate_limit.py new file mode 100644 index 0000000..a49c5e5 --- /dev/null +++ b/tests/test_join_rate_limit.py @@ -0,0 +1,127 @@ +"""Настоящие проверки rate limit всех публичных endpoint сценария `/join`.""" + +import pytest +from fastapi.testclient import TestClient +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +import main + + +@pytest.fixture(autouse=True) +def reset_rate_limit_storage(): + """Изолировать счётчики запросов между тестами без ожидания реального времени.""" + + main.limiter.reset() + yield + main.limiter.reset() + + +@pytest.fixture +def configured_join(monkeypatch): + """Подменить только чтение курса, не обращаясь к YAML или GitHub.""" + + config = main.JoinLabConfig( + course_id="test-course", + course_name="Test course", + lab_id="01", + lab_name="ЛР0.1", + organization="test-org", + github_prefix="task-01", + template_owner="teacher-org", + template_repository="template", + ) + monkeypatch.setattr(main, "get_join_lab_config", lambda course_id, lab_id: config) + monkeypatch.setattr(main, "GITHUB_OAUTH_CLIENT_ID", None) + monkeypatch.setattr(main, "GITHUB_OAUTH_CLIENT_SECRET", None) + return config + + +@pytest.mark.rate_limit +def test_join_info_returns_429_after_sixty_requests(configured_join): + with TestClient(main.app, client=("203.0.113.10", 50000)) as client: + for _ in range(60): + assert client.get("/join/test-course/01").status_code == 200 + + assert client.get("/join/test-course/01").status_code == 429 + + +@pytest.mark.rate_limit +def test_join_start_returns_429_after_ten_requests(configured_join): + with TestClient(main.app, client=("203.0.113.11", 50000)) as client: + for _ in range(10): + response = client.get( + "/join/test-course/01/start", + follow_redirects=False, + ) + assert response.status_code == 303 + + assert client.get( + "/join/test-course/01/start", + follow_redirects=False, + ).status_code == 429 + + +@pytest.mark.rate_limit +def test_join_callback_query_cannot_bypass_twenty_request_limit(): + with TestClient(main.app, client=("203.0.113.12", 50000)) as client: + for index in range(20): + response = client.get( + "/join/callback", + params={"code": f"code-{index}", "state": f"state-{index}"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + response = client.get( + "/join/callback", + params={"code": "new-code", "state": "new-state"}, + follow_redirects=False, + ) + assert response.status_code == 429 + + +@pytest.mark.rate_limit +def test_arbitrary_forwarded_for_header_cannot_bypass_limit(configured_join): + with TestClient(main.app, client=("203.0.113.13", 50000)) as client: + for index in range(10): + response = client.get( + "/join/test-course/01/start", + headers={"X-Forwarded-For": f"198.51.100.{index + 1}"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + response = client.get( + "/join/test-course/01/start", + headers={"X-Forwarded-For": "198.51.100.250"}, + follow_redirects=False, + ) + assert response.status_code == 429 + + +@pytest.mark.rate_limit +def test_trusted_proxy_separates_real_client_addresses(configured_join): + proxy_app = ProxyHeadersMiddleware(main.app, trusted_hosts=["trusted-proxy"]) + with TestClient( + proxy_app, + client=("trusted-proxy", 50000), + ) as client: + for _ in range(10): + response = client.get( + "/join/test-course/01/start", + headers={"X-Forwarded-For": "198.51.100.10"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + assert client.get( + "/join/test-course/01/start", + headers={"X-Forwarded-For": "198.51.100.10"}, + follow_redirects=False, + ).status_code == 429 + + assert client.get( + "/join/test-course/01/start", + headers={"X-Forwarded-For": "198.51.100.11"}, + follow_redirects=False, + ).status_code == 303 diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 85c1f5a..8ccc22b 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -12,13 +12,21 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from main import app +from main import app, limiter @pytest.mark.rate_limit class TestRateLimiting: """Tests for rate limiting functionality.""" + @pytest.fixture(autouse=True) + def reset_limiter(self): + """Очищать in-memory счётчики, чтобы тесты не зависели от порядка запуска.""" + + limiter.reset() + yield + limiter.reset() + @pytest.fixture def client(self): """Create a test client.""" @@ -78,16 +86,30 @@ def test_rate_limit_on_register_endpoint(self, client): 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 + """Проверить сброс минутного окна без реального ожидания.""" + + payload = {"login": "wrong", "password": "wrong"} + for _ in range(5): + assert client.post("/admin/login", json=payload).status_code == 401 + assert client.post("/admin/login", json=payload).status_code == 429 + + # MemoryStorage удаляет счётчик при истёкшем timestamp. Переводим только + # созданные этим тестом записи в истёкшее состояние, не запуская sleep. + for key in list(limiter._storage.expirations): + limiter._storage.expirations[key] = 0 + + assert client.post("/admin/login", json=payload).status_code == 401 + + def test_different_ips_have_separate_limits(self): + """Проверить независимые счётчики для двух адресов клиента.""" + + payload = {"login": "wrong", "password": "wrong"} + with ( + TestClient(app, client=("203.0.113.21", 50000)) as first_client, + TestClient(app, client=("203.0.113.22", 50000)) as second_client, + ): + for _ in range(5): + assert first_client.post("/admin/login", json=payload).status_code == 401 + assert first_client.post("/admin/login", json=payload).status_code == 429 + assert second_client.post("/admin/login", json=payload).status_code == 401 diff --git a/tests/test_repo_generation_endpoints.py b/tests/test_repo_generation_endpoints.py new file mode 100644 index 0000000..8e9a1cd --- /dev/null +++ b/tests/test_repo_generation_endpoints.py @@ -0,0 +1,400 @@ +"""Тесты endpoint полного OAuth-сценария `/join`.""" + +from urllib.parse import parse_qs, urlsplit + +import pytest +import responses +from fastapi.testclient import TestClient +from freezegun import freeze_time + +import main +from grading.github_oauth import GitHubOAuthError +from grading.repository_provisioner import ( + GitHubRepositoryClient, + ProvisionResult, + RepositoryProvisionError, +) + + +@pytest.fixture +def join_course(): + return { + "name": "Operating Systems", + "github": {"organization": "test-org"}, + "labs": { + "01": { + "short-name": "ЛР0.1", + "github-prefix": "os-task-I-1", + "template-repo": "teacher-org/os-template", + } + }, + "_meta": {"filename": "test.yaml"}, + } + + +@pytest.fixture +def join_client(monkeypatch, join_course): + monkeypatch.setattr(main, "GITHUB_OAUTH_CLIENT_ID", "client-id") + monkeypatch.setattr(main, "GITHUB_OAUTH_CLIENT_SECRET", "client-secret") + monkeypatch.setattr( + main, + "GITHUB_OAUTH_CALLBACK_URL", + "https://grader.example/api/v1/join/callback", + ) + monkeypatch.setattr(main, "FRONTEND_BASE_URL", "https://grader.example") + + def fake_get_course(course_id): + if course_id != "os-2026": + raise main.HTTPException(status_code=404, detail="Course not found") + return join_course + + monkeypatch.setattr(main, "get_course_by_id", fake_get_course) + return TestClient(main.app) + + +def signed_state(join_client, course_id="os-2026", lab_id="01", set_cookie=True): + nonce = "test-browser-nonce-with-sufficient-entropy" + if set_cookie: + join_client.cookies.set(main.OAUTH_NONCE_COOKIE_NAME, nonce) + return main.oauth_state_serializer.dumps( + {"course_id": course_id, "lab_id": lab_id, "nonce": nonce} + ) + + +def assert_oauth_state_error(response, expected_code): + """Проверить безопасный redirect общей ошибки без исходных OAuth-параметров.""" + + location = urlsplit(response.headers["location"]) + assert response.status_code == 303 + assert location.path == "/join/error" + assert parse_qs(location.query) == { + "status": ["error"], + "error": [expected_code], + } + assert "code=" not in response.headers["location"] + assert "state=" not in response.headers["location"] + assert "max-age=0" in response.headers["set-cookie"].lower() + + +def test_join_info_uses_exact_lab_key(join_client): + response = join_client.get("/join/os-2026/01") + + assert response.status_code == 200 + assert response.json() == { + "course_id": "os-2026", + "course_name": "Operating Systems", + "lab_id": "01", + "lab_name": "ЛР0.1", + } + assert join_client.get("/join/os-2026/1").status_code == 404 + + +def test_join_info_reports_missing_template(join_client, join_course): + del join_course["labs"]["01"]["template-repo"] + + response = join_client.get("/join/os-2026/01") + + assert response.status_code == 409 + assert "репозиторий-шаблон" in response.json()["detail"] + + +def test_join_info_rejects_malformed_template(join_client, join_course): + join_course["labs"]["01"]["template-repo"] = "teacher-org/template.git" + + response = join_client.get("/join/os-2026/01") + + assert response.status_code == 409 + assert "owner/repo" in response.json()["detail"] + + +def test_join_config_accepts_numeric_prefix_from_existing_courses(join_client, join_course): + join_course["labs"]["01"]["github-prefix"] = 1 + + config = main.get_join_lab_config("os-2026", "01") + + assert config.github_prefix == "1" + + +def test_start_redirect_contains_signed_state(join_client): + response = join_client.get("/join/os-2026/01/start", follow_redirects=False) + location = urlsplit(response.headers["location"]) + query = parse_qs(location.query) + + assert response.status_code == 302 + assert f"{location.scheme}://{location.netloc}{location.path}" == main.GitHubOAuthClient.AUTHORIZE_URL + assert query["scope"] == ["read:user"] + assert query["redirect_uri"] == ["https://grader.example/api/v1/join/callback"] + state_payload = main.oauth_state_serializer.loads(query["state"][0], max_age=600) + assert state_payload["course_id"] == "os-2026" + assert state_payload["lab_id"] == "01" + assert len(state_payload["nonce"]) >= 32 + assert response.cookies[main.OAUTH_NONCE_COOKIE_NAME] == state_payload["nonce"] + set_cookie = response.headers["set-cookie"].lower() + assert "httponly" in set_cookie + assert "samesite=lax" in set_cookie + + +def test_start_reports_missing_oauth_configuration(join_client, monkeypatch): + monkeypatch.setattr(main, "GITHUB_OAUTH_CLIENT_SECRET", None) + + response = join_client.get("/join/os-2026/01/start", follow_redirects=False) + + assert response.status_code == 303 + assert parse_qs(urlsplit(response.headers["location"]).query)["error"] == [ + "oauth_not_configured" + ] + + +def test_tampered_state_redirects_to_frontend_error(join_client): + response = join_client.get( + "/join/callback", + params={ + "code": "sensitive-temporary-code", + "state": f"{signed_state(join_client)}tampered", + }, + follow_redirects=False, + ) + + assert_oauth_state_error(response, "oauth_state_invalid") + + +def test_missing_state_redirects_to_frontend_error(join_client): + response = join_client.get( + "/join/callback", + params={"code": "sensitive-temporary-code"}, + follow_redirects=False, + ) + + assert_oauth_state_error(response, "oauth_state_missing") + + +def test_state_without_browser_nonce_is_rejected(join_client): + state = signed_state(join_client, set_cookie=False) + + response = join_client.get( + "/join/callback", + params={"code": "code", "state": state}, + follow_redirects=False, + ) + + assert_oauth_state_error(response, "oauth_state_mismatch") + + +def test_signed_state_with_invalid_payload_is_rejected(join_client): + nonce = "test-browser-nonce-with-sufficient-entropy" + join_client.cookies.set(main.OAUTH_NONCE_COOKIE_NAME, nonce) + state = main.oauth_state_serializer.dumps( + {"course_id": "os-2026", "nonce": nonce} + ) + + response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": state}, + follow_redirects=False, + ) + + assert_oauth_state_error(response, "oauth_state_invalid") + + +def test_missing_code_redirects_to_retryable_error(join_client): + response = join_client.get( + "/join/callback", + params={"state": signed_state(join_client)}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert parse_qs(urlsplit(response.headers["location"]).query)["error"] == [ + "oauth_failed" + ] + + +def test_expired_state_redirects_to_frontend_error(join_client): + with freeze_time("2026-01-01 12:00:00"): + state = signed_state(join_client) + with freeze_time("2026-01-01 12:11:00"): + response = join_client.get( + "/join/callback", + params={"code": "code", "state": state}, + follow_redirects=False, + ) + + assert_oauth_state_error(response, "oauth_state_expired") + + +def test_replayed_callback_is_rejected_after_nonce_cookie_is_deleted(join_client): + state = signed_state(join_client) + first_response = join_client.get( + "/join/callback", + params={"state": state}, + follow_redirects=False, + ) + assert first_response.status_code == 303 + assert "oauth_failed" in first_response.headers["location"] + assert "max-age=0" in first_response.headers["set-cookie"].lower() + + # TestClient сохраняет вручную установленную cookie иначе, чем браузер. + # Удаляем её явно после проверки Set-Cookie, воспроизводя повторный callback. + join_client.cookies.delete(main.OAUTH_NONCE_COOKIE_NAME) + replay_response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": state}, + follow_redirects=False, + ) + + assert_oauth_state_error(replay_response, "oauth_state_mismatch") + + +def test_access_denied_redirects_to_retryable_frontend_error(join_client): + response = join_client.get( + "/join/callback", + params={"error": "access_denied", "state": signed_state(join_client)}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"].startswith("https://grader.example/join/os-2026/01?") + assert parse_qs(urlsplit(response.headers["location"]).query) == { + "status": ["error"], + "error": ["oauth_denied"], + } + assert "max-age=0" in response.headers["set-cookie"].lower() + + +def test_successful_callback_redirects_to_repository(join_client, monkeypatch): + monkeypatch.setattr( + main.GitHubOAuthClient, + "get_verified_username", + lambda self, code: "VerifiedStudent", + ) + provision_arguments = {} + + def successful_provision(self, **kwargs): + provision_arguments.update(kwargs) + return ProvisionResult( + organization="test-org", + repository="os-task-I-1-VerifiedStudent", + repository_url="https://github.com/test-org/os-task-I-1-VerifiedStudent", + created=True, + access_action="invited", + ) + + monkeypatch.setattr(main.RepositoryProvisioner, "provision", successful_provision) + + response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": signed_state(join_client)}, + follow_redirects=False, + ) + query = parse_qs(urlsplit(response.headers["location"]).query) + + assert response.status_code == 303 + assert query["status"] == ["success"] + assert query["repository"] == [ + "https://github.com/test-org/os-task-I-1-VerifiedStudent" + ] + assert provision_arguments == { + "organization": "test-org", + "github_prefix": "os-task-I-1", + "template_owner": "teacher-org", + "template_repository": "os-template", + "join_key": "VerifiedStudent", + } + + +@responses.activate +def test_complete_callback_uses_separate_student_and_server_tokens(join_client, monkeypatch): + monkeypatch.setattr(main, "GITHUB_TOKEN", "server-organization-token") + responses.add( + responses.POST, + main.GitHubOAuthClient.ACCESS_TOKEN_URL, + json={"access_token": "student-identity-token"}, + status=200, + ) + responses.add( + responses.GET, + main.GitHubOAuthClient.CURRENT_USER_URL, + json={"login": "VerifiedStudent"}, + status=200, + ) + repository_api = GitHubRepositoryClient.BASE_URL + target = "test-org/os-task-I-1-VerifiedStudent" + responses.add(responses.GET, f"{repository_api}/repos/{target}", status=404) + responses.add( + responses.POST, + f"{repository_api}/repos/teacher-org/os-template/generate", + status=201, + ) + responses.add( + responses.GET, + f"{repository_api}/repos/{target}/collaborators", + json=[], + status=200, + ) + responses.add( + responses.GET, + f"{repository_api}/repos/{target}/invitations", + json=[], + status=200, + ) + responses.add( + responses.PUT, + f"{repository_api}/repos/{target}/collaborators/VerifiedStudent", + status=201, + ) + + response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": signed_state(join_client)}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert "status=success" in response.headers["location"] + assert responses.calls[1].request.headers["Authorization"] == "Bearer student-identity-token" + for call in responses.calls[2:]: + assert call.request.headers["Authorization"] == "Bearer server-organization-token" + + +def test_oauth_failure_is_mapped_to_safe_code(join_client, monkeypatch): + def fail_oauth(self, code): + raise GitHubOAuthError("oauth_unavailable", "technical detail") + + monkeypatch.setattr(main.GitHubOAuthClient, "get_verified_username", fail_oauth) + response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": signed_state(join_client)}, + follow_redirects=False, + ) + + assert parse_qs(urlsplit(response.headers["location"]).query)["error"] == [ + "oauth_unavailable" + ] + assert "technical detail" not in response.headers["location"] + + +def test_repository_failure_is_mapped_to_safe_code(join_client, monkeypatch): + monkeypatch.setattr( + main.GitHubOAuthClient, + "get_verified_username", + lambda self, code: "VerifiedStudent", + ) + + def fail_provision(self, **kwargs): + raise RepositoryProvisionError( + "github_rate_limit", + "GitHub response with internal details", + 403, + ) + + monkeypatch.setattr(main.RepositoryProvisioner, "provision", fail_provision) + response = join_client.get( + "/join/callback", + params={"code": "temporary-code", "state": signed_state(join_client)}, + follow_redirects=False, + ) + + assert parse_qs(urlsplit(response.headers["location"]).query)["error"] == [ + "github_rate_limit" + ] + assert "internal" not in response.headers["location"] diff --git a/tests/test_repository_provisioner.py b/tests/test_repository_provisioner.py new file mode 100644 index 0000000..a373973 --- /dev/null +++ b/tests/test_repository_provisioner.py @@ -0,0 +1,472 @@ +"""Тесты всех ветвей создания репозитория и обработки приглашений.""" + +import pytest +import requests +import responses + +from grading.repository_provisioner import ( + GitHubRepositoryClient, + RepositoryProvisionError, + RepositoryProvisioner, +) + + +API = GitHubRepositoryClient.BASE_URL +TARGET = "test-org/os-task1-StudentName" +DIRECT_COLLABORATORS_URL = f"{API}/repos/{TARGET}/collaborators" + + +@pytest.fixture +def provisioner(): + return RepositoryProvisioner(GitHubRepositoryClient("server-token")) + + +def provision(provisioner): + return provisioner.provision( + organization="test-org", + github_prefix="os-task1", + template_owner="teacher-org", + template_repository="lab-template", + join_key="StudentName", + ) + + +@responses.activate +def test_creates_private_repository_and_first_invitation(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=404) + responses.add( + responses.POST, + f"{API}/repos/teacher-org/lab-template/generate", + json={"full_name": "test-org/os-task1-StudentName"}, + status=201, + ) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[], + status=200, + ) + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName/invitations", + json=[], + status=200, + ) + responses.add( + responses.PUT, + f"{API}/repos/test-org/os-task1-StudentName/collaborators/StudentName", + json={"id": 1}, + status=201, + ) + + result = provision(provisioner) + + assert result.created is True + assert result.access_action == "invited" + assert result.repository_url == "https://github.com/test-org/os-task1-StudentName" + create_body = responses.calls[1].request.body.decode() + assert '"private": true' in create_body + assert responses.calls[1].request.headers["Authorization"] == "Bearer server-token" + + +@responses.activate +def test_existing_repository_and_access_are_left_untouched(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=200) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[{"login": "studentname"}], + status=200, + ) + + result = provision(provisioner) + + assert result.created is False + assert result.access_action == "already_has_access" + assert [call.request.method for call in responses.calls] == ["GET", "GET"] + assert "affiliation=direct" in responses.calls[1].request.url + + +@responses.activate +def test_direct_collaborator_lookup_follows_pagination(provisioner): + responses.add(responses.GET, f"{API}/repos/{TARGET}", status=200) + first_page = [{"login": f"other-{index}"} for index in range(100)] + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=first_page, + status=200, + ) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[{"login": "studentname"}], + status=200, + ) + + result = provision(provisioner) + + assert result.access_action == "already_has_access" + collaborator_calls = [ + call + for call in responses.calls + if "/collaborators" in call.request.url and call.request.method == "GET" + ] + assert len(collaborator_calls) == 2 + assert "affiliation=direct" in collaborator_calls[0].request.url + assert "page=2" in collaborator_calls[1].request.url + + +@responses.activate +def test_pending_invitation_is_deleted_and_sent_again(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=200) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[], + status=200, + ) + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName/invitations", + json=[{"id": 77, "invitee": {"login": "studentname"}}], + status=200, + ) + responses.add( + responses.DELETE, + f"{API}/repos/test-org/os-task1-StudentName/invitations/77", + status=204, + ) + responses.add( + responses.PUT, + f"{API}/repos/test-org/os-task1-StudentName/collaborators/StudentName", + status=201, + ) + + result = provision(provisioner) + + assert result.access_action == "reinvited" + assert [call.request.method for call in responses.calls][-2:] == ["DELETE", "PUT"] + + +@responses.activate +def test_invitation_lookup_follows_pagination(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=200) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[], + status=200, + ) + first_page = [ + {"id": invitation_id, "invitee": {"login": f"other-{invitation_id}"}} + for invitation_id in range(100) + ] + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName/invitations", + json=first_page, + status=200, + ) + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName/invitations", + json=[{"id": 701, "invitee": {"login": "studentname"}}], + status=200, + ) + responses.add( + responses.DELETE, + f"{API}/repos/test-org/os-task1-StudentName/invitations/701", + status=204, + ) + responses.add( + responses.PUT, + f"{API}/repos/test-org/os-task1-StudentName/collaborators/StudentName", + status=204, + ) + + result = provision(provisioner) + + assert result.access_action == "reinvited" + invitation_calls = [ + call for call in responses.calls if "/invitations" in call.request.url + and call.request.method == "GET" + ] + assert len(invitation_calls) == 2 + assert "page=2" in invitation_calls[1].request.url + + +@responses.activate +def test_creation_race_is_success_when_repository_appears(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=404) + responses.add( + responses.POST, + f"{API}/repos/teacher-org/lab-template/generate", + json={"message": "name already exists"}, + status=422, + ) + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=200) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[{"login": "StudentName"}], + status=200, + ) + + result = provision(provisioner) + + assert result.created is False + assert result.access_action == "already_has_access" + + +@responses.activate +def test_creation_validation_error_is_not_mistaken_for_race(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=404) + responses.add( + responses.POST, + f"{API}/repos/teacher-org/lab-template/generate", + json={"message": "Validation Failed"}, + status=422, + ) + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=404) + + with pytest.raises(RepositoryProvisionError) as error: + provision(provisioner) + + assert error.value.code == "repository_create_failed" + + +@responses.activate +def test_disappearing_invitation_rechecks_access_before_reinviting(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=200) + responses.add(responses.GET, DIRECT_COLLABORATORS_URL, json=[], status=200) + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName/invitations", + json=[{"id": 91, "invitee": {"login": "StudentName"}}], + status=200, + ) + responses.add( + responses.DELETE, + f"{API}/repos/test-org/os-task1-StudentName/invitations/91", + status=404, + ) + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json=[{"login": "StudentName"}], + status=200, + ) + + result = provision(provisioner) + + assert result.access_action == "already_has_access" + assert not any(call.request.method == "PUT" for call in responses.calls) + + +@responses.activate +def test_rate_limit_is_classified_for_frontend_retry_message(provisioner): + responses.add( + responses.GET, + f"{API}/repos/test-org/os-task1-StudentName", + json={"message": "API rate limit exceeded"}, + status=403, + headers={"X-RateLimit-Remaining": "0"}, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provision(provisioner) + + assert error.value.code == "github_rate_limit" + + +@responses.activate +def test_inaccessible_template_has_specific_error_code(provisioner): + responses.add(responses.GET, f"{API}/repos/test-org/os-task1-StudentName", status=404) + responses.add( + responses.POST, + f"{API}/repos/teacher-org/lab-template/generate", + json={"message": "Not Found"}, + status=404, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provision(provisioner) + + assert error.value.code == "template_unavailable" + + +@pytest.mark.parametrize("status_code", [401, 403, 409, 422, 500, 502, 503]) +@responses.activate +def test_repository_lookup_preserves_http_error_status(provisioner, status_code): + responses.add( + responses.GET, + f"{API}/repos/{TARGET}", + json={"message": "GitHub diagnostic message"}, + status=status_code, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.repository_exists("test-org", "os-task1-StudentName") + + assert error.value.code == "repository_lookup_failed" + assert error.value.status_code == status_code + assert "server-token" not in error.value.log_message + assert "Authorization" not in error.value.log_message + + +@responses.activate +def test_http_429_is_classified_as_github_rate_limit(provisioner): + responses.add( + responses.GET, + f"{API}/repos/{TARGET}", + json={"message": "Too many requests"}, + status=429, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.repository_exists("test-org", "os-task1-StudentName") + + assert error.value.code == "github_rate_limit" + assert error.value.status_code == 429 + + +@pytest.mark.parametrize( + "network_error", + [ + requests.Timeout("timeout"), + requests.ConnectionError("offline"), + ], +) +@responses.activate +def test_repository_network_errors_are_retryable(provisioner, network_error): + responses.add( + responses.GET, + f"{API}/repos/{TARGET}", + body=network_error, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.repository_exists("test-org", "os-task1-StudentName") + + assert error.value.code == "github_unavailable" + + +@responses.activate +def test_invalid_collaborator_json_is_not_treated_as_no_access(provisioner): + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + body="not-json", + status=200, + content_type="text/plain", + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.is_direct_collaborator( + "test-org", + "os-task1-StudentName", + "StudentName", + ) + + assert error.value.code == "access_check_failed" + + +@responses.activate +def test_invalid_invitation_json_is_not_treated_as_missing_invitation(provisioner): + responses.add( + responses.GET, + f"{API}/repos/{TARGET}/invitations", + body="not-json", + status=200, + content_type="text/plain", + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.find_pending_invitation( + "test-org", + "os-task1-StudentName", + "StudentName", + ) + + assert error.value.code == "invitation_lookup_failed" + + +@responses.activate +def test_collaborator_api_error_is_not_treated_as_no_access(provisioner): + responses.add( + responses.GET, + DIRECT_COLLABORATORS_URL, + json={"message": "Forbidden"}, + status=403, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.is_direct_collaborator( + "test-org", + "os-task1-StudentName", + "StudentName", + ) + + assert error.value.code == "access_check_failed" + assert error.value.status_code == 403 + + +@responses.activate +def test_invitation_api_error_is_not_treated_as_empty_list(provisioner): + responses.add( + responses.GET, + f"{API}/repos/{TARGET}/invitations", + json={"message": "Service unavailable"}, + status=503, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.find_pending_invitation( + "test-org", + "os-task1-StudentName", + "StudentName", + ) + + assert error.value.code == "invitation_lookup_failed" + assert error.value.status_code == 503 + + +@responses.activate +def test_invitation_delete_error_is_classified(provisioner): + responses.add( + responses.DELETE, + f"{API}/repos/{TARGET}/invitations/77", + json={"message": "Service unavailable"}, + status=502, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.delete_invitation( + "test-org", + "os-task1-StudentName", + 77, + ) + + assert error.value.code == "invitation_delete_failed" + assert error.value.status_code == 502 + + +@responses.activate +def test_invitation_create_error_is_classified(provisioner): + responses.add( + responses.PUT, + f"{API}/repos/{TARGET}/collaborators/StudentName", + json={"message": "Validation Failed"}, + status=422, + ) + + with pytest.raises(RepositoryProvisionError) as error: + provisioner.client.invite_collaborator( + "test-org", + "os-task1-StudentName", + "StudentName", + ) + + assert error.value.code == "invitation_create_failed" + assert error.value.status_code == 422 diff --git a/tests/test_security_configuration.py b/tests/test_security_configuration.py new file mode 100644 index 0000000..426d2c9 --- /dev/null +++ b/tests/test_security_configuration.py @@ -0,0 +1,131 @@ +"""Проверки безопасной конфигурации ключа и очистки OAuth access log.""" + +import logging +import os +from pathlib import Path +import subprocess +import sys + +import main + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def run_main_import(tmp_path, secret_key): + """Импортировать приложение в отдельном процессе с контролируемым окружением.""" + + environment = os.environ.copy() + environment.update( + { + "ADMIN_LOGIN": "test-admin", + "ADMIN_PASSWORD": "test-password", + "GITHUB_TOKEN": "test-server-token", + "LOG_DIR": str(tmp_path / "logs"), + "PYTHONIOENCODING": "utf-8", + } + ) + if secret_key is None: + environment.pop("SECRET_KEY", None) + else: + environment["SECRET_KEY"] = secret_key + + # Подмена load_dotenv гарантирует, что локальный .env разработчика не + # изменит сценарий отдельного процесса и не попадёт в диагностический вывод. + command = ( + "import dotenv; " + "dotenv.load_dotenv = lambda *args, **kwargs: False; " + "import main" + ) + return subprocess.run( + [sys.executable, "-c", command], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + +def test_missing_secret_key_stops_application_without_disclosure(tmp_path): + result = run_main_import(tmp_path, None) + output = result.stdout + result.stderr + + assert result.returncode != 0 + assert "RuntimeError: SECRET_KEY" in output + + +def test_known_default_secret_key_stops_application_without_disclosure(tmp_path): + result = run_main_import(tmp_path, main.DEFAULT_SECRET_KEY) + output = result.stdout + result.stderr + + assert result.returncode != 0 + assert "RuntimeError: SECRET_KEY" in output + assert main.DEFAULT_SECRET_KEY not in output + + +def test_custom_secret_key_allows_application_import_without_logging_key(tmp_path): + custom_key = "isolated-test-key-that-must-not-appear" + result = run_main_import(tmp_path, custom_key) + output = result.stdout + result.stderr + + assert result.returncode == 0 + assert custom_key not in output + + +def test_uvicorn_access_filter_redacts_callback_secrets(): + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='%s - "%s %s HTTP/%s" %d', + args=( + "127.0.0.1:50000", + "GET", + ( + "/join/callback?code=student-code-secret" + "&state=signed-state-secret" + "&authorization=Bearer-secret" + "&error=access_denied" + ), + "1.1", + 303, + ), + exc_info=None, + ) + + assert main.OAuthCallbackAccessLogFilter().filter(record) is True + rendered = record.getMessage() + + assert "student-code-secret" not in rendered + assert "signed-state-secret" not in rendered + assert "Bearer-secret" not in rendered + assert rendered.count("[REDACTED]") == 3 + assert "GET /join/callback?" in rendered + assert "error=access_denied" in rendered + assert rendered.endswith('HTTP/1.1" 303') + + +def test_access_filter_redacts_callback_from_test_client_log(): + record = logging.LogRecord( + name="httpx", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='HTTP Request: GET %s "HTTP/1.1 303 See Other"', + args=( + "http://testserver/join/callback" + "?code=test-client-code&state=test-client-state", + ), + exc_info=None, + ) + + assert main.OAuthCallbackAccessLogFilter().filter(record) is True + rendered = record.getMessage() + + assert "test-client-code" not in rendered + assert "test-client-state" not in rendered + assert rendered.count("[REDACTED]") == 2 + assert "GET http://testserver/join/callback?" in rendered