From f9354a5c36a36420e6416fca15b427e636a9a0e8 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 18 Oct 2026 15:22:46 +0000 Subject: [PATCH] =?UTF-8?q?member=5Fremove:=20=D0=B1=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=87=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=D0=B0=20(t-261018141319)?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=82=D1=83=D0=BB=D0=B0=20+=20=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D1=8F=D0=B4=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harness/levels.py | 6 +- harness/portal_mcp.py | 7 +- tests/test_member_remove.py | 139 ++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 tests/test_member_remove.py diff --git a/harness/levels.py b/harness/levels.py index 2cd10b8..9f81260 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -97,6 +97,8 @@ LEVELS = { "автоматом), list_calls/get_call — прошедшие звонки и саммари, " "list_members/member_create(email[,name]) — реестр членов и " "создание аккаунта с magic-link (только агентам председателя), " + "member_remove(member_id,reason) — удаление с бэкапом и аудитом " + "(только агентам председателя; Председателя удалить нельзя), " "projects_list/list_agents/treasury_status/list_events — " "реестры, казна, лента. Когда Alex просит назначить/перенести звонок " "или «посмотреть расписание» — это portal_mcp (до 4 вызовов за тик): " @@ -151,7 +153,9 @@ LEVELS = { "booking_slots/booking_create (звонки с Алексом: слот → создание " "jitsi-звонка со ссылкой, гостю письмо), list_calls/get_call, " "list_members/member_create (создание аккаунта члена с magic-link, " - "только агентам председателя), projects_list/list_agents, " + "только агентам председателя)/member_remove(member_id,reason) — " + "удаление члена с бэкапом и аудитом (только агентам председателя; " + "Председателя удалить нельзя), projects_list/list_agents, " "treasury_status, list_events. " "Просьбы про назначение/перенос звонков и «что по расписанию» — " "через него (до 4 вызовов за тик). НЕ создавай звонки без явной " diff --git a/harness/portal_mcp.py b/harness/portal_mcp.py index 61607c8..a933c42 100644 --- a/harness/portal_mcp.py +++ b/harness/portal_mcp.py @@ -244,8 +244,9 @@ def describe_tools(max_names: int = 12) -> str: "kind='call'|'session') — создать jitsi-звонок со ссылкой (гостю " "письмо, Алексу — в TG, запись+транскрипция автоматом); booking_cancel" "(meeting_id); list_calls/get_call — прошедшие звонки и их саммари; " - "list_members/member_create(email[,name]) — реестр членов и создание " - "аккаунта с magic-link письмом (member_create — только агентам " - "председателя); projects_list/list_agents/treasury_status/list_events — " + "list_members/member_create(email[,name])/member_remove(member_id," + "reason) — реестр членов, создание аккаунта с magic-link и " + "безопасное удаление с бэкапом и аудитом (create/remove — только " + "агентам председателя); projects_list/list_agents/treasury_status/list_events — " "реестры и казна; whoami/report_task/report_event/read_context — " "идентичность и лента портала.") diff --git a/tests/test_member_remove.py b/tests/test_member_remove.py new file mode 100644 index 0000000..6e28b64 --- /dev/null +++ b/tests/test_member_remove.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""t-261018141319: member_remove — безопасное удаление члена (ядро domains/member_ops.py). + +Запуск: ./venv/bin/python tests/test_member_remove.py + +Сеть/портал НЕ поднимаются: ядро flask-free (sqlite3 + stdlib), тестируется +на временной базе. Проверяет: +- happy-path: users/login_tokens/project_members зачищены, снимок в + member_archive + json-файл бэкапа (снимок содержит user/shares/votes); +- паи (shares) и голоса (member_votes) НЕ удаляются — финансовая история; +- guardrail: Председателя удалить нельзя (chairman_protected); +- неизвестный id → not_found, без исключения; +- колбэк on_event (MemberRemoved) вызывается с reason/actor; +- файловый бэкап недоступен (dead dir) — удаление всё равно ок (best effort). +""" +import json +import os +import shutil +import sqlite3 +import sys +import tempfile + +sys.path.insert(0, "/root/simbiot-harness") +sys.path.insert(0, "/root/coop") + +from domains import member_ops # noqa: E402 + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("" if cond else " | " + str(extra)[:300])) + if not cond: + fails.append(name) + + +_tmp = tempfile.mkdtemp(prefix="simbiot_memberremove_") +DB = os.path.join(_tmp, "coop.db") +ARCH = os.path.join(_tmp, "archive") + +db = sqlite3.connect(DB) +db.executescript(""" +CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, password TEXT NOT NULL, email TEXT DEFAULT '', role TEXT DEFAULT 'Член', + avatar TEXT DEFAULT '', created_at TEXT DEFAULT '', is_board INTEGER DEFAULT 0, bio TEXT DEFAULT ''); +CREATE TABLE login_tokens (token TEXT PRIMARY KEY, email TEXT NOT NULL, username TEXT DEFAULT '', + created_at TEXT DEFAULT '', used INTEGER DEFAULT 0); +CREATE TABLE project_members (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, + member_type TEXT NOT NULL DEFAULT 'user', username TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member'); +CREATE TABLE shares (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + usd_amount REAL NOT NULL, note TEXT DEFAULT ''); +CREATE TABLE member_votes (application_id INTEGER NOT NULL, username TEXT NOT NULL, vote TEXT NOT NULL); +INSERT INTO users (username, name, password, email, role) VALUES + ('alex', 'Alex', 'x', 'alex@x.io', 'Председатель'), + ('serg_test', 'Serg Test', 'x', 'serg.test@x.io', 'Член'); +INSERT INTO login_tokens (token, email, username) VALUES ('t1', 'serg.test@x.io', 'serg_test'); +INSERT INTO project_members (project_id, member_type, username) VALUES ('vybirai', 'user', 'serg_test'); +INSERT INTO shares (username, usd_amount, note) VALUES ('serg_test', 500.0, 'паевой'); +INSERT INTO member_votes (application_id, username, vote) VALUES (1, 'serg_test', 'yes'); +""") +db.commit() +db.close() + + +def q(sql, *p): + db = sqlite3.connect(DB) + r = db.execute(sql, p).fetchall() + db.close() + return r + + +events = [] + +# ── guardrail: Председателя не трогаем ─────────────────────────────────────── +res = member_ops.remove_member(DB, 1, reason="тест", actor="t", archive_dir=ARCH) +check("chairman protected", res.get("error") == "chairman_protected", res) +check("chairman still in users", q("SELECT COUNT(*) FROM users WHERE username='alex'")[0][0] == 1) + +# ── not_found ──────────────────────────────────────────────────────────────── +res = member_ops.remove_member(DB, 999, reason="нет такого", actor="t", archive_dir=ARCH) +check("not_found without exception", res.get("error") == "not_found", res) + +# ── happy-path: удаление члена с бэкапом и аудитом ─────────────────────────── +def on_event(username, payload, actor): + events.append((username, payload, actor)) + + +res = member_ops.remove_member(DB, 2, reason="вышел из Labs, m-516175137", + actor="mcp:simbiot", on_event=on_event, + archive_dir=ARCH) +check("ok removed", bool(res.get("ok")), res) +check("username in result", res.get("username") == "serg_test", res) +check("users row gone", q("SELECT COUNT(*) FROM users WHERE username='serg_test'")[0][0] == 0) +check("login_tokens cleaned", q("SELECT COUNT(*) FROM login_tokens WHERE username='serg_test' OR email='serg.test@x.io'")[0][0] == 0) +check("project_members cleaned", q("SELECT COUNT(*) FROM project_members WHERE username='serg_test'")[0][0] == 0) +check("shares kept (финансовая история)", q("SELECT COUNT(*) FROM shares WHERE username='serg_test'")[0][0] == 1) +check("votes kept", q("SELECT COUNT(*) FROM member_votes WHERE username='serg_test'")[0][0] == 1) + +arch_rows = q("SELECT user_id, username, snapshot, reason, actor FROM member_archive") +check("member_archive row", len(arch_rows) == 1, arch_rows) +if arch_rows: + snap = json.loads(arch_rows[0][2]) + check("snapshot user email", snap["user"]["email"] == "serg.test@x.io", snap.get("user")) + check("snapshot shares inside", len(snap.get("shares", [])) == 1, snap.get("shares")) + check("snapshot votes inside", len(snap.get("member_votes", [])) == 1, snap.get("member_votes")) + check("archive reason/actor", arch_rows[0][3] == "вышел из Labs, m-516175137" and arch_rows[0][4] == "mcp:simbiot") + +check("json backup file written", isinstance(res.get("archive_file"), str) + and os.path.exists(res["archive_file"]), res.get("archive_file")) +if res.get("archive_file"): + disk = json.load(open(res["archive_file"], encoding="utf-8")) + check("json backup has user+shares", disk["user"]["username"] == "serg_test" + and len(disk.get("shares", [])) == 1) + +check("on_event fired once with payload", len(events) == 1 + and events[0][0] == "serg_test" + and events[0][1].get("reason") == "вышел из Labs, m-516175137" + and events[0][1].get("user_id") == 2, events) + +# ── файловый бэкап недоступен — удаление всё равно проходит ───────────────── +db = sqlite3.connect(DB) +db.execute("INSERT INTO users (username, name, password, email, role) VALUES ('tmp2', 'T2', 'x', 't2@x.io', 'Член')") +db.commit() +# путь-ловушка: archive_dir указывает на обычный файл — makedirs упадёт с OSError +# (chmod для root не барьер, поэтому симулируем отказ так) +not_a_dir = os.path.join(_tmp, "blocked_file.json") +open(not_a_dir, "w").write("i am a file") +res2 = member_ops.remove_member(DB, q("SELECT id FROM users WHERE username='tmp2'")[0][0], + reason="x", actor="t", archive_dir=not_a_dir) +check("removed even if file backup fails", bool(res2.get("ok")) and res2.get("archive_file") is None, res2) +check("second archive row", q("SELECT COUNT(*) FROM member_archive")[0][0] == 2) + +# ── идемпотентность повторного вызова: уже удалён → not_found ─────────────── +res3 = member_ops.remove_member(DB, 2, reason="повтор", actor="t", archive_dir=ARCH) +check("re-remove → not_found", res3.get("error") == "not_found", res3) + +shutil.rmtree(_tmp, ignore_errors=True) +print(("ALL PASS" if not fails else "FAILURES: " + ", ".join(fails))) +sys.exit(1 if fails else 0)