diff --git a/alembic/env.py b/alembic/env.py index 3ab7599..52b6ba1 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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: diff --git a/alembic/versions/202607060001_initial_schema.py b/alembic/versions/202607060001_initial_schema.py index 8b1969a..b2a7cbe 100644 --- a/alembic/versions/202607060001_initial_schema.py +++ b/alembic/versions/202607060001_initial_schema.py @@ -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: diff --git a/alembic/versions/202607060003_feishu_event_receipts.py b/alembic/versions/202607060003_feishu_event_receipts.py new file mode 100644 index 0000000..ed2a4fa --- /dev/null +++ b/alembic/versions/202607060003_feishu_event_receipts.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py index 348d24c..0a89432 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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: ["*"]) diff --git a/app/core/constants.py b/app/core/constants.py index 35fa75e..b56aea4 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -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" diff --git a/app/core/security.py b/app/core/security.py index 2ade8e6..b41f98e 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -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) diff --git a/app/modules/ai_agent/adapters.py b/app/modules/ai_agent/adapters.py index 92d9e7f..d886404 100644 --- a/app/modules/ai_agent/adapters.py +++ b/app/modules/ai_agent/adapters.py @@ -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 [ { diff --git a/app/modules/approvals/routes.py b/app/modules/approvals/routes.py index 1b1fa44..e73f7f2 100644 --- a/app/modules/approvals/routes.py +++ b/app/modules/approvals/routes.py @@ -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) diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py index 0ff406b..a05e490 100644 --- a/app/modules/approvals/service.py +++ b/app/modules/approvals/service.py @@ -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( diff --git a/app/modules/audit/routes.py b/app/modules/audit/routes.py index 86fb0e9..f71f6db 100644 --- a/app/modules/audit/routes.py +++ b/app/modules/audit/routes.py @@ -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) diff --git a/app/modules/feishu/constants.py b/app/modules/feishu/constants.py index 75b1561..1e3514f 100644 --- a/app/modules/feishu/constants.py +++ b/app/modules/feishu/constants.py @@ -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" diff --git a/app/modules/feishu/events.py b/app/modules/feishu/events.py index f3e9125..99f4a7b 100644 --- a/app/modules/feishu/events.py +++ b/app/modules/feishu/events.py @@ -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, + } diff --git a/app/modules/feishu/models.py b/app/modules/feishu/models.py new file mode 100644 index 0000000..0d328ad --- /dev/null +++ b/app/modules/feishu/models.py @@ -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) diff --git a/app/tools/init_db.py b/app/tools/init_db.py index 8e855a8..b9ccd40 100644 --- a/app/tools/init_db.py +++ b/app/tools/init_db.py @@ -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, diff --git a/tests/test_ai_adapters.py b/tests/test_ai_adapters.py index b3698b0..d694683 100644 --- a/tests/test_ai_adapters.py +++ b/tests/test_ai_adapters.py @@ -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" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index f614d9d..736ec47 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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,