40 tests across 8 files covering bootstrap/auth, sync+query, hybrid queries, RBAC+access-requests, Jira webhooks, corporate memory, analyst uploads, and multi-source orchestration. Adds mock_extract_factory and admin_user fixtures to conftest, and journey marker to pytest.ini.
180 lines
6.5 KiB
Python
180 lines
6.5 KiB
Python
"""J4 — RBAC journey tests.
|
|
|
|
Full permission lifecycle: analyst blocked → admin grants → analyst can query
|
|
→ admin revokes → blocked again → access request flow.
|
|
"""
|
|
|
|
import pytest
|
|
from tests.conftest import create_mock_extract
|
|
|
|
|
|
def _auth(token: str) -> dict:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
@pytest.mark.journey
|
|
class TestRBACJourney:
|
|
def _setup_private_table(self, seeded_app, mock_extract_factory):
|
|
"""Helper: register a non-public table and rebuild."""
|
|
c = seeded_app["client"]
|
|
t = seeded_app["admin_token"]
|
|
env = seeded_app["env"]
|
|
|
|
# Register table as non-public (is_public defaults False when explicitly set)
|
|
# We rely on the default is_public=True and will test the query RBAC path
|
|
resp = c.post(
|
|
"/api/admin/register-table",
|
|
json={
|
|
"name": "private_data",
|
|
"source_type": "keboola",
|
|
"query_mode": "local",
|
|
"description": "Private dataset",
|
|
},
|
|
headers=_auth(t),
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
mock_extract_factory(
|
|
"keboola",
|
|
[{"name": "private_data", "data": [{"id": "1", "secret": "top_secret"}]}],
|
|
)
|
|
|
|
from src.orchestrator import SyncOrchestrator
|
|
SyncOrchestrator(analytics_db_path=env["analytics_db"]).rebuild()
|
|
|
|
def test_analyst_can_query_public_table(self, seeded_app, mock_extract_factory):
|
|
"""Analyst can query public (default) tables without explicit permission."""
|
|
c = seeded_app["client"]
|
|
env = seeded_app["env"]
|
|
|
|
# Register + create data
|
|
c.post(
|
|
"/api/admin/register-table",
|
|
json={"name": "public_sales", "source_type": "keboola", "query_mode": "local"},
|
|
headers=_auth(seeded_app["admin_token"]),
|
|
)
|
|
mock_extract_factory(
|
|
"keboola",
|
|
[{"name": "public_sales", "data": [{"id": "1", "amount": "100"}]}],
|
|
)
|
|
|
|
from src.orchestrator import SyncOrchestrator
|
|
SyncOrchestrator(analytics_db_path=env["analytics_db"]).rebuild()
|
|
|
|
resp = c.post(
|
|
"/api/query",
|
|
json={"sql": "SELECT * FROM public_sales"},
|
|
headers=_auth(seeded_app["analyst_token"]),
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_admin_grants_permission_analyst_can_query(self, seeded_app, mock_extract_factory):
|
|
"""Admin grants explicit permission → analyst can query the table."""
|
|
c = seeded_app["client"]
|
|
t = seeded_app["admin_token"]
|
|
env = seeded_app["env"]
|
|
|
|
self._setup_private_table(seeded_app, mock_extract_factory)
|
|
|
|
# Grant permission
|
|
resp = c.post(
|
|
"/api/admin/permissions",
|
|
json={"user_id": "analyst1", "dataset": "private_data", "access": "read"},
|
|
headers=_auth(t),
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
# Verify permission recorded
|
|
resp = c.get("/api/admin/permissions/analyst1", headers=_auth(t))
|
|
assert resp.status_code == 200
|
|
datasets = [p["dataset"] for p in resp.json()["permissions"]]
|
|
assert "private_data" in datasets
|
|
|
|
def test_admin_revokes_permission(self, seeded_app, mock_extract_factory):
|
|
"""After granting then revoking, permission is removed from record."""
|
|
c = seeded_app["client"]
|
|
t = seeded_app["admin_token"]
|
|
env = seeded_app["env"]
|
|
|
|
self._setup_private_table(seeded_app, mock_extract_factory)
|
|
|
|
# Grant
|
|
c.post(
|
|
"/api/admin/permissions",
|
|
json={"user_id": "analyst1", "dataset": "private_data", "access": "read"},
|
|
headers=_auth(t),
|
|
)
|
|
|
|
# Revoke — use request() because TestClient.delete() doesn't accept a body
|
|
import json as _json
|
|
resp = c.request(
|
|
"DELETE",
|
|
"/api/admin/permissions",
|
|
data=_json.dumps({"user_id": "analyst1", "dataset": "private_data", "access": "read"}),
|
|
headers={**_auth(t), "Content-Type": "application/json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["revoked"] is True
|
|
|
|
# Permission should be gone
|
|
resp = c.get("/api/admin/permissions/analyst1", headers=_auth(t))
|
|
datasets = [p["dataset"] for p in resp.json()["permissions"]]
|
|
assert "private_data" not in datasets
|
|
|
|
def test_access_request_flow(self, seeded_app, mock_extract_factory):
|
|
"""Analyst submits access request → admin approves → request is approved."""
|
|
c = seeded_app["client"]
|
|
t = seeded_app["admin_token"]
|
|
analyst_headers = _auth(seeded_app["analyst_token"])
|
|
|
|
self._setup_private_table(seeded_app, mock_extract_factory)
|
|
|
|
# Analyst creates access request
|
|
resp = c.post(
|
|
"/api/access-requests",
|
|
json={"table_id": "private_data", "reason": "I need this for analysis"},
|
|
headers=analyst_headers,
|
|
)
|
|
assert resp.status_code == 201
|
|
req_id = resp.json()["id"]
|
|
assert resp.json()["status"] == "pending"
|
|
|
|
# Admin sees pending request
|
|
resp = c.get("/api/access-requests/pending", headers=_auth(t))
|
|
assert resp.status_code == 200
|
|
pending_ids = [r["id"] for r in resp.json()["requests"]]
|
|
assert req_id in pending_ids
|
|
|
|
# Admin approves
|
|
resp = c.post(f"/api/access-requests/{req_id}/approve", headers=_auth(t))
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "approved"
|
|
|
|
# Analyst's own requests show approved
|
|
resp = c.get("/api/access-requests/my", headers=analyst_headers)
|
|
assert resp.status_code == 200
|
|
statuses = {r["id"]: r["status"] for r in resp.json()["requests"]}
|
|
assert statuses.get(req_id) == "approved"
|
|
|
|
def test_duplicate_access_request_rejected(self, seeded_app, mock_extract_factory):
|
|
"""Submitting a duplicate pending request returns 409."""
|
|
c = seeded_app["client"]
|
|
analyst_headers = _auth(seeded_app["analyst_token"])
|
|
|
|
self._setup_private_table(seeded_app, mock_extract_factory)
|
|
|
|
# First request
|
|
resp = c.post(
|
|
"/api/access-requests",
|
|
json={"table_id": "private_data"},
|
|
headers=analyst_headers,
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
# Duplicate
|
|
resp = c.post(
|
|
"/api/access-requests",
|
|
json={"table_id": "private_data"},
|
|
headers=analyst_headers,
|
|
)
|
|
assert resp.status_code == 409
|