Protect private API routes and scope agent data
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Helpers for API tests that exercise authenticated application routes."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, database
|
||||
|
||||
|
||||
def authenticated_client(app: FastAPI) -> TestClient:
|
||||
database.ensure_user(database.LOCAL_USER_ID)
|
||||
token, _ = auth.issue_session(database.LOCAL_USER_ID)
|
||||
return TestClient(app, headers={"Authorization": f"Bearer {token}"})
|
||||
@@ -5,7 +5,6 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import HTTPException
|
||||
|
||||
_TEST_DATA_DIR = None
|
||||
@@ -14,12 +13,13 @@ if "SOVEREIGNAI_DATA_DIR" not in os.environ:
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from app.main import AgentRequest, _execute_agent, app, safe_arithmetic
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
class AgentSkillTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.client = TestClient(app)
|
||||
cls.client = authenticated_client(app)
|
||||
cls.workspace = str(Path(__file__).resolve().parents[1])
|
||||
|
||||
def test_skill_catalog_discloses_scope_and_permissions(self) -> None:
|
||||
|
||||
@@ -5,7 +5,6 @@ from uuid import UUID
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Importing the API initializes its SQLite schema. Keep this test process isolated
|
||||
# from the real local conversation database.
|
||||
@@ -13,12 +12,13 @@ _TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-api-tests-")
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
class ApiErrorContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.client = TestClient(app)
|
||||
cls.client = authenticated_client(app)
|
||||
|
||||
def test_health_response_has_correlation_id(self) -> None:
|
||||
response = self.client.get("/health")
|
||||
|
||||
@@ -47,6 +47,63 @@ class AuthenticationTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 401, response.text)
|
||||
self.assertEqual(response.headers["www-authenticate"], "Bearer")
|
||||
|
||||
def test_private_api_routes_require_a_valid_bearer_session(self) -> None:
|
||||
for path, method, payload in (
|
||||
("/v1/agent/tools", "get", None),
|
||||
("/v1/agent/skills", "get", None),
|
||||
("/v1/agent/run", "post", {"task": "hello"}),
|
||||
("/v1/chat/completions", "post", {"messages": [{"role": "user", "content": "hi"}]}),
|
||||
("/v1/agent/knowledge/search", "post", {"task": "find", "workspace_path": "."}),
|
||||
("/v1/web/search", "post", {"query": "test"}),
|
||||
):
|
||||
response = getattr(self.client, method)(path, json=payload) if payload is not None else getattr(self.client, method)(path)
|
||||
self.assertEqual(response.status_code, 401, f"{method.upper()} {path}: {response.text}")
|
||||
|
||||
audio = self.client.post(
|
||||
"/v1/audio/transcriptions",
|
||||
files={"file": ("recording.wav", b"", "audio/wav")},
|
||||
)
|
||||
self.assertEqual(audio.status_code, 401, audio.text)
|
||||
|
||||
def test_every_private_v1_openapi_operation_declares_bearer_auth(self) -> None:
|
||||
public = {
|
||||
("/v1/models", "get"),
|
||||
("/v1/auth/local-session", "post"),
|
||||
("/v1/auth/register", "post"),
|
||||
("/v1/auth/login", "post"),
|
||||
}
|
||||
missing: list[str] = []
|
||||
for path, operations in app.openapi()["paths"].items():
|
||||
if not path.startswith("/v1/"):
|
||||
continue
|
||||
for method, operation in operations.items():
|
||||
if (path, method) in public:
|
||||
continue
|
||||
if not operation.get("security"):
|
||||
missing.append(f"{method.upper()} {path}")
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_agent_audit_history_is_scoped_to_session_owner(self) -> None:
|
||||
_, first_token = self._register()
|
||||
_, second_token = self._register()
|
||||
first_tool = self.client.get(
|
||||
"/v1/agent/tools", headers={"Authorization": f"Bearer {first_token}"}
|
||||
)
|
||||
second_tool = self.client.get(
|
||||
"/v1/agent/tools", headers={"Authorization": f"Bearer {second_token}"}
|
||||
)
|
||||
first_audit = self.client.get(
|
||||
"/v1/agent/audit", headers={"Authorization": f"Bearer {first_token}"}
|
||||
)
|
||||
second_audit = self.client.get(
|
||||
"/v1/agent/audit", headers={"Authorization": f"Bearer {second_token}"}
|
||||
)
|
||||
|
||||
self.assertEqual(first_audit.status_code, 200, first_audit.text)
|
||||
self.assertEqual(second_audit.status_code, 200, second_audit.text)
|
||||
self.assertEqual([item["id"] for item in first_audit.json()["data"]], [first_tool.headers["x-agent-audit-id"]])
|
||||
self.assertEqual([item["id"] for item in second_audit.json()["data"]], [second_tool.headers["x-agent-audit-id"]])
|
||||
|
||||
def test_register_login_me_and_logout_use_revocable_sessions(self) -> None:
|
||||
user_id, token = self._register("User@Example.Test")
|
||||
me = self.client.get(
|
||||
|
||||
@@ -3,10 +3,9 @@ import unittest
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, database
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
class ConversationVersionMigrationTests(unittest.TestCase):
|
||||
@@ -47,6 +46,14 @@ class ConversationVersionMigrationTests(unittest.TestCase):
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE agent_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
tool TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO users(id) VALUES ('00000000-0000-4000-8000-000000000001');
|
||||
INSERT INTO user_identities(user_id,provider,provider_subject,email)
|
||||
VALUES ('00000000-0000-4000-8000-000000000001','google','subject-1','old@example.test');
|
||||
@@ -55,6 +62,8 @@ class ConversationVersionMigrationTests(unittest.TestCase):
|
||||
'قديم', '2026-01-01', '2026-01-01');
|
||||
INSERT INTO messages(conversation_id, role, content, created_at)
|
||||
VALUES ('conversation-1', 'assistant', 'جواب قديم', '2026-01-01');
|
||||
INSERT INTO agent_audit_events(id,tool,method,status_code,duration_ms)
|
||||
VALUES ('legacy-event','/v1/agent/run','POST',200,12);
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
@@ -73,9 +82,16 @@ class ConversationVersionMigrationTests(unittest.TestCase):
|
||||
identity = connection.execute(
|
||||
"SELECT provider_subject,email,password_hash FROM user_identities WHERE provider='google'"
|
||||
).fetchone()
|
||||
legacy_audit = connection.execute(
|
||||
"SELECT user_id FROM agent_audit_events WHERE id='legacy-event'"
|
||||
).fetchone()
|
||||
self.assertEqual(identity["provider_subject"], "subject-1")
|
||||
self.assertEqual(identity["email"], "old@example.test")
|
||||
self.assertIsNone(identity["password_hash"])
|
||||
self.assertIsNone(legacy_audit["user_id"])
|
||||
self.assertEqual(database.list_agent_audit_events(
|
||||
"00000000-0000-4000-8000-000000000001"
|
||||
), [])
|
||||
old_conversation = database.get_conversation(
|
||||
"00000000-0000-4000-8000-000000000001", "conversation-1"
|
||||
)
|
||||
@@ -122,7 +138,7 @@ class ConversationVersionMigrationTests(unittest.TestCase):
|
||||
},
|
||||
],
|
||||
}
|
||||
with TestClient(app) as client:
|
||||
with authenticated_client(app) as client:
|
||||
saved = client.put(
|
||||
f"/v1/conversations/{conversation_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
|
||||
@@ -8,15 +8,17 @@ import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-knowledge-tests-")
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from app import knowledge
|
||||
from fastapi.testclient import TestClient
|
||||
from app import auth, database, knowledge
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
class KnowledgeIndexTests(unittest.TestCase):
|
||||
@@ -135,7 +137,7 @@ class KnowledgeIndexTests(unittest.TestCase):
|
||||
def test_api_indexes_selected_file_and_returns_matching_source(self) -> None:
|
||||
file = self.workspace / "guide.md"
|
||||
file.write_text("The project stores conversations in SQLite.", encoding="utf-8")
|
||||
client = TestClient(app)
|
||||
client = authenticated_client(app)
|
||||
|
||||
indexed = client.post(
|
||||
"/v1/agent/knowledge/index",
|
||||
@@ -162,6 +164,57 @@ class KnowledgeIndexTests(unittest.TestCase):
|
||||
self.assertEqual(deleted.status_code, 200, deleted.text)
|
||||
self.assertTrue(deleted.json()["deleted"][0]["deleted"])
|
||||
|
||||
def test_api_knowledge_index_isolated_between_account_sessions(self) -> None:
|
||||
marker = "OwnerScopedKnowledgeMarker"
|
||||
(self.workspace / "private.md").write_text(marker, encoding="utf-8")
|
||||
owner_id = auth.create_account(
|
||||
f"{uuid4().hex}@example.test", "account one secure passphrase"
|
||||
)
|
||||
other_id = auth.create_account(
|
||||
f"{uuid4().hex}@example.test", "account two secure passphrase"
|
||||
)
|
||||
self.addCleanup(self._delete_test_users, owner_id, other_id)
|
||||
owner_token, _ = auth.issue_session(owner_id)
|
||||
other_token, _ = auth.issue_session(other_id)
|
||||
owner_client = TestClient(
|
||||
app, headers={"Authorization": f"Bearer {owner_token}"}
|
||||
)
|
||||
other_client = TestClient(
|
||||
app, headers={"Authorization": f"Bearer {other_token}"}
|
||||
)
|
||||
payload = {
|
||||
"workspace_path": str(self.workspace),
|
||||
"files": ["private.md"],
|
||||
}
|
||||
|
||||
indexed = owner_client.post("/v1/agent/knowledge/index", json=payload)
|
||||
owner_results = owner_client.post(
|
||||
"/v1/agent/knowledge/search",
|
||||
json={"workspace_path": str(self.workspace), "task": marker},
|
||||
)
|
||||
other_results = other_client.post(
|
||||
"/v1/agent/knowledge/search",
|
||||
json={"workspace_path": str(self.workspace), "task": marker},
|
||||
)
|
||||
|
||||
self.assertEqual(indexed.status_code, 200, indexed.text)
|
||||
self.assertEqual(owner_results.status_code, 200, owner_results.text)
|
||||
self.assertEqual(other_results.status_code, 200, other_results.text)
|
||||
self.assertEqual(owner_results.json()["results"][0]["path"], "private.md")
|
||||
self.assertEqual(other_results.json()["results"], [])
|
||||
knowledge.delete_document(
|
||||
user_id=owner_id,
|
||||
workspace_path=self.workspace,
|
||||
relative_path="private.md",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_test_users(*user_ids: str) -> None:
|
||||
with database._connect() as connection:
|
||||
connection.executemany(
|
||||
"DELETE FROM users WHERE id=?", [(user_id,) for user_id in user_ids]
|
||||
)
|
||||
|
||||
def test_api_ocr_indexes_a_scanned_pdf_and_retrieves_its_text(self) -> None:
|
||||
pdf_path = self.workspace / "notice.pdf"
|
||||
image = Image.new("RGB", (900, 1165), "white")
|
||||
@@ -169,7 +222,7 @@ class KnowledgeIndexTests(unittest.TestCase):
|
||||
output = BytesIO()
|
||||
image.save(output, format="PDF", resolution=144)
|
||||
pdf_path.write_bytes(output.getvalue())
|
||||
client = TestClient(app)
|
||||
client = authenticated_client(app)
|
||||
ocr = {
|
||||
"engine": "easyocr-local-ar-en",
|
||||
"text": "Community Reading Meetup Place Amman",
|
||||
@@ -198,7 +251,7 @@ class KnowledgeIndexTests(unittest.TestCase):
|
||||
def test_api_indexes_digital_and_scanned_pages_from_mixed_pdf(self) -> None:
|
||||
pdf_path = self.workspace / "mixed.pdf"
|
||||
pdf_path.write_bytes(b"%PDF-mixed-test")
|
||||
client = TestClient(app)
|
||||
client = authenticated_client(app)
|
||||
rendered = {
|
||||
"pages": [
|
||||
{
|
||||
|
||||
@@ -9,10 +9,9 @@ if "SOVEREIGNAI_DATA_DIR" not in os.environ:
|
||||
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-mixed-pdf-index-tests-")
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import database, knowledge
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
from tests.test_pdf_analysis import make_mixed_pdf
|
||||
|
||||
|
||||
@@ -25,7 +24,6 @@ class MixedPdfKnowledgeIntegrationTests(unittest.TestCase):
|
||||
pdf_path = workspace / "mixed.pdf"
|
||||
pdf_path.write_bytes(make_mixed_pdf())
|
||||
database_path = data_dir / "mixed-pdf-knowledge.sqlite3"
|
||||
client = TestClient(app)
|
||||
ocr = {
|
||||
"engine": "easyocr-local-ar-en",
|
||||
"text": "ScannedIndexMarker ArabicPageText",
|
||||
@@ -45,6 +43,7 @@ class MixedPdfKnowledgeIntegrationTests(unittest.TestCase):
|
||||
):
|
||||
database.initialize_database()
|
||||
knowledge.initialize()
|
||||
client = authenticated_client(app)
|
||||
with patch("app.main.recognize_image_text", return_value=ocr):
|
||||
indexed = client.post(
|
||||
"/v1/agent/knowledge/index",
|
||||
|
||||
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from fastapi.testclient import TestClient
|
||||
from app.local_ocr import LocalOCRError
|
||||
from app.pdf_documents import extract_pdf_pages_text
|
||||
|
||||
@@ -18,6 +17,7 @@ if "SOVEREIGNAI_DATA_DIR" not in os.environ:
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
def make_pdf(text: str | None) -> bytes:
|
||||
@@ -79,7 +79,7 @@ def make_mixed_pdf() -> bytes:
|
||||
class PdfAnalysisTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.client = TestClient(app)
|
||||
cls.client = authenticated_client(app)
|
||||
|
||||
def test_extracts_pdf_text_and_sends_page_number_to_model(self) -> None:
|
||||
completion = {"choices": [{"message": {"content": "يتحدث الملف عن لقاء مجتمعي في عمّان."}}]}
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import HTTPException
|
||||
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-timeout-tests-")
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from app import database
|
||||
from app.main import AgentRequest, run_agent_stream
|
||||
from app.model_provider import OllamaProvider
|
||||
|
||||
@@ -72,7 +73,8 @@ class TimeoutAndCancellationTests(unittest.IsolatedAsyncioTestCase):
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def waiting_agent(_request, *, report_progress):
|
||||
async def waiting_agent(_request, *, report_progress, user_id):
|
||||
self.assertEqual(user_id, database.LOCAL_USER_ID)
|
||||
await report_progress("بدأ الاختبار")
|
||||
started.set()
|
||||
try:
|
||||
@@ -83,7 +85,7 @@ class TimeoutAndCancellationTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
request = AgentRequest(task="اختبار إلغاء البث")
|
||||
with patch("app.main._execute_agent", side_effect=waiting_agent):
|
||||
response = await run_agent_stream(request)
|
||||
response = await run_agent_stream(request, user_id=database.LOCAL_USER_ID)
|
||||
stream = response.body_iterator
|
||||
first_event = await asyncio.wait_for(anext(stream), timeout=1)
|
||||
self.assertIn("event: progress", first_event)
|
||||
|
||||
@@ -10,9 +10,8 @@ if "SOVEREIGNAI_DATA_DIR" not in os.environ:
|
||||
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-web-search-tests-")
|
||||
os.environ["SOVEREIGNAI_DATA_DIR"] = _TEST_DATA_DIR.name
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from tests.api_client import authenticated_client
|
||||
|
||||
|
||||
class _FakeSearchClient:
|
||||
@@ -33,7 +32,7 @@ class _FakeSearchClient:
|
||||
class WebSearchApiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.client = TestClient(app)
|
||||
cls.client = authenticated_client(app)
|
||||
|
||||
def test_search_returns_per_source_and_total_fetch_durations(self) -> None:
|
||||
candidates = [
|
||||
|
||||
@@ -36,6 +36,25 @@ class WorkspaceChangeTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "انتهت صلاحية"):
|
||||
workspace.apply_change_preview(str(preview["token"]))
|
||||
|
||||
def test_preview_can_only_be_applied_by_its_owner(self) -> None:
|
||||
target = self.root / "src" / "owned.py"
|
||||
target.write_text("before = True\n", encoding="utf-8")
|
||||
preview = workspace.create_change_preview(
|
||||
self.root,
|
||||
"src/owned.py",
|
||||
"update",
|
||||
"after = True\n",
|
||||
user_id="account-one",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "لا تخص جلسة"):
|
||||
workspace.apply_change_preview(
|
||||
str(preview["token"]), user_id="account-two"
|
||||
)
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), "before = True\n")
|
||||
workspace.apply_change_preview(str(preview["token"]), user_id="account-one")
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), "after = True\n")
|
||||
|
||||
def test_update_preview_detects_external_changes_before_apply(self) -> None:
|
||||
target = self.root / "src" / "existing.py"
|
||||
target.write_text("old = 1\n", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user