Лабораторная работа №3. Автоматизация процессов обеспечения качества и сопровождения ИС (CI/CD, статический анализ, SonarQube)
1. Теория
1.1. Автоматизация обеспечения качества информационных систем
Обеспечение качества информационных систем является непрерывным процессом, охватывающим этапы разработки, тестирования, внедрения и сопровождения.
Современные подходы предполагают автоматизацию ключевых операций контроля качества с целью снижения человеческого фактора, повышения повторяемости процедур и ускорения выпуска изменений.
Автоматизация качества позволяет: - выявлять дефекты на ранних этапах разработки; - обеспечивать стабильность программного кода; - поддерживать единые стандарты качества; - повышать надёжность и сопровождаемость ИС.
Ключевым принципом автоматизации является принцип «смещения влево» (Shift Left) — чем раньше обнаружен дефект, тем дешевле его устранение. Исправление ошибки на этапе разработки обходится в 5–10 раз дешевле, чем на этапе тестирования, и в 100 раз дешевле, чем после выпуска.
1.2. CI/CD в жизненном цикле информационных систем
CI/CD (Continuous Integration / Continuous Delivery) — подход к разработке, при котором интеграция, тестирование и доставка программного продукта выполняются автоматически и регулярно.
-
Continuous Integration (CI)
Предполагает автоматическую сборку проекта, запуск тестов и проверок при каждом изменении кода. -
Continuous Delivery / Deployment (CD)
Обеспечивает автоматическую подготовку и доставку программного продукта в тестовую или рабочую среду.
Использование CI/CD позволяет повысить качество кода, сократить количество ошибок и ускорить цикл разработки.
Типичный CI/CD-пайплайн состоит из последовательных этапов (стадий):
| Стадия | Действие | Инструмент |
|---|---|---|
| Source | Коммит кода в репозиторий | Git, GitHub |
| Build | Сборка проекта, проверка компиляции | pip, dotnet build |
| Test | Запуск unit- и интеграционных тестов | pytest, xUnit |
| Analyze | Статический анализ кода | SonarQube, flake8 |
| Package | Сборка артефакта (Docker-образ и др.) | Docker |
| Deploy | Развёртывание в целевую среду | GitHub Actions, Kubernetes |
1.3. Статический анализ программного кода
Статический анализ — это метод проверки программного кода без его выполнения.
Он используется для выявления:
- логических ошибок;
- нарушений стандартов кодирования;
- потенциальных уязвимостей безопасности;
- дублирования и избыточности кода.
Статический анализ является важным элементом автоматизированного контроля качества ИС.
Отличие от динамического тестирования:
| Критерий | Статический анализ | Динамическое тестирование |
|---|---|---|
| Момент проверки | Без запуска кода | В процессе выполнения |
| Скорость | Высокая | Зависит от объёма тестов |
| Что обнаруживает | Стиль, уязвимости, дубли | Ошибки поведения, производительность |
| Покрытие | Весь код целиком | Только исполняемые пути |
| Примеры инструментов | SonarQube, flake8, Roslyn | pytest, xUnit, JMeter |
1.4. SonarQube как инструмент контроля качества
SonarQube — платформа для автоматизированного анализа качества программного обеспечения.
Она позволяет оценивать:
- читаемость и сопровождаемость кода;
- уровень технического долга;
- наличие уязвимостей и дефектов;
- соответствие стандартам качества.
SonarQube может быть интегрирован в CI/CD-пайплайн и использоваться как инструмент постоянного мониторинга качества ИС.
Основные метрики SonarQube:
| Метрика | Описание | Целевое значение |
|---|---|---|
| Bugs | Ошибки, приводящие к некорректной работе | 0 |
| Vulnerabilities | Уязвимости безопасности | 0 |
| Code Smells | Проблемы читаемости и сопровождаемости | Минимум |
| Coverage | Процент кода, покрытого тестами | > 80 % |
| Duplications | Дублирование кода | < 3 % |
| Technical Debt | Оценка времени на устранение проблем | Снижение от версии к версии |
Quality Gate — механизм SonarQube, который блокирует прохождение пайплайна, если метрики не соответствуют установленному порогу. Это ключевой инструмент поддержания качества в CI/CD.
2. Задание
2.1. Подготовка окружения
1) Установить на локальный компьютер: - Git; - Python версии 3.10 и выше или .NET SDK 8.0 и выше (в зависимости от варианта); - Docker; - среду разработки (PyCharm, Visual Studio Code или Visual Studio 2022). 2) Создать рабочую директорию для выполнения лабораторной работы. 3) Убедиться в работоспособности окружения:
# Проверка версий установленного ПО
git --version
python --version # для варианта Python
dotnet --version # для варианта C#
docker --version
2.2. Развёртывание SonarQube
1) Развернуть SonarQube с использованием Docker:
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
2) Дождаться готовности сервиса (30–60 секунд). Проверить логи:
docker logs -f sonarqube
# Ожидать строку: SonarQube is operational
3) Открыть браузер: http://localhost:9000
Войти: логин admin, пароль admin. При первом входе система потребует смены пароля.
4) Перейти: My Account (иконка профиля вверху справа) → вкладка Security → раздел Generate Tokens:
- Name: любое имя, например lab3-token
- Type: User Token
- Expiration: No expiration (или 30 дней)
- Нажать Generate
⚠️ Скопировать токен сразу — он показывается только один раз.
45) Создать проект в SonarQube:
- Нажать «Create a local project»
- Project key: lab3-project
- Display name: по варианту задания
- Нажать Next → Use the global setting → Create project
После создания проекта SonarQube предложит выбрать способ анализа — выбрать Locally, ввести ранее сохранённый токен и следовать инструкции по запуску sonar-scanner.
2.3. Создание учебного проекта
1) Создать учебное веб-приложение с минимальной функциональностью:
- регистрация пользователя;
- авторизация;
- смена пароля.
2) Реализовать структуру проекта с разделением логики приложения и тестов. 3) Настроить зависимости проекта.
Реализация выполняется по варианту задания — Python (Flask) или C# (ASP.NET Core). Справочные примеры структуры и кода приведены в разделах 6 и 7.
2.4. Тестирование
1) Реализовать автоматические тесты:
- для варианта Python — с использованием pytest;
- для варианта C# — с использованием xUnit.
2) Обеспечить проверку основных сценариев работы системы:
- успешная регистрация;
- попытка повторной регистрации с тем же логином;
- успешная авторизация;
- авторизация с неверным паролем;
- успешная смена пароля;
- смена пароля с неверным текущим паролем;
- смена на пароль, совпадающий со старым.
3) Выполнить локальный запуск тестов и убедиться, что все тесты проходят. 4) Сгенерировать отчёт о покрытии кода тестами.
2.5. Настройка статического анализа
1) Создать конфигурационный файл sonar-project.properties (см. примеры в разделах 6 и 7).
2) Настроить анализ исходного кода и тестов, указав пути к отчёту о покрытии.
3) Выполнить анализ проекта в SonarQube:
Вариант 1 — SonarScanner CLI (если установлен):
sonar-scanner -Dsonar.token=squ_d67f7fe1b589006ad6d1a9dfc78c9c536e7c1198
Вариант 2 — через Docker в PowerShell:
docker run --rm `
-v ${PWD}:/usr/src `
-e SONAR_TOKEN=squ_d67f7fe1b589006ad6d1a9dfc78c9c536e7c1198 `
-e SONAR_HOST_URL=http://host.docker.internal:9000 `
sonarsource/sonar-scanner-cli
--
⚠️
--network hostна Windows с Docker Desktop не работает так же, как на Linux. Если SonarQube не находится, используйте явный адрес хоста:
docker run --rm `
-v ${PWD}:/usr/src `
sonarsource/sonar-scanner-cli `
-Dsonar.token=squ_d67f7fe1b589006ad6d1a9dfc78c9c536e7c1198 `
-Dsonar.host.url=http://host.docker.internal:9000
host.docker.internal — специальный DNS-адрес, по которому контейнер достучится до SonarQube, запущенного на вашей машине.
4) Открыть результаты анализа в браузере: http://localhost:9000/dashboard?id=lab3-project
2.6. Настройка CI/CD
1) Создать репозиторий проекта в системе контроля версий GitHub.
2) Добавить токен SonarQube как секрет репозитория:
- GitHub → Settings → Secrets and variables → Actions → New repository secret
- Name: SONAR_TOKEN, Value: токен из SonarQube
3) Настроить GitHub Actions (файл .github/workflows/ci.yml, примеры — в разделах 6 и 7):
- установка зависимостей;
- запуск тестов с покрытием;
- отправка результатов анализа в SonarQube.
4) Выполнить коммит и пуш. Убедиться, что пайплайн прошёл успешно (зелёная галочка в разделе Actions).
2.7. Формирование отчёта
В отчёте отразить: 1) процесс развёртывания SonarQube (скриншоты); 2) структуру проекта; 3) примеры тестов с пояснениями; 4) результаты анализа в SonarQube (скриншоты дашборда и метрик); 5) скриншот успешного прохождения CI/CD-пайплайна в GitHub Actions; 6) выводы о качестве кода и возможных направлениях улучшения.
2.8. Для выполнения требуется
- Вариант задания, закреплённый за студентом на семестр и опубликованный в ЛМС.
- Требования к оформлению — согласно методическим указаниям в ЛМС.
2.9. Запрет
❗ Запрещается использование систем искусственного интеллекта для выполнения лабораторной работы.
Работы, выполненные с использованием ИИ, полностью или частично скопированные, а также не сданные, оцениваются в 0 баллов без возможности доработки.
3. Контрольные вопросы
- В чём заключается роль автоматизации в обеспечении качества ИС?
- Что понимается под CI/CD и какие задачи он решает?
- Чем статический анализ отличается от динамического тестирования?
- Какие показатели качества оценивает SonarQube?
- Почему статический анализ рекомендуется интегрировать в CI/CD?
- Какие преимущества даёт автоматизация тестирования?
- Как CI/CD влияет на сопровождаемость и надёжность ИС?
- Что такое Quality Gate в SonarQube и для чего он применяется?
- Что означает принцип «Shift Left» в контексте обеспечения качества?
- Чем отличаются unit-тесты от интеграционных тестов?
4. Чек-лист для самопроверки
| Баллы | Критерии выполнения |
|---|---|
| 3 | SonarQube развернут; проект создан; реализованы тесты; настроен CI/CD; анализ выполнен; сформулирован обоснованный вывод; работа выполнена самостоятельно. |
| 2 | Основные этапы выполнены, но отсутствуют отдельные элементы (тесты, анализ или выводы). |
| 1 | Работа выполнена частично, автоматизация настроена формально. |
| 0 | Работа не соответствует требованиям, отсутствует или выполнена с использованием ИИ. |
5. Ссылка для скачивания
В ЛМС прикрепляется ссылка на GitHub-репозиторий студента.
Отчёт пишется в свободной форме.
6. Справочный пример — вариант Python (Flask)
Раздел носит справочный характер и демонстрирует минимально достаточную реализацию для понимания требований. Студент реализует собственный проект по варианту задания.
6.1. Структура проекта
lab3-python/
├── app/
│ ├── __init__.py # фабрика Flask-приложения
│ ├── auth.py # бизнес-логика аутентификации
│ └── routes.py # HTTP-маршруты (Blueprint)
├── tests/
│ ├── __init__.py
│ ├── conftest.py # фикстуры pytest
│ ├── test_auth.py # тесты бизнес-логики
│ └── test_routes.py # тесты HTTP API
├── .github/
│ └── workflows/
│ └── ci.yml
├── requirements.txt
├── pytest.ini
├── setup.cfg
└── sonar-project.properties
6.2. Зависимости — requirements.txt
flask==3.0.0
pytest==7.4.3
pytest-cov==4.1.0
coverage==7.3.2
6.3. Бизнес-логика — app/auth.py
# app/auth.py
"""Бизнес-логика аутентификации пользователей."""
import hashlib
import re
# Хранилище: {username: hashed_password}
_users: dict[str, str] = {}
def _hash(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def _valid_username(username: str) -> bool:
return bool(re.match(r'^[a-zA-Z0-9_]{3,20}$', username))
def _valid_password(password: str) -> bool:
return len(password) >= 6
def register(username: str, password: str) -> dict:
if not username or not password:
return {'success': False, 'message': 'Логин и пароль обязательны'}
if not _valid_username(username):
return {'success': False, 'message': 'Логин: 3–20 символов, буквы/цифры/подчёркивание'}
if not _valid_password(password):
return {'success': False, 'message': 'Пароль: минимум 6 символов'}
if username in _users:
return {'success': False, 'message': 'Пользователь уже существует'}
_users[username] = _hash(password)
return {'success': True, 'message': 'Регистрация успешна'}
def login(username: str, password: str) -> dict:
if username not in _users or _users[username] != _hash(password):
return {'success': False, 'message': 'Неверный логин или пароль'}
return {'success': True, 'message': f'Добро пожаловать, {username}!'}
def change_password(username: str, old_password: str, new_password: str) -> dict:
if username not in _users:
return {'success': False, 'message': 'Пользователь не найден'}
if _users[username] != _hash(old_password):
return {'success': False, 'message': 'Неверный текущий пароль'}
if not _valid_password(new_password):
return {'success': False, 'message': 'Пароль: минимум 6 символов'}
if old_password == new_password:
return {'success': False, 'message': 'Новый пароль совпадает со старым'}
_users[username] = _hash(new_password)
return {'success': True, 'message': 'Пароль успешно изменён'}
def clear_users() -> None:
"""Используется только в тестах."""
_users.clear()
6.4. Маршруты — app/routes.py
# app/routes.py
from flask import Blueprint, request, jsonify
from . import auth
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['POST'])
def register():
data = request.get_json(silent=True) or {}
result = auth.register(data.get('username', ''), data.get('password', ''))
return jsonify(result), 201 if result['success'] else 400
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.get_json(silent=True) or {}
result = auth.login(data.get('username', ''), data.get('password', ''))
return jsonify(result), 200 if result['success'] else 401
@auth_bp.route('/change-password', methods=['POST'])
def change_password():
data = request.get_json(silent=True) or {}
result = auth.change_password(
data.get('username', ''),
data.get('old_password', ''),
data.get('new_password', '')
)
return jsonify(result), 200 if result['success'] else 400
6.5. Фабрика приложения — app/__init__.py
# app/__init__.py
from flask import Flask
from .routes import auth_bp
def create_app(testing: bool = False) -> Flask:
app = Flask(__name__)
app.config['TESTING'] = testing
app.config['SECRET_KEY'] = 'dev-secret-key'
app.register_blueprint(auth_bp, url_prefix='/api')
return app
6.6. Фикстуры — tests/conftest.py
# tests/conftest.py
import pytest
from app import create_app
from app import auth
@pytest.fixture(autouse=True)
def clean_users():
"""Очищает хранилище перед каждым тестом."""
auth.clear_users()
yield
auth.clear_users()
@pytest.fixture
def client():
app = create_app(testing=True)
return app.test_client()
@pytest.fixture
def registered_user():
auth.register('testuser', 'password123')
return {'username': 'testuser', 'password': 'password123'}
6.7. Тесты бизнес-логики — tests/test_auth.py
# tests/test_auth.py
from app.auth import register, login, change_password
class TestRegister:
def test_success(self):
result = register('alice', 'secret123')
assert result['success'] is True
def test_duplicate(self):
register('alice', 'secret123')
result = register('alice', 'other123')
assert result['success'] is False
assert 'уже существует' in result['message']
def test_short_password(self):
result = register('bob', '123')
assert result['success'] is False
def test_invalid_username(self):
result = register('bad user!', 'password123')
assert result['success'] is False
def test_empty_fields(self):
result = register('', '')
assert result['success'] is False
class TestLogin:
def test_success(self, registered_user):
result = login(registered_user['username'], registered_user['password'])
assert result['success'] is True
def test_wrong_password(self, registered_user):
result = login(registered_user['username'], 'wrongpass')
assert result['success'] is False
def test_unknown_user(self):
result = login('ghost', 'password123')
assert result['success'] is False
class TestChangePassword:
def test_success(self, registered_user):
result = change_password(
registered_user['username'],
registered_user['password'],
'newpass456'
)
assert result['success'] is True
# Проверяем, что новый пароль работает
assert login(registered_user['username'], 'newpass456')['success'] is True
def test_wrong_old_password(self, registered_user):
result = change_password(registered_user['username'], 'wrongold', 'newpass456')
assert result['success'] is False
def test_same_password(self, registered_user):
result = change_password(
registered_user['username'],
registered_user['password'],
registered_user['password']
)
assert result['success'] is False
assert 'совпадает' in result['message']
def test_unknown_user(self):
result = change_password('ghost', 'old123', 'new123456')
assert result['success'] is False
6.8. Тесты маршрутов — tests/test_routes.py
# tests/test_routes.py
class TestRegisterRoute:
def test_returns_201(self, client):
r = client.post('/api/register', json={'username': 'alice', 'password': 'secret123'})
assert r.status_code == 201
def test_duplicate_returns_400(self, client):
client.post('/api/register', json={'username': 'alice', 'password': 'secret123'})
r = client.post('/api/register', json={'username': 'alice', 'password': 'secret123'})
assert r.status_code == 400
def test_invalid_returns_400(self, client):
r = client.post('/api/register', json={'username': 'a', 'password': '12'})
assert r.status_code == 400
class TestLoginRoute:
def test_success_returns_200(self, client):
client.post('/api/register', json={'username': 'bob', 'password': 'mypassword'})
r = client.post('/api/login', json={'username': 'bob', 'password': 'mypassword'})
assert r.status_code == 200
def test_wrong_password_returns_401(self, client):
client.post('/api/register', json={'username': 'bob', 'password': 'mypassword'})
r = client.post('/api/login', json={'username': 'bob', 'password': 'wrong!'})
assert r.status_code == 401
class TestChangePasswordRoute:
def test_success_returns_200(self, client):
client.post('/api/register', json={'username': 'carol', 'password': 'oldpass123'})
r = client.post('/api/change-password', json={
'username': 'carol',
'old_password': 'oldpass123',
'new_password': 'newpass456'
})
assert r.status_code == 200
def test_wrong_old_returns_400(self, client):
client.post('/api/register', json={'username': 'carol', 'password': 'oldpass123'})
r = client.post('/api/change-password', json={
'username': 'carol',
'old_password': 'WRONG',
'new_password': 'newpass456'
})
assert r.status_code == 400
6.9. Конфигурация — pytest.ini и setup.cfg
# pytest.ini
[pytest]
testpaths = tests
addopts = -v --tb=short
# setup.cfg
[coverage:run]
source = app
omit = */tests/*
[coverage:report]
show_missing = True
fail_under = 80
[coverage:xml]
output = coverage.xml
6.10. Конфигурация SonarQube — sonar-project.properties
sonar.projectKey=lab3-python
sonar.projectName=Lab3 Python
sonar.projectVersion=1.0
sonar.sources=app
sonar.tests=tests
sonar.language=py
sonar.python.coverage.reportPaths=coverage.xml
sonar.sourceEncoding=UTF-8
sonar.host.url=http://localhost:9000
6.11. Запуск тестов локально
# Установка зависимостей
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
pip uninstall pytest-asyncio -y
pip install pytest==7.4.3 pytest-cov==4.1.0
# Запуск тестов с отчётом о покрытии
pytest --cov=app --cov-report=term-missing --cov-report=xml
# Ожидаемый результат: все тесты passed, coverage > 80%
#посмотреть пункт 2.5. Настройка статического анализа
6.12. CI/CD пайплайн — .github/workflows/ci.yml
name: CI — Python
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-analyze:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with coverage
run: pytest --cov=app --cov-report=xml --cov-report=term-missing -v
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
7. Справочный пример — вариант C# (ASP.NET Core)
Раздел носит справочный характер. Студент реализует собственный проект по варианту задания.
7.1. Структура проекта
lab3-csharp/
├── Lab3.Auth/ # основной проект
│ ├── Lab3.Auth.csproj
│ ├── Services/
│ │ └── AuthService.cs # бизнес-логика аутентификации
│ └── Controllers/
│ └── AuthController.cs # HTTP-контроллер (REST API)
├── Lab3.Auth.Tests/ # проект с тестами
│ ├── Lab3.Auth.Tests.csproj
│ ├── AuthServiceTests.cs # unit-тесты бизнес-логики
│ └── AuthControllerTests.cs # интеграционные тесты контроллера
├── .github/
│ └── workflows/
│ └── ci.yml
└── sonar-project.properties
7.2. Создание проекта
# Создание структуры через .NET CLI
dotnet new webapi -n Lab3.Auth --no-openapi
cd ..
dotnet new xunit -n Lab3.Auth.Tests
dotnet add Lab3.Auth.Tests/Lab3.Auth.Tests.csproj reference Lab3.Auth/Lab3.Auth.csproj
# Добавление зависимостей для тестов
dotnet add Lab3.Auth.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Lab3.Auth.Tests package coverlet.collector
7.3. Бизнес-логика — Lab3.Auth/Services/AuthService.cs
// Lab3.Auth/Services/AuthService.cs
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Lab3.Auth.Services;
public record AuthResult(bool Success, string Message);
public interface IAuthService
{
AuthResult Register(string username, string password);
AuthResult Login(string username, string password);
AuthResult ChangePassword(string username, string oldPassword, string newPassword);
IReadOnlyList<string> GetUsers();
void ClearUsers(); // для тестов
}
public class AuthService : IAuthService
{
// Потокобезопасное хранилище: {username: hashedPassword}
private readonly ConcurrentDictionary<string, string> _users = new();
private static string Hash(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
return Convert.ToHexString(bytes).ToLower();
}
private static bool IsValidUsername(string username) =>
Regex.IsMatch(username, @"^[a-zA-Z0-9_]{3,20}$");
private static bool IsValidPassword(string password) =>
password.Length >= 6;
public AuthResult Register(string username, string password)
{
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return new AuthResult(false, "Логин и пароль обязательны");
if (!IsValidUsername(username))
return new AuthResult(false, "Логин: 3–20 символов, буквы/цифры/подчёркивание");
if (!IsValidPassword(password))
return new AuthResult(false, "Пароль: минимум 6 символов");
if (_users.ContainsKey(username))
return new AuthResult(false, "Пользователь уже существует");
_users[username] = Hash(password);
return new AuthResult(true, "Регистрация успешна");
}
public AuthResult Login(string username, string password)
{
if (!_users.TryGetValue(username, out var stored) || stored != Hash(password))
return new AuthResult(false, "Неверный логин или пароль");
return new AuthResult(true, $"Добро пожаловать, {username}!");
}
public AuthResult ChangePassword(string username, string oldPassword, string newPassword)
{
if (!_users.TryGetValue(username, out var stored))
return new AuthResult(false, "Пользователь не найден");
if (stored != Hash(oldPassword))
return new AuthResult(false, "Неверный текущий пароль");
if (!IsValidPassword(newPassword))
return new AuthResult(false, "Пароль: минимум 6 символов");
if (oldPassword == newPassword)
return new AuthResult(false, "Новый пароль совпадает со старым");
_users[username] = Hash(newPassword);
return new AuthResult(true, "Пароль успешно изменён");
}
public IReadOnlyList<string> GetUsers() => _users.Keys.ToList();
public void ClearUsers() => _users.Clear();
}
7.4. Контроллер — Lab3.Auth/Controllers/AuthController.cs
// Lab3.Auth/Controllers/AuthController.cs
using Lab3.Auth.Services;
using Microsoft.AspNetCore.Mvc;
namespace Lab3.Auth.Controllers;
[ApiController]
[Route("api")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
public record RegisterRequest(string Username, string Password);
public record LoginRequest(string Username, string Password);
public record ChangePasswordRequest(string Username, string OldPassword, string NewPassword);
[HttpPost("register")]
public IActionResult Register([FromBody] RegisterRequest req)
{
var result = _auth.Register(req.Username, req.Password);
return result.Success ? Created("", result) : BadRequest(result);
}
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest req)
{
var result = _auth.Login(req.Username, req.Password);
return result.Success ? Ok(result) : Unauthorized(result);
}
[HttpPost("change-password")]
public IActionResult ChangePassword([FromBody] ChangePasswordRequest req)
{
var result = _auth.ChangePassword(req.Username, req.OldPassword, req.NewPassword);
return result.Success ? Ok(result) : BadRequest(result);
}
[HttpGet("users")]
public IActionResult GetUsers() => Ok(new { Users = _auth.GetUsers() });
}
7.5. Регистрация сервиса — Lab3.Auth/Program.cs
// Lab3.Auth/Program.cs
using Lab3.Auth.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<IAuthService, AuthService>();
var app = builder.Build();
app.MapControllers();
app.Run();
// Частичный класс нужен для доступа из тестов
public partial class Program { }
7.6. Зависимости тестового проекта — Lab3.Auth.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lab3.Auth\Lab3.Auth.csproj" />
</ItemGroup>
</Project>
7.7. Unit-тесты сервиса — Lab3.Auth.Tests/AuthServiceTests.cs
// Lab3.Auth.Tests/AuthServiceTests.cs
using Lab3.Auth.Services;
using Xunit;
namespace Lab3.Auth.Tests;
public class AuthServiceTests : IDisposable
{
private readonly IAuthService _svc = new AuthService();
// IDisposable: очистка после каждого теста
public void Dispose() => _svc.ClearUsers();
// ── Регистрация ──────────────────────────────────────────────────────────
[Fact]
public void Register_ValidData_ReturnsSuccess()
{
var result = _svc.Register("alice", "secret123");
Assert.True(result.Success);
}
[Fact]
public void Register_Duplicate_ReturnsFail()
{
_svc.Register("alice", "secret123");
var result = _svc.Register("alice", "other123");
Assert.False(result.Success);
Assert.Contains("уже существует", result.Message);
}
[Fact]
public void Register_ShortPassword_ReturnsFail()
{
var result = _svc.Register("bob", "123");
Assert.False(result.Success);
}
[Theory]
[InlineData("", "password123")] // пустой логин
[InlineData("ab", "password123")] // логин слишком короткий
[InlineData("bad user!", "pass123")] // недопустимые символы
public void Register_InvalidUsername_ReturnsFail(string username, string password)
{
var result = _svc.Register(username, password);
Assert.False(result.Success);
}
// ── Авторизация ──────────────────────────────────────────────────────────
[Fact]
public void Login_ValidCredentials_ReturnsSuccess()
{
_svc.Register("alice", "secret123");
var result = _svc.Login("alice", "secret123");
Assert.True(result.Success);
Assert.Contains("alice", result.Message);
}
[Fact]
public void Login_WrongPassword_ReturnsFail()
{
_svc.Register("alice", "secret123");
var result = _svc.Login("alice", "wrongpass");
Assert.False(result.Success);
}
[Fact]
public void Login_UnknownUser_ReturnsFail()
{
var result = _svc.Login("ghost", "password123");
Assert.False(result.Success);
}
// ── Смена пароля ─────────────────────────────────────────────────────────
[Fact]
public void ChangePassword_ValidData_ReturnsSuccess()
{
_svc.Register("carol", "oldpass123");
var result = _svc.ChangePassword("carol", "oldpass123", "newpass456");
Assert.True(result.Success);
// Новый пароль должен работать
Assert.True(_svc.Login("carol", "newpass456").Success);
}
[Fact]
public void ChangePassword_WrongOldPassword_ReturnsFail()
{
_svc.Register("carol", "oldpass123");
var result = _svc.ChangePassword("carol", "WRONG", "newpass456");
Assert.False(result.Success);
}
[Fact]
public void ChangePassword_SameAsOld_ReturnsFail()
{
_svc.Register("carol", "oldpass123");
var result = _svc.ChangePassword("carol", "oldpass123", "oldpass123");
Assert.False(result.Success);
Assert.Contains("совпадает", result.Message);
}
[Fact]
public void ChangePassword_UnknownUser_ReturnsFail()
{
var result = _svc.ChangePassword("ghost", "old123", "new123456");
Assert.False(result.Success);
}
}
7.8. Интеграционные тесты контроллера — Lab3.Auth.Tests/AuthControllerTests.cs
// Lab3.Auth.Tests/AuthControllerTests.cs
using System.Net;
using System.Net.Http.Json;
using Lab3.Auth.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Lab3.Auth.Tests;
public class AuthControllerTests : IClassFixture<WebApplicationFactory<Program>>, IDisposable
{
private readonly HttpClient _client;
private readonly IAuthService _auth;
public AuthControllerTests(WebApplicationFactory<Program> factory)
{
var app = factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
services.AddSingleton<IAuthService, AuthService>()));
_client = app.CreateClient();
_auth = app.Services.GetRequiredService<IAuthService>();
}
public void Dispose() => _auth.ClearUsers();
// ── POST /api/register ───────────────────────────────────────────────────
[Fact]
public async Task Register_ValidData_Returns201()
{
var response = await _client.PostAsJsonAsync("/api/register",
new { Username = "alice", Password = "secret123" });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task Register_Duplicate_Returns400()
{
await _client.PostAsJsonAsync("/api/register",
new { Username = "alice", Password = "secret123" });
var response = await _client.PostAsJsonAsync("/api/register",
new { Username = "alice", Password = "secret123" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// ── POST /api/login ──────────────────────────────────────────────────────
[Fact]
public async Task Login_ValidCredentials_Returns200()
{
await _client.PostAsJsonAsync("/api/register",
new { Username = "bob", Password = "mypassword" });
var response = await _client.PostAsJsonAsync("/api/login",
new { Username = "bob", Password = "mypassword" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Login_WrongPassword_Returns401()
{
await _client.PostAsJsonAsync("/api/register",
new { Username = "bob", Password = "mypassword" });
var response = await _client.PostAsJsonAsync("/api/login",
new { Username = "bob", Password = "wrong!" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
// ── POST /api/change-password ────────────────────────────────────────────
[Fact]
public async Task ChangePassword_ValidData_Returns200()
{
await _client.PostAsJsonAsync("/api/register",
new { Username = "carol", Password = "oldpass123" });
var response = await _client.PostAsJsonAsync("/api/change-password", new
{
Username = "carol",
OldPassword = "oldpass123",
NewPassword = "newpass456"
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
7.9. Запуск тестов с покрытием
# Запуск тестов с генерацией отчёта Cobertura (совместим с SonarQube)
dotnet test --collect:"XPlat Code Coverage" \
--results-directory ./coverage-results \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
# Файл отчёта появится по пути:
# coverage-results/<guid>/coverage.cobertura.xml
7.10. Конфигурация SonarQube — sonar-project.properties
sonar.projectKey=lab3-csharp
sonar.projectName=Lab3 CSharp
sonar.projectVersion=1.0
sonar.sources=Lab3.Auth
sonar.tests=Lab3.Auth.Tests
sonar.language=cs
sonar.cs.opencover.reportsPaths=coverage-results/**/coverage.cobertura.xml
sonar.sourceEncoding=UTF-8
sonar.host.url=http://localhost:9000
7.11. CI/CD пайплайн — .github/workflows/ci.yml
name: CI — C#
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-analyze:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run tests with coverage
run: |
dotnet test --no-build \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage-results \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
8. Тестовые данные
Таблица ниже содержит готовые сценарии для проверки корректности реализации (применима к обоим вариантам).
| № | Сценарий | Входные данные | Ожидаемый результат | HTTP-статус |
|---|---|---|---|---|
| 1 | Регистрация — успех | username: alice, password: secret123 |
success: true |
201 |
| 2 | Регистрация — дублирование | username: alice (повторно) |
success: false, «уже существует» |
400 |
| 3 | Регистрация — короткий пароль | username: bob, password: 123 |
success: false, «минимум 6» |
400 |
| 4 | Регистрация — короткий логин | username: ab, password: pass123 |
success: false |
400 |
| 5 | Регистрация — недопустимые символы | username: bad user!, password: pass123 |
success: false |
400 |
| 6 | Вход — успех | username: alice, password: secret123 |
success: true |
200 |
| 7 | Вход — неверный пароль | username: alice, password: wrong! |
success: false |
401 |
| 8 | Вход — несуществующий пользователь | username: ghost, password: pass123 |
success: false |
401 |
| 9 | Смена пароля — успех | old: secret123, new: newpass456 |
success: true |
200 |
| 10 | Смена пароля — неверный старый | old: WRONG, new: newpass456 |
success: false |
400 |
| 11 | Смена пароля — совпадение | old = new = secret123 |
success: false, «совпадает» |
400 |
| 12 | Смена пароля — короткий новый | old: secret123, new: abc |
success: false |
400 |
9. Типичные ошибки и способы их устранения
| Ошибка | Причина | Решение |
|---|---|---|
ModuleNotFoundError: No module named 'flask' |
venv не активирован | source venv/bin/activate → pip install -r requirements.txt |
SonarQube не открывается на :9000 |
Контейнер ещё запускается | docker logs -f sonarqube — ждать SonarQube is operational |
coverage.xml не найден (Python) |
Забыт флаг --cov-report=xml |
pytest --cov=app --cov-report=xml |
| Ошибка аутентификации SonarScanner | Истёк или неверный токен | Создать новый токен в SonarQube → User → Security |
conftest.py не применяется |
Файл не в папке tests/ |
Перенести conftest.py в директорию tests/ |
dotnet test не находит проект |
Запуск не из корня решения | Запускать из папки, содержащей .sln или .csproj |
GitHub Actions: SONAR_TOKEN not set |
Секрет не добавлен | Settings → Secrets → Actions → New repository secret |
| Порт 9000 занят | Другой процесс | lsof -ti:9000 \| xargs kill (Linux/Mac) |