Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35fa585bd0 | ||
|
|
3accb1212c |
@@ -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 и ничего в нём не изменяет.
|
||||
|
||||
@@ -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` — точность периода
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.12.0"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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]: ")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user