```
feat: 添加飞书集成和改进安全配置 - 集成 lark-oapi 库以支持飞书功能 - 改进 CORS 配置验证器以支持 JSON 格式输入 - 添加安全凭证检查逻辑以防止跨域安全问题 - 在 DirectLLMAdapter 中增加响应解析异常处理 fix: 增强查询参数验证和分页限制 - 为多个路由添加 Query 参数验证器 - 实现 bounded_limit 和 bounded_offset 辅助函数 - 设置查询限制范围为 1-500 之间 - 使用 secrets.compare_digest 提升令牌验证安全性 refactor: 调整文档忽略规则和测试配置 - 更新 .gitignore 文件中的文档路径配置 - 在 smoke 测试中添加必要的环境变量配置 - 重构配置验证器以提高类型兼容性 ```
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
logs/
|
||||||
|
docs/
|
||||||
|
migration/
|
||||||
|
README.md
|
||||||
|
read.md
|
||||||
|
AGENTS.md
|
||||||
|
AGENTS.md.bak-*
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -9,16 +9,11 @@ __pycache__/
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Local docs and agent instructions
|
# Local docs and agent instructions
|
||||||
/docs/*
|
/docs/
|
||||||
!/docs/ai_integration.md
|
/README.md
|
||||||
!/docs/mysql_integration.md
|
|
||||||
/read.md
|
/read.md
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
/AGENTS.md.bak-*
|
/AGENTS.md.bak-*
|
||||||
|
|
||||||
# Local migration workspace
|
# Local migration workspace
|
||||||
/migration/
|
/migration/
|
||||||
|
|
||||||
# Local generated docs
|
|
||||||
/docs/
|
|
||||||
/README.md
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ RUN pip install --no-cache-dir \
|
|||||||
python-dotenv==1.0.1 \
|
python-dotenv==1.0.1 \
|
||||||
alembic==1.14.0 \
|
alembic==1.14.0 \
|
||||||
httpx==0.28.1 \
|
httpx==0.28.1 \
|
||||||
|
lark-oapi==1.6.8 \
|
||||||
apscheduler==3.10.4 \
|
apscheduler==3.10.4 \
|
||||||
redis==5.2.1 \
|
redis==5.2.1 \
|
||||||
celery==5.4.0 \
|
celery==5.4.0 \
|
||||||
|
|||||||
@@ -64,10 +64,18 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@field_validator("cors_origins", mode="before")
|
@field_validator("cors_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_cors_origins(cls, value: str | list[str]) -> list[str]:
|
def parse_cors_origins(cls, value: Any) -> list[str]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return value
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
if value is None:
|
||||||
|
return []
|
||||||
|
text = str(value).strip()
|
||||||
|
if text.startswith("["):
|
||||||
|
data = json.loads(text)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError("CORS_ORIGINS must be a CSV string or JSON list")
|
||||||
|
return [str(item).strip() for item in data if str(item).strip()]
|
||||||
|
return [item.strip() for item in text.split(",") if item.strip()]
|
||||||
|
|
||||||
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
|
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
14
app/core/pagination.py
Normal file
14
app/core/pagination.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
DEFAULT_MAX_LIMIT = 500
|
||||||
|
DEFAULT_MIN_LIMIT = 1
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_limit(limit: int, max_limit: int = DEFAULT_MAX_LIMIT) -> int:
|
||||||
|
"""Clamp database query limits to a safe positive range."""
|
||||||
|
|
||||||
|
return max(DEFAULT_MIN_LIMIT, min(int(limit), max_limit))
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_offset(offset: int) -> int:
|
||||||
|
"""Clamp pagination offsets to zero or above."""
|
||||||
|
|
||||||
|
return max(0, int(offset))
|
||||||
@@ -6,6 +6,10 @@ from app.core.config import get_settings
|
|||||||
from app.core.scheduler import attach_scheduler
|
from app.core.scheduler import attach_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_cors_credentials(cors_origins: list[str]) -> bool:
|
||||||
|
return "*" not in cors_origins
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
|
|
||||||
@@ -23,7 +27,7 @@ def create_app() -> FastAPI:
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=_allow_cors_credentials(settings.cors_origins),
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -350,9 +350,18 @@ class DirectLLMAdapter(AIAdapter):
|
|||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
try:
|
||||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||||
AIHttpPayloadKey.CONTENT
|
AIHttpPayloadKey.CONTENT
|
||||||
]
|
]
|
||||||
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE,
|
||||||
|
AIResponseKey.RAW: data,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data}
|
return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -21,7 +21,7 @@ def create_approval(
|
|||||||
@router.get("", response_model=list[ApprovalRead])
|
@router.get("", response_model=list[ApprovalRead])
|
||||||
def list_approvals(
|
def list_approvals(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
return ApprovalService(db).list(status_filter=status, limit=limit)
|
return ApprovalService(db).list(status_filter=status, limit=limit)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
from app.core.time import utc_now
|
from app.core.time import utc_now
|
||||||
from app.modules.approvals.constants import (
|
from app.modules.approvals.constants import (
|
||||||
ApprovalActionValue,
|
ApprovalActionValue,
|
||||||
@@ -56,7 +57,11 @@ class ApprovalService:
|
|||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
|
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
|
||||||
stmt = select(ApprovalRequest).order_by(ApprovalRequest.id.desc()).limit(min(limit, 500))
|
stmt = (
|
||||||
|
select(ApprovalRequest)
|
||||||
|
.order_by(ApprovalRequest.id.desc())
|
||||||
|
.limit(bounded_limit(limit))
|
||||||
|
)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
stmt = stmt.where(ApprovalRequest.status == status_filter)
|
stmt = stmt.where(ApprovalRequest.status == status_filter)
|
||||||
return list(self.db.execute(stmt).scalars())
|
return list(self.db.execute(stmt).scalars())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -10,5 +10,8 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logs", response_model=list[AuditLogRead])
|
@router.get("/logs", response_model=list[AuditLogRead])
|
||||||
def list_audit_logs(limit: int = 100, db: Session = Depends(get_db)) -> list:
|
def list_audit_logs(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list:
|
||||||
return AuditService(db).list_logs(limit=limit)
|
return AuditService(db).list_logs(limit=limit)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
from app.modules.audit.models import AuditLog
|
from app.modules.audit.models import AuditLog
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
|
|
||||||
@@ -42,5 +43,5 @@ class AuditService:
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
def list_logs(self, limit: int = 100) -> list[AuditLog]:
|
def list_logs(self, limit: int = 100) -> list[AuditLog]:
|
||||||
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(min(limit, 500))
|
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(bounded_limit(limit))
|
||||||
return list(self.db.execute(stmt).scalars())
|
return list(self.db.execute(stmt).scalars())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -18,8 +18,8 @@ def list_domains() -> dict[str, list[str]]:
|
|||||||
@router.get("/{domain}", response_model=DomainListRead)
|
@router.get("/{domain}", response_model=DomainListRead)
|
||||||
def list_records(
|
def list_records(
|
||||||
domain: str,
|
domain: str,
|
||||||
limit: int = 50,
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
offset: int = 0,
|
offset: int = Query(default=0, ge=0),
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.sql.schema import Column
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit, bounded_offset
|
||||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
@@ -89,7 +90,9 @@ class BusinessService:
|
|||||||
if status_filter and hasattr(model, "status"):
|
if status_filter and hasattr(model, "status"):
|
||||||
stmt = stmt.where(model.status == status_filter)
|
stmt = stmt.where(model.status == status_filter)
|
||||||
count_stmt = count_stmt.where(model.status == status_filter)
|
count_stmt = count_stmt.where(model.status == status_filter)
|
||||||
stmt = stmt.order_by(model.id.desc()).limit(min(limit, 500)).offset(max(offset, 0))
|
stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
|
||||||
|
bounded_offset(offset)
|
||||||
|
)
|
||||||
total = int(self.db.execute(count_stmt).scalar() or 0)
|
total = int(self.db.execute(count_stmt).scalar() or 0)
|
||||||
return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from secrets import compare_digest
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -30,7 +31,7 @@ class FeishuService:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="FEISHU_VERIFICATION_TOKEN is required",
|
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||||
)
|
)
|
||||||
if not token or token != expected:
|
if not token or not compare_digest(str(token), expected):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid Feishu token",
|
detail="Invalid Feishu token",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -38,7 +38,10 @@ def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db))
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/projects", response_model=QueryResult)
|
@router.get("/projects", response_model=QueryResult)
|
||||||
def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> dict:
|
def default_project_query(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
return LegacyMySQLService(db).fetch_default_projects(limit=limit)
|
return LegacyMySQLService(db).fetch_default_projects(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import legacy_engine
|
from app.core.database import legacy_engine
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
from app.core.time import utc_now
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
@@ -174,7 +175,7 @@ class LegacyMySQLService:
|
|||||||
self._ensure_readonly(sql)
|
self._ensure_readonly(sql)
|
||||||
engine = self._ensure_engine()
|
engine = self._ensure_engine()
|
||||||
params = dict(params or {})
|
params = dict(params or {})
|
||||||
params.setdefault("limit", min(limit, 500))
|
params.setdefault("limit", bounded_limit(limit))
|
||||||
limited_sql = sql
|
limited_sql = sql
|
||||||
if " limit " not in sql.lower():
|
if " limit " not in sql.lower():
|
||||||
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
|
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
from app.core.time import utc_now
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import AuditSource, AuditTargetType
|
from app.modules.audit.constants import AuditSource, AuditTargetType
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
@@ -137,7 +138,7 @@ class ReportService:
|
|||||||
stmt = stmt.order_by(order_by)
|
stmt = stmt.order_by(order_by)
|
||||||
else:
|
else:
|
||||||
stmt = stmt.order_by(model.id.desc())
|
stmt = stmt.order_by(model.id.desc())
|
||||||
return list(self.db.execute(stmt.limit(limit)).scalars())
|
return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars())
|
||||||
|
|
||||||
def daily_brief(self) -> dict:
|
def daily_brief(self) -> dict:
|
||||||
project_count = self._count(Project)
|
project_count = self._count(Project)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -40,7 +40,7 @@ def supplier_risks(db: Session = Depends(get_db)) -> dict:
|
|||||||
|
|
||||||
@router.get("/events")
|
@router.get("/events")
|
||||||
def risk_events(
|
def risk_events(
|
||||||
limit: int = 100,
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
from app.core.time import utc_now
|
from app.core.time import utc_now
|
||||||
from app.modules.audit.constants import (
|
from app.modules.audit.constants import (
|
||||||
AuditAction,
|
AuditAction,
|
||||||
@@ -75,13 +76,14 @@ class RiskService:
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
status_filter: str | None = None,
|
status_filter: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(min(limit, 500))
|
limit_value = bounded_limit(limit)
|
||||||
|
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RiskEvent)
|
select(RiskEvent)
|
||||||
.where(RiskEvent.status == status_filter)
|
.where(RiskEvent.status == status_filter)
|
||||||
.order_by(RiskEvent.id.desc())
|
.order_by(RiskEvent.id.desc())
|
||||||
.limit(min(limit, 500))
|
.limit(limit_value)
|
||||||
)
|
)
|
||||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ db.close()
|
|||||||
|
|
||||||
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
|
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
|
||||||
os.environ["API_KEY"] = "test-key"
|
os.environ["API_KEY"] = "test-key"
|
||||||
|
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||||
|
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||||
|
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||||
os.environ["MODEL_PROVIDER"] = "noop"
|
os.environ["MODEL_PROVIDER"] = "noop"
|
||||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.modules.ai_agent.constants import (
|
|||||||
OPENCLAW_HERMES_PIPELINE,
|
OPENCLAW_HERMES_PIPELINE,
|
||||||
AIChatRole,
|
AIChatRole,
|
||||||
AIContextKey,
|
AIContextKey,
|
||||||
|
AIErrorKey,
|
||||||
AIHttpHeader,
|
AIHttpHeader,
|
||||||
AIHttpPath,
|
AIHttpPath,
|
||||||
AIHttpPayloadKey,
|
AIHttpPayloadKey,
|
||||||
@@ -237,3 +238,30 @@ def test_openclaw_hermes_adapter_fails_when_requested_tool_is_blocked(monkeypatc
|
|||||||
"http://openclaw.local/healthz",
|
"http://openclaw.local/healthz",
|
||||||
"http://openclaw.local/readyz",
|
"http://openclaw.local/readyz",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_llm_adapter_wraps_unexpected_chat_response(monkeypatch) -> None:
|
||||||
|
class BadChatClient(DummyClient):
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
json: dict[str, Any],
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> DummyResponse:
|
||||||
|
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
||||||
|
return DummyResponse({AIHttpPayloadKey.CHOICES: []})
|
||||||
|
|
||||||
|
DummyClient.calls = []
|
||||||
|
monkeypatch.setattr(adapters.httpx, "Client", BadChatClient)
|
||||||
|
settings = Settings(
|
||||||
|
direct_llm_base_url="http://llm.local/v1",
|
||||||
|
direct_llm_api_key="direct-key",
|
||||||
|
direct_llm_model="company-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
adapters.DirectLLMAdapter(settings).ask("summarize")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
|
||||||
|
assert exc_info.value.detail[AIResponseKey.RAW] == {AIHttpPayloadKey.CHOICES: []}
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
|||||||
os.environ["FEISHU_APP_ID"] = ""
|
os.environ["FEISHU_APP_ID"] = ""
|
||||||
os.environ["FEISHU_APP_SECRET"] = ""
|
os.environ["FEISHU_APP_SECRET"] = ""
|
||||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||||
|
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||||
|
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||||
|
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||||
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
||||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.core.database import Base, engine
|
from app.core.database import Base, engine
|
||||||
|
from app.core.pagination import bounded_limit, bounded_offset
|
||||||
from app.core.security import require_api_key, require_approval_api_key
|
from app.core.security import require_api_key, require_approval_api_key
|
||||||
from app.main import app
|
from app.main import _allow_cors_credentials, app
|
||||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||||
from app.modules.reports.constants import (
|
from app.modules.reports.constants import (
|
||||||
LifecycleAttentionKey,
|
LifecycleAttentionKey,
|
||||||
@@ -133,6 +137,26 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
|
|||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_and_pagination_guardrails() -> None:
|
||||||
|
settings = Settings(cors_origins='["https://app.example.com", "https://admin.example.com"]')
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["https://app.example.com", "https://admin.example.com"]
|
||||||
|
assert _allow_cors_credentials(["*"]) is False
|
||||||
|
assert _allow_cors_credentials(["https://app.example.com"]) is True
|
||||||
|
assert bounded_limit(-1) == 1
|
||||||
|
assert bounded_limit(1000) == 500
|
||||||
|
assert bounded_offset(-10) == 0
|
||||||
|
|
||||||
|
negative_limit_response = client.get("/api/v1/business/projects?limit=-1", headers=headers)
|
||||||
|
assert negative_limit_response.status_code == 422
|
||||||
|
|
||||||
|
oversized_limit_response = client.get("/api/v1/risks/events?limit=501", headers=headers)
|
||||||
|
assert oversized_limit_response.status_code == 422
|
||||||
|
|
||||||
|
negative_offset_response = client.get("/api/v1/business/projects?offset=-1", headers=headers)
|
||||||
|
assert negative_offset_response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_approval_gate_for_high_risk_update() -> None:
|
def test_approval_gate_for_high_risk_update() -> None:
|
||||||
create_payload = {
|
create_payload = {
|
||||||
"code": "FUND-SMOKE-001",
|
"code": "FUND-SMOKE-001",
|
||||||
|
|||||||
Reference in New Issue
Block a user