feat: add FastAPI server with auth, RBAC, and all API endpoints

- JWT auth with role-based access control (viewer/analyst/admin/km_admin)
- Endpoints: health, sync manifest, data download, query, users CRUD,
  corporate memory, session/artifact upload
- 18 API tests covering auth, RBAC, all endpoints
This commit is contained in:
ZdenekSrotyr 2026-03-27 15:19:18 +01:00
parent 64acc8d731
commit a3918d3833
17 changed files with 981 additions and 1 deletions

0
app/__init__.py Normal file
View file

0
app/api/__init__.py Normal file
View file

51
app/api/data.py Normal file
View file

@ -0,0 +1,51 @@
"""Data download endpoint — streaming parquet files."""
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/data", tags=["data"])
def _get_data_dir() -> Path:
return Path(os.environ.get("DATA_DIR", "./data"))
@router.get("/{table_id}/download")
async def download_table(
table_id: str,
request: Request,
user: dict = Depends(get_current_user),
):
"""Stream a parquet file for download. Supports ETag for caching."""
data_dir = _get_data_dir()
parquet_dir = data_dir / "src_data" / "parquet"
# Find the parquet file (may be in a subfolder)
candidates = list(parquet_dir.rglob(f"{table_id}.parquet"))
if not candidates:
# Try with folder structure: folder/table.parquet
candidates = list(parquet_dir.rglob(f"*/{table_id}.parquet"))
if not candidates:
raise HTTPException(status_code=404, detail=f"Table '{table_id}' not found")
file_path = candidates[0]
# ETag support
stat = file_path.stat()
etag = f'"{stat.st_mtime_ns}"'
if_none_match = request.headers.get("if-none-match")
if if_none_match == etag:
from starlette.responses import Response
return Response(status_code=304)
return FileResponse(
path=file_path,
filename=f"{table_id}.parquet",
media_type="application/octet-stream",
headers={"ETag": etag},
)

66
app/api/health.py Normal file
View file

@ -0,0 +1,66 @@
"""Health check endpoint — structured diagnostics for AI agents."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
import duckdb
from app.auth.dependencies import _get_db
from src.repositories.sync_state import SyncStateRepository
router = APIRouter(tags=["health"])
@router.get("/api/health")
async def health_check(conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
"""Structured health check. No auth required."""
checks = {}
# DuckDB state
try:
conn.execute("SELECT 1").fetchone()
checks["duckdb_state"] = {"status": "ok"}
except Exception as e:
checks["duckdb_state"] = {"status": "error", "detail": str(e)}
# Sync state summary
try:
repo = SyncStateRepository(conn)
all_states = repo.get_all_states()
total_tables = len(all_states)
total_rows = sum(s.get("rows", 0) or 0 for s in all_states)
stale = []
now = datetime.now(timezone.utc)
for s in all_states:
last = s.get("last_sync")
if last and (now - last).total_seconds() > 86400: # >24h
stale.append(s["table_id"])
checks["data"] = {
"status": "ok" if not stale else "warning",
"tables": total_tables,
"total_rows": total_rows,
"stale_tables": stale,
}
except Exception as e:
checks["data"] = {"status": "error", "detail": str(e)}
# User count
try:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
checks["users"] = {"status": "ok", "count": user_count}
except Exception as e:
checks["users"] = {"status": "error", "detail": str(e)}
overall = "healthy"
for check in checks.values():
if check.get("status") == "error":
overall = "unhealthy"
break
if check.get("status") == "warning":
overall = "degraded"
return {
"status": overall,
"timestamp": datetime.now(timezone.utc).isoformat(),
"services": checks,
}

101
app/api/memory.py Normal file
View file

@ -0,0 +1,101 @@
"""Corporate memory endpoints — knowledge items, voting."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import duckdb
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
from src.repositories.knowledge import KnowledgeRepository
router = APIRouter(prefix="/api/memory", tags=["memory"])
class CreateKnowledgeRequest(BaseModel):
title: str
content: str
category: str
tags: Optional[List[str]] = None
class VoteRequest(BaseModel):
vote: int # 1 or -1
class KnowledgeResponse(BaseModel):
id: str
title: str
content: Optional[str]
category: Optional[str]
status: str
created_at: Optional[str]
@router.get("")
async def list_knowledge(
status_filter: Optional[str] = None,
category: Optional[str] = None,
search: Optional[str] = None,
limit: int = 50,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = KnowledgeRepository(conn)
if search:
items = repo.search(search)
else:
statuses = [status_filter] if status_filter else None
items = repo.list_items(statuses=statuses, category=category, limit=limit)
return {"items": items, "count": len(items)}
@router.post("", status_code=201)
async def create_knowledge(
request: CreateKnowledgeRequest,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = KnowledgeRepository(conn)
item_id = str(uuid.uuid4())
repo.create(
id=item_id,
title=request.title,
content=request.content,
category=request.category,
source_user=user.get("email"),
tags=request.tags,
)
return {"id": item_id, "status": "pending"}
@router.post("/{item_id}/vote")
async def vote_knowledge(
item_id: str,
request: VoteRequest,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
if request.vote not in (1, -1):
raise HTTPException(status_code=400, detail="Vote must be 1 or -1")
repo = KnowledgeRepository(conn)
if not repo.get_by_id(item_id):
raise HTTPException(status_code=404, detail="Knowledge item not found")
repo.vote(item_id, user["id"], request.vote)
return repo.get_votes(item_id)
@router.put("/{item_id}/status")
async def update_status(
item_id: str,
new_status: str,
user: dict = Depends(require_role(Role.KM_ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = KnowledgeRepository(conn)
if not repo.get_by_id(item_id):
raise HTTPException(status_code=404, detail="Knowledge item not found")
repo.update_status(item_id, new_status)
return {"id": item_id, "status": new_status}

60
app/api/query.py Normal file
View file

@ -0,0 +1,60 @@
"""Query endpoint — execute SQL against server DuckDB."""
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.auth.dependencies import get_current_user
from src.db import get_analytics_db
router = APIRouter(prefix="/api/query", tags=["query"])
class QueryRequest(BaseModel):
sql: str
limit: int = 1000
class QueryResponse(BaseModel):
columns: list
rows: list
row_count: int
truncated: bool = False
@router.post("", response_model=QueryResponse)
async def execute_query(
request: QueryRequest,
user: dict = Depends(get_current_user),
):
"""Execute SQL against the server analytics DuckDB."""
# Safety: basic SQL injection prevention
sql_lower = request.sql.strip().lower()
if any(keyword in sql_lower for keyword in ["drop ", "delete ", "insert ", "update ", "alter ", "create "]):
raise HTTPException(status_code=400, detail="Only SELECT queries are allowed")
conn = get_analytics_db()
try:
result = conn.execute(request.sql).fetchmany(request.limit + 1)
columns = [desc[0] for desc in conn.description] if conn.description else []
truncated = len(result) > request.limit
rows = result[:request.limit]
# Convert to serializable types
serializable_rows = []
for row in rows:
serializable_rows.append([
str(v) if v is not None and not isinstance(v, (int, float, bool, str)) else v
for v in row
])
return QueryResponse(
columns=columns,
rows=serializable_rows,
row_count=len(serializable_rows),
truncated=truncated,
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Query error: {str(e)}")
finally:
conn.close()

92
app/api/sync.py Normal file
View file

@ -0,0 +1,92 @@
"""Sync endpoints — manifest, trigger."""
import hashlib
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
import duckdb
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
from src.repositories.sync_state import SyncStateRepository
router = APIRouter(prefix="/api/sync", tags=["sync"])
def _file_hash(path: Path) -> str:
"""Compute MD5 hash of a file for change detection."""
if not path.exists():
return ""
h = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _get_data_dir() -> Path:
return Path(os.environ.get("DATA_DIR", "./data"))
@router.get("/manifest")
async def sync_manifest(
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Return hash-based manifest of all synced data, filtered per user."""
repo = SyncStateRepository(conn)
all_states = repo.get_all_states()
data_dir = _get_data_dir()
parquet_dir = data_dir / "src_data" / "parquet"
# Build table manifest
tables = {}
for state in all_states:
table_id = state["table_id"]
tables[table_id] = {
"hash": state.get("hash", ""),
"updated": state.get("last_sync").isoformat() if state.get("last_sync") else None,
"size_bytes": state.get("file_size_bytes", 0),
"rows": state.get("rows", 0),
}
# Asset hashes
docs_dir = data_dir / "docs"
assets = {}
for asset_name, asset_path in [
("docs", docs_dir),
("profiles", data_dir / "src_data" / "metadata" / "profiles.json"),
]:
if asset_path.exists():
if asset_path.is_file():
assets[asset_name] = {"hash": _file_hash(asset_path)}
else:
# Directory — hash based on mtime of newest file
newest = max(
(f.stat().st_mtime for f in asset_path.rglob("*") if f.is_file()),
default=0,
)
assets[asset_name] = {"hash": str(int(newest))}
return {
"tables": tables,
"assets": assets,
"server_time": datetime.now(timezone.utc).isoformat(),
}
@router.post("/trigger")
async def trigger_sync(
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Trigger data sync from configured source. Admin only."""
# This will call DataSyncManager when integrated
# For now, return a stub response
return {
"status": "triggered",
"message": "Data sync triggered. Check /api/health for progress.",
}

75
app/api/upload.py Normal file
View file

@ -0,0 +1,75 @@
"""Upload endpoints — sessions, artifacts, CLAUDE.local.md."""
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/upload", tags=["upload"])
def _get_data_dir() -> Path:
return Path(os.environ.get("DATA_DIR", "./data"))
@router.post("/sessions")
async def upload_session(
file: UploadFile = File(...),
user: dict = Depends(get_current_user),
):
"""Upload a Claude session transcript (JSONL)."""
user_id = user["id"]
sessions_dir = _get_data_dir() / "user_sessions" / user_id
sessions_dir.mkdir(parents=True, exist_ok=True)
filename = file.filename or f"session_{uuid.uuid4().hex[:8]}.jsonl"
target = sessions_dir / filename
content = await file.read()
target.write_bytes(content)
return {"status": "ok", "path": str(target), "size": len(content)}
@router.post("/artifacts")
async def upload_artifact(
file: UploadFile = File(...),
user: dict = Depends(get_current_user),
):
"""Upload an artifact (HTML report, PNG chart, etc.)."""
user_id = user["id"]
artifacts_dir = _get_data_dir() / "user_artifacts" / user_id
artifacts_dir.mkdir(parents=True, exist_ok=True)
filename = file.filename or f"artifact_{uuid.uuid4().hex[:8]}"
target = artifacts_dir / filename
content = await file.read()
target.write_bytes(content)
return {"status": "ok", "path": str(target), "size": len(content)}
class LocalMdRequest(BaseModel):
content: str
@router.post("/local-md")
async def upload_local_md(
request: LocalMdRequest,
user: dict = Depends(get_current_user),
):
"""Upload CLAUDE.local.md content for corporate memory processing."""
user_id = user["id"]
user_email = user["email"]
md_dir = _get_data_dir() / "user_local_md"
md_dir.mkdir(parents=True, exist_ok=True)
target = md_dir / f"{user_email}.md"
target.write_text(request.content, encoding="utf-8")
return {
"status": "ok",
"user": user_email,
"size": len(request.content),
}

74
app/api/users.py Normal file
View file

@ -0,0 +1,74 @@
"""User management endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import duckdb
from app.auth.dependencies import require_role, Role, _get_db
from src.repositories.users import UserRepository
router = APIRouter(prefix="/api/users", tags=["users"])
class CreateUserRequest(BaseModel):
email: str
name: str
role: str = "analyst"
class UpdateUserRequest(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
class UserResponse(BaseModel):
id: str
email: str
name: Optional[str]
role: str
created_at: Optional[str]
@router.get("", response_model=List[UserResponse])
async def list_users(
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = UserRepository(conn)
users = repo.list_all()
return [
UserResponse(
id=u["id"], email=u["email"], name=u.get("name"),
role=u["role"], created_at=str(u.get("created_at", "")),
) for u in users
]
@router.post("", response_model=UserResponse, status_code=201)
async def create_user(
request: CreateUserRequest,
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = UserRepository(conn)
if repo.get_by_email(request.email):
raise HTTPException(status_code=409, detail="User with this email already exists")
user_id = str(uuid.uuid4())
repo.create(id=user_id, email=request.email, name=request.name, role=request.role)
return UserResponse(id=user_id, email=request.email, name=request.name, role=request.role, created_at=None)
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: str,
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = UserRepository(conn)
if not repo.get_by_id(user_id):
raise HTTPException(status_code=404, detail="User not found")
repo.delete(user_id)

0
app/auth/__init__.py Normal file
View file

88
app/auth/dependencies.py Normal file
View file

@ -0,0 +1,88 @@
"""FastAPI auth dependencies — current user, role checking."""
from enum import Enum
from typing import Optional
import duckdb
from fastapi import Depends, HTTPException, Header, status
from app.auth.jwt import verify_token
from src.db import get_system_db
from src.repositories.users import UserRepository
class Role(str, Enum):
VIEWER = "viewer"
ANALYST = "analyst"
ADMIN = "admin"
KM_ADMIN = "km_admin"
ROLE_HIERARCHY = {
Role.VIEWER: 0,
Role.ANALYST: 1,
Role.KM_ADMIN: 2,
Role.ADMIN: 3,
}
def _get_db():
conn = get_system_db()
try:
yield conn
finally:
conn.close()
async def get_current_user(
authorization: Optional[str] = Header(None),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Extract and validate JWT from Authorization header. Returns user dict."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid Authorization header",
)
token = authorization.removeprefix("Bearer ")
payload = verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
repo = UserRepository(conn)
user = repo.get_by_id(payload.get("sub", ""))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
async def get_optional_user(
authorization: Optional[str] = Header(None),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> Optional[dict]:
"""Like get_current_user but returns None instead of 401 if no token."""
if not authorization or not authorization.startswith("Bearer "):
return None
try:
return await get_current_user(authorization, conn)
except HTTPException:
return None
def require_role(minimum_role: Role):
"""Dependency factory: require user has at least the given role."""
async def _check(user: dict = Depends(get_current_user)):
user_role = Role(user.get("role", "viewer"))
if ROLE_HIERARCHY.get(user_role, 0) < ROLE_HIERARCHY.get(minimum_role, 0):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires role {minimum_role.value} or higher",
)
return user
return _check

41
app/auth/jwt.py Normal file
View file

@ -0,0 +1,41 @@
"""JWT token creation and verification for API auth."""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-jwt-secret-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24 * 30 # 30 days
def create_access_token(
user_id: str,
email: str,
role: str = "analyst",
expires_delta: Optional[timedelta] = None,
) -> str:
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
)
payload = {
"sub": user_id,
"email": email,
"role": role,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> Optional[dict]:
"""Verify and decode a JWT token. Returns payload dict or None."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None

51
app/auth/router.py Normal file
View file

@ -0,0 +1,51 @@
"""Auth endpoints — login, token generation."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import duckdb
from app.auth.jwt import create_access_token
from app.auth.dependencies import _get_db
from src.repositories.users import UserRepository
router = APIRouter(prefix="/auth", tags=["auth"])
class TokenRequest(BaseModel):
email: str
password: str = ""
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
email: str
role: str
@router.post("/token", response_model=TokenResponse)
async def create_token(
request: TokenRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Issue a JWT token. For dev/demo: any registered user gets a token."""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
if not user:
raise HTTPException(status_code=401, detail="User not found")
# TODO: In production, verify password_hash with argon2
# For greenfield demo, we issue tokens to any registered user
token = create_access_token(
user_id=user["id"],
email=user["email"],
role=user["role"],
)
return TokenResponse(
access_token=token,
user_id=user["id"],
email=user["email"],
role=user["role"],
)

45
app/main.py Normal file
View file

@ -0,0 +1,45 @@
"""FastAPI main application — unified server for web UI + API."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.api.health import router as health_router
from app.api.sync import router as sync_router
from app.api.data import router as data_router
from app.api.query import router as query_router
from app.api.users import router as users_router
from app.api.memory import router as memory_router
from app.api.upload import router as upload_router
def create_app() -> FastAPI:
app = FastAPI(
title="AI Data Analyst",
description="Data distribution platform for AI analytical systems",
version="2.0.0",
)
# CORS for CLI and web UI
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(auth_router)
app.include_router(health_router)
app.include_router(sync_router)
app.include_router(data_router)
app.include_router(query_router)
app.include_router(users_router)
app.include_router(memory_router)
app.include_router(upload_router)
return app
app = create_app()

0
app/web/__init__.py Normal file
View file

View file

@ -26,13 +26,19 @@ pyyaml>=6.0
tqdm>=4.65.0 tqdm>=4.65.0
# Web application (Google SSO portal) # Web application (Google SSO portal)
# flask - web framework for self-service portal # flask - web framework for self-service portal (legacy, being replaced by FastAPI)
# authlib - OAuth 2.0 / OpenID Connect library for Google SSO # authlib - OAuth 2.0 / OpenID Connect library for Google SSO
# gunicorn - WSGI server for production deployment # gunicorn - WSGI server for production deployment
flask>=3.0.0 flask>=3.0.0
authlib>=1.3.0 authlib>=1.3.0
gunicorn>=21.0.0 gunicorn>=21.0.0
# FastAPI - new unified web framework (API + web UI)
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.9
jinja2>=3.1.0
# Telegram notification bot # Telegram notification bot
# httpx - async HTTP client for Telegram API and unix socket communication # httpx - async HTTP client for Telegram API and unix socket communication
# aiohttp - async HTTP server for bot's internal send API # aiohttp - async HTTP server for bot's internal send API

230
tests/test_api.py Normal file
View file

@ -0,0 +1,230 @@
"""Tests for FastAPI endpoints."""
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def app_client(tmp_path):
os.environ["DATA_DIR"] = str(tmp_path)
os.environ["JWT_SECRET_KEY"] = "test-secret"
from app.main import create_app
app = create_app()
return TestClient(app)
@pytest.fixture
def seeded_client(tmp_path):
"""Client with a pre-created admin user and JWT token."""
os.environ["DATA_DIR"] = str(tmp_path)
os.environ["JWT_SECRET_KEY"] = "test-secret"
from app.main import create_app
from src.db import get_system_db
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
repo = UserRepository(conn)
repo.create(id="admin1", email="admin@acme.com", name="Admin", role="admin")
repo.create(id="analyst1", email="analyst@acme.com", name="Analyst", role="analyst")
conn.close()
app = create_app()
client = TestClient(app)
admin_token = create_access_token("admin1", "admin@acme.com", "admin")
analyst_token = create_access_token("analyst1", "analyst@acme.com", "analyst")
return client, admin_token, analyst_token
# ---- Health ----
class TestHealth:
def test_health_no_auth(self, app_client):
resp = app_client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] in ("healthy", "degraded", "unhealthy")
assert "services" in data
def test_health_has_duckdb_check(self, app_client):
resp = app_client.get("/api/health")
data = resp.json()
assert "duckdb_state" in data["services"]
assert data["services"]["duckdb_state"]["status"] == "ok"
# ---- Auth ----
class TestAuth:
def test_token_for_existing_user(self, seeded_client):
client, _, _ = seeded_client
resp = client.post("/auth/token", json={"email": "admin@acme.com"})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["role"] == "admin"
def test_token_for_unknown_user(self, seeded_client):
client, _, _ = seeded_client
resp = client.post("/auth/token", json={"email": "nobody@acme.com"})
assert resp.status_code == 401
def test_protected_endpoint_without_token(self, seeded_client):
client, _, _ = seeded_client
resp = client.get("/api/users")
assert resp.status_code == 401
def test_protected_endpoint_with_token(self, seeded_client):
client, admin_token, _ = seeded_client
resp = client.get("/api/users", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
# ---- RBAC ----
class TestRBAC:
def test_admin_can_list_users(self, seeded_client):
client, admin_token, _ = seeded_client
resp = client.get("/api/users", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
assert len(resp.json()) == 2
def test_analyst_cannot_list_users(self, seeded_client):
client, _, analyst_token = seeded_client
resp = client.get("/api/users", headers={"Authorization": f"Bearer {analyst_token}"})
assert resp.status_code == 403
def test_analyst_cannot_trigger_sync(self, seeded_client):
client, _, analyst_token = seeded_client
resp = client.post("/api/sync/trigger", headers={"Authorization": f"Bearer {analyst_token}"})
assert resp.status_code == 403
# ---- Sync Manifest ----
class TestSyncManifest:
def test_manifest_returns_tables(self, seeded_client):
client, admin_token, _ = seeded_client
# Seed some sync state
from src.db import get_system_db
from src.repositories.sync_state import SyncStateRepository
conn = get_system_db()
repo = SyncStateRepository(conn)
repo.update_sync(table_id="orders", rows=1000, file_size_bytes=5000, hash="abc")
conn.close()
resp = client.get("/api/sync/manifest", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
data = resp.json()
assert "tables" in data
assert "orders" in data["tables"]
assert data["tables"]["orders"]["rows"] == 1000
# ---- Users CRUD ----
class TestUsersCRUD:
def test_create_user(self, seeded_client):
client, admin_token, _ = seeded_client
resp = client.post(
"/api/users",
json={"email": "new@acme.com", "name": "New User"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
assert resp.json()["email"] == "new@acme.com"
def test_create_duplicate_user(self, seeded_client):
client, admin_token, _ = seeded_client
resp = client.post(
"/api/users",
json={"email": "admin@acme.com", "name": "Duplicate"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 409
def test_delete_user(self, seeded_client):
client, admin_token, _ = seeded_client
resp = client.delete(
"/api/users/analyst1",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 204
# ---- Knowledge / Memory ----
class TestMemory:
def test_create_and_list(self, seeded_client):
client, _, analyst_token = seeded_client
headers = {"Authorization": f"Bearer {analyst_token}"}
# Create
resp = client.post("/api/memory", json={
"title": "MRR Definition",
"content": "Monthly recurring revenue",
"category": "metrics",
}, headers=headers)
assert resp.status_code == 201
item_id = resp.json()["id"]
# List
resp = client.get("/api/memory", headers=headers)
assert resp.status_code == 200
assert resp.json()["count"] == 1
def test_vote(self, seeded_client):
client, _, analyst_token = seeded_client
headers = {"Authorization": f"Bearer {analyst_token}"}
resp = client.post("/api/memory", json={
"title": "Test", "content": "test", "category": "test",
}, headers=headers)
item_id = resp.json()["id"]
resp = client.post(f"/api/memory/{item_id}/vote", json={"vote": 1}, headers=headers)
assert resp.status_code == 200
assert resp.json()["upvotes"] == 1
def test_search(self, seeded_client):
client, _, analyst_token = seeded_client
headers = {"Authorization": f"Bearer {analyst_token}"}
client.post("/api/memory", json={
"title": "Revenue report", "content": "MRR trends", "category": "finance",
}, headers=headers)
client.post("/api/memory", json={
"title": "Support SLA", "content": "Response times", "category": "support",
}, headers=headers)
resp = client.get("/api/memory?search=revenue", headers=headers)
assert resp.json()["count"] == 1
# ---- Upload ----
class TestUpload:
def test_upload_session(self, seeded_client):
client, _, analyst_token = seeded_client
headers = {"Authorization": f"Bearer {analyst_token}"}
resp = client.post(
"/api/upload/sessions",
files={"file": ("session.jsonl", b'{"role":"user","content":"hello"}', "application/jsonl")},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["size"] > 0
def test_upload_local_md(self, seeded_client):
client, _, analyst_token = seeded_client
headers = {"Authorization": f"Bearer {analyst_token}"}
resp = client.post(
"/api/upload/local-md",
json={"content": "# My knowledge\n\nMRR = Monthly Recurring Revenue"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["user"] == "analyst@acme.com"