2 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
35fa585bd0 feat: add email confirmation prompt before sending report
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
Require user confirmation (y/N) before sending email via --send.
Shows from/to/cc/bcc/file info. Skips send on anything except y/yes.

Version bump: 1.11.1 → 1.12.0
2026-07-26 16:15:10 +07:00
Кокос Артем Николаевич
3accb1212c fix: use OS trust store for TLS verification via truststore
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
Regression from #62: verify_ssl true used to resolve to the system CA
bundle path, so corporate CAs installed in the OS worked; after the
unification true became requests' default (certifi), breaking setups
with a corporate CA in the system store.

Now verify_ssl true injects truststore, so requests verifies against
the OS trust store on any platform. verify_ssl false / custom CA path
behavior is unchanged. Tests mock truststore via an autouse fixture to
keep the pytest process free of global ssl mutation.

Refs #62
2026-07-17 18:39:53 +07:00
8 changed files with 331 additions and 16 deletions

View File

@@ -96,7 +96,7 @@ CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть про
## Безопасность
- `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`; при более широких правах выводится предупреждение.
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.

View File

@@ -103,14 +103,15 @@ email:
| Значение | Поведение |
|---|---|
| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) |
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части
дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
одинаково — оба включают стандартную проверку без привязки к конкретному пути.
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
(через truststore), без привязки к конкретному пути.
### `period.precision` — точность периода

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "1.11.0"
version = "1.12.0"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
@@ -25,6 +25,7 @@ dependencies = [
"openpyxl>=3.1.0",
"pyyaml>=6.0",
"requests>=2.31",
"truststore>=0.10",
"urllib3>=1.26",
]

View File

@@ -1 +1 @@
__version__ = "1.11.0"
__version__ = "1.12.0"

View File

@@ -256,6 +256,19 @@ def _save_and_maybe_send(
)
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:
send_report(
email_config,

View File

@@ -3,6 +3,7 @@ from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
import truststore
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue
@@ -49,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
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(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": Config.get_redmine_verify(),
"verify": verify,
"timeout": REQUEST_TIMEOUT,
},
)

View File

@@ -964,8 +964,12 @@ class TestSendFlag:
@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")
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 после сохранения."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
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.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
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 сохраняет файл по шаблону, затем отправляет."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
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.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
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."""
mock_input.return_value = "y"
from redmine_reporter.client import RedmineAPIError
issue = _MockIssue()
@@ -1118,10 +1126,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config")
@mock.patch("builtins.input")
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 работают вместе без конфликтов."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
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.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
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."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
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.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
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-части."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration:
@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")
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 в форматтер."""
mock_input.return_value = "y"
import yaml
issue = _MockIssue()
@@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput:
assert "raw details" not in captured.err
assert "raw details" not in caplog.text
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]: ")

View File

@@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1):
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("redmine_reporter.client.Redmine")
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"
@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("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):