Spaces:
Running
Running
Upload 437 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +15 -0
- api/__init__.py +13 -0
- app/api/api_keys.py +0 -1
- app/mcp/server.py +2 -0
- app/mcp/tools/collaboration.py +30 -20
- app/projects/api.py +31 -8
- app/projects/migrations/0014_approval_workspace_integrity.sql +42 -0
- app/projects/repositories/approval_repository.py +19 -7
- app/projects/repositories/collaboration_repository.py +28 -5
- app/projects/schemas/approval.py +5 -0
- app/projects/schemas/collaboration.py +0 -1
- app/projects/services/approval_service.py +2 -2
- app/projects/services/collaboration_service.py +5 -3
- app/security/schemas.py +0 -1
- core/__init__.py +5 -0
- models/__init__.py +3 -0
- operations/__init__.py +14 -0
- services/__init__.py +11 -0
- services/cleanup.py +1 -0
- services/downloader.py +1 -0
- services/ffmpeg_service.py +1 -0
- services/ffprobe_service.py +1 -0
- services/input_resolver.py +1 -0
- services/media_service.py +1 -0
- services/validator.py +1 -0
- services/whisper_service.py +1 -0
- services/ytdlp_service.py +1 -0
- tests/conftest.py +24 -0
- tests/test_ai_copilot.py +117 -0
- tests/test_analytics_phase10_static.py +42 -0
- tests/test_api_contract_regression.py +100 -0
- tests/test_authentication.py +307 -0
- tests/test_brand_kits.py +42 -0
- tests/test_cleanup_worker.py +46 -0
- tests/test_collaboration.py +33 -0
- tests/test_collaboration_full.py +40 -0
- tests/test_content_studio_phase2.py +295 -0
- tests/test_cors.py +62 -0
- tests/test_database_migration_contracts.py +47 -0
- tests/test_database_url.py +16 -0
- tests/test_downloader.py +20 -0
- tests/test_error_handling.py +27 -0
- tests/test_ffmpeg_operations.py +65 -0
- tests/test_ffprobe.py +21 -0
- tests/test_generation_flux.py +324 -0
- tests/test_generation_foundation.py +509 -0
- tests/test_generation_provider_runtime.py +371 -0
- tests/test_generation_wan.py +346 -0
- tests/test_health.py +13 -0
- tests/test_input_resolver.py +103 -0
README.md
CHANGED
|
@@ -84,6 +84,21 @@ media-api/
|
|
| 84 |
|
| 85 |
The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
## Generation foundation
|
| 88 |
|
| 89 |
MediaRouter includes a tenant-scoped generation request/job foundation at
|
|
|
|
| 84 |
|
| 85 |
The top-level `api/`, `services/`, `operations/`, `workers/`, `core/`, and `models/` packages mirror the canonical `app/` modules as import-compatible entry points for integrations that use the requested layout. Runtime composition uses the single implementation under `app/`, so business logic is not duplicated.
|
| 86 |
|
| 87 |
+
## Current implementation surface
|
| 88 |
+
|
| 89 |
+
The current source tree exposes the following product areas. Missing docs or backend modules are recorded as omitted from this working tree, not as unimplemented capabilities elsewhere.
|
| 90 |
+
|
| 91 |
+
- Brand Kit: `/v1/brand`, frontend brand-kit module, Python/TypeScript SDK resources, MCP brand tools, n8n MediaBrandKit node.
|
| 92 |
+
- Projects and collaboration: `/v1/projects`, editor persistence/render jobs, teams/members/invitations, approvals/review comments, notification preferences, Python/TypeScript SDK project resources, MCP collaboration tools, n8n MediaCollaboration node.
|
| 93 |
+
- Publishing and social automation: `/v1/social`, unified publishing, scheduling, retries, reconciliation, analytics sync, Python/TypeScript SDK social resources, MCP social tools, n8n MediaSocial node.
|
| 94 |
+
- Analytics: `/v1/analytics`, overview/timeseries/platform/post analytics, sync runs/cancellation, frontend analytics module, MCP analytics tools, SDK analytics resource. See [`docs/analytics.md`](docs/analytics.md) for implementation and runtime status.
|
| 95 |
+
- AI Studio / Copilot: `/v1/ai`, `/v1/copilot`, durable jobs, deterministic planner, frontend AI/Copilot modules, SDK resources, MCP tools. See [`docs/ai-copilot.md`](docs/ai-copilot.md) for implementation and runtime status.
|
| 96 |
+
- Templates: `/v1/templates`, `/v1/templates/catalog`, template execution, marketplace API, frontend template/marketplace modules, SDK/n8n/MCP coverage. See [`docs/template-marketplace.md`](docs/template-marketplace.md) for implementation and runtime status.
|
| 97 |
+
- Content Studio: editor state, optimistic revisioning, autosave, render jobs, timeline UX, SDK/n8n integration points. See [`docs/content-studio-foundation.md`](docs/content-studio-foundation.md) and [`docs/content-studio-persistence-rendering.md`](docs/content-studio-persistence-rendering.md) for current implementation surface.
|
| 98 |
+
- Brand Kit: `/v1/brand`, frontend brand-kit module, Python/TypeScript SDK resources, MCP brand tools, and an n8n `MediaBrandKit` package entry. See [`docs/brand-kits.md`](docs/brand-kits.md) for implementation and runtime status.
|
| 99 |
+
|
| 100 |
+
Provider certification, PostgreSQL/RLS runtime, Docker, and Hugging Face runtime verification remain deferred until the planned product phases are complete.
|
| 101 |
+
|
| 102 |
## Generation foundation
|
| 103 |
|
| 104 |
MediaRouter includes a tenant-scoped generation request/job foundation at
|
api/__init__.py
CHANGED
|
@@ -1 +1,14 @@
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.api` package."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.api` package."""
|
| 2 |
+
|
| 3 |
+
from app.api.api_keys import * # noqa
|
| 4 |
+
from app.api.audio import * # noqa
|
| 5 |
+
from app.api.generation import * # noqa
|
| 6 |
+
from app.api.health import * # noqa
|
| 7 |
+
from app.api.image import * # noqa
|
| 8 |
+
from app.api.media import * # noqa
|
| 9 |
+
from app.api.probe import * # noqa
|
| 10 |
+
from app.api.social import * # noqa
|
| 11 |
+
from app.api.templates import * # noqa
|
| 12 |
+
from app.api.video import * # noqa
|
| 13 |
+
from app.api.whisper import * # noqa
|
| 14 |
+
from app.api.ytdlp import * # noqa
|
app/api/api_keys.py
CHANGED
|
@@ -40,7 +40,6 @@ async def current_auth_context(request: Request) -> AuthContextView:
|
|
| 40 |
expires_at=context.expires_at,
|
| 41 |
workspace_id=context.workspace_id,
|
| 42 |
user_id=context.user_id,
|
| 43 |
-
membership_role=context.membership_role,
|
| 44 |
)
|
| 45 |
|
| 46 |
|
|
|
|
| 40 |
expires_at=context.expires_at,
|
| 41 |
workspace_id=context.workspace_id,
|
| 42 |
user_id=context.user_id,
|
|
|
|
| 43 |
)
|
| 44 |
|
| 45 |
|
app/mcp/server.py
CHANGED
|
@@ -21,6 +21,7 @@ from app.mcp.tools.analytics import register_analytics_tools
|
|
| 21 |
from app.mcp.tools.audio import register_audio_tools
|
| 22 |
from app.mcp.tools.brand import register_brand_tools
|
| 23 |
from app.mcp.tools.image import register_image_tools
|
|
|
|
| 24 |
from app.mcp.tools.probe import register_probe_tools
|
| 25 |
from app.mcp.tools.social import register_social_tools
|
| 26 |
from app.mcp.tools.system import register_system_tools
|
|
@@ -62,6 +63,7 @@ def create_mcp_server(container: Container) -> FastMCP[Any]:
|
|
| 62 |
register_template_tools(server, registry)
|
| 63 |
register_social_tools(server, registry)
|
| 64 |
register_brand_tools(server, registry)
|
|
|
|
| 65 |
register_ai_tools(server, registry)
|
| 66 |
register_analytics_tools(server, registry)
|
| 67 |
register_resources(server, registry)
|
|
|
|
| 21 |
from app.mcp.tools.audio import register_audio_tools
|
| 22 |
from app.mcp.tools.brand import register_brand_tools
|
| 23 |
from app.mcp.tools.image import register_image_tools
|
| 24 |
+
from app.mcp.tools.collaboration import register_collaboration_tools
|
| 25 |
from app.mcp.tools.probe import register_probe_tools
|
| 26 |
from app.mcp.tools.social import register_social_tools
|
| 27 |
from app.mcp.tools.system import register_system_tools
|
|
|
|
| 63 |
register_template_tools(server, registry)
|
| 64 |
register_social_tools(server, registry)
|
| 65 |
register_brand_tools(server, registry)
|
| 66 |
+
register_collaboration_tools(server, registry)
|
| 67 |
register_ai_tools(server, registry)
|
| 68 |
register_analytics_tools(server, registry)
|
| 69 |
register_resources(server, registry)
|
app/mcp/tools/collaboration.py
CHANGED
|
@@ -1,25 +1,35 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
from typing import Any
|
| 3 |
-
from
|
|
|
|
|
|
|
| 4 |
from app.projects.services.collaboration_service import CollaborationService
|
| 5 |
from app.security.context import auth_context
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
from typing import Any
|
| 3 |
+
from mcp.server.fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
from app.mcp.registry import MCPRegistry
|
| 6 |
from app.projects.services.collaboration_service import CollaborationService
|
| 7 |
from app.security.context import auth_context
|
| 8 |
|
| 9 |
+
|
| 10 |
+
def register_collaboration_tools(server: FastMCP[Any], registry: MCPRegistry) -> None:
|
| 11 |
+
@server.tool(description="List teams in the current workspace.")
|
| 12 |
+
async def list_teams() -> dict[str, Any]:
|
| 13 |
+
auth = auth_context.get()
|
| 14 |
+
if not auth or not auth.workspace_id:
|
| 15 |
+
raise ValueError("Unauthorized")
|
| 16 |
+
|
| 17 |
+
async def action() -> list[dict[str, Any]]:
|
| 18 |
+
service: CollaborationService = registry.container.collaboration
|
| 19 |
+
return [item.model_dump(mode="json") for item in await service.list_teams(auth.workspace_id)]
|
| 20 |
+
|
| 21 |
+
return await registry.run_metadata_tool("collaboration.list_teams", action)
|
| 22 |
+
|
| 23 |
+
@server.tool(description="Create a team in the current workspace.")
|
| 24 |
+
async def create_team(name: str) -> dict[str, Any]:
|
| 25 |
+
auth = auth_context.get()
|
| 26 |
+
if not auth or not auth.workspace_id:
|
| 27 |
+
raise ValueError("Unauthorized")
|
| 28 |
+
|
| 29 |
+
async def action() -> dict[str, Any]:
|
| 30 |
+
service: CollaborationService = registry.container.collaboration
|
| 31 |
+
return (await service.create_team(auth.workspace_id, name)).model_dump(mode="json")
|
| 32 |
+
|
| 33 |
+
return await registry.run_metadata_tool("collaboration.create_team", action)
|
| 34 |
+
|
| 35 |
+
register_collaboration_tools # re-export registration symbol for compatibility
|
app/projects/api.py
CHANGED
|
@@ -32,7 +32,7 @@ from app.projects.schemas.collaboration import (
|
|
| 32 |
TeamBase,
|
| 33 |
MemberResponse
|
| 34 |
)
|
| 35 |
-
from app.projects.schemas.approval import ApprovalRequest, ReviewComment
|
| 36 |
from app.security.errors import ForbiddenError
|
| 37 |
|
| 38 |
router = APIRouter(prefix="/v1/projects", tags=["projects"])
|
|
@@ -82,7 +82,24 @@ async def archive_team(request: Request, team_id: str) -> Response:
|
|
| 82 |
|
| 83 |
@router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
|
| 84 |
async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
@router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
|
|
@@ -90,16 +107,16 @@ async def approve_request(
|
|
| 90 |
request: Request,
|
| 91 |
request_id: str
|
| 92 |
) -> ApprovalRequest:
|
| 93 |
-
|
| 94 |
-
return await request.app.state.container.approval.approve_request(request_id, user_id)
|
| 95 |
|
| 96 |
@router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
|
| 97 |
async def reject_request(
|
| 98 |
request: Request,
|
| 99 |
request_id: str
|
| 100 |
) -> ApprovalRequest:
|
| 101 |
-
|
| 102 |
-
return await request.app.state.container.approval.reject_request(request_id, user_id)
|
| 103 |
|
| 104 |
@router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
|
| 105 |
async def add_review_comment(
|
|
@@ -309,7 +326,10 @@ async def list_project_collaborators(
|
|
| 309 |
request: Request,
|
| 310 |
project_id: UUID
|
| 311 |
) -> list[MemberResponse]:
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
@router.post("/{project_id}/collaborators", response_model=MemberResponse)
|
| 315 |
async def add_project_collaborator(
|
|
@@ -329,7 +349,10 @@ async def remove_project_collaborator(
|
|
| 329 |
project_id: UUID,
|
| 330 |
user_id: str
|
| 331 |
) -> Response:
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
| 333 |
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 334 |
|
| 335 |
|
|
|
|
| 32 |
TeamBase,
|
| 33 |
MemberResponse
|
| 34 |
)
|
| 35 |
+
from app.projects.schemas.approval import ApprovalRequest, ApprovalRequestCreate, ReviewComment
|
| 36 |
from app.security.errors import ForbiddenError
|
| 37 |
|
| 38 |
router = APIRouter(prefix="/v1/projects", tags=["projects"])
|
|
|
|
| 82 |
|
| 83 |
@router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
|
| 84 |
async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
|
| 85 |
+
workspace_id, _, _, _ = _identity(request)
|
| 86 |
+
return await request.app.state.container.approval.list_requests(workspace_id, workflow_id)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.post("/workspace/workflows/{workflow_id}/requests", response_model=ApprovalRequest, status_code=status.HTTP_201_CREATED)
|
| 91 |
+
async def create_approval_request(
|
| 92 |
+
request: Request,
|
| 93 |
+
workflow_id: str,
|
| 94 |
+
payload: ApprovalRequestCreate,
|
| 95 |
+
) -> ApprovalRequest:
|
| 96 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 97 |
+
return await request.app.state.container.approval.create_request(
|
| 98 |
+
workspace_id=workspace_id,
|
| 99 |
+
workflow_id=workflow_id,
|
| 100 |
+
project_id=payload.project_id,
|
| 101 |
+
user_id=user_id,
|
| 102 |
+
)
|
| 103 |
|
| 104 |
|
| 105 |
@router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
|
|
|
|
| 107 |
request: Request,
|
| 108 |
request_id: str
|
| 109 |
) -> ApprovalRequest:
|
| 110 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 111 |
+
return await request.app.state.container.approval.approve_request(workspace_id, request_id, user_id)
|
| 112 |
|
| 113 |
@router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
|
| 114 |
async def reject_request(
|
| 115 |
request: Request,
|
| 116 |
request_id: str
|
| 117 |
) -> ApprovalRequest:
|
| 118 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 119 |
+
return await request.app.state.container.approval.reject_request(workspace_id, request_id, user_id)
|
| 120 |
|
| 121 |
@router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
|
| 122 |
async def add_review_comment(
|
|
|
|
| 326 |
request: Request,
|
| 327 |
project_id: UUID
|
| 328 |
) -> list[MemberResponse]:
|
| 329 |
+
workspace_id, user_id, _, _ = _identity(request)
|
| 330 |
+
return await request.app.state.container.collaboration.list_project_collaborators(
|
| 331 |
+
workspace_id, str(project_id), user_id=user_id
|
| 332 |
+
)
|
| 333 |
|
| 334 |
@router.post("/{project_id}/collaborators", response_model=MemberResponse)
|
| 335 |
async def add_project_collaborator(
|
|
|
|
| 349 |
project_id: UUID,
|
| 350 |
user_id: str
|
| 351 |
) -> Response:
|
| 352 |
+
workspace_id, _, _, _ = _identity(request)
|
| 353 |
+
await request.app.state.container.collaboration.remove_project_collaborator(
|
| 354 |
+
workspace_id, str(project_id), user_id
|
| 355 |
+
)
|
| 356 |
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
| 357 |
|
| 358 |
|
app/projects/migrations/0014_approval_workspace_integrity.sql
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Additive approval workspace integrity migration.
|
| 2 |
+
--
|
| 3 |
+
-- This migration brings the existing approval domain in line with the
|
| 4 |
+
-- authoritative ORM models and closes the confirmed cross-workspace
|
| 5 |
+
-- authorization gap by backfilling approval requests with their owning
|
| 6 |
+
-- workflow workspace, enforcing the relationship, and adding an index
|
| 7 |
+
-- used by authorization checks.
|
| 8 |
+
--
|
| 9 |
+
-- Apply after 0013_notification_preferences.sql.
|
| 10 |
+
|
| 11 |
+
begin;
|
| 12 |
+
|
| 13 |
+
alter table approval_requests
|
| 14 |
+
add column if not exists workspace_id text;
|
| 15 |
+
|
| 16 |
+
do $$
|
| 17 |
+
begin
|
| 18 |
+
if exists (
|
| 19 |
+
select 1
|
| 20 |
+
from approval_requests
|
| 21 |
+
where workspace_id is null
|
| 22 |
+
) then
|
| 23 |
+
update approval_requests
|
| 24 |
+
set workspace_id = approval_workflows.workspace_id
|
| 25 |
+
from approval_workflows
|
| 26 |
+
where approval_workflows.id = approval_requests.workflow_id
|
| 27 |
+
and approval_requests.workspace_id is null;
|
| 28 |
+
end if;
|
| 29 |
+
end $$;
|
| 30 |
+
|
| 31 |
+
alter table approval_requests
|
| 32 |
+
alter column workspace_id set not null;
|
| 33 |
+
|
| 34 |
+
alter table approval_requests
|
| 35 |
+
drop constraint if exists approval_requests_workflow_id_fkey,
|
| 36 |
+
add constraint approval_requests_workflow_id_fkey
|
| 37 |
+
foreign key (workflow_id) references approval_workflows(id) on delete cascade;
|
| 38 |
+
|
| 39 |
+
create index if not exists ix_approval_requests_workspace
|
| 40 |
+
on approval_requests(workspace_id);
|
| 41 |
+
|
| 42 |
+
commit;
|
app/projects/repositories/approval_repository.py
CHANGED
|
@@ -30,9 +30,14 @@ class ApprovalRepository:
|
|
| 30 |
await session.refresh(request)
|
| 31 |
return request
|
| 32 |
|
| 33 |
-
async def update_request_status(self, request_id: str, status: str) -> ApprovalRequest:
|
| 34 |
async with self.database.session() as session:
|
| 35 |
-
request = await session.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if not request:
|
| 37 |
raise Exception("Request not found")
|
| 38 |
request.status = status
|
|
@@ -40,9 +45,14 @@ class ApprovalRepository:
|
|
| 40 |
await session.refresh(request)
|
| 41 |
return request
|
| 42 |
|
| 43 |
-
async def get_request(self, request_id: str) -> ApprovalRequest:
|
| 44 |
async with self.database.session() as session:
|
| 45 |
-
request = await session.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
if not request:
|
| 47 |
raise Exception("Request not found")
|
| 48 |
return request
|
|
@@ -60,10 +70,12 @@ class ApprovalRepository:
|
|
| 60 |
await session.refresh(comment)
|
| 61 |
return comment
|
| 62 |
|
| 63 |
-
async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
|
| 64 |
async with self.database.session() as session:
|
| 65 |
result = await session.scalars(
|
| 66 |
-
select(ApprovalRequest).where(
|
|
|
|
|
|
|
|
|
|
| 67 |
)
|
| 68 |
return list(result.all())
|
| 69 |
-
|
|
|
|
| 30 |
await session.refresh(request)
|
| 31 |
return request
|
| 32 |
|
| 33 |
+
async def update_request_status(self, request_id: str, workspace_id: str, status: str) -> ApprovalRequest:
|
| 34 |
async with self.database.session() as session:
|
| 35 |
+
request = await session.scalar(
|
| 36 |
+
select(ApprovalRequest).where(
|
| 37 |
+
ApprovalRequest.id == request_id,
|
| 38 |
+
ApprovalRequest.workspace_id == workspace_id,
|
| 39 |
+
)
|
| 40 |
+
)
|
| 41 |
if not request:
|
| 42 |
raise Exception("Request not found")
|
| 43 |
request.status = status
|
|
|
|
| 45 |
await session.refresh(request)
|
| 46 |
return request
|
| 47 |
|
| 48 |
+
async def get_request(self, request_id: str, workspace_id: str) -> ApprovalRequest:
|
| 49 |
async with self.database.session() as session:
|
| 50 |
+
request = await session.scalar(
|
| 51 |
+
select(ApprovalRequest).where(
|
| 52 |
+
ApprovalRequest.id == request_id,
|
| 53 |
+
ApprovalRequest.workspace_id == workspace_id,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
if not request:
|
| 57 |
raise Exception("Request not found")
|
| 58 |
return request
|
|
|
|
| 70 |
await session.refresh(comment)
|
| 71 |
return comment
|
| 72 |
|
| 73 |
+
async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
|
| 74 |
async with self.database.session() as session:
|
| 75 |
result = await session.scalars(
|
| 76 |
+
select(ApprovalRequest).where(
|
| 77 |
+
ApprovalRequest.workflow_id == workflow_id,
|
| 78 |
+
ApprovalRequest.workspace_id == workspace_id,
|
| 79 |
+
)
|
| 80 |
)
|
| 81 |
return list(result.all())
|
|
|
app/projects/repositories/collaboration_repository.py
CHANGED
|
@@ -149,14 +149,23 @@ class CollaborationRepository:
|
|
| 149 |
return list(result.all())
|
| 150 |
|
| 151 |
async def list_project_collaborators(self, project_id: str) -> list[ProjectCollaborator]:
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
| 153 |
result = await session.scalars(
|
| 154 |
-
select(ProjectCollaborator).where(
|
|
|
|
|
|
|
|
|
|
| 155 |
)
|
| 156 |
return list(result.all())
|
| 157 |
|
| 158 |
async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
collaborator = ProjectCollaborator(
|
| 161 |
workspace_id=workspace_id,
|
| 162 |
project_id=project_id,
|
|
@@ -168,14 +177,28 @@ class CollaborationRepository:
|
|
| 168 |
await session.refresh(collaborator)
|
| 169 |
return collaborator
|
| 170 |
|
| 171 |
-
async def remove_project_collaborator(self, project_id: str, user_id: str) -> None:
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
collaborator = await session.scalar(
|
| 174 |
select(ProjectCollaborator).where(
|
| 175 |
ProjectCollaborator.project_id == project_id,
|
|
|
|
| 176 |
ProjectCollaborator.user_id == user_id
|
| 177 |
)
|
| 178 |
)
|
| 179 |
if collaborator:
|
| 180 |
await session.delete(collaborator)
|
| 181 |
await session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
return list(result.all())
|
| 150 |
|
| 151 |
async def list_project_collaborators(self, project_id: str) -> list[ProjectCollaborator]:
|
| 152 |
+
project = await self._project_for_resources(project_id)
|
| 153 |
+
async with self.database.tenant_session(
|
| 154 |
+
workspace_id=project.workspace_id, user_id=None
|
| 155 |
+
) as session:
|
| 156 |
result = await session.scalars(
|
| 157 |
+
select(ProjectCollaborator).where(
|
| 158 |
+
ProjectCollaborator.project_id == project_id,
|
| 159 |
+
ProjectCollaborator.workspace_id == project.workspace_id,
|
| 160 |
+
)
|
| 161 |
)
|
| 162 |
return list(result.all())
|
| 163 |
|
| 164 |
async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
|
| 165 |
+
await self._project_for_resources(project_id)
|
| 166 |
+
async with self.database.tenant_session(
|
| 167 |
+
workspace_id=workspace_id, user_id=user_id
|
| 168 |
+
) as session:
|
| 169 |
collaborator = ProjectCollaborator(
|
| 170 |
workspace_id=workspace_id,
|
| 171 |
project_id=project_id,
|
|
|
|
| 177 |
await session.refresh(collaborator)
|
| 178 |
return collaborator
|
| 179 |
|
| 180 |
+
async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
|
| 181 |
+
await self._project_for_resources(project_id)
|
| 182 |
+
async with self.database.tenant_session(
|
| 183 |
+
workspace_id=workspace_id, user_id=user_id
|
| 184 |
+
) as session:
|
| 185 |
collaborator = await session.scalar(
|
| 186 |
select(ProjectCollaborator).where(
|
| 187 |
ProjectCollaborator.project_id == project_id,
|
| 188 |
+
ProjectCollaborator.workspace_id == workspace_id,
|
| 189 |
ProjectCollaborator.user_id == user_id
|
| 190 |
)
|
| 191 |
)
|
| 192 |
if collaborator:
|
| 193 |
await session.delete(collaborator)
|
| 194 |
await session.commit()
|
| 195 |
+
|
| 196 |
+
@staticmethod
|
| 197 |
+
async def _project_for_resources(project_id: str) -> Project:
|
| 198 |
+
async with CollaborationRepository(None).database.session() as session:
|
| 199 |
+
project = await session.scalar(
|
| 200 |
+
select(Project).where(Project.id == project_id)
|
| 201 |
+
)
|
| 202 |
+
if project is None:
|
| 203 |
+
raise Exception("Project not found")
|
| 204 |
+
return project
|
app/projects/schemas/approval.py
CHANGED
|
@@ -9,6 +9,11 @@ class ApprovalRequest(BaseModel):
|
|
| 9 |
created_by: str
|
| 10 |
created_at: str
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
class ReviewComment(BaseModel):
|
| 13 |
id: str
|
| 14 |
request_id: str
|
|
|
|
| 9 |
created_by: str
|
| 10 |
created_at: str
|
| 11 |
|
| 12 |
+
|
| 13 |
+
class ApprovalRequestCreate(BaseModel):
|
| 14 |
+
project_id: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
class ReviewComment(BaseModel):
|
| 18 |
id: str
|
| 19 |
request_id: str
|
app/projects/schemas/collaboration.py
CHANGED
|
@@ -20,7 +20,6 @@ class InvitationResponse(InvitationCreate):
|
|
| 20 |
status: str
|
| 21 |
expires_at: str
|
| 22 |
created_at: str
|
| 23 |
-
token: str | None = None
|
| 24 |
|
| 25 |
class TeamBase(BaseModel):
|
| 26 |
name: str
|
|
|
|
| 20 |
status: str
|
| 21 |
expires_at: str
|
| 22 |
created_at: str
|
|
|
|
| 23 |
|
| 24 |
class TeamBase(BaseModel):
|
| 25 |
name: str
|
app/projects/services/approval_service.py
CHANGED
|
@@ -33,5 +33,5 @@ class ApprovalService:
|
|
| 33 |
async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
|
| 34 |
return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
|
| 35 |
|
| 36 |
-
async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
|
| 37 |
-
return await self.repository.list_requests(workflow_id)
|
|
|
|
| 33 |
async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
|
| 34 |
return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
|
| 35 |
|
| 36 |
+
async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
|
| 37 |
+
return await self.repository.list_requests(workspace_id, workflow_id)
|
app/projects/services/collaboration_service.py
CHANGED
|
@@ -50,7 +50,6 @@ class CollaborationService:
|
|
| 50 |
expires_at=invitation.expires_at.isoformat(),
|
| 51 |
created_at=invitation.created_at.isoformat()
|
| 52 |
)
|
| 53 |
-
setattr(response, "token", token)
|
| 54 |
return response
|
| 55 |
|
| 56 |
async def list_members(self, workspace_id: str) -> list[MemberResponse]:
|
|
@@ -107,14 +106,17 @@ class CollaborationService:
|
|
| 107 |
async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
|
| 108 |
return await self.repository.get_membership(workspace_id, user_id)
|
| 109 |
|
| 110 |
-
async def list_project_collaborators(self, project_id: str) -> list[MemberResponse]:
|
| 111 |
-
collaborators = await self.repository.list_project_collaborators(project_id)
|
| 112 |
return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
|
| 113 |
|
| 114 |
async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
|
| 115 |
collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
|
| 116 |
return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
|
| 117 |
|
|
|
|
|
|
|
|
|
|
| 118 |
async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
|
| 119 |
await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
|
| 120 |
|
|
|
|
| 50 |
expires_at=invitation.expires_at.isoformat(),
|
| 51 |
created_at=invitation.created_at.isoformat()
|
| 52 |
)
|
|
|
|
| 53 |
return response
|
| 54 |
|
| 55 |
async def list_members(self, workspace_id: str) -> list[MemberResponse]:
|
|
|
|
| 106 |
async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
|
| 107 |
return await self.repository.get_membership(workspace_id, user_id)
|
| 108 |
|
| 109 |
+
async def list_project_collaborators(self, workspace_id: str, project_id: str, *, user_id: str) -> list[MemberResponse]:
|
| 110 |
+
collaborators = await self.repository.list_project_collaborators(workspace_id, project_id)
|
| 111 |
return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
|
| 112 |
|
| 113 |
async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
|
| 114 |
collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
|
| 115 |
return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
|
| 116 |
|
| 117 |
+
async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
|
| 118 |
+
await self.repository.remove_project_collaborator(workspace_id, project_id, user_id)
|
| 119 |
+
|
| 120 |
async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
|
| 121 |
await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
|
| 122 |
|
app/security/schemas.py
CHANGED
|
@@ -133,7 +133,6 @@ class AuthContextView(BaseModel):
|
|
| 133 |
expires_at: datetime | None
|
| 134 |
workspace_id: str | None = None
|
| 135 |
user_id: str | None = None
|
| 136 |
-
membership_role: str | None = None
|
| 137 |
|
| 138 |
|
| 139 |
class AuditLogView(BaseModel):
|
|
|
|
| 133 |
expires_at: datetime | None
|
| 134 |
workspace_id: str | None = None
|
| 135 |
user_id: str | None = None
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
class AuditLogView(BaseModel):
|
core/__init__.py
CHANGED
|
@@ -1 +1,6 @@
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.core` package."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.core` package."""
|
| 2 |
+
|
| 3 |
+
from app.core.config import * # noqa
|
| 4 |
+
from app.core.exceptions import * # noqa
|
| 5 |
+
from app.core.logger import * # noqa
|
| 6 |
+
from app.core.response import * # noqa
|
models/__init__.py
CHANGED
|
@@ -1 +1,4 @@
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.models` package."""
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.models` package."""
|
| 2 |
+
|
| 3 |
+
from app.models.media import * # noqa
|
| 4 |
+
from app.models.requests import * # noqa
|
operations/__init__.py
CHANGED
|
@@ -1 +1,15 @@
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.operations` package."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Compatibility exports for the canonical :mod:`app.operations` package."""
|
| 2 |
+
|
| 3 |
+
from app.operations.common import * # noqa
|
| 4 |
+
from app.operations.compress import * # noqa
|
| 5 |
+
from app.operations.concat import * # noqa
|
| 6 |
+
from app.operations.convert import * # noqa
|
| 7 |
+
from app.operations.crop import * # noqa
|
| 8 |
+
from app.operations.extract_audio import * # noqa
|
| 9 |
+
from app.operations.merge import * # noqa
|
| 10 |
+
from app.operations.resize import * # noqa
|
| 11 |
+
from app.operations.rotate import * # noqa
|
| 12 |
+
from app.operations.subtitles import * # noqa
|
| 13 |
+
from app.operations.thumbnails import * # noqa
|
| 14 |
+
from app.operations.trim import * # noqa
|
| 15 |
+
from app.operations.watermark import * # noqa
|
services/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility exports for the canonical :mod:`app.services` package."""
|
| 2 |
+
|
| 3 |
+
from app.services.cleanup import * # noqa
|
| 4 |
+
from app.services.downloader import * # noqa
|
| 5 |
+
from app.services.ffmpeg_service import * # noqa
|
| 6 |
+
from app.services.ffprobe_service import * # noqa
|
| 7 |
+
from app.services.input_resolver import * # noqa
|
| 8 |
+
from app.services.media_service import * # noqa
|
| 9 |
+
from app.services.validator import * # noqa
|
| 10 |
+
from app.services.whisper_service import * # noqa
|
| 11 |
+
from app.services.ytdlp_service import * # noqa
|
services/cleanup.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.cleanup import * # noqa
|
services/downloader.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.downloader import * # noqa
|
services/ffmpeg_service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.ffmpeg_service import * # noqa
|
services/ffprobe_service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.ffprobe_service import * # noqa
|
services/input_resolver.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.input_resolver import * # noqa
|
services/media_service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.media_service import * # noqa
|
services/validator.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.validator import * # noqa
|
services/whisper_service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.whisper_service import * # noqa
|
services/ytdlp_service.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.services.ytdlp_service import * # noqa
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.core.config import Settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def settings(tmp_path: Path) -> Settings:
|
| 12 |
+
return Settings(
|
| 13 |
+
_env_file=None,
|
| 14 |
+
temp_dir=tmp_path / "temp",
|
| 15 |
+
output_dir=tmp_path / "outputs",
|
| 16 |
+
max_upload_size=10 * 1024 * 1024,
|
| 17 |
+
cleanup_minutes=1,
|
| 18 |
+
cleanup_interval_seconds=3600,
|
| 19 |
+
whisper_model="tiny",
|
| 20 |
+
max_workers=1,
|
| 21 |
+
allow_private_urls=True,
|
| 22 |
+
auth_enabled=False,
|
| 23 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 24 |
+
)
|
tests/test_ai_copilot.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from pydantic import ValidationError
|
| 8 |
+
|
| 9 |
+
from app.copilot.actions import CopilotActionRegistry
|
| 10 |
+
from app.copilot.errors import CopilotInvalidRequestError
|
| 11 |
+
from app.copilot.planner import CopilotPlanner
|
| 12 |
+
from app.copilot.schemas import (
|
| 13 |
+
CopilotContext,
|
| 14 |
+
CopilotEditorSummary,
|
| 15 |
+
CopilotPlan,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def context(*, capabilities: list[str], asset: bool = False, clip: bool = False):
|
| 20 |
+
project_id = uuid4()
|
| 21 |
+
return CopilotContext(
|
| 22 |
+
workspace_id=str(uuid4()),
|
| 23 |
+
project_id=project_id,
|
| 24 |
+
selected_asset_ids=[uuid4()] if asset else [],
|
| 25 |
+
selected_clip_ids=["clip-1"] if clip else [],
|
| 26 |
+
editor_summary=CopilotEditorSummary(
|
| 27 |
+
revision=4, duration_ms=30_000, track_count=1, clip_count=1
|
| 28 |
+
),
|
| 29 |
+
available_capabilities=capabilities,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_planner_fails_closed_for_unavailable_transcription() -> None:
|
| 34 |
+
plan = CopilotPlanner().plan(
|
| 35 |
+
"Turn this podcast into a TikTok",
|
| 36 |
+
context(capabilities=["editor.render"], asset=True),
|
| 37 |
+
)
|
| 38 |
+
assert not plan.executable
|
| 39 |
+
assert plan.unsupported_capabilities == ["ai.transcribe"]
|
| 40 |
+
assert plan.actions == []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_planner_requires_confirmation_for_render_and_generation() -> None:
|
| 44 |
+
render = CopilotPlanner().plan("Render this project", context(capabilities=["editor.render"]))
|
| 45 |
+
assert render.executable and render.requires_confirmation
|
| 46 |
+
assert render.actions[0].type == "editor.render"
|
| 47 |
+
image = CopilotPlanner().plan(
|
| 48 |
+
"Generate an image of a lighthouse",
|
| 49 |
+
context(capabilities=["ai.generate_image"]),
|
| 50 |
+
)
|
| 51 |
+
assert image.executable and image.requires_confirmation
|
| 52 |
+
assert image.actions[0].type == "ai.generate_image"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_action_plan_rejects_unknown_model_generated_structures() -> None:
|
| 56 |
+
with pytest.raises(ValidationError):
|
| 57 |
+
CopilotPlan.model_validate(
|
| 58 |
+
{
|
| 59 |
+
"intent": "unsafe",
|
| 60 |
+
"explanation": "unsafe",
|
| 61 |
+
"actions": [
|
| 62 |
+
{
|
| 63 |
+
"id": "a",
|
| 64 |
+
"type": "shell.execute",
|
| 65 |
+
"arguments": {"command": "rm -rf /"},
|
| 66 |
+
"reason": "unsafe",
|
| 67 |
+
"requires_confirmation": False,
|
| 68 |
+
"destructive": False,
|
| 69 |
+
"external_side_effect": False,
|
| 70 |
+
"required_permission": "admin",
|
| 71 |
+
"required_capability": "shell",
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"missing_information": [],
|
| 75 |
+
"unsupported_capabilities": [],
|
| 76 |
+
"executable": True,
|
| 77 |
+
"requires_confirmation": False,
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_action_registry_rejects_policy_metadata_tampering() -> None:
|
| 83 |
+
registry = CopilotActionRegistry(
|
| 84 |
+
projects=None, # type: ignore[arg-type]
|
| 85 |
+
assets=None, # type: ignore[arg-type]
|
| 86 |
+
editor=None, # type: ignore[arg-type]
|
| 87 |
+
renders=None, # type: ignore[arg-type]
|
| 88 |
+
ai=None, # type: ignore[arg-type]
|
| 89 |
+
templates=None, # type: ignore[arg-type]
|
| 90 |
+
)
|
| 91 |
+
plan = CopilotPlanner().plan(
|
| 92 |
+
"Generate an image of a lighthouse",
|
| 93 |
+
context(capabilities=["ai.generate_image"]),
|
| 94 |
+
)
|
| 95 |
+
tampered = plan.actions[0].model_copy(update={"requires_confirmation": False})
|
| 96 |
+
with pytest.raises(CopilotInvalidRequestError):
|
| 97 |
+
registry.validate(tampered)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_copilot_migration_is_additive_and_tenant_isolated() -> None:
|
| 101 |
+
migration = (
|
| 102 |
+
(Path(__file__).resolve().parents[1] / "app/projects/migrations/0005_ai_copilot.sql")
|
| 103 |
+
.read_text(encoding="utf-8")
|
| 104 |
+
.lower()
|
| 105 |
+
)
|
| 106 |
+
for expected in (
|
| 107 |
+
"create table if not exists copilot_runs",
|
| 108 |
+
"unique (workspace_id, idempotency_key)",
|
| 109 |
+
"enable row level security",
|
| 110 |
+
"force row level security",
|
| 111 |
+
"create policy copilot_runs_select",
|
| 112 |
+
"create policy copilot_runs_insert",
|
| 113 |
+
"create policy copilot_runs_update",
|
| 114 |
+
"copilot run identity fields are immutable",
|
| 115 |
+
):
|
| 116 |
+
assert expected in migration
|
| 117 |
+
assert "drop table" not in migration
|
tests/test_analytics_phase10_static.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_analytics_migration_is_additive_and_forces_rls() -> None:
|
| 7 |
+
text = (ROOT / "app/social/migrations/0010_analytics_insights.sql").read_text()
|
| 8 |
+
normalized = text.lower()
|
| 9 |
+
assert "drop table" not in normalized
|
| 10 |
+
for table in (
|
| 11 |
+
"analytics_sync_runs",
|
| 12 |
+
"analytics_metric_snapshots",
|
| 13 |
+
"analytics_post_metrics",
|
| 14 |
+
"analytics_platform_metrics",
|
| 15 |
+
):
|
| 16 |
+
assert f"create table if not exists {table}" in normalized
|
| 17 |
+
assert normalized.count("force row level security") >= 1
|
| 18 |
+
assert "current_setting(''app.workspace_id''" in normalized
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_analytics_routes_and_transports_are_narrow() -> None:
|
| 22 |
+
api = (ROOT / "app/analytics/api.py").read_text()
|
| 23 |
+
mcp = (ROOT / "app/mcp/tools/analytics.py").read_text()
|
| 24 |
+
sdk = (ROOT / "sdk/typescript/src/resources/analytics.ts").read_text()
|
| 25 |
+
for route in (
|
| 26 |
+
'"/overview"',
|
| 27 |
+
'"/timeseries"',
|
| 28 |
+
'"/platforms"',
|
| 29 |
+
'"/posts"',
|
| 30 |
+
'"/sync"',
|
| 31 |
+
):
|
| 32 |
+
assert route in api
|
| 33 |
+
assert "execute analytics query" not in mcp.lower()
|
| 34 |
+
assert "class AnalyticsResource" in sdk
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_analytics_never_fabricates_provider_metrics() -> None:
|
| 38 |
+
service = (ROOT / "app/analytics/service.py").read_text()
|
| 39 |
+
provider = (ROOT / "app/social/providers/base.py").read_text()
|
| 40 |
+
assert "self.social_analytics.post" in service
|
| 41 |
+
assert "get_metrics" in provider
|
| 42 |
+
assert "random.randint" not in service
|
tests/test_api_contract_regression.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import re
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import unittest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROUTES = {
|
| 10 |
+
"brand_api": Path("app/brand/api.py"),
|
| 11 |
+
"projects_api": Path("app/projects/api.py"),
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
FRONTEND_CALLS = {
|
| 15 |
+
"brand_api": Path("frontend/features/brand-kits/api/index.ts"),
|
| 16 |
+
"collaboration_api": Path("frontend/features/workspace/collaboration/api/collaboration.ts"),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
BRAND_ROUTES = {
|
| 20 |
+
"router.post('', response_model=BrandKitResponse, status_code=status.HTTP_201_CREATED)": "/v1/brand POST",
|
| 21 |
+
"router.get('', response_model=list[BrandKitResponse])": "/v1/brand GET",
|
| 22 |
+
"router.patch('/{brand_kit_id}', response_model=BrandKitResponse)": "/v1/brand PATCH",
|
| 23 |
+
"router.delete('/{brand_kit_id}', status_code=status.HTTP_204_NO_CONTENT)": "/v1/brand DELETE",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
EXPECTED_BRAND_FRONTEND_CALLS = [
|
| 27 |
+
"await apiClient.get('/v1/brand');",
|
| 28 |
+
"await apiClient.post('/v1/brand', payload);",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
EXPECTED_COLLABORATION_FRONTEND_CALLS = [
|
| 32 |
+
"await apiClient.get('/v1/projects/workspace/teams');",
|
| 33 |
+
"await apiClient.post('/v1/projects/workspace/teams', payload);",
|
| 34 |
+
"await apiClient.post('/v1/projects/workspace/invitations', payload);",
|
| 35 |
+
"await apiClient.get('/v1/projects/workspace/members');",
|
| 36 |
+
"await apiClient.delete(`/v1/projects/workspace/members/${userId}`);",
|
| 37 |
+
"await apiClient.patch(`/v1/projects/workspace/members/${userId}/role?new_role=${newRole}`);",
|
| 38 |
+
"await apiClient.get(`/v1/projects/workspace/workflows/${workflowId}/requests`);",
|
| 39 |
+
"await apiClient.post(`/v1/projects/workspace/workflows/${workflowId}/requests`, { project_id: projectId });",
|
| 40 |
+
"await apiClient.post(`/v1/projects/workspace/requests/${requestId}/approve`);",
|
| 41 |
+
"await apiClient.post(`/v1/projects/workspace/requests/${requestId}/reject`);",
|
| 42 |
+
"await apiClient.post(`/v1/projects/workspace/requests/${requestId}/comments?content=${encodeURIComponent(content)}`);",
|
| 43 |
+
"await apiClient.get(`/v1/projects/${encodeURIComponent(projectId)}/collaborators`);",
|
| 44 |
+
"await apiClient.post(`/v1/projects/${encodeURIComponent(projectId)}/collaborators?user_id=${encodeURIComponent(userId)}&role=${encodeURIComponent(role)}`);",
|
| 45 |
+
"await apiClient.delete(`/v1/projects/${encodeURIComponent(projectId)}/collaborators/${encodeURIComponent(userId)}`);",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _route_decorators(path: Path) -> list[str]:
|
| 50 |
+
tree = ast.parse(path.read_text())
|
| 51 |
+
calls = []
|
| 52 |
+
for node in tree.body:
|
| 53 |
+
if not isinstance(node, ast.AsyncFunctionDef):
|
| 54 |
+
continue
|
| 55 |
+
for decorator in node.decorator_list:
|
| 56 |
+
if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute) and decorator.func.attr in {"get", "post", "patch", "delete"}:
|
| 57 |
+
calls.append(ast.unparse(decorator))
|
| 58 |
+
return calls
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _frontend_calls(path: Path) -> list[str]:
|
| 62 |
+
return [re.sub(r"^\s*const\s+\{[^}]*\}\s+=\s+", "", line.strip()) for line in path.read_text().splitlines() if "apiClient." in line]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_brand_kit_routes_match_expected_contract() -> None:
|
| 66 |
+
assert _route_decorators(ROUTES["brand_api"]) == list(BRAND_ROUTES.keys())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_approval_request_create_route_is_exposed() -> None:
|
| 70 |
+
decorators = _route_decorators(ROUTES["projects_api"])
|
| 71 |
+
assert any(
|
| 72 |
+
decorator == "router.post('/workspace/workflows/{workflow_id}/requests', response_model=ApprovalRequest, status_code=status.HTTP_201_CREATED)"
|
| 73 |
+
for decorator in decorators
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_brand_kit_frontend_uses_expected_backend_routes() -> None:
|
| 78 |
+
assert _frontend_calls(FRONTEND_CALLS["brand_api"]) == EXPECTED_BRAND_FRONTEND_CALLS
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_collaboration_frontend_uses_expected_backend_routes() -> None:
|
| 82 |
+
assert _frontend_calls(FRONTEND_CALLS["collaboration_api"]) == EXPECTED_COLLABORATION_FRONTEND_CALLS
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ApiContractRegressionTests(unittest.TestCase):
|
| 86 |
+
def test_brand_kit_routes_match_expected_contract(self) -> None:
|
| 87 |
+
test_brand_kit_routes_match_expected_contract()
|
| 88 |
+
|
| 89 |
+
def test_brand_kit_frontend_uses_expected_backend_routes(self) -> None:
|
| 90 |
+
test_brand_kit_frontend_uses_expected_backend_routes()
|
| 91 |
+
|
| 92 |
+
def test_collaboration_frontend_uses_expected_backend_routes(self) -> None:
|
| 93 |
+
test_collaboration_frontend_uses_expected_backend_routes()
|
| 94 |
+
|
| 95 |
+
def test_approval_request_create_route_is_exposed(self) -> None:
|
| 96 |
+
test_approval_request_create_route_is_exposed()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
unittest.main()
|
tests/test_authentication.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
|
| 12 |
+
from app.container import build_container
|
| 13 |
+
from app.core.config import Settings
|
| 14 |
+
from app.mcp.registry import MCPRegistry
|
| 15 |
+
from app.security.context import auth_context
|
| 16 |
+
from app.security.errors import APIKeyConflictError, ForbiddenError, RateLimitError, UnauthorizedError
|
| 17 |
+
from app.security.models import APIKey, AuditLog
|
| 18 |
+
from app.security.schemas import APIKeyCreate
|
| 19 |
+
from app.security.service import APIKeyService
|
| 20 |
+
from main import create_app
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def security_settings(tmp_path: Path, **overrides: object) -> Settings:
|
| 24 |
+
values: dict[str, object] = {
|
| 25 |
+
"_env_file": None,
|
| 26 |
+
"temp_dir": tmp_path / "temp",
|
| 27 |
+
"output_dir": tmp_path / "outputs",
|
| 28 |
+
"database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 29 |
+
"auth_enabled": True,
|
| 30 |
+
"auth_last_used_update_seconds": 0,
|
| 31 |
+
"cleanup_interval_seconds": 3600,
|
| 32 |
+
"whisper_model": "tiny",
|
| 33 |
+
"max_workers": 1,
|
| 34 |
+
}
|
| 35 |
+
values.update(overrides)
|
| 36 |
+
return Settings(**values)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
async def security_container(tmp_path: Path):
|
| 41 |
+
container = build_container(security_settings(tmp_path))
|
| 42 |
+
await container.security_database.initialize()
|
| 43 |
+
try:
|
| 44 |
+
yield container
|
| 45 |
+
finally:
|
| 46 |
+
await container.security_database.close()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def create_key(container, **overrides: object) -> tuple[APIKey, str]:
|
| 50 |
+
values: dict[str, object] = {
|
| 51 |
+
"name": "Automation",
|
| 52 |
+
"environment": "test",
|
| 53 |
+
"role": None,
|
| 54 |
+
"scopes": ["templates:read"],
|
| 55 |
+
}
|
| 56 |
+
values.update(overrides)
|
| 57 |
+
return await container.api_keys.create(APIKeyCreate(**values), created_by="tests")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_key_generation_has_256_bits_and_database_never_stores_secret(
|
| 61 |
+
security_container,
|
| 62 |
+
) -> None:
|
| 63 |
+
record, secret = await create_key(security_container)
|
| 64 |
+
|
| 65 |
+
environment, encoded_secret = secret.split("_", 2)[1:]
|
| 66 |
+
raw_secret = base64.urlsafe_b64decode(encoded_secret + "=")
|
| 67 |
+
assert environment == "test"
|
| 68 |
+
assert len(raw_secret) == 32
|
| 69 |
+
assert record.key_prefix == f"mp_test_{encoded_secret[:8]}"
|
| 70 |
+
assert record.key_hash == hashlib.sha256(secret.encode()).hexdigest()
|
| 71 |
+
|
| 72 |
+
async with security_container.security_database.session() as session:
|
| 73 |
+
stored = await session.get(APIKey, record.id)
|
| 74 |
+
assert stored is not None
|
| 75 |
+
assert secret not in vars(stored).values()
|
| 76 |
+
assert not hasattr(stored, "api_key")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_authentication_rejects_invalid_expired_disabled_and_revoked_keys(
|
| 80 |
+
security_container,
|
| 81 |
+
) -> None:
|
| 82 |
+
active, active_secret = await create_key(security_container)
|
| 83 |
+
assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
|
| 84 |
+
|
| 85 |
+
replacement = "A" if active_secret[-1] != "A" else "B"
|
| 86 |
+
with pytest.raises(UnauthorizedError):
|
| 87 |
+
await security_container.api_keys.authenticate(active_secret[:-1] + replacement)
|
| 88 |
+
|
| 89 |
+
_, expired_secret = await create_key(
|
| 90 |
+
security_container,
|
| 91 |
+
name="Expired",
|
| 92 |
+
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
| 93 |
+
)
|
| 94 |
+
with pytest.raises(UnauthorizedError):
|
| 95 |
+
await security_container.api_keys.authenticate(expired_secret)
|
| 96 |
+
|
| 97 |
+
await security_container.api_keys.set_status(active.id, "disabled")
|
| 98 |
+
with pytest.raises(UnauthorizedError):
|
| 99 |
+
await security_container.api_keys.authenticate(active_secret)
|
| 100 |
+
await security_container.api_keys.set_status(active.id, "active")
|
| 101 |
+
assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id
|
| 102 |
+
|
| 103 |
+
await security_container.api_keys.set_status(active.id, "revoked")
|
| 104 |
+
with pytest.raises(UnauthorizedError):
|
| 105 |
+
await security_container.api_keys.authenticate(active_secret)
|
| 106 |
+
with pytest.raises(APIKeyConflictError):
|
| 107 |
+
await security_container.api_keys.set_status(active.id, "disabled")
|
| 108 |
+
with pytest.raises(APIKeyConflictError):
|
| 109 |
+
await security_container.api_keys.set_status(active.id, "active")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def test_scope_enforcement_and_rotation_grace_period(security_container) -> None:
|
| 113 |
+
old, old_secret = await create_key(security_container)
|
| 114 |
+
context = await security_container.api_keys.authenticate(old_secret)
|
| 115 |
+
security_container.api_keys.authorize(context, "templates:read")
|
| 116 |
+
with pytest.raises(ForbiddenError):
|
| 117 |
+
security_container.api_keys.authorize(context, "operations:execute")
|
| 118 |
+
|
| 119 |
+
replacement, replacement_secret = await security_container.api_keys.rotate(
|
| 120 |
+
old.id, 60, created_by="tests"
|
| 121 |
+
)
|
| 122 |
+
assert replacement.rotated_from_id == old.id
|
| 123 |
+
assert (await security_container.api_keys.authenticate(old_secret)).api_key_id == old.id
|
| 124 |
+
assert (
|
| 125 |
+
await security_container.api_keys.authenticate(replacement_secret)
|
| 126 |
+
).api_key_id == replacement.id
|
| 127 |
+
with pytest.raises(APIKeyConflictError):
|
| 128 |
+
await security_container.api_keys.set_status(old.id, "disabled")
|
| 129 |
+
|
| 130 |
+
async with security_container.security_database.session() as session:
|
| 131 |
+
rotating = await session.get(APIKey, old.id)
|
| 132 |
+
assert rotating is not None
|
| 133 |
+
rotating.grace_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
| 134 |
+
await session.commit()
|
| 135 |
+
with pytest.raises(UnauthorizedError):
|
| 136 |
+
await security_container.api_keys.authenticate(old_secret)
|
| 137 |
+
assert (await security_container.api_keys.get(old.id)).status == "revoked"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_per_key_request_and_concurrent_job_limits(security_container) -> None:
|
| 141 |
+
_, request_secret = await create_key(
|
| 142 |
+
security_container, name="RPM", requests_per_minute=1
|
| 143 |
+
)
|
| 144 |
+
request_context = await security_container.api_keys.authenticate(request_secret)
|
| 145 |
+
lease = await security_container.rate_limiter.acquire(
|
| 146 |
+
request_context, is_job=False, is_upload=False, uploaded_bytes=0
|
| 147 |
+
)
|
| 148 |
+
await lease.release()
|
| 149 |
+
with pytest.raises(RateLimitError) as rate_error:
|
| 150 |
+
await security_container.rate_limiter.acquire(
|
| 151 |
+
request_context, is_job=False, is_upload=False, uploaded_bytes=0
|
| 152 |
+
)
|
| 153 |
+
assert rate_error.value.retry_after >= 1
|
| 154 |
+
|
| 155 |
+
_, job_secret = await create_key(
|
| 156 |
+
security_container, name="Concurrency", concurrent_jobs=1
|
| 157 |
+
)
|
| 158 |
+
job_context = await security_container.api_keys.authenticate(job_secret)
|
| 159 |
+
running = await security_container.rate_limiter.acquire(
|
| 160 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 161 |
+
)
|
| 162 |
+
with pytest.raises(RateLimitError):
|
| 163 |
+
await security_container.rate_limiter.acquire(
|
| 164 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 165 |
+
)
|
| 166 |
+
await running.release()
|
| 167 |
+
next_job = await security_container.rate_limiter.acquire(
|
| 168 |
+
job_context, is_job=True, is_upload=False, uploaded_bytes=0
|
| 169 |
+
)
|
| 170 |
+
await next_job.release()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_stdio_mcp_uses_shared_context_scopes_rate_limits_and_audit(
|
| 174 |
+
security_container,
|
| 175 |
+
) -> None:
|
| 176 |
+
_, secret = await create_key(
|
| 177 |
+
security_container, name="MCP Reader", scopes=["mcp:read"]
|
| 178 |
+
)
|
| 179 |
+
context = await security_container.api_keys.authenticate(secret)
|
| 180 |
+
registry = MCPRegistry(security_container)
|
| 181 |
+
unauthorized = await registry.run_metadata_tool("system_info", registry.system_info_data)
|
| 182 |
+
token = auth_context.set(context)
|
| 183 |
+
try:
|
| 184 |
+
resource = await registry.safe_resource("version", registry.version_data)
|
| 185 |
+
forbidden = await registry.run_metadata_tool("system_info", registry.system_info_data)
|
| 186 |
+
finally:
|
| 187 |
+
auth_context.reset(token)
|
| 188 |
+
|
| 189 |
+
assert unauthorized["success"] is False
|
| 190 |
+
assert unauthorized["error"]["code"] == "UNAUTHORIZED"
|
| 191 |
+
assert resource["success"] is True
|
| 192 |
+
assert forbidden["success"] is False
|
| 193 |
+
assert forbidden["error"]["code"] == "FORBIDDEN"
|
| 194 |
+
async with security_container.security_database.session() as session:
|
| 195 |
+
logs = list((await session.scalars(select(AuditLog))).all())
|
| 196 |
+
assert {log.endpoint for log in logs} >= {
|
| 197 |
+
"mcp://tools/resource.version",
|
| 198 |
+
"mcp://tools/system_info",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_http_middleware_public_and_authentication_contracts(tmp_path: Path) -> None:
|
| 203 |
+
material = APIKeyService.generate_material("test")
|
| 204 |
+
settings = security_settings(
|
| 205 |
+
tmp_path,
|
| 206 |
+
auth_bootstrap_key_hash=material.key_hash,
|
| 207 |
+
auth_bootstrap_key_prefix=material.key_prefix,
|
| 208 |
+
auth_bootstrap_environment="test",
|
| 209 |
+
auth_default_requests_per_minute=1000,
|
| 210 |
+
)
|
| 211 |
+
application = create_app(settings)
|
| 212 |
+
authorization = {"Authorization": f"Bearer {material.api_key}"}
|
| 213 |
+
|
| 214 |
+
with TestClient(application) as client:
|
| 215 |
+
for path in ("/", "/health", "/version", "/docs", "/openapi.json", "/redoc"):
|
| 216 |
+
assert client.get(path).status_code == 200
|
| 217 |
+
|
| 218 |
+
missing = client.get("/v1/auth/context")
|
| 219 |
+
malformed = client.get(
|
| 220 |
+
"/v1/auth/context", headers={"Authorization": "Basic not-a-mediarouter-key"}
|
| 221 |
+
)
|
| 222 |
+
invalid = client.get(
|
| 223 |
+
"/v1/auth/context", headers={"Authorization": "Bearer mp_test_invalid"}
|
| 224 |
+
)
|
| 225 |
+
for response in (missing, malformed, invalid):
|
| 226 |
+
assert response.status_code == 401
|
| 227 |
+
assert response.json() == {
|
| 228 |
+
"error": "Unauthorized",
|
| 229 |
+
"message": "Invalid or expired API key.",
|
| 230 |
+
}
|
| 231 |
+
assert response.headers["www-authenticate"] == "Bearer"
|
| 232 |
+
|
| 233 |
+
mcp_missing = client.post("/mcp/", json={"jsonrpc": "2.0", "id": 1})
|
| 234 |
+
assert mcp_missing.status_code == 401
|
| 235 |
+
|
| 236 |
+
identity = client.get("/v1/auth/context", headers=authorization)
|
| 237 |
+
assert identity.status_code == 200
|
| 238 |
+
assert identity.json()["key_prefix"] == material.key_prefix
|
| 239 |
+
assert "admin" in identity.json()["scopes"]
|
| 240 |
+
|
| 241 |
+
created = client.post(
|
| 242 |
+
"/v1/api-keys",
|
| 243 |
+
headers=authorization,
|
| 244 |
+
json={
|
| 245 |
+
"name": "Template Reader",
|
| 246 |
+
"environment": "test",
|
| 247 |
+
"role": None,
|
| 248 |
+
"scopes": ["templates:read"],
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
assert created.status_code == 201
|
| 252 |
+
limited_authorization = {
|
| 253 |
+
"Authorization": f"Bearer {created.json()['api_key']}"
|
| 254 |
+
}
|
| 255 |
+
assert client.get("/v1/auth/context", headers=limited_authorization).status_code == 200
|
| 256 |
+
forbidden = client.get("/v1/health", headers=limited_authorization)
|
| 257 |
+
assert forbidden.status_code == 403
|
| 258 |
+
assert forbidden.json() == {
|
| 259 |
+
"error": "Forbidden",
|
| 260 |
+
"message": "Missing required scope.",
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
mcp_forbidden = client.post(
|
| 264 |
+
"/mcp/",
|
| 265 |
+
headers=limited_authorization,
|
| 266 |
+
json={
|
| 267 |
+
"jsonrpc": "2.0",
|
| 268 |
+
"id": 1,
|
| 269 |
+
"method": "tools/call",
|
| 270 |
+
"params": {"name": "health", "arguments": {}},
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
assert mcp_forbidden.status_code == 403
|
| 274 |
+
|
| 275 |
+
audit_logs = client.get("/v1/audit-logs", headers=authorization)
|
| 276 |
+
assert audit_logs.status_code == 200
|
| 277 |
+
entries = audit_logs.json()
|
| 278 |
+
assert any(
|
| 279 |
+
entry["endpoint"] == "/v1/auth/context"
|
| 280 |
+
and entry["api_key_id"] == identity.json()["id"]
|
| 281 |
+
and entry["response_code"] == 200
|
| 282 |
+
for entry in entries
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_http_rate_limit_returns_retry_after(tmp_path: Path) -> None:
|
| 287 |
+
material = APIKeyService.generate_material("test")
|
| 288 |
+
application = create_app(
|
| 289 |
+
security_settings(
|
| 290 |
+
tmp_path,
|
| 291 |
+
auth_bootstrap_key_hash=material.key_hash,
|
| 292 |
+
auth_bootstrap_key_prefix=material.key_prefix,
|
| 293 |
+
auth_bootstrap_environment="test",
|
| 294 |
+
auth_default_requests_per_minute=1,
|
| 295 |
+
)
|
| 296 |
+
)
|
| 297 |
+
headers = {"Authorization": f"Bearer {material.api_key}"}
|
| 298 |
+
with TestClient(application) as client:
|
| 299 |
+
assert client.get("/v1/auth/context", headers=headers).status_code == 200
|
| 300 |
+
limited = client.get("/v1/auth/context", headers=headers)
|
| 301 |
+
|
| 302 |
+
assert limited.status_code == 429
|
| 303 |
+
assert limited.json() == {
|
| 304 |
+
"error": "Rate limit exceeded",
|
| 305 |
+
"message": "Retry later.",
|
| 306 |
+
}
|
| 307 |
+
assert int(limited.headers["retry-after"]) >= 1
|
tests/test_brand_kits.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock
|
| 3 |
+
from app.brand.services.brand_service import BrandKitService
|
| 4 |
+
from app.brand.services.validation_service import BrandKitValidationService
|
| 5 |
+
from app.brand.models.brand import BrandKitVersion
|
| 6 |
+
|
| 7 |
+
@pytest.fixture
|
| 8 |
+
def validation_service():
|
| 9 |
+
return BrandKitValidationService()
|
| 10 |
+
|
| 11 |
+
def test_brand_kit_validation_missing_logo(validation_service):
|
| 12 |
+
version = BrandKitVersion(version_number=1, created_by="user1")
|
| 13 |
+
result = validation_service.validate(version)
|
| 14 |
+
assert not result['valid']
|
| 15 |
+
assert any(issue['field'] == 'logo_asset_id' for issue in result['issues'])
|
| 16 |
+
|
| 17 |
+
def test_brand_kit_validation_valid(validation_service):
|
| 18 |
+
version = BrandKitVersion(version_number=1, created_by="user1", logo_asset_id="asset123")
|
| 19 |
+
result = validation_service.validate(version)
|
| 20 |
+
assert result['valid']
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_brand_kit_service_create():
|
| 24 |
+
mock_repo = AsyncMock()
|
| 25 |
+
mock_assets = AsyncMock()
|
| 26 |
+
mock_audit = AsyncMock()
|
| 27 |
+
|
| 28 |
+
service = BrandKitService(mock_repo, mock_assets, mock_audit)
|
| 29 |
+
|
| 30 |
+
workspace_id = "ws1"
|
| 31 |
+
name = "Test Kit"
|
| 32 |
+
data = {"logo_asset_id": "asset123"}
|
| 33 |
+
user_id = "user1"
|
| 34 |
+
|
| 35 |
+
mock_assets.get_asset.return_value = {"id": "asset123"}
|
| 36 |
+
mock_repo.create.return_value = (MagicMock(id="kit1"), MagicMock(id="ver1"))
|
| 37 |
+
|
| 38 |
+
await service.create_brand_kit(workspace_id, name, data, user_id=user_id)
|
| 39 |
+
|
| 40 |
+
mock_assets.get_asset.assert_called_once()
|
| 41 |
+
mock_repo.create.assert_called_once()
|
| 42 |
+
mock_audit.log_event.assert_called_once()
|
tests/test_cleanup_worker.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
|
| 8 |
+
from app.services.cleanup import CleanupService
|
| 9 |
+
from app.workers.cleanup_worker import CleanupWorker
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def test_cleanup_removes_expired_workspace(settings) -> None:
|
| 13 |
+
service = CleanupService(settings)
|
| 14 |
+
request_id = str(uuid4())
|
| 15 |
+
workspace = await service.create_workspace(request_id)
|
| 16 |
+
await service.complete(request_id)
|
| 17 |
+
old = time.time() - 120
|
| 18 |
+
os.utime(workspace.root, (old, old))
|
| 19 |
+
removed = await service.cleanup_expired()
|
| 20 |
+
assert removed == 1
|
| 21 |
+
assert not workspace.root.exists()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def test_cleanup_keeps_active_workspace(settings) -> None:
|
| 25 |
+
service = CleanupService(settings)
|
| 26 |
+
workspace = await service.create_workspace(str(uuid4()))
|
| 27 |
+
old = time.time() - 120
|
| 28 |
+
os.utime(workspace.root, (old, old))
|
| 29 |
+
assert await service.cleanup_expired() == 0
|
| 30 |
+
assert workspace.root.exists()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def test_cleanup_worker_runs_and_stops() -> None:
|
| 34 |
+
class FakeCleanup:
|
| 35 |
+
def __init__(self) -> None:
|
| 36 |
+
self.called = asyncio.Event()
|
| 37 |
+
|
| 38 |
+
async def cleanup_expired(self) -> int:
|
| 39 |
+
self.called.set()
|
| 40 |
+
return 0
|
| 41 |
+
|
| 42 |
+
service = FakeCleanup()
|
| 43 |
+
worker = CleanupWorker(service, interval_seconds=60) # type: ignore[arg-type]
|
| 44 |
+
await worker.start()
|
| 45 |
+
await asyncio.wait_for(service.called.wait(), timeout=1)
|
| 46 |
+
await worker.stop()
|
tests/test_collaboration.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from app.projects.repositories.collaboration_repository import CollaborationRepository
|
| 3 |
+
from app.projects.services.collaboration_service import CollaborationService
|
| 4 |
+
from app.projects.errors import CollaborationUnauthorizedError
|
| 5 |
+
|
| 6 |
+
@pytest.mark.asyncio
|
| 7 |
+
async def test_collaboration_logic_admin_removal_constraint(db_session):
|
| 8 |
+
# Setup test workspace and admin users
|
| 9 |
+
repo = CollaborationRepository(db_session)
|
| 10 |
+
service = CollaborationService(repo)
|
| 11 |
+
|
| 12 |
+
workspace_id = "test_workspace"
|
| 13 |
+
admin_user_id = "admin_user"
|
| 14 |
+
target_user_id = "member_user"
|
| 15 |
+
|
| 16 |
+
# 1. Mock memberships: 2 admins
|
| 17 |
+
# Use actual DB insert here if needed for true integration test
|
| 18 |
+
# ... setup DB state ...
|
| 19 |
+
|
| 20 |
+
# 2. Test prevention of last admin removal
|
| 21 |
+
with pytest.raises(CollaborationUnauthorizedError, match="Cannot remove the last administrator."):
|
| 22 |
+
await service.remove_member(workspace_id, admin_user_id, target_user_id)
|
| 23 |
+
|
| 24 |
+
@pytest.mark.asyncio
|
| 25 |
+
async def test_collaboration_logic_self_elevation_prevention(db_session):
|
| 26 |
+
repo = CollaborationRepository(db_session)
|
| 27 |
+
service = CollaborationService(repo)
|
| 28 |
+
|
| 29 |
+
workspace_id = "test_workspace"
|
| 30 |
+
actor_user_id = "user_1"
|
| 31 |
+
|
| 32 |
+
with pytest.raises(CollaborationUnauthorizedError, match="Cannot elevate your own privileges."):
|
| 33 |
+
await service.update_member_role(workspace_id, actor_user_id, actor_user_id, "admin")
|
tests/test_collaboration_full.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from app.projects.repositories.collaboration_repository import CollaborationRepository
|
| 3 |
+
from app.projects.services.collaboration_service import CollaborationService
|
| 4 |
+
|
| 5 |
+
@pytest.mark.asyncio
|
| 6 |
+
async def test_collaboration_team_lifecycle(db_session):
|
| 7 |
+
repo = CollaborationRepository(db_session)
|
| 8 |
+
service = CollaborationService(repo)
|
| 9 |
+
|
| 10 |
+
workspace_id = "test_workspace"
|
| 11 |
+
|
| 12 |
+
# 1. Create Team
|
| 13 |
+
team = await service.create_team(workspace_id, "Engineering")
|
| 14 |
+
assert team.name == "Engineering"
|
| 15 |
+
|
| 16 |
+
# 2. List Teams
|
| 17 |
+
teams = await service.list_teams(workspace_id)
|
| 18 |
+
assert len(teams) >= 1
|
| 19 |
+
|
| 20 |
+
# 3. Update Team
|
| 21 |
+
updated = await service.update_team(workspace_id, team.id, "Product")
|
| 22 |
+
assert updated.name == "Product"
|
| 23 |
+
|
| 24 |
+
# 4. Archive
|
| 25 |
+
await service.archive_team(workspace_id, team.id)
|
| 26 |
+
teams = await service.list_teams(workspace_id)
|
| 27 |
+
assert not any(t.id == team.id for t in teams)
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_collaboration_invitation_lifecycle(db_session):
|
| 31 |
+
repo = CollaborationRepository(db_session)
|
| 32 |
+
service = CollaborationService(repo)
|
| 33 |
+
|
| 34 |
+
workspace_id = "test_workspace"
|
| 35 |
+
email = "test@example.com"
|
| 36 |
+
|
| 37 |
+
# Test invitation
|
| 38 |
+
invitation = await service.invite_member(workspace_id, email, "member")
|
| 39 |
+
assert invitation.email == email
|
| 40 |
+
assert hasattr(invitation, "token") # Check if token is returned
|
tests/test_content_studio_phase2.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
|
| 10 |
+
from app.container import build_container
|
| 11 |
+
from app.core.config import Settings
|
| 12 |
+
from app.projects.editor_schemas import EditorDocument, EditorSaveRequest, ProjectRenderCreate
|
| 13 |
+
from app.projects.errors import (
|
| 14 |
+
ProjectEditorConflictError,
|
| 15 |
+
ProjectNotFoundError,
|
| 16 |
+
ProjectRenderLimitError,
|
| 17 |
+
)
|
| 18 |
+
from app.projects.schemas import ProjectCreate
|
| 19 |
+
from app.projects.services.render_compiler import compile_render
|
| 20 |
+
from app.security.models import AuditEvent
|
| 21 |
+
from app.security.schemas import APIKeyCreate
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def settings(tmp_path: Path) -> Settings:
|
| 25 |
+
return Settings(
|
| 26 |
+
_env_file=None,
|
| 27 |
+
auth_enabled=True,
|
| 28 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 29 |
+
social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}",
|
| 30 |
+
social_auto_migrate=True,
|
| 31 |
+
social_worker_enabled=False,
|
| 32 |
+
generation_worker_enabled=False,
|
| 33 |
+
render_worker_enabled=False,
|
| 34 |
+
social_oauth_encryption_key="test-only-encryption-material",
|
| 35 |
+
temp_dir=tmp_path / "temp",
|
| 36 |
+
output_dir=tmp_path / "outputs",
|
| 37 |
+
whisper_model="tiny",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def document(project_id: str, asset_id: str) -> EditorDocument:
|
| 42 |
+
return EditorDocument.model_validate(
|
| 43 |
+
{
|
| 44 |
+
"schemaVersion": 1,
|
| 45 |
+
"projectId": project_id,
|
| 46 |
+
"timeline": {
|
| 47 |
+
"timeUnit": "milliseconds",
|
| 48 |
+
"tracks": [
|
| 49 |
+
{
|
| 50 |
+
"id": "video-1",
|
| 51 |
+
"type": "video",
|
| 52 |
+
"name": "Video 1",
|
| 53 |
+
"order": 0,
|
| 54 |
+
"muted": False,
|
| 55 |
+
"locked": False,
|
| 56 |
+
"visible": True,
|
| 57 |
+
"clips": [
|
| 58 |
+
{
|
| 59 |
+
"id": "clip-1",
|
| 60 |
+
"kind": "media",
|
| 61 |
+
"trackId": "video-1",
|
| 62 |
+
"assetId": asset_id,
|
| 63 |
+
"label": "source.mp4",
|
| 64 |
+
"startMs": 0,
|
| 65 |
+
"durationMs": 1000,
|
| 66 |
+
"sourceStartMs": 0,
|
| 67 |
+
"sourceDurationMs": 1000,
|
| 68 |
+
"mediaType": "video",
|
| 69 |
+
"transform": {
|
| 70 |
+
"x": 0,
|
| 71 |
+
"y": 0,
|
| 72 |
+
"scaleX": 1,
|
| 73 |
+
"scaleY": 1,
|
| 74 |
+
"rotation": 0,
|
| 75 |
+
},
|
| 76 |
+
"volume": 1,
|
| 77 |
+
"opacity": 1,
|
| 78 |
+
"visible": True,
|
| 79 |
+
"metadata": {},
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
}
|
| 83 |
+
],
|
| 84 |
+
"transitions": [],
|
| 85 |
+
"markers": [],
|
| 86 |
+
},
|
| 87 |
+
"renderSettings": {"format": "mp4", "width": 1280, "height": 720, "frameRate": 30},
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def actor(container, name: str):
|
| 93 |
+
key, secret = await container.api_keys.create(
|
| 94 |
+
APIKeyCreate(
|
| 95 |
+
name=name,
|
| 96 |
+
environment="test",
|
| 97 |
+
role=None,
|
| 98 |
+
scopes=[
|
| 99 |
+
"projects:read",
|
| 100 |
+
"projects:create",
|
| 101 |
+
"projects:update",
|
| 102 |
+
"jobs:create",
|
| 103 |
+
"jobs:cancel",
|
| 104 |
+
],
|
| 105 |
+
),
|
| 106 |
+
created_by="tests",
|
| 107 |
+
)
|
| 108 |
+
return key, await container.api_keys.authenticate(secret)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@pytest.mark.asyncio
|
| 112 |
+
async def test_editor_revision_isolation_render_idempotency_and_cancellation(
|
| 113 |
+
tmp_path: Path,
|
| 114 |
+
) -> None:
|
| 115 |
+
container = build_container(settings(tmp_path))
|
| 116 |
+
await container.security_database.initialize()
|
| 117 |
+
try:
|
| 118 |
+
key_a, actor_a = await actor(container, "A")
|
| 119 |
+
_, actor_b = await actor(container, "B")
|
| 120 |
+
project = await container.projects.create(
|
| 121 |
+
workspace_id=actor_a.workspace_id,
|
| 122 |
+
user_id=actor_a.user_id,
|
| 123 |
+
api_key_id=key_a.id,
|
| 124 |
+
request_id=str(uuid4()),
|
| 125 |
+
payload=ProjectCreate(name="Studio"),
|
| 126 |
+
)
|
| 127 |
+
request_id = str(uuid4())
|
| 128 |
+
output = container.settings.output_dir / request_id
|
| 129 |
+
output.mkdir(parents=True)
|
| 130 |
+
source = output / "source.mp4"
|
| 131 |
+
source.write_bytes(b"test media")
|
| 132 |
+
asset = await container.assets.register_output(
|
| 133 |
+
workspace_id=actor_a.workspace_id,
|
| 134 |
+
user_id=actor_a.user_id,
|
| 135 |
+
request_id=request_id,
|
| 136 |
+
path=source,
|
| 137 |
+
mime_type="video/mp4",
|
| 138 |
+
project_id=project.id,
|
| 139 |
+
)
|
| 140 |
+
editor_document = document(project.id, asset.id)
|
| 141 |
+
saved = await container.editor.save(
|
| 142 |
+
workspace_id=actor_a.workspace_id,
|
| 143 |
+
user_id=actor_a.user_id,
|
| 144 |
+
api_key_id=key_a.id,
|
| 145 |
+
request_id=str(uuid4()),
|
| 146 |
+
project_id=project.id,
|
| 147 |
+
payload=EditorSaveRequest(expected_revision=0, schema_version=1, state=editor_document),
|
| 148 |
+
)
|
| 149 |
+
assert saved.revision == 1
|
| 150 |
+
with pytest.raises(ProjectEditorConflictError):
|
| 151 |
+
await container.editor.save(
|
| 152 |
+
workspace_id=actor_a.workspace_id,
|
| 153 |
+
user_id=actor_a.user_id,
|
| 154 |
+
api_key_id=key_a.id,
|
| 155 |
+
request_id=str(uuid4()),
|
| 156 |
+
project_id=project.id,
|
| 157 |
+
payload=EditorSaveRequest(
|
| 158 |
+
expected_revision=0, schema_version=1, state=editor_document
|
| 159 |
+
),
|
| 160 |
+
)
|
| 161 |
+
with pytest.raises(ProjectNotFoundError):
|
| 162 |
+
await container.editor.get(
|
| 163 |
+
workspace_id=actor_b.workspace_id,
|
| 164 |
+
user_id=actor_b.user_id,
|
| 165 |
+
project_id=project.id,
|
| 166 |
+
)
|
| 167 |
+
render_payload = ProjectRenderCreate(
|
| 168 |
+
editor_revision=1, output_format="mp4", width=1280, height=720
|
| 169 |
+
)
|
| 170 |
+
first = await container.renders.create(
|
| 171 |
+
workspace_id=actor_a.workspace_id,
|
| 172 |
+
user_id=actor_a.user_id,
|
| 173 |
+
api_key_id=key_a.id,
|
| 174 |
+
request_id=str(uuid4()),
|
| 175 |
+
project_id=project.id,
|
| 176 |
+
payload=render_payload,
|
| 177 |
+
idempotency_key="render-1",
|
| 178 |
+
)
|
| 179 |
+
second = await container.renders.create(
|
| 180 |
+
workspace_id=actor_a.workspace_id,
|
| 181 |
+
user_id=actor_a.user_id,
|
| 182 |
+
api_key_id=key_a.id,
|
| 183 |
+
request_id=str(uuid4()),
|
| 184 |
+
project_id=project.id,
|
| 185 |
+
payload=render_payload,
|
| 186 |
+
idempotency_key="render-1",
|
| 187 |
+
)
|
| 188 |
+
assert first.id == second.id and first.status == "queued"
|
| 189 |
+
with pytest.raises(ProjectRenderLimitError):
|
| 190 |
+
await container.renders.create(
|
| 191 |
+
workspace_id=actor_a.workspace_id,
|
| 192 |
+
user_id=actor_a.user_id,
|
| 193 |
+
api_key_id=key_a.id,
|
| 194 |
+
request_id=str(uuid4()),
|
| 195 |
+
project_id=project.id,
|
| 196 |
+
payload=render_payload,
|
| 197 |
+
idempotency_key="render-2",
|
| 198 |
+
)
|
| 199 |
+
cancelled = await container.renders.cancel(
|
| 200 |
+
workspace_id=actor_a.workspace_id,
|
| 201 |
+
user_id=actor_a.user_id,
|
| 202 |
+
api_key_id=key_a.id,
|
| 203 |
+
request_id=str(uuid4()),
|
| 204 |
+
project_id=project.id,
|
| 205 |
+
render_id=first.id,
|
| 206 |
+
)
|
| 207 |
+
assert cancelled.status == "cancelled"
|
| 208 |
+
repeated = await container.renders.cancel(
|
| 209 |
+
workspace_id=actor_a.workspace_id,
|
| 210 |
+
user_id=actor_a.user_id,
|
| 211 |
+
api_key_id=key_a.id,
|
| 212 |
+
request_id=str(uuid4()),
|
| 213 |
+
project_id=project.id,
|
| 214 |
+
render_id=first.id,
|
| 215 |
+
)
|
| 216 |
+
assert repeated.status == "cancelled"
|
| 217 |
+
async with container.security_database.tenant_session(
|
| 218 |
+
workspace_id=actor_a.workspace_id,
|
| 219 |
+
user_id=actor_a.user_id,
|
| 220 |
+
) as session:
|
| 221 |
+
cancellation_events = list(
|
| 222 |
+
(
|
| 223 |
+
await session.scalars(
|
| 224 |
+
select(AuditEvent).where(
|
| 225 |
+
AuditEvent.entity_id == first.id,
|
| 226 |
+
AuditEvent.event_type == "project.render_cancelled",
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
).all()
|
| 230 |
+
)
|
| 231 |
+
assert len(cancellation_events) == 1
|
| 232 |
+
finally:
|
| 233 |
+
await container.security_database.close()
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_render_compiler_is_deterministic_and_uses_server_paths(tmp_path: Path) -> None:
|
| 237 |
+
source = tmp_path / "source.mp4"
|
| 238 |
+
source.write_bytes(b"media")
|
| 239 |
+
state = document(str(uuid4()), str(uuid4()))
|
| 240 |
+
asset_id = next(iter(state.asset_ids()))
|
| 241 |
+
first = compile_render(
|
| 242 |
+
state,
|
| 243 |
+
asset_paths={asset_id: (source, "video/mp4")},
|
| 244 |
+
width=1280,
|
| 245 |
+
height=720,
|
| 246 |
+
frame_rate=30,
|
| 247 |
+
output_format="mp4",
|
| 248 |
+
quality="standard",
|
| 249 |
+
preset="balanced",
|
| 250 |
+
)
|
| 251 |
+
second = compile_render(
|
| 252 |
+
state,
|
| 253 |
+
asset_paths={asset_id: (source, "video/mp4")},
|
| 254 |
+
width=1280,
|
| 255 |
+
height=720,
|
| 256 |
+
frame_rate=30,
|
| 257 |
+
output_format="mp4",
|
| 258 |
+
quality="standard",
|
| 259 |
+
preset="balanced",
|
| 260 |
+
)
|
| 261 |
+
assert first == second
|
| 262 |
+
assert source in first.args
|
| 263 |
+
assert first.duration_ms == 1000
|
| 264 |
+
assert "yuv420p" in first.args
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_render_compiler_ignores_hidden_timeline_tail(tmp_path: Path) -> None:
|
| 268 |
+
source = tmp_path / "source.mp4"
|
| 269 |
+
source.write_bytes(b"media")
|
| 270 |
+
state = document(str(uuid4()), str(uuid4()))
|
| 271 |
+
payload = state.model_dump(by_alias=True)
|
| 272 |
+
hidden_track = deepcopy(payload["timeline"]["tracks"][0])
|
| 273 |
+
hidden_track.update({"id": "video-hidden", "name": "Hidden", "order": 1, "visible": False})
|
| 274 |
+
hidden_track["clips"][0].update(
|
| 275 |
+
{"id": "clip-hidden", "trackId": "video-hidden", "startMs": 120_000}
|
| 276 |
+
)
|
| 277 |
+
payload["timeline"]["tracks"].append(hidden_track)
|
| 278 |
+
state_with_hidden_tail = EditorDocument.model_validate(payload)
|
| 279 |
+
asset_id = next(iter(state_with_hidden_tail.asset_ids()))
|
| 280 |
+
|
| 281 |
+
plan = compile_render(
|
| 282 |
+
state_with_hidden_tail,
|
| 283 |
+
asset_paths={asset_id: (source, "video/mp4")},
|
| 284 |
+
width=1280,
|
| 285 |
+
height=720,
|
| 286 |
+
frame_rate=30,
|
| 287 |
+
output_format="webm",
|
| 288 |
+
quality="high",
|
| 289 |
+
preset="quality",
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
assert plan.duration_ms == 1000
|
| 293 |
+
assert plan.args.count(source) == 1
|
| 294 |
+
assert "18" in plan.args
|
| 295 |
+
assert "0" in plan.args
|
tests/test_cors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from app.core.config import Settings
|
| 8 |
+
from main import create_app
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def test_configured_frontend_origin_receives_cors_headers(tmp_path: Path) -> None:
|
| 12 |
+
settings = Settings(
|
| 13 |
+
_env_file=None,
|
| 14 |
+
auth_enabled=False,
|
| 15 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 16 |
+
temp_dir=tmp_path / "temp",
|
| 17 |
+
output_dir=tmp_path / "outputs",
|
| 18 |
+
cors_allowed_origins="https://workspace.example.vercel.app",
|
| 19 |
+
)
|
| 20 |
+
app = create_app(settings)
|
| 21 |
+
transport = httpx.ASGITransport(app=app)
|
| 22 |
+
|
| 23 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
| 24 |
+
response = await client.options(
|
| 25 |
+
"/v1/projects",
|
| 26 |
+
headers={
|
| 27 |
+
"Origin": "https://workspace.example.vercel.app",
|
| 28 |
+
"Access-Control-Request-Method": "GET",
|
| 29 |
+
"Access-Control-Request-Headers": "Authorization",
|
| 30 |
+
},
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
assert response.status_code == 200
|
| 34 |
+
assert response.headers["access-control-allow-origin"] == (
|
| 35 |
+
"https://workspace.example.vercel.app"
|
| 36 |
+
)
|
| 37 |
+
assert "authorization" in response.headers["access-control-allow-headers"].lower()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def test_unconfigured_origin_receives_no_cors_authorization(tmp_path: Path) -> None:
|
| 41 |
+
settings = Settings(
|
| 42 |
+
_env_file=None,
|
| 43 |
+
auth_enabled=False,
|
| 44 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 45 |
+
temp_dir=tmp_path / "temp",
|
| 46 |
+
output_dir=tmp_path / "outputs",
|
| 47 |
+
cors_allowed_origins="https://workspace.example.vercel.app",
|
| 48 |
+
)
|
| 49 |
+
app = create_app(settings)
|
| 50 |
+
transport = httpx.ASGITransport(app=app)
|
| 51 |
+
|
| 52 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
| 53 |
+
response = await client.options(
|
| 54 |
+
"/v1/projects",
|
| 55 |
+
headers={
|
| 56 |
+
"Origin": "https://attacker.example",
|
| 57 |
+
"Access-Control-Request-Method": "GET",
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
assert response.status_code == 400
|
| 62 |
+
assert "access-control-allow-origin" not in response.headers
|
tests/test_database_migration_contracts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
MIGRATION_FILES = sorted(Path("app/projects/migrations").glob("*.sql"))
|
| 9 |
+
ALLOWED_TABLES = {
|
| 10 |
+
"teams",
|
| 11 |
+
"team_members",
|
| 12 |
+
"invitations",
|
| 13 |
+
"project_collaborators",
|
| 14 |
+
"approval_workflows",
|
| 15 |
+
"approval_requests",
|
| 16 |
+
"review_comments",
|
| 17 |
+
"collaboration_activity",
|
| 18 |
+
"notification_preferences",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MigrationContractTests(unittest.TestCase):
|
| 23 |
+
def test_latest_migration_adds_approval_request_workspace_integrity(self) -> None:
|
| 24 |
+
latest = MIGRATION_FILES[-1].read_text()
|
| 25 |
+
|
| 26 |
+
self.assertIn("alter table approval_requests", latest)
|
| 27 |
+
self.assertIn("add column if not exists workspace_id text", latest)
|
| 28 |
+
self.assertIn("alter column workspace_id set not null", latest)
|
| 29 |
+
self.assertIn("create index if not exists ix_approval_requests_workspace", latest)
|
| 30 |
+
|
| 31 |
+
def test_migrations_do_not_recreate_shared_tables(self) -> None:
|
| 32 |
+
counts: dict[str, int] = {}
|
| 33 |
+
for path in MIGRATION_FILES:
|
| 34 |
+
body = path.read_text()
|
| 35 |
+
for table in ALLOWED_TABLES:
|
| 36 |
+
counts[table] = counts.get(table, 0) + body.count(f"create table if not exists {table}")
|
| 37 |
+
|
| 38 |
+
for table, count in counts.items():
|
| 39 |
+
self.assertEqual(
|
| 40 |
+
count,
|
| 41 |
+
1,
|
| 42 |
+
f"Duplicate table creation detected for {table}: {count} migrations recreate it",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
unittest.main()
|
tests/test_database_url.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.database_url import normalize_async_database_url
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_bare_postgres_urls_use_the_installed_async_driver() -> None:
|
| 5 |
+
assert (
|
| 6 |
+
normalize_async_database_url("postgresql://user:secret@db.example/app")
|
| 7 |
+
== "postgresql+asyncpg://user:secret@db.example/app"
|
| 8 |
+
)
|
| 9 |
+
assert (
|
| 10 |
+
normalize_async_database_url("postgres://user:secret@db.example/app")
|
| 11 |
+
== "postgresql+asyncpg://user:secret@db.example/app"
|
| 12 |
+
)
|
| 13 |
+
assert (
|
| 14 |
+
normalize_async_database_url("postgresql+asyncpg://user:secret@db.example/app")
|
| 15 |
+
== "postgresql+asyncpg://user:secret@db.example/app"
|
| 16 |
+
)
|
tests/test_downloader.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import AsyncMock
|
| 2 |
+
|
| 3 |
+
import respx
|
| 4 |
+
from httpx import Response
|
| 5 |
+
|
| 6 |
+
from app.services.downloader import Downloader
|
| 7 |
+
from app.services.validator import MediaValidator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@respx.mock
|
| 11 |
+
async def test_url_download_streams_to_disk(settings, tmp_path) -> None:
|
| 12 |
+
url = "https://media.example.test/sample.mp3"
|
| 13 |
+
respx.get(url).mock(
|
| 14 |
+
return_value=Response(200, content=b"ID3data", headers={"content-type": "audio/mpeg"})
|
| 15 |
+
)
|
| 16 |
+
downloader = Downloader(settings, MediaValidator(settings))
|
| 17 |
+
downloader.validate_url = AsyncMock() # type: ignore[method-assign]
|
| 18 |
+
path, mime_type = await downloader.download(url, tmp_path)
|
| 19 |
+
assert path.read_bytes() == b"ID3data"
|
| 20 |
+
assert mime_type == "audio/mpeg"
|
tests/test_error_handling.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
|
| 4 |
+
from app.core.exceptions import NotFoundError
|
| 5 |
+
from main import create_app
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_errors_use_safe_standard_envelope(settings) -> None:
|
| 9 |
+
with TestClient(create_app(settings), raise_server_exceptions=False) as client:
|
| 10 |
+
response = client.post(
|
| 11 |
+
"/v1/probe",
|
| 12 |
+
json={"base64": "not-valid-base64!", "filename": "sample.mp3"},
|
| 13 |
+
)
|
| 14 |
+
assert response.status_code == 422
|
| 15 |
+
payload = response.json()
|
| 16 |
+
assert payload["success"] is False
|
| 17 |
+
assert payload["request_id"]
|
| 18 |
+
assert payload["error"]["code"] == "INVALID_INPUT"
|
| 19 |
+
assert "traceback" not in response.text.lower()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_download_path_traversal_is_rejected(settings) -> None:
|
| 23 |
+
app = create_app(settings)
|
| 24 |
+
with pytest.raises(NotFoundError):
|
| 25 |
+
app.state.container.cleanup.resolve_download(
|
| 26 |
+
"00000000-0000-0000-0000-000000000000", "../secret.mp4"
|
| 27 |
+
)
|
tests/test_ffmpeg_operations.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import shutil
|
| 4 |
+
import subprocess
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.models.media import InputMedia, MediaSource
|
| 9 |
+
from app.operations.convert import convert_audio
|
| 10 |
+
from app.services.ffmpeg_service import FFmpegService
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is not installed")
|
| 14 |
+
async def test_ffmpeg_audio_conversion(settings, tmp_path) -> None:
|
| 15 |
+
source = tmp_path / "tone.wav"
|
| 16 |
+
subprocess.run(
|
| 17 |
+
[
|
| 18 |
+
"ffmpeg",
|
| 19 |
+
"-hide_banner",
|
| 20 |
+
"-loglevel",
|
| 21 |
+
"error",
|
| 22 |
+
"-f",
|
| 23 |
+
"lavfi",
|
| 24 |
+
"-i",
|
| 25 |
+
"sine=frequency=440:duration=0.2",
|
| 26 |
+
"-y",
|
| 27 |
+
str(source),
|
| 28 |
+
],
|
| 29 |
+
check=True,
|
| 30 |
+
)
|
| 31 |
+
media = InputMedia(
|
| 32 |
+
source=MediaSource.MULTIPART,
|
| 33 |
+
filename=source.name,
|
| 34 |
+
mime_type="audio/wav",
|
| 35 |
+
temp_path=source,
|
| 36 |
+
size=source.stat().st_size,
|
| 37 |
+
)
|
| 38 |
+
result = await convert_audio(
|
| 39 |
+
FFmpegService(settings), [media], {"format": "mp3"}, tmp_path / "out"
|
| 40 |
+
)
|
| 41 |
+
assert result.path is not None
|
| 42 |
+
assert result.path.is_file()
|
| 43 |
+
assert result.path.stat().st_size > 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_ffmpeg_codec_listing_is_structured(settings, monkeypatch) -> None:
|
| 47 |
+
service = FFmpegService(settings)
|
| 48 |
+
|
| 49 |
+
async def fake_capture(*args, **kwargs) -> str:
|
| 50 |
+
return """Codecs:
|
| 51 |
+
D..... = Decoding supported
|
| 52 |
+
.E.... = Encoding supported
|
| 53 |
+
-------
|
| 54 |
+
DEV.LS h264 H.264 / AVC / MPEG-4 AVC
|
| 55 |
+
DEA.L. aac AAC (Advanced Audio Coding)
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
monkeypatch.setattr(service, "_capture", fake_capture)
|
| 59 |
+
|
| 60 |
+
codecs = await service.codecs()
|
| 61 |
+
|
| 62 |
+
assert [codec["name"] for codec in codecs] == ["h264", "aac"]
|
| 63 |
+
assert codecs[0]["decode"] is True
|
| 64 |
+
assert codecs[0]["encode"] is True
|
| 65 |
+
assert codecs[0]["type"] == "video"
|
tests/test_ffprobe.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import shutil
|
| 4 |
+
import wave
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.services.ffprobe_service import FFprobeService
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.mark.skipif(shutil.which("ffprobe") is None, reason="ffprobe is not installed")
|
| 12 |
+
async def test_ffprobe_returns_audio_metadata(settings, tmp_path) -> None:
|
| 13 |
+
audio = tmp_path / "tone.wav"
|
| 14 |
+
with wave.open(str(audio), "wb") as stream:
|
| 15 |
+
stream.setnchannels(1)
|
| 16 |
+
stream.setsampwidth(2)
|
| 17 |
+
stream.setframerate(8000)
|
| 18 |
+
stream.writeframes(b"\x00\x00" * 8000)
|
| 19 |
+
metadata = await FFprobeService(settings).probe(audio)
|
| 20 |
+
assert metadata["duration"] == pytest.approx(1.0, abs=0.01)
|
| 21 |
+
assert metadata["audio_streams"][0]["codec"] == "pcm_s16le"
|
tests/test_generation_flux.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mocked protocol tests for the audited FLUX.2 Klein worker integration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from app.core.config import Settings
|
| 13 |
+
from app.generation.domain.enums import (
|
| 14 |
+
GenerationModality,
|
| 15 |
+
WorkerCancellationStatus,
|
| 16 |
+
WorkerErrorCategory,
|
| 17 |
+
WorkerJobStatus,
|
| 18 |
+
)
|
| 19 |
+
from app.generation.domain.errors import (
|
| 20 |
+
GenerationCapabilityUnsupportedError,
|
| 21 |
+
GenerationValidationError,
|
| 22 |
+
GenerationWorkerError,
|
| 23 |
+
)
|
| 24 |
+
from app.generation.domain.retry import GenerationRetryPolicy
|
| 25 |
+
from app.generation.model_registry import GenerationModelRegistration, GenerationModelRegistry
|
| 26 |
+
from app.generation.providers.flux import (
|
| 27 |
+
FLUX_BASE_MODEL_ID,
|
| 28 |
+
FLUX_DISTILLED_MODEL_ID,
|
| 29 |
+
FLUX_MODEL_CAPABILITY,
|
| 30 |
+
FLUX_MODEL_ID,
|
| 31 |
+
FLUX_PROVIDER_ID,
|
| 32 |
+
FluxProviderAdapter,
|
| 33 |
+
)
|
| 34 |
+
from app.generation.providers.worker_client import RemoteWorkerClient
|
| 35 |
+
from app.generation.schemas.requests import GenerationRequestCreate
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def _no_sleep() -> None:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _client(
|
| 43 |
+
handler: Callable[[httpx.Request], httpx.Response], *, retries: int = 2
|
| 44 |
+
) -> RemoteWorkerClient:
|
| 45 |
+
return RemoteWorkerClient(
|
| 46 |
+
base_url="https://flux-worker.example",
|
| 47 |
+
bearer_token="x" * 32,
|
| 48 |
+
connect_timeout_seconds=1,
|
| 49 |
+
request_timeout_seconds=1,
|
| 50 |
+
read_timeout_seconds=1,
|
| 51 |
+
retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
|
| 52 |
+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
| 53 |
+
sleep=lambda _: _no_sleep(),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _info() -> dict[str, object]:
|
| 58 |
+
return {
|
| 59 |
+
"id": FLUX_MODEL_ID,
|
| 60 |
+
"name": "FLUX.2 Klein 4B",
|
| 61 |
+
"type": "image",
|
| 62 |
+
"license": "Apache-2.0",
|
| 63 |
+
"status": "ready",
|
| 64 |
+
"models": {"distilled": FLUX_DISTILLED_MODEL_ID, "base": FLUX_BASE_MODEL_ID},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _payload(**overrides: object) -> GenerationRequestCreate:
|
| 69 |
+
value: dict[str, object] = {
|
| 70 |
+
"provider": FLUX_PROVIDER_ID,
|
| 71 |
+
"model_id": FLUX_MODEL_ID,
|
| 72 |
+
"modality": "image",
|
| 73 |
+
"prompt": "A cinematic coastal city at sunrise",
|
| 74 |
+
"flux": {
|
| 75 |
+
"mode_choice": "Distilled (4 steps)",
|
| 76 |
+
"seed": 42,
|
| 77 |
+
"randomize_seed": False,
|
| 78 |
+
"width": 1024,
|
| 79 |
+
"height": 1024,
|
| 80 |
+
"num_inference_steps": 4,
|
| 81 |
+
"guidance_scale": 1.0,
|
| 82 |
+
"prompt_upsampling": False,
|
| 83 |
+
},
|
| 84 |
+
}
|
| 85 |
+
value.update(overrides)
|
| 86 |
+
return GenerationRequestCreate.model_validate(value)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.asyncio
|
| 90 |
+
async def test_flux_exact_model_discovery_and_readiness() -> None:
|
| 91 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 92 |
+
if request.url.path == "/health":
|
| 93 |
+
return httpx.Response(200, json={"status": "ok"})
|
| 94 |
+
if request.url.path == "/ready":
|
| 95 |
+
return httpx.Response(
|
| 96 |
+
200,
|
| 97 |
+
json={
|
| 98 |
+
"status": "ready",
|
| 99 |
+
"model_loaded": True,
|
| 100 |
+
"model": FLUX_MODEL_ID,
|
| 101 |
+
"accepting_jobs": True,
|
| 102 |
+
},
|
| 103 |
+
)
|
| 104 |
+
return httpx.Response(200, json=_info())
|
| 105 |
+
|
| 106 |
+
adapter = FluxProviderAdapter(client=_client(handler))
|
| 107 |
+
registry = GenerationModelRegistry(
|
| 108 |
+
[
|
| 109 |
+
GenerationModelRegistration(
|
| 110 |
+
provider_id=FLUX_PROVIDER_ID,
|
| 111 |
+
model=FLUX_MODEL_CAPABILITY,
|
| 112 |
+
configuration_reference="flux-space",
|
| 113 |
+
)
|
| 114 |
+
]
|
| 115 |
+
)
|
| 116 |
+
assert (await adapter.health()).status.value == "healthy"
|
| 117 |
+
models = registry.verify_readiness(
|
| 118 |
+
provider_id=FLUX_PROVIDER_ID,
|
| 119 |
+
worker_info=await adapter.info(),
|
| 120 |
+
readiness=await adapter.ready(),
|
| 121 |
+
provider_configured=adapter.available,
|
| 122 |
+
)
|
| 123 |
+
assert models[0].model.id == FLUX_MODEL_ID
|
| 124 |
+
assert models[0].model.modality is GenerationModality.IMAGE
|
| 125 |
+
assert models[0].available
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@pytest.mark.asyncio
|
| 129 |
+
async def test_flux_identity_mismatch_and_not_ready_are_not_advertised() -> None:
|
| 130 |
+
wrong = {**_info(), "models": {"distilled": "untrusted/model", "base": FLUX_BASE_MODEL_ID}}
|
| 131 |
+
|
| 132 |
+
def identity_handler(_: httpx.Request) -> httpx.Response:
|
| 133 |
+
return httpx.Response(200, json=wrong)
|
| 134 |
+
|
| 135 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 136 |
+
await FluxProviderAdapter(client=_client(identity_handler)).info()
|
| 137 |
+
assert raised.value.category is WorkerErrorCategory.PROVIDER_ERROR
|
| 138 |
+
|
| 139 |
+
def not_ready_handler(request: httpx.Request) -> httpx.Response:
|
| 140 |
+
if request.url.path == "/ready":
|
| 141 |
+
return httpx.Response(
|
| 142 |
+
503,
|
| 143 |
+
json={
|
| 144 |
+
"status": "not_ready",
|
| 145 |
+
"model_loaded": False,
|
| 146 |
+
"model": FLUX_MODEL_ID,
|
| 147 |
+
"accepting_jobs": False,
|
| 148 |
+
},
|
| 149 |
+
)
|
| 150 |
+
return httpx.Response(200, json=_info())
|
| 151 |
+
|
| 152 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 153 |
+
await FluxProviderAdapter(client=_client(not_ready_handler)).ready()
|
| 154 |
+
assert raised.value.category is WorkerErrorCategory.WORKER_NOT_READY
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@pytest.mark.asyncio
|
| 158 |
+
async def test_flux_text_submission_uses_strict_form_and_has_no_automatic_retry() -> None:
|
| 159 |
+
requests: list[httpx.Request] = []
|
| 160 |
+
|
| 161 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 162 |
+
requests.append(request)
|
| 163 |
+
return httpx.Response(202, json={"job_id": "flux_" + "a" * 32, "status": "queued"})
|
| 164 |
+
|
| 165 |
+
job = await FluxProviderAdapter(client=_client(handler)).submit(
|
| 166 |
+
payload={"prompt": "A city at sunrise", "flux": {"width": 1024, "height": 1024}},
|
| 167 |
+
idempotency_key="generation-request-id",
|
| 168 |
+
)
|
| 169 |
+
assert job.status is WorkerJobStatus.QUEUED
|
| 170 |
+
assert requests[0].headers["authorization"] == "Bearer " + "x" * 32
|
| 171 |
+
assert requests[0].headers["content-type"].startswith("application/x-www-form-urlencoded")
|
| 172 |
+
assert b"width=1024" in requests[0].content
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@pytest.mark.asyncio
|
| 176 |
+
async def test_flux_optional_canonical_image_uses_multipart(tmp_path: Path) -> None:
|
| 177 |
+
source = tmp_path / "input.png"
|
| 178 |
+
source.write_bytes(b"image-input")
|
| 179 |
+
|
| 180 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 181 |
+
body = request.content.decode("latin-1")
|
| 182 |
+
assert 'name="input_images"' in body
|
| 183 |
+
assert 'name="prompt"' in body
|
| 184 |
+
return httpx.Response(202, json={"job_id": "flux_" + "b" * 32, "status": "queued"})
|
| 185 |
+
|
| 186 |
+
job = await FluxProviderAdapter(client=_client(handler)).submit(
|
| 187 |
+
payload={"prompt": "Edit this image"},
|
| 188 |
+
idempotency_key="generation-request-id",
|
| 189 |
+
input_path=source,
|
| 190 |
+
input_mime_type="image/png",
|
| 191 |
+
)
|
| 192 |
+
assert job.external_job_id.startswith("flux_")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@pytest.mark.asyncio
|
| 196 |
+
async def test_flux_rejects_invalid_requests_and_input_assets() -> None:
|
| 197 |
+
adapter = FluxProviderAdapter(client=None)
|
| 198 |
+
for invalid in ({"prompt": " "}, {"modality": "video"}):
|
| 199 |
+
with pytest.raises(Exception):
|
| 200 |
+
await adapter.validate_request(_payload(**invalid))
|
| 201 |
+
|
| 202 |
+
with pytest.raises(GenerationValidationError):
|
| 203 |
+
await adapter.validate_input_asset(
|
| 204 |
+
_payload(), SimpleNamespace(mime_type="video/mp4", file_size=100)
|
| 205 |
+
)
|
| 206 |
+
with pytest.raises(GenerationValidationError):
|
| 207 |
+
await adapter.validate_input_asset(
|
| 208 |
+
_payload(), SimpleNamespace(mime_type="image/png", file_size=21 * 1024 * 1024)
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@pytest.mark.parametrize(
|
| 213 |
+
"field,value",
|
| 214 |
+
[
|
| 215 |
+
("negative_prompt", "unsupported"),
|
| 216 |
+
("scheduler", "unsupported"),
|
| 217 |
+
("width", 1023),
|
| 218 |
+
("height", 1032),
|
| 219 |
+
],
|
| 220 |
+
)
|
| 221 |
+
def test_flux_schema_rejects_unsupported_or_invalid_parameters(field: str, value: object) -> None:
|
| 222 |
+
raw = _payload().model_dump()
|
| 223 |
+
flux = dict(raw["flux"] or {})
|
| 224 |
+
flux[field] = value
|
| 225 |
+
raw["flux"] = flux
|
| 226 |
+
with pytest.raises(ValueError):
|
| 227 |
+
GenerationRequestCreate.model_validate(raw)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@pytest.mark.asyncio
|
| 231 |
+
async def test_flux_rejects_controls_for_another_provider() -> None:
|
| 232 |
+
payload = _payload(wan={"duration_seconds": 1.0})
|
| 233 |
+
with pytest.raises(GenerationCapabilityUnsupportedError):
|
| 234 |
+
await FluxProviderAdapter(client=None).validate_request(payload)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@pytest.mark.asyncio
|
| 238 |
+
async def test_flux_completed_job_maps_a_safe_png_output_and_retrieves_it() -> None:
|
| 239 |
+
job_id = "flux_" + "c" * 32
|
| 240 |
+
|
| 241 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 242 |
+
if request.url.path.endswith("/output"):
|
| 243 |
+
return httpx.Response(200, content=b"png-output")
|
| 244 |
+
return httpx.Response(
|
| 245 |
+
200,
|
| 246 |
+
json={
|
| 247 |
+
"job_id": job_id,
|
| 248 |
+
"status": "completed",
|
| 249 |
+
"output": {"type": "image", "filename": "output.png"},
|
| 250 |
+
},
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
adapter = FluxProviderAdapter(client=_client(handler))
|
| 254 |
+
job = await adapter.get_job(external_job_id=job_id)
|
| 255 |
+
assert job.output is not None
|
| 256 |
+
assert job.output.mime_type == "image/png"
|
| 257 |
+
assert job.output.download_path == f"/v1/jobs/{job_id}/output"
|
| 258 |
+
output = await adapter.retrieve_output(external_job_id=job_id)
|
| 259 |
+
async with adapter.stream_output(output) as chunks:
|
| 260 |
+
assert b"".join([chunk async for chunk in chunks]) == b"png-output"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.asyncio
|
| 264 |
+
@pytest.mark.parametrize("status_code", [429, 502, 503, 504])
|
| 265 |
+
async def test_flux_polling_uses_shared_bounded_transient_retry(status_code: int) -> None:
|
| 266 |
+
calls = 0
|
| 267 |
+
job_id = "flux_" + "d" * 32
|
| 268 |
+
|
| 269 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 270 |
+
nonlocal calls
|
| 271 |
+
calls += 1
|
| 272 |
+
if calls < 3:
|
| 273 |
+
return httpx.Response(status_code, json={"detail": {"token": "never-store"}})
|
| 274 |
+
return httpx.Response(200, json={"job_id": job_id, "status": "running"})
|
| 275 |
+
|
| 276 |
+
job = await FluxProviderAdapter(client=_client(handler, retries=2)).get_job(
|
| 277 |
+
external_job_id=job_id
|
| 278 |
+
)
|
| 279 |
+
assert job.status is WorkerJobStatus.RUNNING
|
| 280 |
+
assert calls == 3
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@pytest.mark.asyncio
|
| 284 |
+
async def test_flux_permanent_error_is_not_retried_and_cancellation_is_accurate() -> None:
|
| 285 |
+
job_id = "flux_" + "e" * 32
|
| 286 |
+
calls = 0
|
| 287 |
+
|
| 288 |
+
def permanent_handler(_: httpx.Request) -> httpx.Response:
|
| 289 |
+
nonlocal calls
|
| 290 |
+
calls += 1
|
| 291 |
+
return httpx.Response(400, json={"detail": {"code": "FLUX_REQUEST_INVALID"}})
|
| 292 |
+
|
| 293 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 294 |
+
await FluxProviderAdapter(client=_client(permanent_handler, retries=3)).get_job(
|
| 295 |
+
external_job_id=job_id
|
| 296 |
+
)
|
| 297 |
+
assert raised.value.category is WorkerErrorCategory.INVALID_REQUEST
|
| 298 |
+
assert calls == 1
|
| 299 |
+
|
| 300 |
+
def queued_handler(_: httpx.Request) -> httpx.Response:
|
| 301 |
+
return httpx.Response(200, json={"job_id": job_id, "status": "cancelled"})
|
| 302 |
+
|
| 303 |
+
def running_handler(_: httpx.Request) -> httpx.Response:
|
| 304 |
+
return httpx.Response(
|
| 305 |
+
409,
|
| 306 |
+
json={"detail": {"code": "FLUX_JOB_NOT_CANCELLABLE", "status": "running"}},
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
assert (
|
| 310 |
+
await FluxProviderAdapter(client=_client(queued_handler)).cancel(external_job_id=job_id)
|
| 311 |
+
).status is WorkerCancellationStatus.CANCELLED
|
| 312 |
+
assert (
|
| 313 |
+
await FluxProviderAdapter(client=_client(running_handler)).cancel(external_job_id=job_id)
|
| 314 |
+
).status is WorkerCancellationStatus.FAILED
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_flux_configuration_is_optional_and_does_not_change_wan_configuration() -> None:
|
| 318 |
+
disabled = FluxProviderAdapter.from_settings(Settings(_env_file=None))
|
| 319 |
+
invalid = FluxProviderAdapter.from_settings(
|
| 320 |
+
Settings(_env_file=None, flux_space_url="https://flux-worker.example")
|
| 321 |
+
)
|
| 322 |
+
assert not disabled.available
|
| 323 |
+
assert not invalid.available
|
| 324 |
+
assert invalid.configuration_error is not None
|
tests/test_generation_foundation.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
|
| 10 |
+
from app.container import build_container
|
| 11 |
+
from app.ai.schemas import AiGenerateImageRequest
|
| 12 |
+
from app.core.config import Settings
|
| 13 |
+
from app.generation.domain.capabilities import (
|
| 14 |
+
GenerationModelCapability,
|
| 15 |
+
GenerationProviderCapabilities,
|
| 16 |
+
)
|
| 17 |
+
from app.generation.domain.enums import (
|
| 18 |
+
GenerationJobStatus,
|
| 19 |
+
GenerationModality,
|
| 20 |
+
WorkerCancellationStatus,
|
| 21 |
+
WorkerHealthStatus,
|
| 22 |
+
WorkerJobStatus,
|
| 23 |
+
WorkerReadinessStatus,
|
| 24 |
+
)
|
| 25 |
+
from app.generation.domain.errors import (
|
| 26 |
+
GenerationIdempotencyConflictError,
|
| 27 |
+
GenerationInputAssetNotFoundError,
|
| 28 |
+
GenerationJobNotFoundError,
|
| 29 |
+
GenerationProviderJobConflictError,
|
| 30 |
+
)
|
| 31 |
+
from app.generation.domain.runtime import (
|
| 32 |
+
WorkerCancellationResult,
|
| 33 |
+
WorkerHealth,
|
| 34 |
+
WorkerInfo,
|
| 35 |
+
WorkerJob,
|
| 36 |
+
WorkerOutput,
|
| 37 |
+
WorkerReadiness,
|
| 38 |
+
)
|
| 39 |
+
from app.generation.model_registry import (
|
| 40 |
+
GenerationModelRegistration,
|
| 41 |
+
GenerationModelRegistry,
|
| 42 |
+
)
|
| 43 |
+
from app.generation.providers.base import GenerationProviderAdapter
|
| 44 |
+
from app.generation.providers.registry import GenerationProviderRegistry
|
| 45 |
+
from app.generation.schemas.requests import GenerationRequestCreate
|
| 46 |
+
from app.security.schemas import APIKeyCreate
|
| 47 |
+
from main import create_app
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def generation_settings(tmp_path: Path) -> Settings:
|
| 51 |
+
return Settings(
|
| 52 |
+
_env_file=None,
|
| 53 |
+
auth_enabled=True,
|
| 54 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
|
| 55 |
+
social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}",
|
| 56 |
+
social_auto_migrate=True,
|
| 57 |
+
social_worker_enabled=False,
|
| 58 |
+
social_oauth_encryption_key="test-only-encryption-material",
|
| 59 |
+
temp_dir=tmp_path / "temp",
|
| 60 |
+
output_dir=tmp_path / "outputs",
|
| 61 |
+
cleanup_interval_seconds=3600,
|
| 62 |
+
whisper_model="tiny",
|
| 63 |
+
generation_enabled=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class AvailableTestProvider(GenerationProviderAdapter):
|
| 68 |
+
capabilities = GenerationProviderCapabilities(
|
| 69 |
+
provider="test-generation",
|
| 70 |
+
name="Test generation adapter",
|
| 71 |
+
implementation_status="test",
|
| 72 |
+
models=[
|
| 73 |
+
GenerationModelCapability(
|
| 74 |
+
id="test-image-v1",
|
| 75 |
+
name="Test image v1",
|
| 76 |
+
modality=GenerationModality.IMAGE,
|
| 77 |
+
input_asset_supported=True,
|
| 78 |
+
)
|
| 79 |
+
],
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def __init__(self) -> None:
|
| 83 |
+
self.cancellation_result = WorkerCancellationResult(
|
| 84 |
+
status=WorkerCancellationStatus.REQUESTED
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def available(self) -> bool:
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
async def validate_request(self, payload: GenerationRequestCreate) -> dict[str, object]:
|
| 92 |
+
return {"prompt": payload.prompt}
|
| 93 |
+
|
| 94 |
+
async def health(self) -> WorkerHealth:
|
| 95 |
+
return WorkerHealth(status=WorkerHealthStatus.HEALTHY)
|
| 96 |
+
|
| 97 |
+
async def info(self) -> WorkerInfo:
|
| 98 |
+
return WorkerInfo(
|
| 99 |
+
id="test-generation-worker",
|
| 100 |
+
name="Test generation worker",
|
| 101 |
+
media_types=[GenerationModality.IMAGE],
|
| 102 |
+
models=[
|
| 103 |
+
{
|
| 104 |
+
"id": "test-image-v1",
|
| 105 |
+
"name": "Test image v1",
|
| 106 |
+
"media_types": [GenerationModality.IMAGE],
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
status=WorkerHealthStatus.HEALTHY,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
async def ready(self) -> WorkerReadiness:
|
| 113 |
+
return WorkerReadiness(
|
| 114 |
+
status=WorkerReadinessStatus.READY,
|
| 115 |
+
model_loaded=True,
|
| 116 |
+
model_ids=["test-image-v1"],
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
async def cancel(self, *, external_job_id: str) -> WorkerCancellationResult:
|
| 120 |
+
assert external_job_id == "worker-job-1"
|
| 121 |
+
return self.cancellation_result
|
| 122 |
+
|
| 123 |
+
async def get_job(self, *, external_job_id: str) -> WorkerJob:
|
| 124 |
+
assert external_job_id == "worker-job-1"
|
| 125 |
+
return WorkerJob(
|
| 126 |
+
external_job_id=external_job_id,
|
| 127 |
+
status=WorkerJobStatus.COMPLETED,
|
| 128 |
+
output=WorkerOutput(
|
| 129 |
+
output_type=GenerationModality.IMAGE,
|
| 130 |
+
mime_type="image/png",
|
| 131 |
+
provider_output_id="worker-output-1",
|
| 132 |
+
download_path="/v1/outputs/worker-output-1",
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
@asynccontextmanager
|
| 137 |
+
async def stream_output(self, output: WorkerOutput):
|
| 138 |
+
assert output.provider_output_id == "worker-output-1"
|
| 139 |
+
|
| 140 |
+
async def chunks():
|
| 141 |
+
yield base64.b64decode(
|
| 142 |
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
| 143 |
+
"AAAADUlEQVQIHWP4z8DwHwAFgAI/ScL9aQAAAABJRU5ErkJggg=="
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
yield chunks()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def create_context(container, name: str):
|
| 150 |
+
_, secret = await container.api_keys.create(
|
| 151 |
+
APIKeyCreate(
|
| 152 |
+
name=name,
|
| 153 |
+
environment="test",
|
| 154 |
+
role=None,
|
| 155 |
+
scopes=[
|
| 156 |
+
"generation:providers:read",
|
| 157 |
+
"generation:requests:read",
|
| 158 |
+
"generation:requests:create",
|
| 159 |
+
"generation:jobs:cancel",
|
| 160 |
+
],
|
| 161 |
+
),
|
| 162 |
+
created_by="tests",
|
| 163 |
+
)
|
| 164 |
+
return await container.api_keys.authenticate(secret)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def request_payload(*, prompt: str = "A test image") -> GenerationRequestCreate:
|
| 168 |
+
return GenerationRequestCreate(
|
| 169 |
+
provider="test-generation",
|
| 170 |
+
model_id="test-image-v1",
|
| 171 |
+
modality=GenerationModality.IMAGE,
|
| 172 |
+
prompt=prompt,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@pytest.fixture
|
| 177 |
+
async def generation_container(tmp_path: Path):
|
| 178 |
+
container = build_container(generation_settings(tmp_path))
|
| 179 |
+
await container.security_database.initialize()
|
| 180 |
+
provider = AvailableTestProvider()
|
| 181 |
+
container.generation.providers = GenerationProviderRegistry([provider])
|
| 182 |
+
container.generation.models = GenerationModelRegistry(
|
| 183 |
+
[
|
| 184 |
+
GenerationModelRegistration(
|
| 185 |
+
provider_id=provider.provider,
|
| 186 |
+
model=provider.capabilities.models[0],
|
| 187 |
+
configuration_reference="test-generation-worker",
|
| 188 |
+
)
|
| 189 |
+
]
|
| 190 |
+
)
|
| 191 |
+
await container.generation.initialize()
|
| 192 |
+
await container.generation.refresh_provider_runtime(provider.provider)
|
| 193 |
+
try:
|
| 194 |
+
yield container
|
| 195 |
+
finally:
|
| 196 |
+
await container.security_database.close()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@pytest.mark.asyncio
|
| 200 |
+
async def test_optional_generation_providers_start_unavailable_without_configuration(
|
| 201 |
+
tmp_path: Path,
|
| 202 |
+
) -> None:
|
| 203 |
+
container = build_container(generation_settings(tmp_path))
|
| 204 |
+
await container.security_database.initialize()
|
| 205 |
+
await container.generation.initialize()
|
| 206 |
+
try:
|
| 207 |
+
providers = container.generation.list_providers()
|
| 208 |
+
assert [provider.capabilities.provider for provider in providers] == ["flux", "wan"]
|
| 209 |
+
assert not any(provider.available for provider in providers)
|
| 210 |
+
assert not container.generation.get_model("flux", "flux.2-klein-4b").available
|
| 211 |
+
assert not container.generation.get_model("wan", "wan2.2").available
|
| 212 |
+
finally:
|
| 213 |
+
await container.security_database.close()
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@pytest.mark.asyncio
|
| 217 |
+
async def test_ai_studio_advertises_and_isolates_real_generation_history(
|
| 218 |
+
generation_container,
|
| 219 |
+
) -> None:
|
| 220 |
+
context = await create_context(generation_container, "AI Studio")
|
| 221 |
+
capabilities = generation_container.ai.capabilities()
|
| 222 |
+
image_tool = next(tool for tool in capabilities.tools if tool.operation == "generate_image")
|
| 223 |
+
video_tool = next(tool for tool in capabilities.tools if tool.operation == "generate_video")
|
| 224 |
+
assert image_tool.available
|
| 225 |
+
assert not video_tool.available
|
| 226 |
+
|
| 227 |
+
ordinary = await generation_container.generation.create(
|
| 228 |
+
workspace_id=context.workspace_id,
|
| 229 |
+
user_id=context.user_id,
|
| 230 |
+
payload=request_payload(prompt="ordinary generation"),
|
| 231 |
+
idempotency_key="ordinary-generation-key",
|
| 232 |
+
)
|
| 233 |
+
ai_job = await generation_container.ai.create(
|
| 234 |
+
workspace_id=context.workspace_id,
|
| 235 |
+
user_id=context.user_id,
|
| 236 |
+
api_key_id=context.api_key_id,
|
| 237 |
+
request_id="ai-request",
|
| 238 |
+
payload=AiGenerateImageRequest(
|
| 239 |
+
operation="generate_image",
|
| 240 |
+
prompt="AI Studio generation",
|
| 241 |
+
),
|
| 242 |
+
idempotency_key="ai-studio-generation-key",
|
| 243 |
+
)
|
| 244 |
+
history = await generation_container.ai.history(
|
| 245 |
+
workspace_id=context.workspace_id,
|
| 246 |
+
user_id=context.user_id,
|
| 247 |
+
offset=0,
|
| 248 |
+
limit=25,
|
| 249 |
+
)
|
| 250 |
+
assert [item.generation_id for item in history.items] == [ai_job.generation_id]
|
| 251 |
+
assert ordinary.id not in {item.generation_id for item in history.items}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def test_application_starts_with_optional_providers_disabled_when_unconfigured(
|
| 255 |
+
tmp_path: Path,
|
| 256 |
+
) -> None:
|
| 257 |
+
"""No worker URL/token is needed merely to start the application."""
|
| 258 |
+
|
| 259 |
+
with TestClient(create_app(generation_settings(tmp_path))) as client:
|
| 260 |
+
providers = client.app.state.container.generation.list_providers()
|
| 261 |
+
assert [provider.capabilities.provider for provider in providers] == ["flux", "wan"]
|
| 262 |
+
models = client.app.state.container.generation.list_models()
|
| 263 |
+
assert [model.model.id for model in models] == ["flux.2-klein-4b", "wan2.2"]
|
| 264 |
+
assert not any(model.available for model in models)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@pytest.mark.asyncio
|
| 268 |
+
async def test_provider_discovery_requires_a_verified_model(generation_container) -> None:
|
| 269 |
+
"""A configured adapter is not publicly usable before runtime verification."""
|
| 270 |
+
|
| 271 |
+
provider_id = "test-generation"
|
| 272 |
+
generation_container.generation.models.mark_unavailable(provider_id)
|
| 273 |
+
assert not generation_container.generation.get_provider(provider_id).available
|
| 274 |
+
assert not generation_container.generation.list_providers()[0].available
|
| 275 |
+
|
| 276 |
+
await generation_container.generation.refresh_provider_runtime(provider_id)
|
| 277 |
+
assert generation_container.generation.get_provider(provider_id).available
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.asyncio
|
| 281 |
+
async def test_generation_request_idempotency_and_cancel(generation_container) -> None:
|
| 282 |
+
context = await create_context(generation_container, "Generation A")
|
| 283 |
+
workspace_id = str(context.workspace_id)
|
| 284 |
+
user_id = str(context.user_id)
|
| 285 |
+
|
| 286 |
+
first = await generation_container.generation.create(
|
| 287 |
+
workspace_id=workspace_id,
|
| 288 |
+
user_id=user_id,
|
| 289 |
+
payload=request_payload(),
|
| 290 |
+
idempotency_key="generation-request-key",
|
| 291 |
+
)
|
| 292 |
+
replay = await generation_container.generation.create(
|
| 293 |
+
workspace_id=workspace_id,
|
| 294 |
+
user_id=user_id,
|
| 295 |
+
payload=request_payload(),
|
| 296 |
+
idempotency_key="generation-request-key",
|
| 297 |
+
)
|
| 298 |
+
assert replay.id == first.id
|
| 299 |
+
assert replay.job.id == first.job.id
|
| 300 |
+
with pytest.raises(GenerationIdempotencyConflictError):
|
| 301 |
+
await generation_container.generation.create(
|
| 302 |
+
workspace_id=workspace_id,
|
| 303 |
+
user_id=user_id,
|
| 304 |
+
payload=request_payload(prompt="Different request"),
|
| 305 |
+
idempotency_key="generation-request-key",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
cancelled = await generation_container.generation.cancel(workspace_id, user_id, first.job.id)
|
| 309 |
+
assert cancelled.status is GenerationJobStatus.CANCELLED
|
| 310 |
+
retrieved = await generation_container.generation.get_request(workspace_id, user_id, first.id)
|
| 311 |
+
assert retrieved.status is GenerationJobStatus.CANCELLED
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
@pytest.mark.asyncio
|
| 315 |
+
async def test_generation_records_are_workspace_isolated(generation_container) -> None:
|
| 316 |
+
context_a = await create_context(generation_container, "Generation A")
|
| 317 |
+
context_b = await create_context(generation_container, "Generation B")
|
| 318 |
+
created = await generation_container.generation.create(
|
| 319 |
+
workspace_id=str(context_a.workspace_id),
|
| 320 |
+
user_id=str(context_a.user_id),
|
| 321 |
+
payload=request_payload(),
|
| 322 |
+
idempotency_key="generation-isolation-key",
|
| 323 |
+
)
|
| 324 |
+
with pytest.raises(GenerationJobNotFoundError):
|
| 325 |
+
await generation_container.generation.get_job(
|
| 326 |
+
str(context_b.workspace_id), str(context_b.user_id), created.job.id
|
| 327 |
+
)
|
| 328 |
+
assert (
|
| 329 |
+
await generation_container.generation.list_requests(
|
| 330 |
+
str(context_b.workspace_id), str(context_b.user_id)
|
| 331 |
+
)
|
| 332 |
+
== []
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@pytest.mark.asyncio
|
| 337 |
+
async def test_generation_rejects_another_workspace_canonical_input_asset(
|
| 338 |
+
generation_container,
|
| 339 |
+
) -> None:
|
| 340 |
+
context_a = await create_context(generation_container, "Generation A")
|
| 341 |
+
context_b = await create_context(generation_container, "Generation B")
|
| 342 |
+
request_id = "00000000-0000-0000-0000-000000000010"
|
| 343 |
+
output_dir = generation_container.settings.output_dir / request_id
|
| 344 |
+
output_dir.mkdir(parents=True)
|
| 345 |
+
output = output_dir / "owned-input.png"
|
| 346 |
+
output.write_bytes(b"canonical image")
|
| 347 |
+
asset = await generation_container.assets.register_output(
|
| 348 |
+
workspace_id=str(context_a.workspace_id),
|
| 349 |
+
user_id=str(context_a.user_id),
|
| 350 |
+
request_id=request_id,
|
| 351 |
+
path=output,
|
| 352 |
+
mime_type="image/png",
|
| 353 |
+
)
|
| 354 |
+
with pytest.raises(GenerationInputAssetNotFoundError):
|
| 355 |
+
await generation_container.generation.create(
|
| 356 |
+
workspace_id=str(context_b.workspace_id),
|
| 357 |
+
user_id=str(context_b.user_id),
|
| 358 |
+
payload=GenerationRequestCreate(
|
| 359 |
+
provider="test-generation",
|
| 360 |
+
model_id="test-image-v1",
|
| 361 |
+
modality=GenerationModality.IMAGE,
|
| 362 |
+
prompt="Use another workspace asset",
|
| 363 |
+
input_asset_id=asset.id,
|
| 364 |
+
),
|
| 365 |
+
idempotency_key="generation-cross-asset-key",
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@pytest.mark.parametrize("forbidden_field", ["provider_payload", "worker_url", "output_url"])
|
| 370 |
+
def test_generation_request_schema_rejects_client_supplied_provider_controls(
|
| 371 |
+
forbidden_field: str,
|
| 372 |
+
) -> None:
|
| 373 |
+
payload: dict[str, object] = {
|
| 374 |
+
"provider": "test-generation",
|
| 375 |
+
"model_id": "test-image-v1",
|
| 376 |
+
"modality": "image",
|
| 377 |
+
"prompt": "A test image",
|
| 378 |
+
}
|
| 379 |
+
payload[forbidden_field] = {"unsafe": True}
|
| 380 |
+
with pytest.raises(ValueError):
|
| 381 |
+
GenerationRequestCreate.model_validate(payload)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
@pytest.mark.asyncio
|
| 385 |
+
async def test_remote_cancellation_preserves_requested_and_confirmed_states(
|
| 386 |
+
generation_container,
|
| 387 |
+
) -> None:
|
| 388 |
+
context = await create_context(generation_container, "Generation cancellation")
|
| 389 |
+
workspace_id = str(context.workspace_id)
|
| 390 |
+
user_id = str(context.user_id)
|
| 391 |
+
created = await generation_container.generation.create(
|
| 392 |
+
workspace_id=workspace_id,
|
| 393 |
+
user_id=user_id,
|
| 394 |
+
payload=request_payload(),
|
| 395 |
+
idempotency_key="generation-cancellation-key",
|
| 396 |
+
)
|
| 397 |
+
await generation_container.generation.repository.transition_job(
|
| 398 |
+
workspace_id,
|
| 399 |
+
created.job.id,
|
| 400 |
+
GenerationJobStatus.SUBMITTING,
|
| 401 |
+
user_id=user_id,
|
| 402 |
+
)
|
| 403 |
+
await generation_container.generation.bind_provider_job(
|
| 404 |
+
workspace_id=workspace_id,
|
| 405 |
+
user_id=user_id,
|
| 406 |
+
job_id=created.job.id,
|
| 407 |
+
worker_job_id="worker-job-1",
|
| 408 |
+
)
|
| 409 |
+
await generation_container.generation.repository.transition_job(
|
| 410 |
+
workspace_id,
|
| 411 |
+
created.job.id,
|
| 412 |
+
GenerationJobStatus.RUNNING,
|
| 413 |
+
user_id=user_id,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
requested = await generation_container.generation.cancel(workspace_id, user_id, created.job.id)
|
| 417 |
+
assert requested.status is GenerationJobStatus.CANCEL_REQUESTED
|
| 418 |
+
|
| 419 |
+
provider = generation_container.generation.providers.get("test-generation")
|
| 420 |
+
assert isinstance(provider, AvailableTestProvider)
|
| 421 |
+
provider.cancellation_result = WorkerCancellationResult(
|
| 422 |
+
status=WorkerCancellationStatus.CANCELLED
|
| 423 |
+
)
|
| 424 |
+
confirmed = await generation_container.generation.cancel(workspace_id, user_id, created.job.id)
|
| 425 |
+
assert confirmed.status is GenerationJobStatus.CANCELLED
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
@pytest.mark.asyncio
|
| 429 |
+
async def test_provider_job_binding_and_output_ingestion_are_workspace_scoped(
|
| 430 |
+
generation_container,
|
| 431 |
+
) -> None:
|
| 432 |
+
context_a = await create_context(generation_container, "Generation output A")
|
| 433 |
+
context_b = await create_context(generation_container, "Generation output B")
|
| 434 |
+
workspace_a, user_a = str(context_a.workspace_id), str(context_a.user_id)
|
| 435 |
+
workspace_b, user_b = str(context_b.workspace_id), str(context_b.user_id)
|
| 436 |
+
job_a = await generation_container.generation.create(
|
| 437 |
+
workspace_id=workspace_a,
|
| 438 |
+
user_id=user_a,
|
| 439 |
+
payload=request_payload(),
|
| 440 |
+
idempotency_key="generation-output-a",
|
| 441 |
+
)
|
| 442 |
+
job_b = await generation_container.generation.create(
|
| 443 |
+
workspace_id=workspace_b,
|
| 444 |
+
user_id=user_b,
|
| 445 |
+
payload=request_payload(),
|
| 446 |
+
idempotency_key="generation-output-b",
|
| 447 |
+
)
|
| 448 |
+
for workspace_id, user_id, job_id in (
|
| 449 |
+
(workspace_a, user_a, job_a.job.id),
|
| 450 |
+
(workspace_b, user_b, job_b.job.id),
|
| 451 |
+
):
|
| 452 |
+
await generation_container.generation.repository.transition_job(
|
| 453 |
+
workspace_id,
|
| 454 |
+
job_id,
|
| 455 |
+
GenerationJobStatus.SUBMITTING,
|
| 456 |
+
user_id=user_id,
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
await generation_container.generation.bind_provider_job(
|
| 460 |
+
workspace_id=workspace_a,
|
| 461 |
+
user_id=user_a,
|
| 462 |
+
job_id=job_a.job.id,
|
| 463 |
+
worker_job_id="worker-job-1",
|
| 464 |
+
)
|
| 465 |
+
with pytest.raises(GenerationProviderJobConflictError):
|
| 466 |
+
await generation_container.generation.bind_provider_job(
|
| 467 |
+
workspace_id=workspace_b,
|
| 468 |
+
user_id=user_b,
|
| 469 |
+
job_id=job_b.job.id,
|
| 470 |
+
worker_job_id="worker-job-1",
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
await generation_container.generation.repository.transition_job(
|
| 474 |
+
workspace_a,
|
| 475 |
+
job_a.job.id,
|
| 476 |
+
GenerationJobStatus.RUNNING,
|
| 477 |
+
user_id=user_a,
|
| 478 |
+
)
|
| 479 |
+
completed = await generation_container.generation.ingest_completed_provider_output(
|
| 480 |
+
workspace_id=workspace_a,
|
| 481 |
+
user_id=user_a,
|
| 482 |
+
job_id=job_a.job.id,
|
| 483 |
+
)
|
| 484 |
+
assert completed.status is GenerationJobStatus.SUCCEEDED
|
| 485 |
+
assert completed.output_asset_id is not None
|
| 486 |
+
output_asset = await generation_container.assets.get_owned_by_id(
|
| 487 |
+
workspace_id=workspace_a,
|
| 488 |
+
user_id=user_a,
|
| 489 |
+
asset_id=completed.output_asset_id,
|
| 490 |
+
)
|
| 491 |
+
assert output_asset.mime_type == "image/png"
|
| 492 |
+
assert output_asset.metadata_json["generation"]["media"]["resolution"] == {
|
| 493 |
+
"width": 1,
|
| 494 |
+
"height": 1,
|
| 495 |
+
}
|
| 496 |
+
assert (
|
| 497 |
+
await generation_container.generation.ingest_completed_provider_output(
|
| 498 |
+
workspace_id=workspace_a,
|
| 499 |
+
user_id=user_a,
|
| 500 |
+
job_id=job_a.job.id,
|
| 501 |
+
)
|
| 502 |
+
== completed
|
| 503 |
+
)
|
| 504 |
+
with pytest.raises(GenerationJobNotFoundError):
|
| 505 |
+
await generation_container.generation.ingest_completed_provider_output(
|
| 506 |
+
workspace_id=workspace_b,
|
| 507 |
+
user_id=user_b,
|
| 508 |
+
job_id=job_a.job.id,
|
| 509 |
+
)
|
tests/test_generation_provider_runtime.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import pytest
|
| 7 |
+
from pydantic import ValidationError
|
| 8 |
+
|
| 9 |
+
from app.generation.domain.capabilities import (
|
| 10 |
+
GenerationModelCapability,
|
| 11 |
+
GenerationProviderCapabilities,
|
| 12 |
+
)
|
| 13 |
+
from app.generation.domain.enums import (
|
| 14 |
+
GenerationModality,
|
| 15 |
+
WorkerCancellationStatus,
|
| 16 |
+
WorkerErrorCategory,
|
| 17 |
+
WorkerHealthStatus,
|
| 18 |
+
WorkerReadinessStatus,
|
| 19 |
+
)
|
| 20 |
+
from app.generation.domain.errors import GenerationWorkerError
|
| 21 |
+
from app.generation.domain.retry import GenerationRetryPolicy
|
| 22 |
+
from app.generation.domain.runtime import WorkerInfo, WorkerOutput, WorkerReadiness
|
| 23 |
+
from app.generation.model_registry import (
|
| 24 |
+
GenerationModelRegistration,
|
| 25 |
+
GenerationModelRegistry,
|
| 26 |
+
)
|
| 27 |
+
from app.generation.providers.base import GenerationProviderAdapter
|
| 28 |
+
from app.generation.providers.registry import GenerationProviderRegistry
|
| 29 |
+
from app.generation.providers.worker_client import RemoteWorkerClient
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def worker_client(
|
| 33 |
+
handler: Callable[[httpx.Request], httpx.Response] | None = None,
|
| 34 |
+
*,
|
| 35 |
+
retries: int = 2,
|
| 36 |
+
sleep_calls: list[float] | None = None,
|
| 37 |
+
) -> RemoteWorkerClient:
|
| 38 |
+
async def sleep(delay: float) -> None:
|
| 39 |
+
if sleep_calls is not None:
|
| 40 |
+
sleep_calls.append(delay)
|
| 41 |
+
|
| 42 |
+
client = httpx.AsyncClient(
|
| 43 |
+
transport=httpx.MockTransport(
|
| 44 |
+
handler
|
| 45 |
+
or (lambda _: httpx.Response(200, json={"status": "ok"}))
|
| 46 |
+
)
|
| 47 |
+
)
|
| 48 |
+
return RemoteWorkerClient(
|
| 49 |
+
base_url="https://worker.example",
|
| 50 |
+
bearer_token="test-worker-token",
|
| 51 |
+
connect_timeout_seconds=1,
|
| 52 |
+
request_timeout_seconds=1,
|
| 53 |
+
read_timeout_seconds=1,
|
| 54 |
+
retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
|
| 55 |
+
http_client=client,
|
| 56 |
+
sleep=sleep,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class RuntimeTestProvider(GenerationProviderAdapter):
|
| 61 |
+
capabilities = GenerationProviderCapabilities(
|
| 62 |
+
provider="runtime-test",
|
| 63 |
+
name="Runtime test provider",
|
| 64 |
+
implementation_status="test",
|
| 65 |
+
models=[
|
| 66 |
+
GenerationModelCapability(
|
| 67 |
+
id="runtime-image-v1",
|
| 68 |
+
name="Runtime image v1",
|
| 69 |
+
modality=GenerationModality.IMAGE,
|
| 70 |
+
)
|
| 71 |
+
],
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_provider_and_model_registration_starts_unavailable() -> None:
|
| 76 |
+
provider = RuntimeTestProvider()
|
| 77 |
+
providers = GenerationProviderRegistry([provider])
|
| 78 |
+
assert providers.get("runtime-test") is provider
|
| 79 |
+
models = GenerationModelRegistry(
|
| 80 |
+
[
|
| 81 |
+
GenerationModelRegistration(
|
| 82 |
+
provider_id=provider.provider,
|
| 83 |
+
model=provider.capabilities.models[0],
|
| 84 |
+
configuration_reference="runtime-test-config",
|
| 85 |
+
metadata={
|
| 86 |
+
"access_token": "must-not-survive",
|
| 87 |
+
"diagnostic": (
|
| 88 |
+
"Bearer must-not-survive "
|
| 89 |
+
"https://worker.example/output?sig=secret"
|
| 90 |
+
),
|
| 91 |
+
"download_url": "https://worker.example/output?sig=secret",
|
| 92 |
+
},
|
| 93 |
+
)
|
| 94 |
+
]
|
| 95 |
+
)
|
| 96 |
+
view = models.get(provider.provider, "runtime-image-v1")
|
| 97 |
+
assert not view.available
|
| 98 |
+
assert "access_token" not in view.metadata
|
| 99 |
+
assert "download_url" not in view.metadata
|
| 100 |
+
assert "must-not-survive" not in str(view.metadata)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_model_availability_requires_readiness_info_and_configuration() -> None:
|
| 104 |
+
model = GenerationModelCapability(
|
| 105 |
+
id="runtime-image-v1", name="Runtime", modality=GenerationModality.IMAGE
|
| 106 |
+
)
|
| 107 |
+
registry = GenerationModelRegistry(
|
| 108 |
+
[
|
| 109 |
+
GenerationModelRegistration(
|
| 110 |
+
provider_id="runtime-test",
|
| 111 |
+
model=model,
|
| 112 |
+
configuration_reference="runtime-test-config",
|
| 113 |
+
)
|
| 114 |
+
]
|
| 115 |
+
)
|
| 116 |
+
info = WorkerInfo(
|
| 117 |
+
id="runtime-test-worker",
|
| 118 |
+
name="Runtime worker",
|
| 119 |
+
media_types=[GenerationModality.IMAGE],
|
| 120 |
+
models=[
|
| 121 |
+
{
|
| 122 |
+
"id": model.id,
|
| 123 |
+
"name": model.name,
|
| 124 |
+
"media_types": [GenerationModality.IMAGE],
|
| 125 |
+
}
|
| 126 |
+
],
|
| 127 |
+
)
|
| 128 |
+
not_ready = WorkerReadiness(
|
| 129 |
+
status=WorkerReadinessStatus.STARTING,
|
| 130 |
+
model_loaded=False,
|
| 131 |
+
model_ids=[model.id],
|
| 132 |
+
)
|
| 133 |
+
assert not registry.verify_readiness(
|
| 134 |
+
provider_id="runtime-test",
|
| 135 |
+
worker_info=info,
|
| 136 |
+
readiness=not_ready,
|
| 137 |
+
provider_configured=True,
|
| 138 |
+
)[0].available
|
| 139 |
+
ready = WorkerReadiness(
|
| 140 |
+
status=WorkerReadinessStatus.READY, model_loaded=True, model_ids=[model.id]
|
| 141 |
+
)
|
| 142 |
+
assert registry.verify_readiness(
|
| 143 |
+
provider_id="runtime-test",
|
| 144 |
+
worker_info=info,
|
| 145 |
+
readiness=ready,
|
| 146 |
+
provider_configured=True,
|
| 147 |
+
)[0].available
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.asyncio
|
| 151 |
+
async def test_worker_health_readiness_info_and_bearer_authentication() -> None:
|
| 152 |
+
seen_headers: list[str] = []
|
| 153 |
+
|
| 154 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 155 |
+
seen_headers.append(request.headers.get("authorization", ""))
|
| 156 |
+
if request.url.path == "/health":
|
| 157 |
+
return httpx.Response(200, json={"status": "ok"})
|
| 158 |
+
if request.url.path == "/ready":
|
| 159 |
+
return httpx.Response(
|
| 160 |
+
200,
|
| 161 |
+
json={"status": "ready", "model_loaded": True, "model": "model-v1"},
|
| 162 |
+
)
|
| 163 |
+
return httpx.Response(
|
| 164 |
+
200,
|
| 165 |
+
json={"id": "model-v1", "name": "Worker model", "type": "image", "status": "ready"},
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
client = worker_client(handler)
|
| 169 |
+
assert (await client.health()).status is WorkerHealthStatus.HEALTHY
|
| 170 |
+
readiness = await client.ready()
|
| 171 |
+
assert readiness.status is WorkerReadinessStatus.READY
|
| 172 |
+
assert readiness.model_ids == ["model-v1"]
|
| 173 |
+
info = await client.info()
|
| 174 |
+
assert info.media_types == [GenerationModality.IMAGE]
|
| 175 |
+
assert info.models[0].id == "model-v1"
|
| 176 |
+
assert seen_headers == ["Bearer test-worker-token"] * 3
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@pytest.mark.asyncio
|
| 180 |
+
async def test_timeout_and_connection_failure_are_retryable_and_safe() -> None:
|
| 181 |
+
request = httpx.Request("GET", "https://worker.example/health")
|
| 182 |
+
for exception, category in (
|
| 183 |
+
(httpx.ReadTimeout("secret-token", request=request), WorkerErrorCategory.TIMEOUT),
|
| 184 |
+
(
|
| 185 |
+
httpx.ConnectError("Bearer test-worker-token", request=request),
|
| 186 |
+
WorkerErrorCategory.WORKER_UNAVAILABLE,
|
| 187 |
+
),
|
| 188 |
+
):
|
| 189 |
+
calls = 0
|
| 190 |
+
|
| 191 |
+
def handler(_: httpx.Request, error: Exception = exception) -> httpx.Response:
|
| 192 |
+
nonlocal calls
|
| 193 |
+
calls += 1
|
| 194 |
+
raise error
|
| 195 |
+
|
| 196 |
+
client = worker_client(handler, retries=1)
|
| 197 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 198 |
+
await client.health()
|
| 199 |
+
assert raised.value.category is category
|
| 200 |
+
assert "test-worker-token" not in str(raised.value)
|
| 201 |
+
assert calls == 2
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@pytest.mark.asyncio
|
| 205 |
+
@pytest.mark.parametrize("status_code", [429, 502, 503, 504])
|
| 206 |
+
async def test_retryable_http_failures_use_bounded_retry(status_code: int) -> None:
|
| 207 |
+
calls = 0
|
| 208 |
+
delays: list[float] = []
|
| 209 |
+
|
| 210 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 211 |
+
nonlocal calls
|
| 212 |
+
calls += 1
|
| 213 |
+
if calls < 3:
|
| 214 |
+
return httpx.Response(status_code, json={"secret": "not surfaced"})
|
| 215 |
+
return httpx.Response(200, json={"status": "ok"})
|
| 216 |
+
|
| 217 |
+
client = worker_client(handler, retries=2, sleep_calls=delays)
|
| 218 |
+
assert (await client.health()).status is WorkerHealthStatus.HEALTHY
|
| 219 |
+
assert calls == 3
|
| 220 |
+
assert delays == [0, 0]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@pytest.mark.asyncio
|
| 224 |
+
@pytest.mark.parametrize("status_code", [400, 401])
|
| 225 |
+
async def test_non_retryable_http_failures_do_not_retry(status_code: int) -> None:
|
| 226 |
+
calls = 0
|
| 227 |
+
|
| 228 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 229 |
+
nonlocal calls
|
| 230 |
+
calls += 1
|
| 231 |
+
return httpx.Response(status_code)
|
| 232 |
+
|
| 233 |
+
client = worker_client(handler, retries=3)
|
| 234 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 235 |
+
await client.health()
|
| 236 |
+
assert calls == 1
|
| 237 |
+
assert raised.value.http_status == status_code
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@pytest.mark.asyncio
|
| 241 |
+
async def test_unexpected_exception_is_not_automatically_retryable() -> None:
|
| 242 |
+
calls = 0
|
| 243 |
+
|
| 244 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 245 |
+
nonlocal calls
|
| 246 |
+
calls += 1
|
| 247 |
+
raise RuntimeError("programming failure with secret-token")
|
| 248 |
+
|
| 249 |
+
client = worker_client(handler, retries=3)
|
| 250 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 251 |
+
await client.health()
|
| 252 |
+
assert raised.value.category is WorkerErrorCategory.UNKNOWN_ERROR
|
| 253 |
+
assert calls == 1
|
| 254 |
+
assert "secret-token" not in str(raised.value)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@pytest.mark.asyncio
|
| 258 |
+
async def test_worker_cancellation_and_output_contract() -> None:
|
| 259 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 260 |
+
if request.method == "POST":
|
| 261 |
+
return httpx.Response(202, json={"status": "cancellation_requested"})
|
| 262 |
+
return httpx.Response(
|
| 263 |
+
200,
|
| 264 |
+
json={
|
| 265 |
+
"job_id": "job-1",
|
| 266 |
+
"status": "completed",
|
| 267 |
+
"output": {
|
| 268 |
+
"type": "image",
|
| 269 |
+
"mime_type": "image/png",
|
| 270 |
+
"id": "output-1",
|
| 271 |
+
"download_path": "/v1/outputs/output-1",
|
| 272 |
+
"filename": "output.png",
|
| 273 |
+
},
|
| 274 |
+
},
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
client = worker_client(handler)
|
| 278 |
+
cancellation = await client.cancel("job-1")
|
| 279 |
+
assert cancellation.status is WorkerCancellationStatus.REQUESTED
|
| 280 |
+
output = await client.retrieve_output("job-1")
|
| 281 |
+
assert output.provider_output_id == "output-1"
|
| 282 |
+
assert output.download_path == "/v1/outputs/output-1"
|
| 283 |
+
with pytest.raises(ValidationError):
|
| 284 |
+
WorkerOutput(
|
| 285 |
+
output_type=GenerationModality.IMAGE,
|
| 286 |
+
mime_type="image/png",
|
| 287 |
+
provider_output_id="output-1",
|
| 288 |
+
download_path="https://attacker.example/output.png",
|
| 289 |
+
)
|
| 290 |
+
with pytest.raises(ValidationError):
|
| 291 |
+
WorkerOutput(
|
| 292 |
+
output_type=GenerationModality.IMAGE,
|
| 293 |
+
mime_type="image/png",
|
| 294 |
+
provider_output_id="output-1",
|
| 295 |
+
download_path="/v1/outputs/%2e%2e/secrets",
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@pytest.mark.asyncio
|
| 300 |
+
async def test_empty_successful_cancellation_response_means_requested_not_cancelled() -> None:
|
| 301 |
+
client = worker_client(lambda _: httpx.Response(204))
|
| 302 |
+
result = await client.cancel("job-1")
|
| 303 |
+
assert result.status is WorkerCancellationStatus.REQUESTED
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@pytest.mark.asyncio
|
| 307 |
+
async def test_output_stream_is_scoped_to_the_configured_worker_origin() -> None:
|
| 308 |
+
client = worker_client(lambda _: httpx.Response(200, content=b"worker-output"))
|
| 309 |
+
output = WorkerOutput(
|
| 310 |
+
output_type=GenerationModality.IMAGE,
|
| 311 |
+
mime_type="image/png",
|
| 312 |
+
provider_output_id="output-1",
|
| 313 |
+
download_path="/v1/outputs/output-1",
|
| 314 |
+
)
|
| 315 |
+
async with client.stream_output(output) as chunks:
|
| 316 |
+
received = b"".join([chunk async for chunk in chunks])
|
| 317 |
+
assert received == b"worker-output"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@pytest.mark.asyncio
|
| 321 |
+
async def test_worker_info_requires_a_discovered_model_match_for_availability() -> None:
|
| 322 |
+
model = GenerationModelCapability(
|
| 323 |
+
id="runtime-image-v1", name="Runtime", modality=GenerationModality.IMAGE
|
| 324 |
+
)
|
| 325 |
+
registry = GenerationModelRegistry(
|
| 326 |
+
[
|
| 327 |
+
GenerationModelRegistration(
|
| 328 |
+
provider_id="runtime-test",
|
| 329 |
+
model=model,
|
| 330 |
+
configuration_reference="runtime-test-config",
|
| 331 |
+
)
|
| 332 |
+
]
|
| 333 |
+
)
|
| 334 |
+
readiness = WorkerReadiness(
|
| 335 |
+
status=WorkerReadinessStatus.READY, model_loaded=True, model_ids=[model.id]
|
| 336 |
+
)
|
| 337 |
+
undiscovered = WorkerInfo(
|
| 338 |
+
id="worker",
|
| 339 |
+
name="Worker",
|
| 340 |
+
media_types=[GenerationModality.IMAGE],
|
| 341 |
+
models=[{"id": "other-model", "name": "Other", "media_types": ["image"]}],
|
| 342 |
+
)
|
| 343 |
+
assert not registry.verify_readiness(
|
| 344 |
+
provider_id="runtime-test",
|
| 345 |
+
worker_info=undiscovered,
|
| 346 |
+
readiness=readiness,
|
| 347 |
+
provider_configured=True,
|
| 348 |
+
)[0].available
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def test_worker_url_and_path_validation_blocks_ssrf_and_traversal() -> None:
|
| 352 |
+
policy = GenerationRetryPolicy(max_retries=0, backoff_seconds=0)
|
| 353 |
+
for url in (
|
| 354 |
+
"http://example.com",
|
| 355 |
+
"https://10.0.0.1",
|
| 356 |
+
"http://169.254.169.254",
|
| 357 |
+
"https://169.254.169.254",
|
| 358 |
+
"https://worker.example/%2e%2e/internal",
|
| 359 |
+
"file:///etc/passwd",
|
| 360 |
+
):
|
| 361 |
+
with pytest.raises(ValueError):
|
| 362 |
+
RemoteWorkerClient(
|
| 363 |
+
base_url=url,
|
| 364 |
+
bearer_token=None,
|
| 365 |
+
connect_timeout_seconds=1,
|
| 366 |
+
request_timeout_seconds=1,
|
| 367 |
+
read_timeout_seconds=1,
|
| 368 |
+
retry_policy=policy,
|
| 369 |
+
)
|
| 370 |
+
with pytest.raises(GenerationWorkerError):
|
| 371 |
+
RemoteWorkerClient._safe_external_id("job/../../metadata")
|
tests/test_generation_wan.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mocked protocol tests for the audited WAN 2.2 worker integration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.core.config import Settings
|
| 12 |
+
from app.generation.domain.enums import (
|
| 13 |
+
GenerationModality,
|
| 14 |
+
WorkerCancellationStatus,
|
| 15 |
+
WorkerErrorCategory,
|
| 16 |
+
WorkerJobStatus,
|
| 17 |
+
)
|
| 18 |
+
from app.generation.domain.errors import GenerationWorkerError
|
| 19 |
+
from app.generation.domain.retry import GenerationRetryPolicy
|
| 20 |
+
from app.generation.model_registry import GenerationModelRegistration, GenerationModelRegistry
|
| 21 |
+
from app.generation.providers.wan import (
|
| 22 |
+
WAN_MODEL_CAPABILITY,
|
| 23 |
+
WAN_MODEL_ID,
|
| 24 |
+
WAN_PROVIDER_ID,
|
| 25 |
+
WanProviderAdapter,
|
| 26 |
+
)
|
| 27 |
+
from app.generation.providers.worker_client import RemoteWorkerClient
|
| 28 |
+
from app.generation.schemas.requests import GenerationRequestCreate
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _client(
|
| 32 |
+
handler: Callable[[httpx.Request], httpx.Response], *, retries: int = 2
|
| 33 |
+
) -> RemoteWorkerClient:
|
| 34 |
+
return RemoteWorkerClient(
|
| 35 |
+
base_url="https://wan-worker.example",
|
| 36 |
+
bearer_token="x" * 32,
|
| 37 |
+
connect_timeout_seconds=1,
|
| 38 |
+
request_timeout_seconds=1,
|
| 39 |
+
read_timeout_seconds=1,
|
| 40 |
+
retry_policy=GenerationRetryPolicy(max_retries=retries, backoff_seconds=0),
|
| 41 |
+
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
| 42 |
+
sleep=lambda _: _no_sleep(),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def _no_sleep() -> None:
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _payload(**overrides: object) -> GenerationRequestCreate:
|
| 51 |
+
value: dict[str, object] = {
|
| 52 |
+
"provider": WAN_PROVIDER_ID,
|
| 53 |
+
"model_id": WAN_MODEL_ID,
|
| 54 |
+
"modality": "video",
|
| 55 |
+
"input_asset_id": "11111111-1111-4111-8111-111111111111",
|
| 56 |
+
"prompt": "Slow cinematic cloud movement",
|
| 57 |
+
"wan": {
|
| 58 |
+
"duration_seconds": 0.5,
|
| 59 |
+
"steps": 4,
|
| 60 |
+
"guidance_scale": 1.0,
|
| 61 |
+
"guidance_scale_2": 1.0,
|
| 62 |
+
"seed": 42,
|
| 63 |
+
"randomize_seed": False,
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
value.update(overrides)
|
| 67 |
+
return GenerationRequestCreate.model_validate(value)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _info() -> dict[str, object]:
|
| 71 |
+
return {
|
| 72 |
+
"id": "wan2.2",
|
| 73 |
+
"name": "WAN 2.2 FP8 AOTI Faster",
|
| 74 |
+
"type": "video",
|
| 75 |
+
"task": "image-to-video",
|
| 76 |
+
"status": "ready",
|
| 77 |
+
"model_id": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
| 78 |
+
"fps": 16,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@pytest.mark.asyncio
|
| 83 |
+
async def test_wan_exact_model_discovery_and_readiness() -> None:
|
| 84 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 85 |
+
if request.url.path == "/health":
|
| 86 |
+
return httpx.Response(200, json={"status": "ok", "service": "mediarouter-wan-worker"})
|
| 87 |
+
if request.url.path == "/ready":
|
| 88 |
+
return httpx.Response(
|
| 89 |
+
200,
|
| 90 |
+
json={
|
| 91 |
+
"status": "ready",
|
| 92 |
+
"model_loaded": True,
|
| 93 |
+
"model": "wan2.2",
|
| 94 |
+
"accepting_jobs": True,
|
| 95 |
+
},
|
| 96 |
+
)
|
| 97 |
+
return httpx.Response(200, json=_info())
|
| 98 |
+
|
| 99 |
+
adapter = WanProviderAdapter(client=_client(handler))
|
| 100 |
+
registry = GenerationModelRegistry(
|
| 101 |
+
[
|
| 102 |
+
GenerationModelRegistration(
|
| 103 |
+
provider_id=WAN_PROVIDER_ID,
|
| 104 |
+
model=WAN_MODEL_CAPABILITY,
|
| 105 |
+
configuration_reference="wan-space",
|
| 106 |
+
)
|
| 107 |
+
]
|
| 108 |
+
)
|
| 109 |
+
models = registry.verify_readiness(
|
| 110 |
+
provider_id=WAN_PROVIDER_ID,
|
| 111 |
+
worker_info=await adapter.info(),
|
| 112 |
+
readiness=await adapter.ready(),
|
| 113 |
+
provider_configured=adapter.available,
|
| 114 |
+
)
|
| 115 |
+
assert models[0].model.id == WAN_MODEL_ID
|
| 116 |
+
assert models[0].model.modality is GenerationModality.VIDEO
|
| 117 |
+
assert models[0].available
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@pytest.mark.asyncio
|
| 121 |
+
async def test_wan_not_ready_and_model_mismatch_are_not_advertised() -> None:
|
| 122 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 123 |
+
if request.url.path == "/ready":
|
| 124 |
+
return httpx.Response(
|
| 125 |
+
503,
|
| 126 |
+
json={
|
| 127 |
+
"status": "not_ready",
|
| 128 |
+
"model_loaded": False,
|
| 129 |
+
"model": "other-model",
|
| 130 |
+
"accepting_jobs": False,
|
| 131 |
+
},
|
| 132 |
+
)
|
| 133 |
+
return httpx.Response(
|
| 134 |
+
200, json={"status": "ok"} if request.url.path == "/health" else _info()
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
adapter = WanProviderAdapter(client=_client(handler))
|
| 138 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 139 |
+
await adapter.ready()
|
| 140 |
+
assert raised.value.category is WorkerErrorCategory.WORKER_NOT_READY
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@pytest.mark.asyncio
|
| 144 |
+
async def test_wan_model_identity_mismatch_remains_unavailable() -> None:
|
| 145 |
+
wrong_info = {
|
| 146 |
+
**_info(),
|
| 147 |
+
"id": "different-wan-model",
|
| 148 |
+
"name": "Different model",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 152 |
+
if request.url.path == "/ready":
|
| 153 |
+
return httpx.Response(
|
| 154 |
+
200,
|
| 155 |
+
json={
|
| 156 |
+
"status": "ready",
|
| 157 |
+
"model_loaded": True,
|
| 158 |
+
"model": WAN_MODEL_ID,
|
| 159 |
+
"accepting_jobs": True,
|
| 160 |
+
},
|
| 161 |
+
)
|
| 162 |
+
return httpx.Response(200, json=wrong_info)
|
| 163 |
+
|
| 164 |
+
adapter = WanProviderAdapter(client=_client(handler))
|
| 165 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 166 |
+
await adapter.info()
|
| 167 |
+
assert raised.value.category is WorkerErrorCategory.PROVIDER_ERROR
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@pytest.mark.asyncio
|
| 171 |
+
async def test_wan_submission_is_multipart_and_has_no_automatic_retry(tmp_path: Path) -> None:
|
| 172 |
+
seen: list[httpx.Request] = []
|
| 173 |
+
|
| 174 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 175 |
+
seen.append(request)
|
| 176 |
+
return httpx.Response(202, json={"job_id": "wan_" + "a" * 32, "status": "queued"})
|
| 177 |
+
|
| 178 |
+
source = tmp_path / "input.png"
|
| 179 |
+
source.write_bytes(b"not-decoded-in-adapter-test")
|
| 180 |
+
adapter = WanProviderAdapter(client=_client(handler))
|
| 181 |
+
job = await adapter.submit(
|
| 182 |
+
payload={"prompt": "slow movement", "wan": {"duration_seconds": 0.5, "steps": 4}},
|
| 183 |
+
idempotency_key="generation-request-id",
|
| 184 |
+
input_path=source,
|
| 185 |
+
input_mime_type="image/png",
|
| 186 |
+
)
|
| 187 |
+
assert job.status is WorkerJobStatus.QUEUED
|
| 188 |
+
assert job.external_job_id.startswith("wan_")
|
| 189 |
+
assert seen[0].headers["authorization"] == "Bearer " + "x" * 32
|
| 190 |
+
body = seen[0].content.decode("latin-1")
|
| 191 |
+
assert 'name="image"' in body
|
| 192 |
+
assert 'name="duration_seconds"' in body
|
| 193 |
+
assert 'name="width"' not in body
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@pytest.mark.asyncio
|
| 197 |
+
async def test_wan_submission_connection_ambiguity_is_not_retried(tmp_path: Path) -> None:
|
| 198 |
+
calls = 0
|
| 199 |
+
request = httpx.Request("POST", "https://wan-worker.example/v1/generate")
|
| 200 |
+
|
| 201 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 202 |
+
nonlocal calls
|
| 203 |
+
calls += 1
|
| 204 |
+
raise httpx.ConnectError("Bearer " + "x" * 32, request=request)
|
| 205 |
+
|
| 206 |
+
source = tmp_path / "input.png"
|
| 207 |
+
source.write_bytes(b"input")
|
| 208 |
+
adapter = WanProviderAdapter(client=_client(handler, retries=3))
|
| 209 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 210 |
+
await adapter.submit(
|
| 211 |
+
payload={"prompt": "slow movement"},
|
| 212 |
+
idempotency_key="generation-request-id",
|
| 213 |
+
input_path=source,
|
| 214 |
+
input_mime_type="image/png",
|
| 215 |
+
)
|
| 216 |
+
assert raised.value.category is WorkerErrorCategory.WORKER_UNAVAILABLE
|
| 217 |
+
assert calls == 1
|
| 218 |
+
assert "Bearer" not in str(raised.value)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@pytest.mark.asyncio
|
| 222 |
+
async def test_wan_completed_job_maps_a_safe_video_output() -> None:
|
| 223 |
+
job_id = "wan_" + "b" * 32
|
| 224 |
+
|
| 225 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 226 |
+
return httpx.Response(
|
| 227 |
+
200,
|
| 228 |
+
json={
|
| 229 |
+
"job_id": job_id,
|
| 230 |
+
"status": "completed",
|
| 231 |
+
"output": {"type": "video", "filename": f"{job_id}.mp4"},
|
| 232 |
+
},
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
adapter = WanProviderAdapter(client=_client(handler))
|
| 236 |
+
job = await adapter.get_job(external_job_id=job_id)
|
| 237 |
+
assert job.output is not None
|
| 238 |
+
assert job.output.mime_type == "video/mp4"
|
| 239 |
+
assert job.output.provider_output_id == job_id
|
| 240 |
+
assert job.output.download_path == f"/v1/jobs/{job_id}/output"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@pytest.mark.asyncio
|
| 244 |
+
@pytest.mark.parametrize("status_code", [429, 502, 503, 504])
|
| 245 |
+
async def test_wan_polling_uses_shared_bounded_transient_retry(status_code: int) -> None:
|
| 246 |
+
calls = 0
|
| 247 |
+
job_id = "wan_" + "d" * 32
|
| 248 |
+
|
| 249 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 250 |
+
nonlocal calls
|
| 251 |
+
calls += 1
|
| 252 |
+
if calls < 3:
|
| 253 |
+
return httpx.Response(status_code, json={"detail": {"token": "never-store"}})
|
| 254 |
+
return httpx.Response(200, json={"job_id": job_id, "status": "running"})
|
| 255 |
+
|
| 256 |
+
job = await WanProviderAdapter(client=_client(handler, retries=2)).get_job(
|
| 257 |
+
external_job_id=job_id
|
| 258 |
+
)
|
| 259 |
+
assert job.status is WorkerJobStatus.RUNNING
|
| 260 |
+
assert calls == 3
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.asyncio
|
| 264 |
+
async def test_wan_polling_does_not_retry_permanent_client_errors() -> None:
|
| 265 |
+
calls = 0
|
| 266 |
+
job_id = "wan_" + "e" * 32
|
| 267 |
+
|
| 268 |
+
def handler(_: httpx.Request) -> httpx.Response:
|
| 269 |
+
nonlocal calls
|
| 270 |
+
calls += 1
|
| 271 |
+
return httpx.Response(400, json={"detail": {"code": "WAN_PARAMETERS_INVALID"}})
|
| 272 |
+
|
| 273 |
+
with pytest.raises(GenerationWorkerError) as raised:
|
| 274 |
+
await WanProviderAdapter(client=_client(handler, retries=3)).get_job(
|
| 275 |
+
external_job_id=job_id
|
| 276 |
+
)
|
| 277 |
+
assert raised.value.category is WorkerErrorCategory.INVALID_REQUEST
|
| 278 |
+
assert calls == 1
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@pytest.mark.asyncio
|
| 282 |
+
async def test_wan_cancellation_only_confirms_queued_worker_cancellation() -> None:
|
| 283 |
+
def queued_handler(_: httpx.Request) -> httpx.Response:
|
| 284 |
+
return httpx.Response(200, json={"job_id": "wan_" + "c" * 32, "status": "cancelled"})
|
| 285 |
+
|
| 286 |
+
def running_handler(_: httpx.Request) -> httpx.Response:
|
| 287 |
+
return httpx.Response(
|
| 288 |
+
409,
|
| 289 |
+
json={
|
| 290 |
+
"detail": {
|
| 291 |
+
"code": "WAN_JOB_NOT_CANCELLABLE",
|
| 292 |
+
"message": "A running job cannot be cancelled.",
|
| 293 |
+
"status": "running",
|
| 294 |
+
}
|
| 295 |
+
},
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
assert (
|
| 299 |
+
await WanProviderAdapter(client=_client(queued_handler)).cancel(
|
| 300 |
+
external_job_id="wan_" + "c" * 32
|
| 301 |
+
)
|
| 302 |
+
).status is WorkerCancellationStatus.CANCELLED
|
| 303 |
+
assert (
|
| 304 |
+
await WanProviderAdapter(client=_client(running_handler)).cancel(
|
| 305 |
+
external_job_id="wan_" + "c" * 32
|
| 306 |
+
)
|
| 307 |
+
).status is WorkerCancellationStatus.FAILED
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@pytest.mark.parametrize(
|
| 311 |
+
"invalid",
|
| 312 |
+
[
|
| 313 |
+
{"prompt": " "},
|
| 314 |
+
{"modality": "image"},
|
| 315 |
+
{"input_asset_id": None},
|
| 316 |
+
],
|
| 317 |
+
)
|
| 318 |
+
@pytest.mark.asyncio
|
| 319 |
+
async def test_wan_request_validation_rejects_invalid_required_values(
|
| 320 |
+
invalid: dict[str, object]
|
| 321 |
+
) -> None:
|
| 322 |
+
adapter = WanProviderAdapter(client=None)
|
| 323 |
+
with pytest.raises(Exception):
|
| 324 |
+
payload = _payload(**invalid)
|
| 325 |
+
await adapter.validate_request(payload)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.parametrize("field", ["width", "height", "num_frames", "provider_payload"])
|
| 329 |
+
def test_wan_schema_rejects_unsupported_parameters(field: str) -> None:
|
| 330 |
+
raw = _payload().model_dump()
|
| 331 |
+
wan = dict(raw["wan"] or {})
|
| 332 |
+
wan[field] = 1
|
| 333 |
+
raw["wan"] = wan
|
| 334 |
+
with pytest.raises(ValueError):
|
| 335 |
+
GenerationRequestCreate.model_validate(raw)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def test_wan_configuration_is_optional_and_never_enables_flux() -> None:
|
| 339 |
+
disabled = WanProviderAdapter.from_settings(Settings(_env_file=None))
|
| 340 |
+
invalid = WanProviderAdapter.from_settings(
|
| 341 |
+
Settings(_env_file=None, wan_space_url="https://wan-worker.example")
|
| 342 |
+
)
|
| 343 |
+
assert not disabled.available
|
| 344 |
+
assert not invalid.available
|
| 345 |
+
assert invalid.configuration_error is not None
|
| 346 |
+
assert WAN_PROVIDER_ID == "wan"
|
tests/test_health.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
|
| 3 |
+
from main import create_app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_health_endpoint(settings) -> None:
|
| 7 |
+
with TestClient(create_app(settings)) as client:
|
| 8 |
+
response = client.get("/health")
|
| 9 |
+
assert response.status_code == 200
|
| 10 |
+
payload = response.json()
|
| 11 |
+
assert payload["success"] is True
|
| 12 |
+
assert payload["metadata"]["status"] == "healthy"
|
| 13 |
+
assert response.headers["x-request-id"] == payload["request_id"]
|
tests/test_input_resolver.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from starlette.requests import Request
|
| 8 |
+
|
| 9 |
+
from app.container import build_container
|
| 10 |
+
from app.core.exceptions import InputError
|
| 11 |
+
from app.models.media import MediaSource
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def json_request(payload: bytes) -> Request:
|
| 15 |
+
sent = False
|
| 16 |
+
|
| 17 |
+
async def receive():
|
| 18 |
+
nonlocal sent
|
| 19 |
+
if sent:
|
| 20 |
+
return {"type": "http.disconnect"}
|
| 21 |
+
sent = True
|
| 22 |
+
return {"type": "http.request", "body": payload, "more_body": False}
|
| 23 |
+
|
| 24 |
+
request = Request(
|
| 25 |
+
{
|
| 26 |
+
"type": "http",
|
| 27 |
+
"method": "POST",
|
| 28 |
+
"path": "/v1/probe",
|
| 29 |
+
"headers": [(b"content-type", b"application/json")],
|
| 30 |
+
"query_string": b"",
|
| 31 |
+
},
|
| 32 |
+
receive,
|
| 33 |
+
)
|
| 34 |
+
request.state.request_id = str(uuid4())
|
| 35 |
+
return request
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def test_resolves_json_base64(settings) -> None:
|
| 39 |
+
container = build_container(settings)
|
| 40 |
+
encoded = base64.b64encode(b"ID3-not-real-audio").decode()
|
| 41 |
+
request = json_request(
|
| 42 |
+
('{"base64":"%s","filename":"sample.mp3","format":"wav"}' % encoded).encode()
|
| 43 |
+
)
|
| 44 |
+
resolved = await container.resolver.resolve(request)
|
| 45 |
+
assert resolved.primary.source is MediaSource.JSON_BASE64
|
| 46 |
+
assert resolved.primary.filename == "sample.mp3"
|
| 47 |
+
assert resolved.primary.temp_path.read_bytes() == b"ID3-not-real-audio"
|
| 48 |
+
assert resolved.params["filename"] == "sample.mp3"
|
| 49 |
+
assert resolved.params["format"] == "wav"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def test_resolves_n8n_binary_property(settings) -> None:
|
| 53 |
+
container = build_container(settings)
|
| 54 |
+
encoded = base64.b64encode(b"audio").decode()
|
| 55 |
+
payload = (
|
| 56 |
+
'{"binary":{"audio":{"data":"%s","fileName":"voice.mp3",'
|
| 57 |
+
'"mimeType":"audio/mpeg"}}}' % encoded
|
| 58 |
+
).encode()
|
| 59 |
+
resolved = await container.resolver.resolve(json_request(payload))
|
| 60 |
+
assert resolved.primary.source is MediaSource.N8N_BINARY
|
| 61 |
+
assert resolved.primary.filename == "voice.mp3"
|
| 62 |
+
assert resolved.primary.temp_path.read_bytes() == b"audio"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def test_resolves_nested_template_input(settings) -> None:
|
| 66 |
+
container = build_container(settings)
|
| 67 |
+
encoded = base64.b64encode(b"RIFF-template-audio").decode()
|
| 68 |
+
request = json_request(
|
| 69 |
+
(
|
| 70 |
+
'{"template":"mp3","input":{"base64":"%s",'
|
| 71 |
+
'"filename":"source.wav","mime_type":"audio/wav"},"parameters":{}}' % encoded
|
| 72 |
+
).encode()
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
resolved = await container.resolver.resolve(request)
|
| 76 |
+
|
| 77 |
+
assert resolved.primary.source is MediaSource.JSON_BASE64
|
| 78 |
+
assert resolved.primary.filename == "source.wav"
|
| 79 |
+
assert resolved.params["template"] == "mp3"
|
| 80 |
+
assert resolved.params["parameters"] == {}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def test_resolve_payload_copies_managed_temp_file(settings) -> None:
|
| 84 |
+
settings.output_dir.mkdir(parents=True)
|
| 85 |
+
source = settings.output_dir / "previous" / "clip.mp3"
|
| 86 |
+
source.parent.mkdir()
|
| 87 |
+
source.write_bytes(b"ID3-managed-media")
|
| 88 |
+
container = build_container(settings)
|
| 89 |
+
|
| 90 |
+
resolved = await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4()))
|
| 91 |
+
|
| 92 |
+
assert resolved.primary.source is MediaSource.LOCAL_PATH
|
| 93 |
+
assert resolved.primary.temp_path != source
|
| 94 |
+
assert resolved.primary.temp_path.read_bytes() == source.read_bytes()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def test_resolve_payload_rejects_unmanaged_path(settings, tmp_path) -> None:
|
| 98 |
+
source = tmp_path / "outside.mp3"
|
| 99 |
+
source.write_bytes(b"ID3-unmanaged-media")
|
| 100 |
+
container = build_container(settings)
|
| 101 |
+
|
| 102 |
+
with pytest.raises(InputError, match="TEMP_DIR or OUTPUT_DIR"):
|
| 103 |
+
await container.resolver.resolve_payload({"temp_path": str(source)}, str(uuid4()))
|