From d1922c9354c4f470de53fdb016ae78932a9f4f89 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 09:56:16 +0400 Subject: [PATCH] =?UTF-8?q?fix(forms):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8E=201936=20=D0=B2=2000?= =?UTF-8?q?56,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20del=5Fbudget=5Fline=20=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=85=200053,=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 0050_budget_line_delete_owner_guard -> 0056 (down 0055, стек поверх 0055 AURORA-1938). Тело v3.del_budget_line = актуальное тело из 0053_form2_ahr_source_sheet (AURORA-1907: запрет удаления АХР-строк с source_sheet IS NOT NULL, ручные удаляются) + только owner-guard; раньше копия опиралась на 0040 и откатывала 1907. Тесты: доступ к форме id=1 даёт отдельная пара исполнителей isp2/isp3 (isp1 больше не добавляется в org 1 — это ломало тесты «исполнитель из чужой организации»); окно фазы DFIP в фикстуре продлено (истекло 2026-06-06, add_line возвращал 403); маркер internal_order не приходит в INPUT-строке add_line — берём единственную INPUT-строку; добавлен тест 1907 (строка из сателлита не удаляется напрямую). Тест «владелец удаляет свою строку» помечен xfail(strict): add_budget_line не заполняет budget_line.created_by (O-1). Co-Authored-By: Claude Opus 5.5 --- ...=> 0056_budget_line_delete_owner_guard.py} | 6 +-- ...> 0056_budget_line_delete_owner_guard.sql} | 32 +++--------- api/tests/integration/conftest.py | 14 +++++- .../test_form2_ahr_satellite_integration.py | 21 ++++++++ api/tests/integration/test_forms_api_smoke.py | 49 ++++++++++++++----- api/tests/sqls/fixture.sql | 11 +++-- 6 files changed, 87 insertions(+), 46 deletions(-) rename api/alembic/versions/{0050_budget_line_delete_owner_guard.py => 0056_budget_line_delete_owner_guard.py} (59%) rename api/alembic/versions/sql/{0050_budget_line_delete_owner_guard.sql => 0056_budget_line_delete_owner_guard.sql} (85%) diff --git a/api/alembic/versions/0050_budget_line_delete_owner_guard.py b/api/alembic/versions/0056_budget_line_delete_owner_guard.py similarity index 59% rename from api/alembic/versions/0050_budget_line_delete_owner_guard.py rename to api/alembic/versions/0056_budget_line_delete_owner_guard.py index 6f49e35..4ee393a 100644 --- a/api/alembic/versions/0050_budget_line_delete_owner_guard.py +++ b/api/alembic/versions/0056_budget_line_delete_owner_guard.py @@ -1,14 +1,14 @@ from sql_migration import run_sql_file_migration -revision = "0050" -down_revision = "0049" +revision = "0056" +down_revision = "0055" branch_labels = None depends_on = None def upgrade() -> None: - run_sql_file_migration("0050_budget_line_delete_owner_guard.sql") + run_sql_file_migration("0056_budget_line_delete_owner_guard.sql") def downgrade() -> None: diff --git a/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql similarity index 85% rename from api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql rename to api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql index 07aeb26..df54e32 100644 --- a/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql +++ b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql @@ -1,3 +1,7 @@ +-- AURORA-1936: v3.del_budget_line — исполнитель (не ADMIN) удаляет только строки, созданные им самим +-- (budget_line.created_by = текущий пользователь). +-- 0056: тело пересобрано поверх 0053_form2_ahr_source_sheet (AURORA-1907: блок удаления АХР-строк +-- по budget_line.source_sheet IS NOT NULL, ручные строки с NULL удаляются) + только owner-guard. CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying) RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) LANGUAGE plpgsql @@ -10,6 +14,7 @@ DECLARE v_eid INT; v_line_id INT; v_ei_path INT[]; + v_source_sheet VARCHAR; v_actor_id INT; v_role VARCHAR; v_created_by INT; @@ -75,8 +80,8 @@ BEGIN RETURN; END IF; - SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.created_by - INTO v_form_id, v_sheet, v_eid, v_created_by + SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.source_sheet, bl.created_by + INTO v_form_id, v_sheet, v_eid, v_source_sheet, v_created_by FROM v3.budget_line bl JOIN v3.expense_item ei ON ei.id = bl.expense_item_id WHERE bl.id = p_id; @@ -89,28 +94,7 @@ BEGIN RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку'; END IF; - IF v_sheet = 'AHR' AND ( - EXISTS ( - SELECT 1 - FROM v3.form2_ahr_satellite_line_source m - WHERE m.target_line_id = p_id - ) - OR EXISTS ( - SELECT 1 - FROM v3.budget_line bl - JOIN v3.expense_item ei ON ei.id = bl.expense_item_id - JOIN v3.limit_template lt - ON lt.row_type = 'LEAF' - AND ( - (ei.depth = 3 AND ei.item_id = lt.expense_item_code) - OR ei.num_group_id = lt.expense_item_code - ) - WHERE bl.id = p_id - AND bl.budget_form_id = v_form_id - AND ei.sheet = 'AHR' - AND ei.direction = 'Support' - ) - ) THEN + IF v_sheet = 'AHR' AND v_source_sheet IS NOT NULL THEN RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую' USING ERRCODE = '23503', HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты'; diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 9167928..383ca63 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -90,7 +90,8 @@ def isp_tokens(client, isp_password: str): @pytest.fixture def isp2_tokens(client, isp_password: str): - # Второй исполнитель в том же org_unit, что и budget_form id=1 — нужен, + # Исполнитель в org_unit формы id=1 (isp1 в нём намеренно не состоит — на этом + # держатся тесты «исполнитель из чужой организации»). Нужен вместе с isp3_tokens, # чтобы проверять, что исполнитель не может удалить чужую строку. response = client.post( "/api/v1/auth/login", @@ -98,3 +99,14 @@ def isp2_tokens(client, isp_password: str): ) assert response.status_code == 200 return response.json() + + +@pytest.fixture +def isp3_tokens(client, isp_password: str): + # Второй исполнитель в том же org_unit, что и isp2 (форма id=1). + response = client.post( + "/api/v1/auth/login", + json={"username": "isp3", "password": isp_password}, + ) + assert response.status_code == 200 + return response.json() diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py index 39ca5d7..9fb08cf 100644 --- a/api/tests/integration/test_form2_ahr_satellite_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -760,3 +760,24 @@ def test_manual_line_add_still_works_for_ordinary_statja_on_ahr(client, admin_to assert manual_row["data"]["header"]["source"] == "MANUAL" assert manual_row["data"]["header"]["can_add_row"] is True assert manual_row["data"]["plan"]["q1"] == 500 + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_sourced_ahr_line_not_deletable_directly_after_owner_guard( + client, admin_tokens, auth_headers, sheet +): + """AURORA-1907 + AURORA-1936: миграция 0056 (owner-guard в v3.del_budget_line) + пересобрана поверх 0053 — строку АХР, созданную из сателлита + (budget_line.source_sheet IS NOT NULL), по-прежнему нельзя удалить напрямую + даже ADMIN'у, а ручную строку (source_sheet NULL) — можно + (см. test_manual_line_stays_deletable_after_satellite_add).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2061) + satellite_data = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + line_id = satellite_data["line_id"] + + response = client.delete( + f"/api/v1/form/{form_id}/sheet/AHR/line/{line_id}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code != 200, response.text + assert "нельзя удалить напрямую" in response.text, response.text diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py index 49bf30d..6208730 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -168,35 +168,58 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_head def _added_line_id(payload: dict, marker: str) -> int: - for row in payload["result"]: - data = row.get("data") or {} - if data.get("internal_order") == marker: - return data.get("line_id") or data.get("id") - raise AssertionError(f"строка с internal_order={marker!r} не найдена в ответе add_line") + # add_line возвращает ветку дерева статьи и ровно одну INPUT-строку — только что созданную. + # internal_order в data INPUT-строки не приходит (лежит во вложенных/скрытых полях), + # поэтому по маркеру строку не найти — marker оставлен для читаемости теста. + input_rows = [row for row in payload["result"] if row.get("row_type") == "INPUT"] + assert len(input_rows) == 1, f"ожидалась одна INPUT-строка для {marker!r}, получено {len(input_rows)}" + return input_rows[0]["data"]["line_id"] -def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp_tokens, isp2_tokens, auth_headers): - # AURORA-1936: исполнитель может удалить только им же созданную строку. +def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp2_tokens, isp3_tokens, auth_headers): + # AURORA-1936: исполнитель не может удалить чужую строку. + # Ошибка БД откатывает общую транзакцию тестового клиента (см. conftest), поэтому + # «свою строку удалить можно» проверяется отдельным тестом ниже. marker = "aurora-1936-owner-guard" add_response = client.post( "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary", json={"expense_item_id": 120, "direction": "Support", "internal_order": marker}, - headers=auth_headers(isp_tokens), + headers=auth_headers(isp2_tokens), ) - assert add_response.status_code == 200 + assert add_response.status_code == 200, add_response.text line_id = _added_line_id(add_response.json(), marker) forbidden_response = client.delete( f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support", - headers=auth_headers(isp2_tokens), + headers=auth_headers(isp3_tokens), ) assert forbidden_response.status_code == 403 + +@pytest.mark.xfail( + strict=True, + reason=( + "O-1 (AURORA-1936): v3.add_budget_line нигде не заполняет budget_line.created_by " + "(INSERT без created_by во всех версиях функции, триггера/default нет), поэтому owner-guard " + "из 0056 блокирует удаление исполнителю и своих строк. Нужно решение: проставлять created_by " + "в add_budget_line (+ как трактовать legacy-строки с NULL). Тест упадёт как XPASS, когда это починят." + ), +) +def test_forms_delete_line_allowed_for_owner_executor(client, isp2_tokens, auth_headers): + marker = "aurora-1936-own-delete" + add_response = client.post( + "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary", + json={"expense_item_id": 120, "direction": "Support", "internal_order": marker}, + headers=auth_headers(isp2_tokens), + ) + assert add_response.status_code == 200, add_response.text + line_id = _added_line_id(add_response.json(), marker) + own_delete_response = client.delete( f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support", - headers=auth_headers(isp_tokens), + headers=auth_headers(isp2_tokens), ) - assert own_delete_response.status_code == 200 + assert own_delete_response.status_code == 200, own_delete_response.text def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_tokens, admin_tokens, auth_headers): @@ -206,7 +229,7 @@ def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_to json={"expense_item_id": 120, "direction": "Support", "internal_order": marker}, headers=auth_headers(isp2_tokens), ) - assert add_response.status_code == 200 + assert add_response.status_code == 200, add_response.text line_id = _added_line_id(add_response.json(), marker) response = client.delete( diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 2c51a63..eeb6e92 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -1,8 +1,9 @@ INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_active,created_at,updated_at) VALUES (1,'admin@rshb.ru','admin','{admin_password}','admin',1,true,'2026-05-20 11:09:16.987331+03',NULL), (2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'), - (3,'ispolnitel2@rshb.ru','isp2','{isp_password}','Роль Исполнитель Второй',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'); -SELECT setval('v3.app_user_id_seq', 3); + (3,'ispolnitel2@rshb.ru','isp2','{isp_password}','Роль Исполнитель Второй',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'), + (4,'ispolnitel3@rshb.ru','isp3','{isp_password}','Роль Исполнитель Третий',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'); +SELECT setval('v3.app_user_id_seq', 4); INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES (1,'Test_SSP',true,true,'+03'), @@ -14,7 +15,7 @@ INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES (2,1,1), (3,2,2), (4,3,1), - (5,2,1); + (5,4,1); SELECT setval('v3.user_org_id_seq', 5); INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES @@ -27,8 +28,8 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or SELECT setval('v3.budget_line_id_seq', 1); INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at,direction) VALUES - (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Support'), - (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Development'), + (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-06-06 03:00:00+03','Support'), + (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-06-06 03:00:00+03','Development'), (4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05',NULL); INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES