```
feat: 添加飞书集成和审计API密钥认证 - 在数据库配置中添加飞书模型导入 - 添加审计API密钥配置项和认证中间件 - 实现飞书事件重复处理防止机制 - 为审批路由添加API密钥认证 - 优化AI适配器错误处理并添加JSON解析异常捕获 - 更新测试用例以包含新的认证和事件处理逻辑 ```
This commit is contained in:
@@ -8,6 +8,7 @@ from app.core.db_base import Base
|
||||
from app.modules.approvals import models as approval_models
|
||||
from app.modules.audit import models as audit_models
|
||||
from app.modules.business import models as business_models
|
||||
from app.modules.feishu import models as feishu_models
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -18,7 +19,7 @@ target_metadata = Base.metadata
|
||||
settings = get_settings()
|
||||
|
||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
|
||||
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.db_base import Base
|
||||
from app.modules.approvals import models as approval_models
|
||||
from app.modules.audit import models as audit_models
|
||||
from app.modules.business import models as business_models
|
||||
from app.modules.feishu import models as feishu_models
|
||||
|
||||
revision = "202607060001"
|
||||
down_revision = None
|
||||
@@ -18,7 +19,7 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
|
||||
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
91
alembic/versions/202607060003_feishu_event_receipts.py
Normal file
91
alembic/versions/202607060003_feishu_event_receipts.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Add Feishu event receipts.
|
||||
|
||||
Revision ID: 202607060003
|
||||
Revises: 202607060002
|
||||
Create Date: 2026-07-06
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "202607060003"
|
||||
down_revision = "202607060002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
FEISHU_EVENT_RECEIPTS_TABLE = "feishu_event_receipts"
|
||||
|
||||
|
||||
def _table_exists() -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return FEISHU_EVENT_RECEIPTS_TABLE in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _table_exists():
|
||||
return
|
||||
op.create_table(
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("event_key", sa.String(length=256), nullable=False),
|
||||
sa.Column("source", sa.String(length=64), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("message_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_feishu_event_receipts_event_id"),
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
["event_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_feishu_event_receipts_event_key"),
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
["event_key"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_feishu_event_receipts_message_id"),
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
["message_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_feishu_event_receipts_received_at"),
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
["received_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_feishu_event_receipts_source"),
|
||||
FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
["source"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _table_exists():
|
||||
return
|
||||
op.drop_index(op.f("ix_feishu_event_receipts_source"), table_name=FEISHU_EVENT_RECEIPTS_TABLE)
|
||||
op.drop_index(
|
||||
op.f("ix_feishu_event_receipts_received_at"),
|
||||
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_feishu_event_receipts_message_id"),
|
||||
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_feishu_event_receipts_event_key"),
|
||||
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_feishu_event_receipts_event_id"),
|
||||
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
|
||||
)
|
||||
op.drop_table(FEISHU_EVENT_RECEIPTS_TABLE)
|
||||
@@ -21,6 +21,8 @@ class Settings(BaseSettings):
|
||||
api_prefix: str = "/api/v1"
|
||||
api_key: str | None = None
|
||||
api_actor: str = ActorValue.API
|
||||
audit_api_key: str | None = None
|
||||
audit_api_actor: str = ActorValue.AUDITOR
|
||||
approval_api_key: str | None = None
|
||||
approval_api_actor: str = ActorValue.APPROVER
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||
|
||||
@@ -3,6 +3,7 @@ from enum import StrEnum
|
||||
|
||||
class ActorValue(StrEnum):
|
||||
API = "api"
|
||||
AUDITOR = "auditor"
|
||||
APPROVER = "approver"
|
||||
SYSTEM = "system"
|
||||
SCHEDULER = "scheduler"
|
||||
@@ -12,6 +13,7 @@ class ActorValue(StrEnum):
|
||||
class HttpHeader(StrEnum):
|
||||
AUTHORIZATION = "Authorization"
|
||||
X_API_KEY = "X-API-Key"
|
||||
X_AUDIT_API_KEY = "X-Audit-API-Key"
|
||||
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,25 @@ def require_approval_api_key(
|
||||
detail="Invalid approval API key",
|
||||
)
|
||||
return ApiPrincipal(actor=settings.approval_api_actor)
|
||||
|
||||
|
||||
def require_audit_api_key(
|
||||
x_audit_api_key: str | None = Header(
|
||||
default=None,
|
||||
alias=HttpHeader.X_AUDIT_API_KEY,
|
||||
),
|
||||
) -> ApiPrincipal:
|
||||
"""Validate the audit API key and return the audit principal."""
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.audit_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AUDIT_API_KEY is required",
|
||||
)
|
||||
if not x_audit_api_key or not compare_digest(x_audit_api_key, settings.audit_api_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid audit API key",
|
||||
)
|
||||
return ApiPrincipal(actor=settings.audit_api_actor)
|
||||
|
||||
@@ -169,7 +169,7 @@ class HermesAdapter(AIAdapter):
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text})
|
||||
data = response.json()
|
||||
data = _chat_completion_payload(response, AIErrorKey.HERMES)
|
||||
try:
|
||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||
AIHttpPayloadKey.CONTENT
|
||||
@@ -349,7 +349,7 @@ class DirectLLMAdapter(AIAdapter):
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
||||
data = response.json()
|
||||
data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM)
|
||||
try:
|
||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||
AIHttpPayloadKey.CONTENT
|
||||
@@ -399,6 +399,19 @@ def _response_payload(response: httpx.Response) -> dict[str, Any]:
|
||||
return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data}
|
||||
|
||||
|
||||
def _chat_completion_payload(response: httpx.Response, error_key: AIErrorKey) -> dict[str, Any]:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
error_key: UNEXPECTED_HERMES_RESPONSE,
|
||||
AIResponseKey.RAW: {AIResponseKey.TEXT: response.text},
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -23,12 +23,19 @@ def list_approvals(
|
||||
status: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
_ = principal
|
||||
return ApprovalService(db).list(status_filter=status, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=ApprovalRead)
|
||||
def get_approval(ticket_id: str, db: Session = Depends(get_db)):
|
||||
def get_approval(
|
||||
ticket_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
_ = principal
|
||||
return ApprovalService(db).get_by_ticket(ticket_id)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.pagination import bounded_limit
|
||||
@@ -135,11 +135,29 @@ class ApprovalService:
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
||||
)
|
||||
ticket.status = ApprovalStatus.USED
|
||||
ticket.used_by = actor
|
||||
ticket.used_at = utc_now()
|
||||
used_at = utc_now()
|
||||
values: dict[str, Any] = {
|
||||
"status": ApprovalStatus.USED,
|
||||
"used_by": actor,
|
||||
"used_at": used_at,
|
||||
}
|
||||
if record_id is not None and not ticket.record_id:
|
||||
ticket.record_id = str(record_id)
|
||||
values["record_id"] = str(record_id)
|
||||
result = self.db.execute(
|
||||
update(ApprovalRequest)
|
||||
.where(
|
||||
ApprovalRequest.ticket_id == ticket_id,
|
||||
ApprovalRequest.status == ApprovalStatus.APPROVED,
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.NOT_APPROVED,
|
||||
)
|
||||
for key, value in values.items():
|
||||
setattr(ticket, key, value)
|
||||
return ticket
|
||||
|
||||
def is_approved_for(
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_audit_api_key
|
||||
from app.modules.audit.schemas import AuditLogRead
|
||||
from app.modules.audit.service import AuditService
|
||||
|
||||
@@ -13,5 +13,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
def list_audit_logs(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_audit_api_key),
|
||||
) -> list:
|
||||
_ = principal
|
||||
return AuditService(db).list_logs(limit=limit)
|
||||
|
||||
@@ -11,6 +11,12 @@ class FeishuMessageType(StrEnum):
|
||||
|
||||
|
||||
class FeishuPayloadKey(StrEnum):
|
||||
HEADER = "header"
|
||||
EVENT = "event"
|
||||
EVENT_ID = "event_id"
|
||||
EVENT_TYPE = "event_type"
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ID = "message_id"
|
||||
RECEIVE_ID = "receive_id"
|
||||
RECEIVE_ID_TYPE = "receive_id_type"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
@@ -38,3 +44,5 @@ FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
FEISHU_WEBHOOK_EVENT_ACTION = "webhook_event"
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION = "long_connection_event"
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import FeishuCommandKey
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
FeishuCommandKey,
|
||||
FeishuPayloadKey,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
"webhook": FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
}
|
||||
|
||||
|
||||
class FeishuEventService:
|
||||
"""Handle Feishu message events from webhook or long connection."""
|
||||
@@ -25,11 +37,16 @@ class FeishuEventService:
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.verify_event(payload)
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity and not self._register_event(event_identity):
|
||||
return {"ok": True, "handled": False, "duplicate": True}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=f"{source}_event",
|
||||
action=FEISHU_EVENT_ACTIONS.get(source, FEISHU_WEBHOOK_EVENT_ACTION),
|
||||
target_type=source,
|
||||
target_id=event_identity.get("event_key") if event_identity else None,
|
||||
request_payload=payload,
|
||||
response_payload={"accepted": True},
|
||||
)
|
||||
@@ -44,3 +61,40 @@ class FeishuEventService:
|
||||
auto_reply=auto_reply,
|
||||
)
|
||||
return {"ok": True, "handled": True, "result": result}
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity["event_key"]),
|
||||
source=str(event_identity["source"]),
|
||||
event_id=event_identity.get("event_id"),
|
||||
message_id=event_identity.get("message_id"),
|
||||
)
|
||||
self.db.add(receipt)
|
||||
try:
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | None] | None:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
event_id = header.get(FeishuPayloadKey.EVENT_ID)
|
||||
message_id = message.get(FeishuPayloadKey.MESSAGE_ID)
|
||||
stable_id = event_id or message_id
|
||||
if not stable_id:
|
||||
return None
|
||||
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
event_key = ":".join(
|
||||
str(part)
|
||||
for part in (source, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
return {
|
||||
"event_key": event_key,
|
||||
"source": source,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
"message_id": str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
18
app/modules/feishu/models.py
Normal file
18
app/modules/feishu/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
|
||||
|
||||
class FeishuEventReceipt(Base):
|
||||
__tablename__ = "feishu_event_receipts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_key: Mapped[str] = mapped_column(String(256), unique=True, index=True)
|
||||
source: Mapped[str] = mapped_column(String(64), index=True)
|
||||
event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
@@ -16,10 +16,12 @@ from app.modules.business.models import (
|
||||
WorkReport,
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
|
||||
_MODELS = [
|
||||
ApprovalRequest,
|
||||
AuditLog,
|
||||
FeishuEventReceipt,
|
||||
Project,
|
||||
WorkTask,
|
||||
Procurement,
|
||||
|
||||
@@ -28,6 +28,15 @@ class DummyResponse:
|
||||
return self._data
|
||||
|
||||
|
||||
class NonJsonResponse(DummyResponse):
|
||||
def __init__(self, text: str, status_code: int = 200):
|
||||
super().__init__({}, status_code=status_code)
|
||||
self.text = text
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
raise ValueError("invalid json")
|
||||
|
||||
|
||||
def chat_response(content: str) -> DummyResponse:
|
||||
return DummyResponse(
|
||||
{
|
||||
@@ -265,3 +274,53 @@ def test_direct_llm_adapter_wraps_unexpected_chat_response(monkeypatch) -> None:
|
||||
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: []}
|
||||
|
||||
|
||||
def test_hermes_adapter_wraps_non_json_chat_response(monkeypatch) -> None:
|
||||
class BadJsonClient(DummyClient):
|
||||
def post(
|
||||
self,
|
||||
url: str,
|
||||
json: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
) -> NonJsonResponse:
|
||||
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
||||
return NonJsonResponse("upstream html")
|
||||
|
||||
DummyClient.calls = []
|
||||
monkeypatch.setattr(adapters.httpx, "Client", BadJsonClient)
|
||||
settings = Settings(hermes_base_url="http://hermes.local/v1", hermes_api_key="hermes-key")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
adapters.HermesAdapter(settings).ask("summarize")
|
||||
|
||||
assert exc_info.value.status_code == 502
|
||||
assert exc_info.value.detail[AIErrorKey.HERMES] == "Unexpected chat completion response"
|
||||
assert exc_info.value.detail[AIResponseKey.RAW][AIResponseKey.TEXT] == "upstream html"
|
||||
|
||||
|
||||
def test_direct_llm_adapter_wraps_non_json_chat_response(monkeypatch) -> None:
|
||||
class BadJsonClient(DummyClient):
|
||||
def post(
|
||||
self,
|
||||
url: str,
|
||||
json: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
) -> NonJsonResponse:
|
||||
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
||||
return NonJsonResponse("upstream html")
|
||||
|
||||
DummyClient.calls = []
|
||||
monkeypatch.setattr(adapters.httpx, "Client", BadJsonClient)
|
||||
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][AIResponseKey.TEXT] == "upstream html"
|
||||
|
||||
@@ -15,6 +15,8 @@ _db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
||||
os.environ["API_KEY"] = "test-key"
|
||||
os.environ["AUDIT_API_KEY"] = "audit-key"
|
||||
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
|
||||
os.environ["APPROVAL_API_KEY"] = "approval-key"
|
||||
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
||||
os.environ["FEISHU_APP_ID"] = ""
|
||||
@@ -31,7 +33,7 @@ from fastapi.testclient import TestClient
|
||||
from app.core.config import Settings, get_settings
|
||||
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, require_audit_api_key
|
||||
from app.main import _allow_cors_credentials, app
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||
@@ -48,6 +50,7 @@ from app.modules.reports.constants import (
|
||||
Base.metadata.create_all(bind=engine)
|
||||
client = TestClient(app)
|
||||
headers = {"X-API-Key": "test-key"}
|
||||
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
|
||||
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
|
||||
|
||||
|
||||
@@ -93,11 +96,16 @@ def test_project_report_and_feishu_command_preview() -> None:
|
||||
def test_feishu_webhook_routes_message_event() -> None:
|
||||
payload = {
|
||||
"schema": "2.0",
|
||||
"header": {"event_type": "im.message.receive_v1", "token": "test-feishu-token"},
|
||||
"header": {
|
||||
"event_id": "evt-smoke-risk-001",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"token": "test-feishu-token",
|
||||
},
|
||||
"event": {
|
||||
"sender": {"sender_id": {"open_id": "ou_test"}},
|
||||
"message": {
|
||||
"chat_id": "oc_test",
|
||||
"message_id": "om_smoke_risk_001",
|
||||
"message_type": "text",
|
||||
"content": json.dumps({"text": "risk"}),
|
||||
},
|
||||
@@ -109,7 +117,14 @@ def test_feishu_webhook_routes_message_event() -> None:
|
||||
assert data["handled"] is True
|
||||
assert data["result"]["command"] == "risk_summary"
|
||||
|
||||
logs_response = client.get("/api/v1/audit/logs", headers=headers)
|
||||
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
|
||||
assert duplicate_response.status_code == 200
|
||||
assert duplicate_response.json()["duplicate"] is True
|
||||
|
||||
blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers)
|
||||
assert blocked_logs_response.status_code == 401
|
||||
|
||||
logs_response = client.get("/api/v1/audit/logs", headers=audit_headers)
|
||||
assert logs_response.status_code == 200
|
||||
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
|
||||
assert "test-feishu-token" not in audit_payload
|
||||
@@ -138,8 +153,15 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
|
||||
with pytest.raises(HTTPException) as approval_exc_info:
|
||||
require_approval_api_key("approval-key")
|
||||
assert approval_exc_info.value.status_code == 503
|
||||
|
||||
monkeypatch.setenv("AUDIT_API_KEY", "")
|
||||
get_settings.cache_clear()
|
||||
with pytest.raises(HTTPException) as audit_exc_info:
|
||||
require_audit_api_key("audit-key")
|
||||
assert audit_exc_info.value.status_code == 503
|
||||
finally:
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
|
||||
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -199,6 +221,21 @@ def test_approval_gate_for_high_risk_update() -> None:
|
||||
assert create_approval_response.json()["applicant"] == "api"
|
||||
create_ticket_id = create_approval_response.json()["ticket_id"]
|
||||
|
||||
approval_list_without_approval_key_response = client.get("/api/v1/approvals", headers=headers)
|
||||
assert approval_list_without_approval_key_response.status_code == 401
|
||||
|
||||
approval_detail_without_approval_key_response = client.get(
|
||||
f"/api/v1/approvals/{create_ticket_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert approval_detail_without_approval_key_response.status_code == 401
|
||||
|
||||
approval_detail_response = client.get(
|
||||
f"/api/v1/approvals/{create_ticket_id}",
|
||||
headers=approval_headers,
|
||||
)
|
||||
assert approval_detail_response.status_code == 200
|
||||
|
||||
approve_create_response = client.post(
|
||||
f"/api/v1/approvals/{create_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
|
||||
Reference in New Issue
Block a user