- 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
619 lines
26 KiB
Python
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
|