fix(api): PUT /settings проверяет значения до записи, 422 (I-5); убран мёртвый except, честное имя теста

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:09:45 +04:00
parent 0dba6b7672
commit 4f055a80d0
3 changed files with 71 additions and 7 deletions

View File

@ -223,16 +223,20 @@ def h_settings_get(hub, conn, m, q, body):
def h_settings_put(hub, conn, m, q, body):
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
(settings.validate): ошибка — 422, версия настроек не меняется."""
bad = [k for k in body if k in settings.PROTECTED]
if bad:
raise ApiError(422, "protected", ", ".join(bad))
for key, value in body.items():
try:
settings.validate(key, value)
except ValueError as e:
raise ApiError(422, "bad_setting", str(e))
version = settings.get(conn, "settings_version")
try:
with db.transaction(conn):
for key, value in body.items():
version = settings.set_(conn, key, value)
except ValueError as e: # запрет на защищённые ключи из settings.set_
raise ApiError(422, "protected", str(e))
with db.transaction(conn):
for key, value in body.items():
version = settings.set_(conn, key, value)
return 200, {"settings_version": version}

View File

@ -39,6 +39,39 @@ AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "f
PROTECTED = {"settings_version", "schema_version", "tokens"}
def _is_int(v) -> bool:
return isinstance(v, int) and not isinstance(v, bool)
def validate(key, value) -> None:
"""Проверить значение настройки до записи; ValueError — на 422 в API.
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535;
остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
неизвестные ключи и ключи с умолчанием None принимаются как есть."""
if key == "pacing":
if not isinstance(value, dict):
raise ValueError("pacing — объект {тип команды: пауза в секундах}")
for ctype, pause in value.items():
if ctype not in DEFAULT_PACING:
raise ValueError(f"pacing: неизвестный тип команды «{ctype}»")
if not _is_int(pause) or pause < 0:
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
return
if key == "port":
if not _is_int(value) or not 1 <= value <= 65535:
raise ValueError("port — целое 1..65535")
return
if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"):
if not _is_int(value) or value < 0:
raise ValueError(f"{key} — целое >= 0")
return
default = DEFAULTS.get(key)
if default is not None and type(value) is not type(default):
names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"}
raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}")
def ensure_defaults(conn) -> None:
for key, value in DEFAULTS.items():
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))

View File

@ -234,7 +234,7 @@ def test_bad_body_shapes_are_422(hub, path, body):
assert e.value.status == 422
def test_settings_put_invalid_is_4xx_and_atomic(hub):
def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
with pytest.raises(HubError) as e:
@ -243,6 +243,33 @@ def test_settings_put_invalid_is_4xx_and_atomic(hub):
assert c.get("/settings")["settings_version"] == v0
@pytest.mark.parametrize("body", [
{"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}},
{"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1},
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5},
{"labels": {}}, {"initialized": 1},
{"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается
])
def test_settings_put_invalid_value_is_422(hub, body):
c = hub["client"]
before = c.get("/settings")
with pytest.raises(HubError) as e:
c.put("/settings", body)
assert e.value.status == 422
assert c.get("/settings") == before
def test_settings_put_valid_values_pass(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
out = c.put("/settings", {"pacing": {"refresh": 0, "ensure_fields": 90}, "backup_keep": 0, "port": 8800,
"auto_refresh_minutes": 15, "min_agent_version": 3, "link_types": {"x": "Y"},
"labels": ["AWPCA"], "board_id": 12})
assert out["settings_version"] == v0 + 8
got = c.get("/settings")
assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 and got["board_id"] == 12
def _run_refresh(hub, body, issues, neighbor_keys=()):
"""Поставить refresh, взять его агентом, загрузить состояние, отчитаться done."""
hub["client"].post("/refresh", body)