Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35fa585bd0 | ||
|
|
3accb1212c |
@@ -96,7 +96,7 @@ CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть про
|
|||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
||||||
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
|
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
|
||||||
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
||||||
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
||||||
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
||||||
|
|||||||
@@ -103,14 +103,15 @@ email:
|
|||||||
|
|
||||||
| Значение | Поведение |
|
| Значение | Поведение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) |
|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
|
||||||
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
||||||
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
||||||
|
|
||||||
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
||||||
`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части
|
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
|
||||||
дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
||||||
одинаково — оба включают стандартную проверку без привязки к конкретному пути.
|
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
|
||||||
|
(через truststore), без привязки к конкретному пути.
|
||||||
|
|
||||||
### `period.precision` — точность периода
|
### `period.precision` — точность периода
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"openpyxl>=3.1.0",
|
"openpyxl>=3.1.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
|
"truststore>=0.10",
|
||||||
"urllib3>=1.26",
|
"urllib3>=1.26",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.11.0"
|
__version__ = "1.12.0"
|
||||||
|
|||||||
@@ -256,6 +256,19 @@ def _save_and_maybe_send(
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
print("\n📧 Готово к отправке:")
|
||||||
|
print(f" From: {email_config.from_}")
|
||||||
|
print(f" To: {', '.join(email_config.to)}")
|
||||||
|
if email_config.cc:
|
||||||
|
print(f" Cc: {', '.join(email_config.cc)}")
|
||||||
|
if email_config.bcc:
|
||||||
|
print(f" Bcc: {', '.join(email_config.bcc)}")
|
||||||
|
print(f" File: {output_arg}")
|
||||||
|
response = input("Отправить? [y/N]: ").strip().lower()
|
||||||
|
if response not in ("y", "yes"):
|
||||||
|
print("⏭️ Отправка отменена.")
|
||||||
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_report(
|
send_report(
|
||||||
email_config,
|
email_config,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import truststore
|
||||||
from redminelib import Redmine
|
from redminelib import Redmine
|
||||||
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
@@ -49,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
|||||||
|
|
||||||
|
|
||||||
def _create_redmine() -> Redmine:
|
def _create_redmine() -> Redmine:
|
||||||
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
|
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
|
||||||
|
|
||||||
|
При verify=True подключает системное хранилище CA ОС через
|
||||||
|
truststore.inject_into_ssl() (#62).
|
||||||
|
"""
|
||||||
|
verify = Config.get_redmine_verify()
|
||||||
|
if verify is True:
|
||||||
|
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
|
||||||
|
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
|
||||||
|
truststore.inject_into_ssl()
|
||||||
redmine = Redmine(
|
redmine = Redmine(
|
||||||
Config.get_redmine_url(),
|
Config.get_redmine_url(),
|
||||||
**_get_redmine_auth_kwargs(),
|
**_get_redmine_auth_kwargs(),
|
||||||
requests={
|
requests={
|
||||||
"verify": Config.get_redmine_verify(),
|
"verify": verify,
|
||||||
"timeout": REQUEST_TIMEOUT,
|
"timeout": REQUEST_TIMEOUT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -964,8 +964,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_triggers_mailer(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--send с --output вызывает send_report после сохранения."""
|
"""--send с --output вызывает send_report после сохранения."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1003,10 +1007,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_without_output_saves_to_default_path(
|
def test_send_without_output_saves_to_default_path(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1071,10 +1077,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_smtp_error_reported_to_stderr(
|
def test_send_smtp_error_reported_to_stderr(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||||||
):
|
):
|
||||||
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
@@ -1118,10 +1126,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_with_commit_works_together(
|
def test_send_with_commit_works_together(
|
||||||
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send и --commit работают вместе без конфликтов."""
|
"""--send и --commit работают вместе без конфликтов."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1175,10 +1185,12 @@ class TestSendHtmlBody:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_passes_rows_to_send_report(
|
def test_send_passes_rows_to_send_report(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send передаёт rows в send_report для генерации HTML."""
|
"""--send передаёт rows в send_report для генерации HTML."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1220,10 +1232,12 @@ class TestSendHtmlBody:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_html_false_does_not_require_html_part(
|
def test_send_html_false_does_not_require_html_part(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""При email.html: false письмо отправляется без HTML-части."""
|
"""При email.html: false письмо отправляется без HTML-части."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration:
|
|||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path):
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_with_report_no_time_true(
|
||||||
|
self, mock_input, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
@@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput:
|
|||||||
assert "raw details" not in captured.err
|
assert "raw details" not in captured.err
|
||||||
assert "raw details" not in caplog.text
|
assert "raw details" not in caplog.text
|
||||||
assert "safe message" in captured.err
|
assert "safe message" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email confirmation prompt before --send
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendConfirmation:
|
||||||
|
"""Tests for email confirmation prompt before sending."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_yes_calls_send_report(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Подтверждение 'y' — send_report вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "y"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
" cc:\n"
|
||||||
|
" - cc@example.com\n"
|
||||||
|
" bcc:\n"
|
||||||
|
" - bcc@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_no_skips_send(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Ответ 'n' — send_report не вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "n"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_default_skips_send(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Пустой ввод (Enter) — send_report не вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = ""
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_shows_recipients(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||||||
|
):
|
||||||
|
"""Подтверждение показывает from, to, cc, bcc, file."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "y"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: sender@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
" cc:\n"
|
||||||
|
" - cc@example.com\n"
|
||||||
|
" bcc:\n"
|
||||||
|
" - bcc@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "sender@example.com" in captured.out
|
||||||
|
assert "boss@example.com" in captured.out
|
||||||
|
assert "cc@example.com" in captured.out
|
||||||
|
assert "bcc@example.com" in captured.out
|
||||||
|
assert output in captured.out
|
||||||
|
mock_input.assert_called_once_with("Отправить? [y/N]: ")
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1):
|
|||||||
mock_redmine.user.get.return_value = mock_user
|
mock_redmine.user.get.return_value = mock_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_truststore():
|
||||||
|
"""Мок truststore для всех тестов клиента.
|
||||||
|
|
||||||
|
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
|
||||||
|
который глобально подменяет ssl.SSLContext без восстановления (#62).
|
||||||
|
Не даём реальной инъекции выполниться в pytest-процессе.
|
||||||
|
"""
|
||||||
|
with mock.patch("redmine_reporter.client.truststore") as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
||||||
@@ -172,6 +184,70 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
|||||||
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
|
||||||
|
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_called_once_with()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
|
||||||
|
"""verify=False → truststore.inject_into_ssl() не вызывается."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_not_called()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
|
||||||
|
)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_custom_path_skips_truststore_injection(
|
||||||
|
mock_redmine_class, mock_truststore
|
||||||
|
):
|
||||||
|
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_not_called()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
|
||||||
|
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_called_once_with()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is True
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
||||||
|
|||||||
Reference in New Issue
Block a user