Перейти к содержанию

Лабораторная работа №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. Для выполнения требуется

  1. Вариант задания, закреплённый за студентом на семестр и опубликованный в ЛМС.
  2. Требования к оформлению — согласно методическим указаниям в ЛМС.

2.9. Запрет

Запрещается использование систем искусственного интеллекта для выполнения лабораторной работы.

Работы, выполненные с использованием ИИ, полностью или частично скопированные, а также не сданные, оцениваются в 0 баллов без возможности доработки.


3. Контрольные вопросы

  1. В чём заключается роль автоматизации в обеспечении качества ИС?
  2. Что понимается под CI/CD и какие задачи он решает?
  3. Чем статический анализ отличается от динамического тестирования?
  4. Какие показатели качества оценивает SonarQube?
  5. Почему статический анализ рекомендуется интегрировать в CI/CD?
  6. Какие преимущества даёт автоматизация тестирования?
  7. Как CI/CD влияет на сопровождаемость и надёжность ИС?
  8. Что такое Quality Gate в SonarQube и для чего он применяется?
  9. Что означает принцип «Shift Left» в контексте обеспечения качества?
  10. Чем отличаются 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/activatepip 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)