Spaces:
Running
Running
sync: 172 file da Baida98/AI@789d6ce1 (2026-08-22 17:41 UTC) [deploy-all]
Browse files- api/admin_state.py +75 -0
- api/auth_guard.py +61 -1
- api/me_tasks.py +117 -0
- api/public_status.py +53 -0
- main.py +3 -0
- tests/test_public_personal_boundary.py +42 -0
api/admin_state.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stato operativo amministrativo protetto da JWT Supabase admin."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, Query
|
| 8 |
+
|
| 9 |
+
from .auth_guard import require_admin_user
|
| 10 |
+
from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
|
| 11 |
+
|
| 12 |
+
router = APIRouter(
|
| 13 |
+
prefix="/api/admin/state",
|
| 14 |
+
tags=["admin"],
|
| 15 |
+
dependencies=[Depends(require_admin_user)],
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/sessions")
|
| 20 |
+
async def admin_sessions(
|
| 21 |
+
max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
|
| 22 |
+
limit: int = Query(default=100, ge=1, le=200),
|
| 23 |
+
) -> dict[str, object]:
|
| 24 |
+
cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
|
| 25 |
+
|
| 26 |
+
def operation(client: Any):
|
| 27 |
+
return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
|
| 28 |
+
|
| 29 |
+
result = await _call(operation)
|
| 30 |
+
sessions = []
|
| 31 |
+
for row in result.data or []:
|
| 32 |
+
context = _json_object(row.get("context"))
|
| 33 |
+
session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
|
| 34 |
+
if not session_id:
|
| 35 |
+
continue
|
| 36 |
+
claimed = context.get("claimedFiles")
|
| 37 |
+
sessions.append({
|
| 38 |
+
"session_id": session_id,
|
| 39 |
+
"session_name": str(context.get("sessionName") or session_id)[:160],
|
| 40 |
+
"sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
|
| 41 |
+
"claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
|
| 42 |
+
"last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
|
| 43 |
+
"current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
|
| 44 |
+
})
|
| 45 |
+
return {"sessions": sessions}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/tasks")
|
| 49 |
+
async def admin_tasks(
|
| 50 |
+
limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
|
| 51 |
+
offset: int = Query(default=0, ge=0, le=10_000),
|
| 52 |
+
status: str | None = Query(default=None, max_length=64),
|
| 53 |
+
) -> dict[str, object]:
|
| 54 |
+
normalized_status = status.strip().upper() if status else ""
|
| 55 |
+
|
| 56 |
+
def operation(client: Any):
|
| 57 |
+
query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
|
| 58 |
+
if normalized_status:
|
| 59 |
+
query = query.eq("status", normalized_status)
|
| 60 |
+
page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
|
| 61 |
+
all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
|
| 62 |
+
return page, all_statuses
|
| 63 |
+
|
| 64 |
+
page, all_statuses = await _call(operation)
|
| 65 |
+
counts: dict[str, int] = {}
|
| 66 |
+
for row in all_statuses.data or []:
|
| 67 |
+
key = str(row.get("status") or "UNKNOWN").upper()
|
| 68 |
+
counts[key] = counts.get(key, 0) + 1
|
| 69 |
+
tasks = [{
|
| 70 |
+
"task_id": str(row.get("task_id") or ""),
|
| 71 |
+
"goal": str(row.get("goal") or "")[:1_000],
|
| 72 |
+
"status": str(row.get("status") or "UNKNOWN"),
|
| 73 |
+
"updated_at": _as_epoch_ms(row.get("updated_at")),
|
| 74 |
+
} for row in page.data or []]
|
| 75 |
+
return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
|
api/auth_guard.py
CHANGED
|
@@ -33,7 +33,7 @@ from __future__ import annotations
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
from enum import IntEnum
|
| 36 |
-
from typing import Optional
|
| 37 |
|
| 38 |
from fastapi import Depends, Header, HTTPException, Request
|
| 39 |
|
|
@@ -187,6 +187,66 @@ def _check_rate_limit(
|
|
| 187 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 188 |
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
class AuthRole(IntEnum):
|
| 191 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 192 |
USER = 0
|
|
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
from enum import IntEnum
|
| 36 |
+
from typing import Optional, Any
|
| 37 |
|
| 38 |
from fastapi import Depends, Header, HTTPException, Request
|
| 39 |
|
|
|
|
| 187 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 188 |
|
| 189 |
|
| 190 |
+
async def require_supabase_user(request: Request) -> dict[str, Any]:
|
| 191 |
+
"""Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
|
| 192 |
+
|
| 193 |
+
La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
|
| 194 |
+
Authorization del chiamante e non viene scritto nei log.
|
| 195 |
+
"""
|
| 196 |
+
import httpx
|
| 197 |
+
|
| 198 |
+
authorization = request.headers.get("Authorization", "")
|
| 199 |
+
if not authorization.lower().startswith("bearer "):
|
| 200 |
+
raise HTTPException(status_code=401, detail="Bearer token richiesto")
|
| 201 |
+
jwt = authorization[7:].strip()
|
| 202 |
+
if not jwt:
|
| 203 |
+
raise HTTPException(status_code=401, detail="Bearer token non valido")
|
| 204 |
+
|
| 205 |
+
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 206 |
+
api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 207 |
+
if not supabase_url or not api_key:
|
| 208 |
+
raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
async with httpx.AsyncClient(timeout=5) as client:
|
| 212 |
+
response = await client.get(
|
| 213 |
+
f"{supabase_url}/auth/v1/user",
|
| 214 |
+
headers={
|
| 215 |
+
"apikey": api_key,
|
| 216 |
+
"Authorization": f"Bearer {jwt}",
|
| 217 |
+
"Accept": "application/json",
|
| 218 |
+
},
|
| 219 |
+
)
|
| 220 |
+
except httpx.HTTPError as exc:
|
| 221 |
+
logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
|
| 222 |
+
raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
|
| 223 |
+
|
| 224 |
+
if response.status_code != 200:
|
| 225 |
+
raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
|
| 226 |
+
try:
|
| 227 |
+
user = response.json()
|
| 228 |
+
except ValueError as exc:
|
| 229 |
+
raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
|
| 230 |
+
if not isinstance(user, dict) or not user.get("id"):
|
| 231 |
+
raise HTTPException(status_code=401, detail="Utente Supabase non valido")
|
| 232 |
+
return user
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
async def require_admin_user(request: Request) -> dict[str, Any]:
|
| 236 |
+
"""Richiede un JWT Supabase con app_metadata.role=admin.
|
| 237 |
+
|
| 238 |
+
app_metadata Γ¨ server-controlled; user_metadata non viene mai considerato
|
| 239 |
+
per autorizzare lβarea amministrativa.
|
| 240 |
+
"""
|
| 241 |
+
user = await require_supabase_user(request)
|
| 242 |
+
app_metadata = user.get("app_metadata") or {}
|
| 243 |
+
roles = app_metadata.get("roles") or []
|
| 244 |
+
is_admin = app_metadata.get("role") == "admin" or "admin" in roles
|
| 245 |
+
if not is_admin:
|
| 246 |
+
raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
|
| 247 |
+
return user
|
| 248 |
+
|
| 249 |
+
|
| 250 |
class AuthRole(IntEnum):
|
| 251 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 252 |
USER = 0
|
api/me_tasks.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task personali del prodotto pubblico.
|
| 2 |
+
|
| 3 |
+
Tutte le query applicano owner_id derivato dal JWT Supabase verificato. Il client
|
| 4 |
+
non puΓ² scegliere o sostituire il proprietario nel body o nella query.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
from uuid import UUID
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
from .auth_guard import require_supabase_user
|
| 17 |
+
from .state import sb
|
| 18 |
+
|
| 19 |
+
_logger = logging.getLogger("agente_ai.api.me_tasks")
|
| 20 |
+
router = APIRouter(prefix="/api/me/tasks", tags=["me"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TaskCreate(BaseModel):
|
| 24 |
+
goal: str = Field(min_length=1, max_length=10_000)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TaskUpdate(BaseModel):
|
| 28 |
+
status: str = Field(pattern="^(queued|in_progress|done|failed|cancelled)$")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_ALLOWED = "id,goal,status,created_at,updated_at"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _owner(user: dict[str, Any]) -> str:
|
| 35 |
+
return str(user["id"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _client():
|
| 39 |
+
client = sb()
|
| 40 |
+
if client is None:
|
| 41 |
+
raise HTTPException(status_code=503, detail="Database non configurato")
|
| 42 |
+
return client
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.get("")
|
| 46 |
+
async def list_my_tasks(
|
| 47 |
+
user: dict[str, Any] = Depends(require_supabase_user),
|
| 48 |
+
limit: int = Query(50, ge=1, le=100),
|
| 49 |
+
offset: int = Query(0, ge=0),
|
| 50 |
+
) -> dict[str, Any]:
|
| 51 |
+
client = _client()
|
| 52 |
+
owner_id = _owner(user)
|
| 53 |
+
|
| 54 |
+
def operation():
|
| 55 |
+
return client.table("user_agent_tasks").select(_ALLOWED).eq("owner_id", owner_id).order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
result = await asyncio.to_thread(operation)
|
| 59 |
+
return {"tasks": result.data or [], "offset": offset, "limit": limit}
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
_logger.warning("list own tasks failed: %s", type(exc).__name__)
|
| 62 |
+
raise HTTPException(status_code=503, detail="Task personali temporaneamente non disponibili") from exc
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.post("", status_code=201)
|
| 66 |
+
async def create_my_task(
|
| 67 |
+
body: TaskCreate,
|
| 68 |
+
user: dict[str, Any] = Depends(require_supabase_user),
|
| 69 |
+
) -> dict[str, Any]:
|
| 70 |
+
client = _client()
|
| 71 |
+
owner_id = _owner(user)
|
| 72 |
+
|
| 73 |
+
def operation():
|
| 74 |
+
return client.table("user_agent_tasks").insert({"owner_id": owner_id, "goal": body.goal.strip(), "status": "queued"}).select(_ALLOWED).single().execute()
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
result = await asyncio.to_thread(operation)
|
| 78 |
+
if not result.data:
|
| 79 |
+
raise HTTPException(status_code=502, detail="Task personale non creato")
|
| 80 |
+
return result.data
|
| 81 |
+
except HTTPException:
|
| 82 |
+
raise
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
_logger.warning("create own task failed: %s", type(exc).__name__)
|
| 85 |
+
raise HTTPException(status_code=503, detail="Task personale temporaneamente non disponibile") from exc
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.patch("/{task_id}")
|
| 89 |
+
async def update_my_task(
|
| 90 |
+
task_id: UUID,
|
| 91 |
+
body: TaskUpdate,
|
| 92 |
+
user: dict[str, Any] = Depends(require_supabase_user),
|
| 93 |
+
) -> dict[str, Any]:
|
| 94 |
+
client = _client()
|
| 95 |
+
owner_id = _owner(user)
|
| 96 |
+
|
| 97 |
+
def operation():
|
| 98 |
+
return client.table("user_agent_tasks").update({"status": body.status}).eq("id", str(task_id)).eq("owner_id", owner_id).select(_ALLOWED).maybe_single().execute()
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
result = await asyncio.to_thread(operation)
|
| 102 |
+
if not result.data:
|
| 103 |
+
raise HTTPException(status_code=404, detail="Task personale non trovato")
|
| 104 |
+
return result.data
|
| 105 |
+
except HTTPException:
|
| 106 |
+
raise
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
_logger.warning("update own task failed: %s", type(exc).__name__)
|
| 109 |
+
raise HTTPException(status_code=503, detail="Task personale temporaneamente non disponibile") from exc
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.post("/{task_id}/cancel")
|
| 113 |
+
async def cancel_my_task(
|
| 114 |
+
task_id: UUID,
|
| 115 |
+
user: dict[str, Any] = Depends(require_supabase_user),
|
| 116 |
+
) -> dict[str, Any]:
|
| 117 |
+
return await update_my_task(task_id, TaskUpdate(status="cancelled"), user)
|
api/public_status.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DTO pubblico e sanificato dello stato del servizio.
|
| 2 |
+
|
| 3 |
+
Questa route non legge agent_tasks, sessioni operative o log. La tabella
|
| 4 |
+
public_dashboard_snapshot viene aggiornata dal backend con service_role e letta
|
| 5 |
+
qui tramite una whitelist di campi.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException
|
| 14 |
+
|
| 15 |
+
from .state import sb
|
| 16 |
+
|
| 17 |
+
_logger = logging.getLogger("agente_ai.api.public_status")
|
| 18 |
+
router = APIRouter(prefix="/api/public", tags=["public"])
|
| 19 |
+
|
| 20 |
+
_PUBLIC_FIELDS = (
|
| 21 |
+
"singleton,service_status,active_sessions,queued_tasks,in_progress_tasks,"
|
| 22 |
+
"app_version,updated_at"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/status")
|
| 27 |
+
async def public_status() -> dict[str, Any]:
|
| 28 |
+
"""Restituisce esclusivamente lo snapshot deliberatamente pubblico."""
|
| 29 |
+
client = sb()
|
| 30 |
+
if client is None:
|
| 31 |
+
raise HTTPException(status_code=503, detail="Public status non configurato")
|
| 32 |
+
|
| 33 |
+
def operation():
|
| 34 |
+
return client.table("public_dashboard_snapshot").select(_PUBLIC_FIELDS).eq("singleton", True).limit(1).execute()
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
result = await asyncio.to_thread(operation)
|
| 38 |
+
except Exception as exc:
|
| 39 |
+
_logger.warning("public status snapshot unavailable: %s", type(exc).__name__)
|
| 40 |
+
raise HTTPException(status_code=503, detail="Public status temporaneamente non disponibile") from exc
|
| 41 |
+
|
| 42 |
+
row = (result.data or [None])[0]
|
| 43 |
+
if not row:
|
| 44 |
+
raise HTTPException(status_code=503, detail="Public status snapshot non inizializzato")
|
| 45 |
+
|
| 46 |
+
return {
|
| 47 |
+
"service_status": str(row.get("service_status") or "unknown"),
|
| 48 |
+
"active_sessions": int(row.get("active_sessions") or 0),
|
| 49 |
+
"queued_tasks": int(row.get("queued_tasks") or 0),
|
| 50 |
+
"in_progress_tasks": int(row.get("in_progress_tasks") or 0),
|
| 51 |
+
"app_version": row.get("app_version"),
|
| 52 |
+
"updated_at": row.get("updated_at"),
|
| 53 |
+
}
|
main.py
CHANGED
|
@@ -145,6 +145,9 @@ _ROUTER_MAP = {
|
|
| 145 |
"skills": "skills",
|
| 146 |
"private_state": "private_state",
|
| 147 |
"auth": "auth_managed",
|
|
|
|
|
|
|
|
|
|
| 148 |
# ββ Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) βββββββββββββββββ
|
| 149 |
"agent_checkpoint": "agent_checkpoint",
|
| 150 |
"agent_telemetry": "agent_telemetry",
|
|
|
|
| 145 |
"skills": "skills",
|
| 146 |
"private_state": "private_state",
|
| 147 |
"auth": "auth_managed",
|
| 148 |
+
"public_status": "public_status",
|
| 149 |
+
"me_tasks": "me_tasks",
|
| 150 |
+
"admin_state": "admin_state",
|
| 151 |
# ββ Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) βββββββββββββββββ
|
| 152 |
"agent_checkpoint": "agent_checkpoint",
|
| 153 |
"agent_telemetry": "agent_telemetry",
|
tests/test_public_personal_boundary.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from api.me_tasks import TaskCreate, TaskUpdate, _owner
|
| 4 |
+
from api.public_status import _PUBLIC_FIELDS
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 7 |
+
MIGRATION = ROOT / "supabase/migrations/20260822_public_personal_state_phase1.sql"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_public_status_whitelist_contains_no_operational_columns():
|
| 11 |
+
assert "service_status" in _PUBLIC_FIELDS
|
| 12 |
+
assert "active_sessions" in _PUBLIC_FIELDS
|
| 13 |
+
assert "context" not in _PUBLIC_FIELDS
|
| 14 |
+
assert "goal" not in _PUBLIC_FIELDS
|
| 15 |
+
assert "agent_tasks" not in _PUBLIC_FIELDS
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_personal_task_owner_is_derived_from_verified_user():
|
| 19 |
+
assert _owner({"id": "user-a", "user_metadata": {"id": "user-b"}}) == "user-a"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_task_models_boundaries():
|
| 23 |
+
task = TaskCreate(goal=" test goal ")
|
| 24 |
+
assert task.goal == " test goal "
|
| 25 |
+
assert TaskUpdate(status="cancelled").status == "cancelled"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_migration_keeps_operational_table_closed_to_client_roles():
|
| 29 |
+
sql = MIGRATION.read_text()
|
| 30 |
+
sql_without_comments = "\n".join(line for line in sql.splitlines() if not line.lstrip().startswith("--"))
|
| 31 |
+
assert "public.agent_tasks" not in sql_without_comments
|
| 32 |
+
assert "REVOKE ALL PRIVILEGES ON TABLE public.user_agent_tasks FROM PUBLIC, anon, authenticated" in sql
|
| 33 |
+
assert "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.user_agent_tasks TO authenticated" in sql
|
| 34 |
+
assert "CREATE POLICY \"user_agent_tasks_read_own\"" in sql
|
| 35 |
+
assert "CREATE TABLE IF NOT EXISTS public.public_dashboard_snapshot" in sql
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_admin_role_uses_app_metadata_not_user_metadata():
|
| 39 |
+
source = (ROOT / "backend/api/auth_guard.py").read_text()
|
| 40 |
+
assert "app_metadata" in source
|
| 41 |
+
admin_source = source[source.index("async def require_admin_user"):source.index("class AuthRole")]
|
| 42 |
+
assert "user.get(\"user_metadata\")" not in admin_source
|