From 4f055a80d013bfa679bf41918ae0926d183858f9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:09:45 +0400 Subject: [PATCH] =?UTF-8?q?fix(api):=20PUT=20/settings=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8,=20422=20(I-5);=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20exc?= =?UTF-8?q?ept,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=BC=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 16 ++++++++++------ src/jira_hub/settings.py | 33 +++++++++++++++++++++++++++++++++ tests/test_api.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1898b7d..d8dbd89 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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} diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 8093fe1..6a063c0 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -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))) diff --git a/tests/test_api.py b/tests/test_api.py index 055eb2c..62d3ec1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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)