fix: resolve --user-login by exact match
Redmine user.filter(login=...) performs an inexact substring search, but _resolve_user_id took users[0].id unconditionally, so a report could silently be built for the wrong user (e.g. 'ivanov' matching 'ivanov2'). Now only exact (case-sensitive) login matches are considered: exactly one match resolves to its id, multiple matches raise an ambiguity error, no match falls through to the name lookup and then to a 'not found' error — symmetric with the --user-name resolution. Closes #60
This commit is contained in:
@@ -205,8 +205,18 @@ def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
|
|||||||
# Затем ищем по логину
|
# Затем ищем по логину
|
||||||
try:
|
try:
|
||||||
users = redmine.user.filter(login=text)
|
users = redmine.user.filter(login=text)
|
||||||
if users:
|
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
|
||||||
return int(users[0].id)
|
# выбираем только точные регистрозависимые совпадения логина (#60).
|
||||||
|
exact_matches = [u for u in users if getattr(u, "login", None) == text]
|
||||||
|
if len(exact_matches) == 1:
|
||||||
|
return int(exact_matches[0].id)
|
||||||
|
if len(exact_matches) > 1:
|
||||||
|
matches = ", ".join(str(u.id) for u in exact_matches[:5])
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
|
||||||
|
)
|
||||||
|
except RedmineAPIError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError(
|
||||||
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
|
|||||||
|
|
||||||
mock_user = mock.MagicMock()
|
mock_user = mock.MagicMock()
|
||||||
mock_user.id = 7
|
mock_user.id = 7
|
||||||
|
mock_user.login = "ivanov"
|
||||||
mock_redmine.user.filter.return_value = [mock_user]
|
mock_redmine.user.filter.return_value = [mock_user]
|
||||||
mock_redmine.time_entry.filter.return_value = []
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
@@ -287,6 +288,91 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
|
|||||||
assert kwargs["user_id"] == 7
|
assert kwargs["user_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_rejects_inexact_login_match(mock_redmine_class):
|
||||||
|
"""Фильтр Redmine по логину неточный (#60): без точного совпадения — ошибка."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
near_match = mock.MagicMock()
|
||||||
|
near_match.id = 8
|
||||||
|
near_match.login = "ivanov2"
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[near_match], # неточное совпадение по логину
|
||||||
|
[], # по имени никого не найдено
|
||||||
|
]
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="not found"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_resolves_exact_login_among_near_matches(mock_redmine_class):
|
||||||
|
"""Среди неточных результатов фильтра выбирается точное совпадение логина (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
near = mock.MagicMock()
|
||||||
|
near.id = 8
|
||||||
|
near.login = "ivanov2"
|
||||||
|
exact = mock.MagicMock()
|
||||||
|
exact.id = 7
|
||||||
|
exact.login = "ivanov"
|
||||||
|
mock_redmine.user.filter.return_value = [near, exact]
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
|
||||||
|
|
||||||
|
_, kwargs = mock_redmine.time_entry.filter.call_args
|
||||||
|
assert kwargs["user_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_rejects_multiple_exact_login_matches(mock_redmine_class):
|
||||||
|
"""Несколько пользователей с точным логином — ошибка неоднозначности (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
user1 = mock.MagicMock()
|
||||||
|
user1.id = 7
|
||||||
|
user1.login = "ivanov"
|
||||||
|
user2 = mock.MagicMock()
|
||||||
|
user2.id = 9
|
||||||
|
user2.login = "ivanov"
|
||||||
|
mock_redmine.user.filter.return_value = [user1, user2]
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Multiple users match"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_login_exact_match_is_case_sensitive(mock_redmine_class):
|
||||||
|
"""Точное совпадение логина регистрозависимое (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
other_case = mock.MagicMock()
|
||||||
|
other_case.id = 8
|
||||||
|
other_case.login = "Ivanov"
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[other_case], # логин отличается регистром
|
||||||
|
[], # по имени никого не найдено
|
||||||
|
]
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="not found"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
|
||||||
|
|
||||||
|
|
||||||
@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_resolves_user_name_to_id(mock_redmine_class):
|
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
|
||||||
|
|||||||
Reference in New Issue
Block a user