Files
flowdeck/tests/test_v72_enterprise.py
T
bruno d125eb399e
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00

619 lines
26 KiB
Python

"""FlowDeck — v7.2.0 Enterprise: SCIM 2.0, TOTP 2FA, WebAuthn, audit, agent governance.
Covers migration 28, SCIM CRUD + suspend/revoke, SCIM token admin, TOTP
setup/activate/login gating + backup codes, domain claims with SSO
enforcement, passkey routes, unified audit log (+CSV) and agent policies
with the human approval gate.
"""
from __future__ import annotations
import json
import secrets
from conftest import anon
from app.db import get_conn
# ── helpers ────────────────────────────────────────────────────────────────
def _make_user(login=None, is_admin=0, email=None):
login = login or f"v72_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?,?,?,?)",
(login, login, email if email is not None else f"{login}@test.com", is_admin))
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
return uid, login
def _session(uid, login):
from app.auth.session import SessionManager
return SessionManager.create_session({"id": uid, "login": login})
def _admin_client(client):
uid, login = _make_user(is_admin=1)
return client, {"flowdeck_session": _session(uid, login)}
def _mk_scim_token(client, cookies, name="IT"):
r = client.post("/api/v2/scim/tokens", json={"name": name}, cookies=cookies)
assert r.status_code == 201, r.text
return r.json()["token"]
# ── migration 28 ───────────────────────────────────────────────────────────
def test_migration_28_tables_exist(client):
with get_conn() as conn:
names = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
for t in ("scim_tokens", "domain_claims", "webauthn_credentials",
"agent_policies", "agent_approvals"):
assert t in names, f"missing {t}"
def test_migration_28_user_columns(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
assert {"totp_secret_enc", "totp_backup_hashes", "is_active"} <= cols
# ── SCIM tokens ────────────────────────────────────────────────────────────
def test_scim_token_requires_auth(client):
assert client.get("/scim/v2/Users").status_code == 401
def test_scim_token_admin_only(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.post("/api/v2/scim/tokens", json={"name": "x"}, cookies=c).status_code == 403
def test_scim_token_create_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/scim/tokens", json={"name": "Okta"}, cookies=c)
assert r.status_code == 201, r.text
body = r.json()
assert body["token"].startswith("scim_")
lst = client.get("/api/v2/scim/tokens", cookies=c)
assert lst.status_code == 200
assert any(t["name"] == "Okta" for t in lst.json()["tokens"])
# raw token is never stored in clear
with get_conn() as conn:
row = conn.execute("SELECT token_hash FROM scim_tokens ORDER BY id DESC").fetchone()
assert body["token"] not in row["token_hash"]
def test_scim_token_revoke(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.delete("/api/v2/scim/tokens/1", cookies=c)
assert r.status_code == 200
assert client.get("/scim/v2/Users",
headers={"Authorization": f"Bearer {token}"}).status_code == 401
# ── SCIM /Users ────────────────────────────────────────────────────────────
def test_scim_list_requires_bearer(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.get("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["schemas"] == ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
def test_scim_create_user(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
r = client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "jane.smith",
"name": {"formatted": "Jane Smith"},
"emails": [{"value": "[email protected]"}]})
assert r.status_code == 201, r.text
body = r.json()
assert body["userName"] == "jane.smith"
assert body["active"] is True
with get_conn() as conn:
row = conn.execute("SELECT email, auth_method FROM users WHERE login=?",
("jane.smith",)).fetchone()
assert row["email"] == "[email protected]"
assert row["auth_method"] == "saml"
def test_scim_create_duplicate_conflict(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
payload = {"userName": "dup.user", "emails": [{"value": "[email protected]"}]}
assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json=payload).status_code == 201
assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"},
json=payload).status_code == 409
def test_scim_get_user(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.get.me")
r = client.get(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["id"] == str(uid)
assert client.get("/scim/v2/Users/999999",
headers={"Authorization": f"Bearer {token}"}).status_code == 404
def test_scim_patch_deactivate_revokes_sessions(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, login = _make_user(login="scim.suspend.me")
with get_conn() as conn:
conn.execute("INSERT INTO user_sessions (user_id, ip_address, user_agent)"
" VALUES (?, '10.0.0.9', 'pytest')", (uid,))
conn.commit()
r = client.patch(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"},
json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": False}]})
assert r.status_code == 200, r.text
assert r.json()["active"] is False
with get_conn() as conn:
assert conn.execute("SELECT is_active FROM users WHERE id=?", (uid,)).fetchone()[0] == 0
assert conn.execute("SELECT revoked FROM user_sessions WHERE user_id=?",
(uid,)).fetchone()["revoked"] == 1
def test_scim_put_updates_fields(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.put.me", email="[email protected]")
r = client.put(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"},
json={"userName": "scim.put.renamed",
"name": {"formatted": "Renamed"},
"emails": [{"value": "[email protected]"}], "active": True})
assert r.status_code == 200, r.text
with get_conn() as conn:
row = conn.execute("SELECT login, email, full_name FROM users WHERE id=?",
(uid,)).fetchone()
assert row["login"] == "scim.put.renamed"
assert row["email"] == "[email protected]"
assert row["full_name"] == "Renamed"
def test_scim_delete_suspends(client):
client, c = _admin_client(client)
token = _mk_scim_token(client, c)
uid, _ = _make_user(login="scim.del.me")
r = client.delete(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 204
with get_conn() as conn:
assert conn.execute("SELECT is_active FROM users WHERE id=?",
(uid,)).fetchone()[0] == 0
# ── TOTP 2FA ───────────────────────────────────────────────────────────────
def test_2fa_status_disabled_by_default(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.get("/auth/2fa/status", cookies=c)
assert r.status_code == 200
assert r.json() == {"enabled": False, "backup_remaining": 0}
def test_2fa_setup_returns_secret_and_uri(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/2fa/setup", cookies=c)
assert r.status_code == 200, r.text
body = r.json()
assert body["secret"]
assert body["otpauth_url"].startswith("otpauth://totp/FlowDeck:")
def test_2fa_activate_rejects_bad_code(client):
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
r = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": "000000"})
assert r.status_code == 400
assert not t2f.is_enabled(uid)
def test_2fa_activate_success_returns_backup_codes(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
r = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
assert r.status_code == 200, r.text
codes = r.json()["backup_codes"]
assert len(codes) == 10 and len(set(codes)) == 10
assert t2f.is_enabled(uid)
assert t2f.remaining_backup_codes(uid) == 10
def test_2fa_secret_encrypted_at_rest(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
with get_conn() as conn:
stored = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?", (uid,)).fetchone()[0]
assert secret not in stored
assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True
def test_2fa_verify_totp_and_backup_code(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
codes = client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret,
"code": pyotp.TOTP(secret).now()}).json()["backup_codes"]
assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True
assert t2f.verify_code(uid, codes[0]) is True
assert t2f.verify_code(uid, codes[0]) is False # single use
assert t2f.remaining_backup_codes(uid) == 9
def test_2fa_verify_rejects_bad_code(client):
from app.services import two_factor as t2f
uid, _ = _make_user()
assert t2f.verify_code(uid, "123456") is False
def test_2fa_disable(client):
import pyotp
from app.services import two_factor as t2f
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"]
client.post("/auth/2fa/activate", cookies=c,
json={"secret": secret, "code": pyotp.TOTP(secret).now()})
assert client.post("/auth/2fa/disable", cookies=c).status_code == 200
assert t2f.is_enabled(uid) is False
def test_2fa_routes_require_session(client):
anon(client)
assert client.get("/auth/2fa/status").status_code == 401
assert client.post("/auth/2fa/setup").status_code == 401
def test_2fa_pending_token_roundtrip(client):
from app.services import two_factor as t2f
uid, _ = _make_user()
token = t2f.mint_pending(uid)
assert t2f.redeem_pending(token) == uid
assert t2f.redeem_pending("forged") is None
assert t2f.redeem_pending(t2f.mint_pending(uid), max_age=-1) is None
# ── domain claims ──────────────────────────────────────────────────────────
def test_domain_claim_create_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "Corp.Example", "auto_join_role": "viewer",
"enforce_sso": True})
assert r.status_code == 201, r.text
body = r.json()
assert body["domain"] == "corp.example"
assert body["expected_content"].startswith("flowdeck-verify=")
lst = client.get("/api/v2/domain-claims", cookies=c)
assert lst.status_code == 200
# txt token is never leaked by the list endpoint
assert "txt_token" not in lst.json()["domains"][0]
def test_domain_claim_invalid_domain(client):
client, c = _admin_client(client)
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "not-a-domain"}).status_code == 400
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "a/b.example"}).status_code == 400
def test_domain_claim_duplicate(client):
client, c = _admin_client(client)
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "dup.example"}).status_code == 201
assert client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "dup.example"}).status_code == 409
def test_domain_claim_delete(client):
client, c = _admin_client(client)
did = client.post("/api/v2/domain-claims", cookies=c,
json={"domain": "gone.example"}).json()["id"]
assert client.delete(f"/api/v2/domain-claims/{did}", cookies=c).status_code == 200
assert client.get("/api/v2/domain-claims", cookies=c).json()["domains"] == []
def test_domain_routes_require_admin(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/api/v2/domain-claims", cookies=c).status_code == 403
# ── WebAuthn / passkeys ────────────────────────────────────────────────────
def test_webauthn_register_begin(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/webauthn/register/begin", cookies=c)
assert r.status_code == 200, r.text
body = r.json()
assert body["challenge"] and body["rp"]["id"]
assert body["user"]["id"]
def test_webauthn_register_begin_requires_session(client):
anon(client)
assert client.post("/auth/webauthn/register/begin").status_code == 401
def test_webauthn_register_finish_rejects_bad_credential(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
client.post("/auth/webauthn/register/begin", cookies=c)
r = client.post("/auth/webauthn/register/finish", cookies=c,
json={"credential": {"id": "abc", "type": "public-key"}})
assert r.status_code == 400
def test_webauthn_register_finish_expired_challenge(client):
from app.routers import webauthn as wa
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
r = client.post("/auth/webauthn/register/finish", cookies=c,
json={"credential": {"id": "abc", "type": "public-key"}})
assert r.status_code == 400
assert "Challenge" in r.json()["detail"]
wa.reset_challenges()
def test_webauthn_login_begin_no_passkeys(client):
uid, login = _make_user()
r = client.post("/auth/webauthn/login/begin", json={"login": login})
assert r.status_code == 400
assert "passkey" in r.json()["detail"].lower()
def test_webauthn_login_begin_unknown_user(client):
r = client.post("/auth/webauthn/login/begin", json={"login": "ghost_user_xyz"})
assert r.status_code == 401
def test_webauthn_login_begin_requires_login(client):
assert client.post("/auth/webauthn/login/begin", json={}).status_code == 400
def test_webauthn_keys_empty_and_delete_404(client):
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/auth/webauthn/keys", cookies=c).json() == {"keys": []}
assert client.delete("/auth/webauthn/keys/9999", cookies=c).status_code == 404
# ── unified audit log ──────────────────────────────────────────────────────
def test_audit_requires_admin(client):
anon(client)
uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)}
assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403
assert client.get("/api/v2/audit/logs").status_code == 401
def test_audit_merges_sources(client):
client, c = _admin_client(client)
aid, alogin = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute(
"""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id,
detail, ip_address)
VALUES (?, 'api.page.create', 'page', '12', 'created', '10.0.0.1')""",
(aid,))
conn.execute(
"""INSERT INTO permission_audit_log (performed_by, action, resource_type,
resource_id, target_user_id, old_role, new_role)
VALUES (?, 'role.change', 'workspace', '1', 42, 'viewer', 'editor')""",
(aid,))
conn.execute(
"""INSERT INTO sso_login_history (user_id, provider_name, provider_type,
success, ip_address, sso_identifier)
VALUES (?, 'okta', 'oidc', 1, '10.0.0.2', '[email protected]')""",
(aid,))
conn.commit()
r = client.get("/api/v2/audit/logs", cookies=c)
assert r.status_code == 200, r.text
logs = r.json()["logs"]
sources = {row["source"] for row in logs}
assert {"api", "permissions", "sso"} <= sources
assert all({"at", "source", "actor", "action", "resource", "detail"} <= set(row)
for row in logs)
def test_audit_source_filter(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'api.x', 'page', '1')""", (aid,))
conn.commit()
r = client.get("/api/v2/audit/logs?source=sso", cookies=c)
assert all(row["source"] == "sso" for row in r.json()["logs"])
assert client.get("/api/v2/audit/logs?source=bogus", cookies=c).status_code == 400
def test_audit_actor_and_action_filters(client):
client, c = _admin_client(client)
uid, _ = _make_user()
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.create', 'page', '1')""", (uid,))
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.delete', 'page', '2')""", (uid,))
conn.commit()
r = client.get(f"/api/v2/audit/logs?actor={uid}&action=create", cookies=c)
assert r.status_code == 200
assert len(r.json()["logs"]) == 1
assert r.json()["logs"][0]["action"] == "page.create"
def test_audit_csv_export(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id)
VALUES (?, 'page.create', 'page', '1')""", (aid,))
conn.commit()
r = client.get("/api/v2/audit/logs?format=csv", cookies=c)
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/csv")
assert "attachment" in r.headers["content-disposition"]
text = r.text
assert text.splitlines()[0].startswith("at,source,actor,action")
assert "page.create" in text
def test_audit_pagination(client):
client, c = _admin_client(client)
aid, _ = _make_user(is_admin=1)
with get_conn() as conn:
for i in range(10):
conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type,
resource_id)
VALUES (?, 'page.create', 'page', ?)""", (aid, i))
conn.commit()
r = client.get("/api/v2/audit/logs?limit=3&offset=0", cookies=c)
assert len(r.json()["logs"]) == 3
assert r.json()["limit"] == 3
# ── agent governance ───────────────────────────────────────────────────────
def test_policy_defaults(client):
from app.services.agent_policies import get_policy
p = get_policy(999999)
assert p["allowed_tools"] is None
assert p["require_approval"] is False
def test_policy_upsert_and_list(client):
client, c = _admin_client(client)
r = client.post("/api/v2/agent-policies", cookies=c,
json={"workspace_id": None, "allowed_tools": ["search", "read_page"],
"max_steps": 5, "require_approval": True})
assert r.status_code == 201, r.text
body = r.json()
assert json.loads(body["allowed_tools_json"]) == ["search", "read_page"]
assert body["require_approval"] == 1
lst = client.get("/api/v2/agent-policies", cookies=c)
assert len(lst.json()["policies"]) == 1
def test_policy_max_steps_clamped(client):
client, c = _admin_client(client)
r = client.post("/api/v2/agent-policies", cookies=c,
json={"max_steps": 9999, "allowed_tools": None})
assert r.json()["max_steps"] == 50
def test_policy_rejects_bad_tools(client):
client, c = _admin_client(client)
assert client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": "search"}).status_code == 400
def test_check_tool_denies_out_of_scope(client):
from app.services.agent_policies import check_tool, get_policy
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search"], "max_steps": 5})
uid, _ = _make_user()
out = check_tool(uid, None, "delete_page", is_write=True, conversation_id=0)
assert out["allowed"] is False
assert "policy scope" in out["reason"]
assert get_policy(None)["max_steps"] == 5
def test_check_tool_allows_reads(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search"], "require_approval": True})
uid, _ = _make_user()
assert check_tool(uid, None, "search", is_write=False)["allowed"] is True
def test_check_tool_requires_approval_for_writes(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["search", "create_page"],
"require_approval": True})
uid, _ = _make_user()
out = check_tool(uid, None, "create_page", is_write=True, conversation_id=0)
assert out["allowed"] is False
assert out["approval_id"]
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(out["approval_id"],)).fetchone()
assert row["status"] == "pending"
assert row["tool"] == "create_page"
assert row["requester_id"] == uid
def test_approval_decision_flow(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["create_page"], "require_approval": True})
uid, _ = _make_user()
aid = check_tool(uid, None, "create_page", is_write=True)["approval_id"]
q = client.get("/api/v2/agent-approvals", cookies=c)
assert any(a["id"] == aid for a in q.json()["approvals"])
r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": True})
assert r.status_code == 200
assert r.json()["status"] == "approved"
# a decided approval cannot be decided again
assert client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": False}).status_code == 404
def test_approval_rejection(client):
from app.services.agent_policies import check_tool
client, c = _admin_client(client)
client.post("/api/v2/agent-policies", cookies=c,
json={"allowed_tools": ["delete_page"], "require_approval": True})
uid, _ = _make_user()
aid = check_tool(uid, None, "delete_page", is_write=True)["approval_id"]
r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c,
json={"approve": False})
assert r.json()["status"] == "rejected"
def test_governance_routes_require_auth(client):
anon(client)
assert client.get("/api/v2/agent-policies").status_code == 401
assert client.get("/api/v2/agent-approvals").status_code == 401