Финальное ревью: пробельные поля ensure_fields, версия агента, чтение файлов, форма issues (#129, I-1, I-2, I-3, M-2, T2.1)

I-1 (R-8): ensure_fields с полем из одних пробелов даёт 422, очистка только "" или null.
I-2: agent_view не падает на нецифровой версии агента (GET /agent, /agents).
I-3 (R-9): files.size через fstat у открытого файла, _intact ловит OSError, put переписывает нечитаемый файл.
M-2: /agent/state отвергает пустые не-списки в issues.
T2.1, T3.1, T4.1-T4.3, T5.2, T6.1, T1.5: тесты и мелкие правки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 03:16:39 +04:00
parent 98faabad67
commit e20977ef49
12 changed files with 86 additions and 18 deletions

View File

@ -2,8 +2,8 @@
команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения:
каталог передаётся параметром (Hub.files_dir, решение 10)."""
import hashlib
import os
import logging
import os
import re
import shutil
import stat as stat_mod
@ -50,7 +50,8 @@ def size(root, sha) -> int | None:
return None
target = path(root, sha)
try:
st = target.stat()
with open(target, "rb") as f: # fstat у дескриптора: Path.stat() не требует права на чтение (R-9)
st = os.fstat(f.fileno())
except FileNotFoundError:
return None
except OSError as e:
@ -77,7 +78,7 @@ def _intact(target, sha) -> bool:
"""Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью)."""
try:
return hashlib.sha256(target.read_bytes()).hexdigest() == sha
except FileNotFoundError:
except OSError: # нет файла, нечитаем или каталог — не целая копия, put перепишет
return False

View File

@ -10,6 +10,7 @@ from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import db, registry, settings
from .errors import CommandError
from .http import is_digits
log = logging.getLogger(__name__)
@ -427,7 +428,8 @@ def agent_view(conn, agent: str) -> dict:
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
m = registry.module(agent)
min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
known = is_digits(str(d["agent_version"])) if d["agent_version"] else False # «²», «10-dev» — версия неизвестна
d["warning"] = "обновите агента" if known and int(d["agent_version"]) < min_version else None
d["rate_limited_24h"] = conn.execute(
f"SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ? AND type IN ({_in(types)})",
((now - timedelta(days=1)).isoformat(), *types)).fetchone()["n"]

View File

@ -21,7 +21,8 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def h_agent_state(hub, conn, m, q, body):
issues = body.get("issues") or []
issues = body.get("issues")
issues = [] if issues is None else issues # {} "" 0 false — не список: check_issues отвергнет
boards = body.get("boards")
try:
mirror.check_issues(issues)

View File

@ -234,6 +234,11 @@ def validate(commands) -> list[dict]:
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
if bad:
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
if ctype == "ensure_fields": # « » стало бы «» = «очистить поле»; очистка — только явная (R-8)
for name in FIELD_PARAMS:
v = params.get(name)
if name not in TEXT_PARAMS and isinstance(v, str) and v and not v.strip():
raise CommandError(f'команда {i} ({ctype}): {name} — из одних пробелов; для очистки — "" или null')
params = normalize(ctype, params)
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
if missing:

View File

@ -351,7 +351,8 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
prefix = ""
if not found:
want = unicodedata.normalize("NFC", attachment.strip())
found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] # хаб отдаёт вложения новыми сверху
# хаб отдаёт вложения новыми сверху
found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want]
if len(found) > 1:
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
if not found:

View File

@ -32,6 +32,7 @@ def check_comments(issues) -> None:
if c.get(f) is not None and not isinstance(c[f], str):
raise ValueError(f"{where}.{f} — строка или null")
def check_issues(issues) -> None:
"""Форма списка задач состояния: список объектов с непустым строковым key. Нарушение — ValueError."""
if not isinstance(issues, list):

View File

@ -1209,6 +1209,8 @@ def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad):
hub["client"].post("/batches", {"title": "x", "commands": [
{"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]})
assert e.value.status == 422
want = "attachment_id — строка" if bad == 7 else "attachment_id — цифры id вложения из issue"
assert want in e.value.detail
assert hub["client"].get("/batches")["batches"] == []
@ -1326,3 +1328,24 @@ def test_agent_state_bad_issue_shape_422(hub, issues, detail):
with pytest.raises(HubError) as e:
hub["agent"].post("/agent/state", {"issues": issues, "links": []})
assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail
@pytest.mark.parametrize("bad", [{}, "", 0, False])
def test_agent_state_falsy_non_list_issues_422(hub, bad):
"""M-2: пустые не-списки не маскируются под «нет задач»."""
with pytest.raises(HubError) as e:
hub["agent"].post("/agent/state", {"issues": bad, "links": []})
assert e.value.status == 422 and "issues — список задач" in e.value.detail
@pytest.mark.parametrize("body", [{"links": []}, {"issues": None, "links": []}])
def test_agent_state_missing_or_null_issues_ok(hub, body):
hub["agent"].post("/agent/state", body)
@pytest.mark.parametrize("version", ["²", "10-dev"])
def test_agent_endpoints_survive_non_digit_agent_version(hub, version):
"""I-2: GET /agent и /agents отвечают 200 при нецифровой версии агента."""
hub["agent"].post("/agent/claim", {"agent_version": version, "settings_version": 1})
assert hub["client"].get("/agent")["agent_version"] == version
assert any(a["agent_version"] == version for a in hub["client"].get("/agents")["agents"])

View File

@ -385,3 +385,23 @@ def test_is_digits_ascii_only():
assert is_digits("123") is True
for bad in ("", "²", "١٢", "12a", 7, None):
assert is_digits(bad) is False
@pytest.mark.parametrize("field", ["assignee", "epic_key", "priority", "duedate", "planned_start"])
def test_ensure_fields_whitespace_only_rejected(field):
"""I-1 (R-8): « » после обрезки стало бы «» («очистить поле») — отвергается."""
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", field: " "}}])
assert f'{field} — из одних пробелов; для очистки — "" или null' in str(e.value)
assert "команда 1 (ensure_fields)" in str(e.value)
@pytest.mark.parametrize("value", ["", None])
def test_ensure_fields_explicit_clear_accepted(value):
cmds = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "assignee": value}}])
assert cmds[0]["params"]["assignee"] == value
def test_rank_before_after_whitespace_still_means_not_given():
cmds = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": "A-2", "after": " "}}])
assert cmds[0]["params"]["after"] == ""

View File

@ -191,17 +191,21 @@ def test_sweep_downloads(tmp_path):
assert files.sweep_downloads(tmp_path / "нет") == []
@pytest.mark.parametrize("func,attr", [(files.size, "stat"), (files.read, "read_bytes")])
def test_unreadable_file_is_none_with_warning(root, monkeypatch, caplog, func, attr):
from pathlib import Path
@pytest.mark.skipif(os.geteuid() == 0, reason="root читает любой файл")
def test_unreadable_file_is_none_with_warning_and_put_rewrites(root, caplog):
"""I-3 (R-9): настоящий chmod 0 — size и read дают None с предупреждением, put пишет файл заново."""
sha = files.put(root, b"abc")[0]
def denied(self, *args, **kwargs):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(Path, attr, denied)
target = root / sha
os.chmod(target, 0)
try:
with caplog.at_level("WARNING"):
assert func(root, sha) is None
assert sha in caplog.text and "не читается" in caplog.text
assert files.size(root, sha) is None
assert files.read(root, sha) is None
assert str(target) in caplog.text and "не читается" in caplog.text
assert files.put(root, b"abc") == (sha, 3)
assert target.read_bytes() == b"abc"
finally:
os.chmod(target, 0o600)
@pytest.mark.parametrize("func", [files.size, files.read])
@ -209,6 +213,7 @@ def test_directory_instead_of_file_is_none(root, caplog, func):
(root / ABC).mkdir(parents=True)
with caplog.at_level("WARNING"):
assert func(root, ABC) is None
assert str(root / ABC) in caplog.text and "не читается" in caplog.text
@pytest.mark.parametrize("func", [files.size, files.read])

View File

@ -1373,3 +1373,11 @@ def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
cmds = queue.batch_view(conn, r["id"])["commands"]
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
@pytest.mark.parametrize("version", ["²", "10-dev", "١٢"])
def test_agent_view_non_digit_version_is_unknown(conn, version):
"""I-2: нецифровая версия агента — неизвестная: agent_view не бросает ValueError и не предупреждает."""
queue.claim(conn, "jira", agent_version=version, settings_version=1)
v = queue.agent_view(conn, "jira")
assert v["agent_version"] == version and v["warning"] is None

View File

@ -335,7 +335,8 @@ def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch):
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
monkeypatch.undo()
assert (rd / "agent.py").read_bytes() == v5
assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5
assert not (rd / "agent.py.prev").exists() # _undo вернул .prev на место agent.py
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
assert release.load(rd)["version"] == 5

View File

@ -174,7 +174,7 @@ def test_v3_keys_removed_on_hub_start_api(tmp_path):
try:
client = HubClient(f"http://127.0.0.1:{srv.server_port}", token)
got = client.get("/settings")
assert "board_id" not in got and got["base_jql"] != "project = AURORA" # base_jql вычисляется (без проектов — None)
assert "board_id" not in got and got["base_jql"] is None # base_jql вычисляется (без проектов — None)
for key in ("board_id", "base_jql"):
with pytest.raises(HubError) as e:
client.put("/settings", {key: 1})