member_remove: безопасное удаление члена портала (t-261018141319) — описания тула + тест ядра

This commit is contained in:
Simbiot Harness 2026-10-18 15:22:46 +00:00
parent 0f182236f7
commit f9354a5c36
3 changed files with 148 additions and 4 deletions

View file

@ -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 вызовов за тик). НЕ создавай звонки без явной "

View file

@ -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 — "
"идентичность и лента портала.")

139
tests/test_member_remove.py Normal file
View file

@ -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)