Protect private API routes and scope agent data
This commit is contained in:
@@ -163,7 +163,8 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
|
||||
- تحفظ FastAPI المحادثات والرسائل في SQLite محليًا. على Windows يوجد الملف في `%LOCALAPPDATA%\SovereignAI\data\sovereign_ai.sqlite3`، وخارج مجلد المشروع لتجنب مزامنة قاعدة البيانات مع OneDrive.
|
||||
- لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback.
|
||||
- يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. تسجيل الحساب يعمل عبر الـAPI، لكن واجهة الدخول والحفظ الآمن للجلسة على الأجهزة غير مكتملين.
|
||||
- المصادقة الحالية تحمي سجل المحادثات فقط؛ مسارات الوكيل والملفات والصوت والويب لم تُربط كلها بهوية المستخدم بعد. أبقِ الخدمة على `127.0.0.1` ولا تعرضها على الشبكة؛ يلزم إكمال التفويض لكل المسارات، واجهة الدخول، وحدود محاولات تسجيل الدخول قبل دعم مستخدمين/أجهزة عبر الشبكة.
|
||||
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
|
||||
- أساس API متعدد الحسابات لا يكفي لفتح الخدمة للشبكة: واجهة تسجيل الدخول غير موجودة، ورمز الحساب لا يُخزن بعد إغلاق التطبيق، ولا توجد حدود لمحاولات الدخول. كذلك يجب حصر مساحات العمل التي يستطيع كل حساب قراءتها ضمن مجلدات مصرح بها؛ عملية FastAPI ترى صلاحيات ملفات نظام التشغيل الذي يشغلها. أبقِ الخدمة على `127.0.0.1` إلى أن تكتمل هذه الضوابط ويُختبر النشر عبر TLS.
|
||||
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
|
||||
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
|
||||
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
- إعدادات عنوان API والنموذج المختار والإشعارات والمظهر الداكن تُحفظ محليًا: ملف إعدادات عبر `path_provider` للأجهزة و`localStorage` للويب. عند غياب plugin يُبلّغ التطبيق أن الإعداد لن يستمر بعد الجلسة. Windows Debug أنشأ ملف الإعدادات فعليًا؛ اختبار Cubit أكد الاستعادة بعد إعادة إنشائه، واختبار الواجهة أكد تبديل السمة، وبناء Flutter Web نجح (2026-10-02).
|
||||
- `/v1/models` يعرض القدرات التي يعلنها Ollama فعلًا لكل نموذج، مع علامة تحقق واضحة؛ قائمة Flutter تعرض النص/الصور/الصوت/الأدوات/التفكير/التضمينات دون تخمين. تحقق حي أظهر Gemma 4 E2B: نص، صور، صوت، أدوات، تفكير؛ Ministral 3:3b: نص، صور، أدوات.
|
||||
- أضيف زر البحث العميق متعدد المصادر وزرا اختيار الملفات إلى الواجهة؛ أضيف تقييم الإجابة بإعجاب/عدم إعجاب محفوظ لكل نسخة في SQLite. التقييم يجمع بيانات تقييم، ولا يدرّب أوزان Gemma تلقائيًا.
|
||||
- Flutter Windows Debug وFastAPI يعملان محليًا؛ مسار تحليل المرفقات يدعم PDF الرقمي، ويحوّل أول 3 صفحات من PDF الممسوح محليًا ثم يرسلها لنموذج الرؤية المحلي. تجربة حية على صورة اللقاء استخرجت العنوان والمكان عمان والتاريخ 15 تشرين الأول والوقت 6:30 مساءً دون ذكر سنة غير ظاهرة. لم تُنفذ بعد مصادقة متعددة المستخدمين أو نشر شبكي آمن. الصوت يعتمد على Groq خارجي.
|
||||
- Flutter Windows Debug وFastAPI يعملان محليًا؛ مسار تحليل المرفقات يدعم PDF الرقمي، ويحوّل أول 3 صفحات من PDF الممسوح محليًا ثم يرسلها لنموذج الرؤية المحلي. تجربة حية على صورة اللقاء استخرجت العنوان والمكان عمان والتاريخ 15 تشرين الأول والوقت 6:30 مساءً دون ذكر سنة غير ظاهرة. أُضيفت جلسات حسابات وعزل سجل المحادثات والمعرفة والتدقيق، لكن واجهة الدخول وصلاحيات نظام الملفات وحدود الدخول لم تكتمل؛ لا يوجد نشر شبكي آمن بعد. الصوت يعتمد على Groq خارجي.
|
||||
- التدريب والضبط الدقيق وتوزيع Windows مراحل لاحقة، وليست مما يفعّله التطبيق حاليًا.
|
||||
- 2026-10-03: اكتمل فرض Bearer على كل عمليات `/v1` الخاصة (عدا الصحة وقائمة النماذج ومسارات بدء المصادقة العامة)، وربط Flutter بالجلسة لكل طلب محادثة/وكيل/ملف/معرفة/ويب/صوت. أصبحت ملكية فهرس المعرفة وسجل التدقيق حسب الحساب، ومعاينة تعديل الملف لا تُطبق إلا بجلسة صاحبها. تحقق OpenAPI وواجهات رفض الرمز وعزل حسابين وترحيل SQLite: 61 اختبار Python ناجح، و11 اختبار Flutter وتحليل Flutter بلا ملاحظات. يبقى قصر الوصول إلى مسارات نظام الملفات لكل مستخدم، وواجهة الدخول وتخزين الرمز الآمن وحدود محاولات الدخول؛ لذلك يظل التشغيل loopback فقط.
|
||||
|
||||
## المرحلة 1 — تجربة المحادثة
|
||||
|
||||
@@ -60,9 +61,11 @@
|
||||
|
||||
- [x] أساس مصادقة محلي في FastAPI: تسجيل/دخول بالبريد وكلمة مرور عبر API، تجزئة PBKDF2 مملحة، جلسات Bearer عشوائية قابلة للإلغاء وتنتهي بعد 7 أيام، وترحيل SQLite يحافظ على هويات OAuth القديمة. `X-User-ID` لم يعد يخول الوصول لسجل المحادثات؛ الجلسة المحلية التلقائية لا تصدر إلا لعميل loopback. اختبارات المصادقة والعزل والترحيل: 7 ناجحة (2026-10-03).
|
||||
- [x] ربط CRUD المحادثات والتقييم بهوية الجلسة، والتحقق من أن حسابًا ثانيًا لا يقرأ محادثة الحساب الأول.
|
||||
- [ ] قبل دعم عدة مستخدمين أو أي ربط شبكي: فرض المصادقة والتفويض على كل مسارات المحادثة/النموذج والوكيل والملفات والمعرفة والبحث والصوت، وربط الفهرس وسجل التدقيق بمالك المستخدم بدل هوية محلية ثابتة.
|
||||
- [x] فرض Bearer على جميع عمليات `/v1` الخاصة وإرسال الجلسة من Flutter للمحادثة/الوكيل/الملفات/المعرفة/البحث والصوت؛ فحص OpenAPI يضمن ألا توجد عملية خاصة بلا HTTP Bearer.
|
||||
- [x] عزل فهرس المعرفة وسجل التدقيق بمعرّف الحساب، وربط رمز معاينة تعديل الملفات بصاحبها؛ اختبار API أثبت عدم استرجاع حساب لمحتوى فهرسه حساب آخر.
|
||||
- [ ] قصر مسارات مساحة العمل على مجلدات مصرح بها لكل مستخدم، وإزالة صلاحيات FastAPI العامة عن ملفات المضيف قبل السماح بعميل شبكي أو خدمة مستضافة.
|
||||
- [ ] بناء شاشة إنشاء الحساب/الدخول والخروج، وتخزين الرموز في مخزن آمن مناسب لكل منصة؛ حاليًا عميل Flutter يطلب جلسة محلية تلقائيًا، ودوال الحساب غير موصولة بواجهة ولا تحفظ رمزها بعد إغلاق التطبيق.
|
||||
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور، ثم اختبارات تفويض شاملة لكل المسارات.
|
||||
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
|
||||
- تصميم بيانات المستخدمين والمحادثات والمرفقات ونسخ الإجابات مع ملكية واضحة وفهارس وترحيلات قاعدة بيانات.
|
||||
- SQLite مناسب لنسخة محلية أحادية الجهاز. عند تشغيل خدمة لعدة مستخدمين/أجهزة، ننتقل إلى PostgreSQL، مع نسخ احتياطية وسياسة حذف وتصدير.
|
||||
- تخزين الملفات الكبيرة في مساحة ملفات منظّمة، وحفظ بياناتها الوصفية ومراجعها في قاعدة البيانات، لا في سجل الرسائل ككتل ضخمة.
|
||||
|
||||
@@ -116,6 +116,7 @@ def initialize_database() -> None:
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
@@ -138,6 +139,14 @@ def initialize_database() -> None:
|
||||
}
|
||||
if "password_hash" not in identity_columns:
|
||||
connection.execute("ALTER TABLE user_identities ADD COLUMN password_hash TEXT")
|
||||
audit_columns = {
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(agent_audit_events)")
|
||||
}
|
||||
if "user_id" not in audit_columns:
|
||||
connection.execute("ALTER TABLE agent_audit_events ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE CASCADE")
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_audit_user_created ON agent_audit_events(user_id, created_at DESC)"
|
||||
)
|
||||
|
||||
|
||||
def ensure_user(user_id: str) -> None:
|
||||
@@ -351,29 +360,30 @@ def record_agent_audit_event(
|
||||
method: str,
|
||||
status_code: int,
|
||||
duration_ms: int,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
"""Record agent route metadata only; never persist prompts or file contents."""
|
||||
with _connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO agent_audit_events(id, tool, method, status_code, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO agent_audit_events(id, user_id, tool, method, status_code, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(event_id, tool, method, status_code, duration_ms),
|
||||
(event_id, user_id, tool, method, status_code, duration_ms),
|
||||
)
|
||||
|
||||
|
||||
def list_agent_audit_events(limit: int = 50) -> list[dict[str, Any]]:
|
||||
def list_agent_audit_events(user_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
bounded_limit = max(1, min(limit, 200))
|
||||
with _connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, tool, method, status_code, duration_ms, created_at
|
||||
FROM agent_audit_events
|
||||
FROM agent_audit_events WHERE user_id=?
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(bounded_limit,),
|
||||
(user_id, bounded_limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@@ -62,6 +62,30 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
def get_authenticated_user_id(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
||||
) -> str:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="سجّل الدخول للوصول إلى هذه الواجهة.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user_id = auth.resolve_session(credentials.credentials.strip())
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="انتهت الجلسة أو أُلغيت؛ سجّل الدخول مجددًا.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
def _request_authenticated_user_id(request: Request) -> str | None:
|
||||
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
|
||||
return auth.resolve_session(token.strip()) if scheme.casefold() == "bearer" else None
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
return getattr(request.state, "request_id", "unknown")
|
||||
|
||||
@@ -151,6 +175,7 @@ async def audit_agent_routes(request: Request, call_next: Any):
|
||||
method=request.method,
|
||||
status_code=status_code,
|
||||
duration_ms=max(0, round((perf_counter() - started) * 1000)),
|
||||
user_id=_request_authenticated_user_id(request),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Unable to write agent audit metadata")
|
||||
@@ -210,7 +235,7 @@ class WorkspaceAgentRequest(AgentRequest):
|
||||
task: str = Field(min_length=1, max_length=4000, description="سؤال عن ملفات مساحة العمل المحلية")
|
||||
|
||||
|
||||
@app.post("/v1/agent/workspace/files")
|
||||
@app.post("/v1/agent/workspace/files", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]:
|
||||
"""Return bounded relative file names from the folder chosen by the desktop user."""
|
||||
try:
|
||||
@@ -224,10 +249,13 @@ async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]
|
||||
|
||||
|
||||
@app.post("/v1/agent/files/apply")
|
||||
async def apply_agent_file_change(request: ApplyFileChangeRequest) -> dict[str, str]:
|
||||
async def apply_agent_file_change(
|
||||
request: ApplyFileChangeRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, str]:
|
||||
"""Apply a one-time proposal only when the client explicitly confirms it."""
|
||||
try:
|
||||
return workspace.apply_change_preview(str(request.token))
|
||||
return workspace.apply_change_preview(str(request.token), user_id=user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@@ -390,25 +418,6 @@ async def _read_public_page(raw_url: str) -> tuple[str, str, str]:
|
||||
raise HTTPException(status_code=502, detail="تعذر الوصول إلى الموقع؛ تحقق من الإنترنت أو من إعدادات الموقع.") from exc
|
||||
|
||||
|
||||
def get_authenticated_user_id(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
||||
) -> str:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="سجّل الدخول للوصول إلى سجل المحادثات.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user_id = auth.resolve_session(credentials.credentials.strip())
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="انتهت الجلسة أو أُلغيت؛ سجّل الدخول مجددًا.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
def validate_conversation_id(value: str) -> str:
|
||||
try:
|
||||
return str(UUID(value))
|
||||
@@ -461,7 +470,7 @@ async def list_local_models() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/agent/tools")
|
||||
@app.get("/v1/agent/tools", dependencies=[Depends(get_authenticated_user_id)])
|
||||
def list_agent_tools() -> dict[str, Any]:
|
||||
"""Describe the currently available bounded tools in a stable JSON contract."""
|
||||
return {
|
||||
@@ -541,7 +550,7 @@ def list_agent_tools() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/agent/skills")
|
||||
@app.get("/v1/agent/skills", dependencies=[Depends(get_authenticated_user_id)])
|
||||
def list_agent_skills() -> dict[str, Any]:
|
||||
"""List curated local skills and their bounded tool permissions."""
|
||||
return {
|
||||
@@ -577,7 +586,10 @@ async def _search_local_knowledge(
|
||||
|
||||
|
||||
@app.post("/v1/agent/knowledge/index")
|
||||
async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]:
|
||||
async def index_workspace_knowledge(
|
||||
request: KnowledgeIndexRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, Any]:
|
||||
"""Index selected files and optionally add local semantic vectors."""
|
||||
try:
|
||||
root = workspace.selected_root(request.workspace_path)
|
||||
@@ -669,7 +681,7 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
|
||||
|
||||
try:
|
||||
document = knowledge.index_document(
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=root,
|
||||
relative_path=path.relative_to(root).as_posix(),
|
||||
text=text,
|
||||
@@ -683,7 +695,7 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
|
||||
try:
|
||||
vectors = await embeddings.embed_texts(knowledge._chunks(text), model=model_name)
|
||||
knowledge.store_embeddings(
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=root,
|
||||
relative_path=path.relative_to(root).as_posix(),
|
||||
model=model_name,
|
||||
@@ -703,7 +715,10 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
|
||||
|
||||
|
||||
@app.delete("/v1/agent/knowledge/index")
|
||||
def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]:
|
||||
def delete_workspace_knowledge(
|
||||
request: KnowledgeIndexRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete selected files from this user's local knowledge index."""
|
||||
try:
|
||||
root = workspace.selected_root(request.workspace_path)
|
||||
@@ -718,7 +733,7 @@ def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
was_deleted = knowledge.delete_document(
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=root,
|
||||
relative_path=path.relative_to(root).as_posix(),
|
||||
)
|
||||
@@ -727,7 +742,10 @@ def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]
|
||||
|
||||
|
||||
@app.post("/v1/agent/knowledge/search")
|
||||
async def search_workspace_knowledge(request: AgentRequest) -> dict[str, Any]:
|
||||
async def search_workspace_knowledge(
|
||||
request: AgentRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
root = workspace.selected_root(request.workspace_path)
|
||||
except ValueError as exc:
|
||||
@@ -736,19 +754,22 @@ async def search_workspace_knowledge(request: AgentRequest) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=422, detail="اختر مجلد مساحة العمل أولًا.")
|
||||
results, search_mode = await _search_local_knowledge(
|
||||
request.task,
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=root,
|
||||
)
|
||||
return {"results": results, "search_mode": search_mode}
|
||||
|
||||
|
||||
@app.get("/v1/agent/audit")
|
||||
def list_agent_audit(limit: int = 50) -> dict[str, Any]:
|
||||
def list_agent_audit(
|
||||
limit: int = 50,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, Any]:
|
||||
"""Return metadata-only history for recent agent API calls."""
|
||||
return {"data": database.list_agent_audit_events(limit)}
|
||||
return {"data": database.list_agent_audit_events(user_id, limit)}
|
||||
|
||||
|
||||
@app.post("/v1/agent/workspace")
|
||||
@app.post("/v1/agent/workspace", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
|
||||
"""Answer using read-only excerpts from the configured project directory."""
|
||||
root = workspace.configured_root()
|
||||
@@ -788,7 +809,7 @@ async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/agent/files/analyze")
|
||||
@app.post("/v1/agent/files/analyze", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def analyze_code_files(
|
||||
files: list[UploadFile] = File(...),
|
||||
question: str = Form(default="حلّل الملفات المرفقة واشرح وظيفتها وعلاقاتها."),
|
||||
@@ -1005,7 +1026,7 @@ async def analyze_code_files(
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/v1/agent/images/analyze")
|
||||
@app.post("/v1/agent/images/analyze", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def analyze_images(
|
||||
files: list[UploadFile] = File(...),
|
||||
question: str = Form(default="اقرأ النصوص في الصورة واشرح محتواها."),
|
||||
@@ -1232,14 +1253,14 @@ def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
@app.post("/v1/chat/completions", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||
"""إرسال طلب المحادثة إلى مزوّد النموذج المضبوط."""
|
||||
payload = chat_payload(request, stream=False)
|
||||
return await get_completion(payload)
|
||||
|
||||
|
||||
@app.post("/v1/chat/stream")
|
||||
@app.post("/v1/chat/stream", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
"""Pass provider token deltas to clients as newline-delimited JSON."""
|
||||
provider = get_model_provider()
|
||||
@@ -1389,12 +1410,20 @@ def requested_calculation(task: str) -> str | None:
|
||||
|
||||
|
||||
@app.post("/v1/agent/run")
|
||||
async def run_agent(request: AgentRequest) -> dict[str, Any]:
|
||||
async def run_agent(
|
||||
request: AgentRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> dict[str, Any]:
|
||||
"""One-step local tool loop with bounded local knowledge search."""
|
||||
return await _execute_agent(request)
|
||||
return await _execute_agent(request, user_id=user_id)
|
||||
|
||||
|
||||
async def _execute_agent(request: AgentRequest, report_progress: Any | None = None) -> dict[str, Any]:
|
||||
async def _execute_agent(
|
||||
request: AgentRequest,
|
||||
report_progress: Any | None = None,
|
||||
*,
|
||||
user_id: str = database.LOCAL_USER_ID,
|
||||
) -> dict[str, Any]:
|
||||
async def report(message: str) -> None:
|
||||
if report_progress is not None:
|
||||
await report_progress(message)
|
||||
@@ -1455,7 +1484,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
|
||||
):
|
||||
prefetched_knowledge, _ = await _search_local_knowledge(
|
||||
request.task,
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=selected_workspace,
|
||||
)
|
||||
|
||||
@@ -1702,7 +1731,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
|
||||
raise HTTPException(status_code=503, detail="اختر مساحة عمل قبل البحث في الفهرس.")
|
||||
matches, _ = await _search_local_knowledge(
|
||||
query,
|
||||
user_id=database.LOCAL_USER_ID,
|
||||
user_id=user_id,
|
||||
workspace_path=selected_workspace,
|
||||
)
|
||||
source_files = list(dict.fromkeys(item["path"] for item in matches))
|
||||
@@ -1727,7 +1756,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
|
||||
raise HTTPException(status_code=422, detail="بيانات معاينة الملف غير صالحة.")
|
||||
try:
|
||||
proposal = workspace.create_change_preview(
|
||||
selected_workspace, path, operation, content
|
||||
selected_workspace, path, operation, content, user_id=user_id
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -1775,7 +1804,10 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
|
||||
|
||||
|
||||
@app.post("/v1/agent/run/stream")
|
||||
async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
|
||||
async def run_agent_stream(
|
||||
request: AgentRequest,
|
||||
user_id: str = Depends(get_authenticated_user_id),
|
||||
) -> StreamingResponse:
|
||||
"""Stream real agent phase updates, then the final answer as server-sent events."""
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
|
||||
@@ -1783,7 +1815,9 @@ async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
|
||||
await queue.put(message)
|
||||
|
||||
async def event_stream():
|
||||
task = asyncio.create_task(_execute_agent(request, report_progress=report))
|
||||
task = asyncio.create_task(
|
||||
_execute_agent(request, report_progress=report, user_id=user_id)
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
if task.done() and queue.empty():
|
||||
@@ -1816,7 +1850,7 @@ async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/web/read")
|
||||
@app.post("/v1/web/read", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
|
||||
"""Fetch a user-provided public web page and ask the local model about its text."""
|
||||
source_url, title, page_text = await _read_public_page(request.url)
|
||||
@@ -1857,7 +1891,7 @@ async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/web/search")
|
||||
@app.post("/v1/web/search", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def search_web(request: WebSearchRequest) -> dict[str, Any]:
|
||||
"""Search several public sources, fetch their pages, and summarize with citations."""
|
||||
search_url = "https://html.duckduckgo.com/html/"
|
||||
@@ -1975,7 +2009,7 @@ async def search_web(request: WebSearchRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/audio/transcriptions")
|
||||
@app.post("/v1/audio/transcriptions", dependencies=[Depends(get_authenticated_user_id)])
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(...),
|
||||
language: str | None = Form(default=None),
|
||||
|
||||
@@ -157,6 +157,8 @@ def create_change_preview(
|
||||
relative_path: str,
|
||||
operation: str,
|
||||
content: str,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build and retain a short-lived diff; this function never writes the file."""
|
||||
raw_content = content.encode("utf-8")
|
||||
@@ -202,6 +204,7 @@ def create_change_preview(
|
||||
"content": proposed_bytes,
|
||||
"expected_hash": hashlib.sha256(original).hexdigest(),
|
||||
"expires_at": time.time() + PROPOSAL_TTL_SECONDS,
|
||||
"user_id": user_id,
|
||||
}
|
||||
return {
|
||||
"token": token,
|
||||
@@ -212,11 +215,17 @@ def create_change_preview(
|
||||
}
|
||||
|
||||
|
||||
def apply_change_preview(token: str) -> dict[str, str]:
|
||||
def apply_change_preview(
|
||||
token: str, *, user_id: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Apply a reviewed proposal once, only if its target is still unchanged."""
|
||||
proposal = _pending_changes.pop(token, None)
|
||||
proposal = _pending_changes.get(token)
|
||||
if proposal is None or float(proposal["expires_at"]) <= time.time():
|
||||
_pending_changes.pop(token, None)
|
||||
raise ValueError("انتهت صلاحية معاينة التغيير أو استُخدمت مسبقًا؛ أنشئ معاينة جديدة.")
|
||||
if proposal.get("user_id") != user_id:
|
||||
raise ValueError("معاينة التعديل لا تخص جلسة المستخدم الحالية.")
|
||||
_pending_changes.pop(token, None)
|
||||
|
||||
root = Path(str(proposal["root"])).resolve(strict=True)
|
||||
relative_path = str(proposal["path"])
|
||||
|
||||
@@ -262,7 +262,7 @@ class ApiRepository {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_baseUrl/v1/chat/completions'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
headers: await _userHeaders(),
|
||||
body: jsonEncode({
|
||||
'messages':
|
||||
messages
|
||||
@@ -288,7 +288,7 @@ class ApiRepository {
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/v1/agent/workspace/files'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
headers: await _userHeaders(),
|
||||
body: jsonEncode({'workspace_path': workspacePath}),
|
||||
);
|
||||
_checkStatus(response);
|
||||
@@ -303,7 +303,7 @@ class ApiRepository {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_baseUrl/v1/agent/knowledge/index'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
headers: await _userHeaders(),
|
||||
body: jsonEncode({'workspace_path': workspacePath, 'files': files}),
|
||||
)
|
||||
.timeout(const Duration(minutes: 2));
|
||||
@@ -318,7 +318,7 @@ class ApiRepository {
|
||||
final response = await http
|
||||
.delete(
|
||||
Uri.parse('$_baseUrl/v1/agent/knowledge/index'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
headers: await _userHeaders(),
|
||||
body: jsonEncode({'workspace_path': workspacePath, 'files': files}),
|
||||
)
|
||||
.timeout(const Duration(minutes: 1));
|
||||
@@ -327,7 +327,10 @@ class ApiRepository {
|
||||
}
|
||||
|
||||
Future<List<AgentSkillDescriptor>> getAgentSkills() async {
|
||||
final response = await http.get(Uri.parse('$_baseUrl/v1/agent/skills'));
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/v1/agent/skills'),
|
||||
headers: await _userHeaders(),
|
||||
);
|
||||
_checkStatus(response);
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['skills'] as List<dynamic>? ?? const [])
|
||||
@@ -350,7 +353,7 @@ class ApiRepository {
|
||||
try {
|
||||
final request =
|
||||
http.Request('POST', Uri.parse('$_baseUrl/v1/agent/run/stream'))
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..headers.addAll(await _userHeaders())
|
||||
..headers['Accept'] = 'text/event-stream'
|
||||
..body = jsonEncode({
|
||||
'task': task,
|
||||
@@ -436,7 +439,7 @@ class ApiRepository {
|
||||
Future<String> applyFileChange(FileChangeProposal proposal) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/v1/agent/files/apply'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
headers: await _userHeaders(),
|
||||
body: jsonEncode({'token': proposal.token, 'confirm': true}),
|
||||
);
|
||||
_checkStatus(response);
|
||||
@@ -453,7 +456,7 @@ class ApiRepository {
|
||||
try {
|
||||
final request =
|
||||
http.Request('POST', Uri.parse('$_baseUrl/v1/web/search'))
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..headers.addAll(await _userHeaders())
|
||||
..body = jsonEncode({
|
||||
'query': query,
|
||||
'max_results': 5,
|
||||
@@ -500,6 +503,7 @@ class ApiRepository {
|
||||
'POST',
|
||||
Uri.parse('$_baseUrl/v1/agent/files/analyze'),
|
||||
)..fields['question'] = question;
|
||||
request.headers['Authorization'] = 'Bearer ${await _localSessionToken()}';
|
||||
if (model != null) request.fields['model'] = model;
|
||||
for (final file in files) {
|
||||
request.files.add(
|
||||
@@ -540,6 +544,7 @@ class ApiRepository {
|
||||
'POST',
|
||||
Uri.parse('$_baseUrl/v1/agent/images/analyze'),
|
||||
)..fields['question'] = question;
|
||||
request.headers['Authorization'] = 'Bearer ${await _localSessionToken()}';
|
||||
if (model != null) request.fields['model'] = model;
|
||||
for (final file in files) {
|
||||
request.files.add(
|
||||
@@ -593,10 +598,8 @@ class ApiRepository {
|
||||
try {
|
||||
final request =
|
||||
http.Request('POST', Uri.parse('$_baseUrl/v1/chat/stream'))
|
||||
..headers.addAll({
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/x-ndjson',
|
||||
})
|
||||
..headers.addAll(await _userHeaders())
|
||||
..headers.addAll({'Accept': 'application/x-ndjson'})
|
||||
..body = jsonEncode({
|
||||
if (model != null && model.isNotEmpty) 'model': model,
|
||||
'messages':
|
||||
@@ -657,6 +660,7 @@ class ApiRepository {
|
||||
contentType: MediaType('audio', 'wav'),
|
||||
),
|
||||
);
|
||||
request.headers['Authorization'] = 'Bearer ${await _localSessionToken()}';
|
||||
final streamed = await request.send().timeout(const Duration(minutes: 3));
|
||||
final response = await http.Response.fromStream(streamed);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
|
||||
@@ -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