From e5236af708bee1f51bd7548e7a1db5b6c649a011 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:22:29 +0000 Subject: [PATCH 1/6] Initial plan From 28dd226beb2b41b0a46a0ff7b95977bf2e928bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:29:43 +0000 Subject: [PATCH 2/6] feat: implement artifact upload/download backend (type detector, content hash, blob storage, events) - Add type_detector.py for content-based artifact type detection - Add content_hash.py for SHA-256 content hashing - Add publish_event and build_cloud_event to EventGridPublisher - Add upload_blob, download_blob, download_blob_stream to BlobService - Add blob_path field to Artifact model - Add update method to ArtifactRepository - Implement upload_artifact and download_artifact in ArtifactService - Add POST upload and GET download endpoints to artifact router - Add pyyaml dependency Agent-Logs-Url: https://github.com/christopherhouse/Azure-Integration-Copilot/sessions/4cb396ca-06ce-4a66-999a-7a41a96ba68f Co-authored-by: christopherhouse <748998+christopherhouse@users.noreply.github.com> --- src/backend/domains/artifacts/content_hash.py | 19 +++ src/backend/domains/artifacts/models.py | 1 + src/backend/domains/artifacts/repository.py | 15 +++ src/backend/domains/artifacts/router.py | 119 +++++++++++++++++- src/backend/domains/artifacts/service.py | 111 +++++++++++++++- .../domains/artifacts/type_detector.py | 91 ++++++++++++++ src/backend/pyproject.toml | 1 + src/backend/shared/blob.py | 28 +++++ src/backend/shared/events.py | 41 ++++++ src/backend/uv.lock | 2 + 10 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/backend/domains/artifacts/content_hash.py create mode 100644 src/backend/domains/artifacts/type_detector.py diff --git a/src/backend/domains/artifacts/content_hash.py b/src/backend/domains/artifacts/content_hash.py new file mode 100644 index 0000000..b156da6 --- /dev/null +++ b/src/backend/domains/artifacts/content_hash.py @@ -0,0 +1,19 @@ +"""Content hashing utility for artifact uploads.""" + +import hashlib + +from fastapi import UploadFile + + +async def compute_hash(file: UploadFile) -> str: + """Compute a SHA-256 hash of the uploaded file content. + + The file position is reset to 0 after hashing so the file can + be read again by subsequent callers. + """ + sha256 = hashlib.sha256() + await file.seek(0) + while chunk := await file.read(8192): + sha256.update(chunk) + await file.seek(0) + return f"sha256:{sha256.hexdigest()}" diff --git a/src/backend/domains/artifacts/models.py b/src/backend/domains/artifacts/models.py index f8a5778..a6ef7de 100644 --- a/src/backend/domains/artifacts/models.py +++ b/src/backend/domains/artifacts/models.py @@ -81,6 +81,7 @@ class Artifact(BaseModel): artifact_type: str | None = Field(default=None, alias="artifactType") status: ArtifactStatus = ArtifactStatus.UPLOADING file_size_bytes: int | None = Field(default=None, alias="fileSizeBytes") + blob_path: str | None = Field(default=None, alias="blobPath") content_hash: str | None = Field(default=None, alias="contentHash") error: ArtifactError | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") diff --git a/src/backend/domains/artifacts/repository.py b/src/backend/domains/artifacts/repository.py index 19a1cae..aec60d4 100644 --- a/src/backend/domains/artifacts/repository.py +++ b/src/backend/domains/artifacts/repository.py @@ -120,6 +120,21 @@ async def update_status( updated.etag = result.get("_etag") return updated + async def update(self, artifact: Artifact) -> Artifact: + """Persist an artifact document (full replace) with optimistic concurrency.""" + container = await self._get_container() + artifact.updated_at = datetime.now(UTC) + doc = artifact.model_dump(by_alias=True) + kwargs: dict = {} + if artifact.etag: + kwargs["etag"] = artifact.etag + kwargs["match_condition"] = "IfMatch" + result = await container.replace_item(item=artifact.id, body=doc, **kwargs) + logger.info("artifact_updated", artifact_id=artifact.id) + updated = Artifact.model_validate(result) + updated.etag = result.get("_etag") + return updated + async def soft_delete(self, tenant_id: str, artifact_id: str) -> Artifact | None: """Soft-delete an artifact by setting deletedAt timestamp.""" artifact = await self.get_by_id(tenant_id, artifact_id) diff --git a/src/backend/domains/artifacts/router.py b/src/backend/domains/artifacts/router.py index b7f3ad1..3c4a12c 100644 --- a/src/backend/domains/artifacts/router.py +++ b/src/backend/domains/artifacts/router.py @@ -5,8 +5,8 @@ from datetime import UTC, datetime import structlog -from fastapi import APIRouter, Query, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, File, Form, Query, Request, UploadFile +from fastapi.responses import JSONResponse, Response from shared.models import Meta, PaginatedResponse, PaginationInfo, ResponseEnvelope @@ -22,6 +22,13 @@ def _request_id(request: Request) -> str: return request.headers.get("X-Request-ID", str(uuid.uuid4())) +def _get_tenant(request: Request): + """Return (tenant, tier) or (None, None) from request state.""" + tenant = getattr(request.state, "tenant", None) + tier = getattr(request.state, "tier", None) + return tenant, tier + + def _get_tenant_id(request: Request) -> str | None: tenant = getattr(request.state, "tenant", None) if tenant is None: @@ -29,6 +36,70 @@ def _get_tenant_id(request: Request) -> str | None: return tenant.id +# --------------------------------------------------------------------------- +# POST — Upload artifact +# --------------------------------------------------------------------------- + + +@router.post("", status_code=202) +async def upload_artifact( + project_id: str, + request: Request, + file: UploadFile = File(...), # noqa: B008 + artifact_type: str | None = Form(default=None), # noqa: B008 +): + """Upload an artifact file (multipart/form-data).""" + req_id = _request_id(request) + tenant, tier = _get_tenant(request) + + if tenant is None or tier is None: + return JSONResponse( + status_code=401, + content={ + "error": { + "code": "UNAUTHORIZED", + "message": "Tenant context required.", + "request_id": req_id, + } + }, + ) + + try: + artifact = await artifact_service.upload_artifact( + tenant=tenant, + tier=tier, + project_id=project_id, + file=file, + artifact_type_override=artifact_type, + ) + except ValueError as exc: + return JSONResponse( + status_code=413, + content={ + "error": { + "code": "FILE_TOO_LARGE", + "message": str(exc), + "request_id": req_id, + } + }, + ) + + artifact_resp = ArtifactResponse.from_artifact(artifact) + envelope = ResponseEnvelope( + data=artifact_resp.model_dump(by_alias=True), + meta=Meta(request_id=req_id, timestamp=datetime.now(UTC)), + ) + return JSONResponse( + status_code=202, + content=envelope.model_dump(mode="json"), + ) + + +# --------------------------------------------------------------------------- +# GET — List artifacts +# --------------------------------------------------------------------------- + + @router.get("") async def list_artifacts( project_id: str, @@ -144,3 +215,47 @@ async def delete_artifact(project_id: str, artifact_id: str, request: Request): ) return None + + +# --------------------------------------------------------------------------- +# GET — Download artifact file +# --------------------------------------------------------------------------- + + +@router.get("/{artifact_id}/download") +async def download_artifact(project_id: str, artifact_id: str, request: Request): + """Download the raw artifact file from Blob Storage.""" + req_id = _request_id(request) + tenant_id = _get_tenant_id(request) + + if tenant_id is None: + return JSONResponse( + status_code=401, + content={ + "error": { + "code": "UNAUTHORIZED", + "message": "Tenant context required.", + "request_id": req_id, + } + }, + ) + + result = await artifact_service.download_artifact(tenant_id, project_id, artifact_id) + if result is None: + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Artifact not found.", + "request_id": req_id, + } + }, + ) + + content, filename = result + return Response( + content=content, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/src/backend/domains/artifacts/service.py b/src/backend/domains/artifacts/service.py index 8798d90..e4aa878 100644 --- a/src/backend/domains/artifacts/service.py +++ b/src/backend/domains/artifacts/service.py @@ -1,9 +1,19 @@ """Artifact domain service — business logic for artifact metadata.""" +import uuid + import structlog +from fastapi import UploadFile + +from domains.tenants.models import Tenant, TierDefinition +from domains.tenants.repository import tenant_repository +from shared.blob import blob_service +from shared.events import ARTIFACT_UPLOADED, build_cloud_event, event_grid_publisher -from .models import Artifact, ArtifactStatus +from .content_hash import compute_hash +from .models import Artifact, ArtifactStatus, transition_status from .repository import artifact_repository +from .type_detector import detect_artifact_type logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -11,6 +21,105 @@ class ArtifactService: """Manages artifact metadata operations.""" + async def upload_artifact( + self, + tenant: Tenant, + tier: TierDefinition, + project_id: str, + file: UploadFile, + artifact_type_override: str | None = None, + ) -> Artifact: + """Upload an artifact file, store in Blob Storage, and publish event. + + Returns the created artifact metadata document. + Raises ValueError for file size violations. + """ + # --- File size validation --- + max_size = tier.limits.max_file_size_mb * 1024 * 1024 + content = await file.read() + file_size = len(content) + await file.seek(0) + + if file_size > max_size: + raise ValueError( + f"File size {file_size} exceeds maximum {max_size} bytes " + f"({tier.limits.max_file_size_mb} MB)." + ) + + # --- Generate artifact ID --- + artifact_id = f"art_{uuid.uuid4().hex[:12]}" + + # --- Detect artifact type --- + filename = file.filename or "unknown" + if artifact_type_override: + detected_type = artifact_type_override + else: + detected_type = await detect_artifact_type(filename, file) + + # --- Create initial metadata (status: uploading) --- + artifact = Artifact( + id=artifact_id, + partitionKey=tenant.id, + tenantId=tenant.id, + projectId=project_id, + name=filename, + artifactType=detected_type, + status=ArtifactStatus.UPLOADING, + fileSizeBytes=file_size, + ) + artifact = await artifact_repository.create(artifact) + + # --- Upload to Blob Storage --- + blob_path = f"tenants/{tenant.id}/projects/{project_id}/artifacts/{artifact_id}/{filename}" + await blob_service.upload_blob(blob_path, content, content_type=file.content_type or "application/octet-stream") + + # --- Compute content hash --- + content_hash = await compute_hash(file) + + # --- Update metadata to uploaded / unsupported --- + target_status = ArtifactStatus.UNSUPPORTED if detected_type == "unknown" else ArtifactStatus.UPLOADED + transition_status(artifact.status, target_status) + artifact.status = target_status + artifact.blob_path = blob_path + artifact.content_hash = content_hash + artifact = await artifact_repository.update(artifact) + + # --- Increment tenant usage --- + await tenant_repository.increment_usage(tenant.id, "total_artifact_count") + + # --- Publish ArtifactUploaded event --- + if target_status == ArtifactStatus.UPLOADED: + event = build_cloud_event( + event_type=ARTIFACT_UPLOADED, + subject=f"tenants/{tenant.id}/projects/{project_id}/artifacts/{artifact_id}", + data={ + "tenantId": tenant.id, + "projectId": project_id, + "artifactId": artifact_id, + "artifactType": detected_type, + "blobPath": blob_path, + "fileSizeBytes": file_size, + "contentHash": content_hash, + }, + ) + await event_grid_publisher.publish_event(event) + + return artifact + + async def download_artifact( + self, tenant_id: str, project_id: str, artifact_id: str + ) -> tuple[bytes, str] | None: + """Download artifact file content from Blob Storage. + + Returns (content_bytes, filename) or None if artifact not found. + """ + artifact = await self.get_artifact(tenant_id, project_id, artifact_id) + if artifact is None or artifact.blob_path is None: + return None + + content = await blob_service.download_blob(artifact.blob_path) + return content, artifact.name + async def get_artifact( self, tenant_id: str, project_id: str, artifact_id: str ) -> Artifact | None: diff --git a/src/backend/domains/artifacts/type_detector.py b/src/backend/domains/artifacts/type_detector.py new file mode 100644 index 0000000..522c15d --- /dev/null +++ b/src/backend/domains/artifacts/type_detector.py @@ -0,0 +1,91 @@ +"""Artifact type detection based on file extension and content inspection.""" + +import json + +import structlog +import yaml +from fastapi import UploadFile + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# Maximum bytes to read for content-based detection. +_DETECTION_HEAD = 8192 + + +async def detect_artifact_type(filename: str, file: UploadFile) -> str: + """Detect the artifact type from filename extension and content. + + After reading content for detection the file position is reset to 0 + so that callers can continue to stream the file for upload. + """ + ext = _extension(filename) + + try: + if ext in (".json", ".yaml", ".yml"): + return await _detect_json_yaml(file, ext) + if ext == ".xml": + return await _detect_xml(file) + if ext == ".tf": + return "terraform" + if ext == ".bicep": + return "bicep" + except Exception: + logger.warning("type_detection_error", filename=filename, exc_info=True) + finally: + await file.seek(0) + + return "unknown" + + +def _extension(filename: str) -> str: + """Return the lowercased file extension including the dot.""" + dot = filename.rfind(".") + if dot == -1: + return "" + return filename[dot:].lower() + + +async def _detect_json_yaml(file: UploadFile, ext: str) -> str: + """Detect Logic App workflows and OpenAPI specs from JSON/YAML content.""" + head = await file.read(_DETECTION_HEAD) + if not head: + return "unknown" + + text = head.decode("utf-8", errors="replace") + + try: + if ext == ".json": + data = json.loads(text) + else: + data = yaml.safe_load(text) + except Exception: + return "unknown" + + if not isinstance(data, dict): + return "unknown" + + # Logic App workflow: has a "definition" key containing triggers/actions + definition = data.get("definition") + if isinstance(definition, dict) and ("triggers" in definition or "actions" in definition): + return "logic_app_workflow" + + # OpenAPI / Swagger spec + if "openapi" in data or "swagger" in data: + return "openapi_spec" + + return "unknown" + + +async def _detect_xml(file: UploadFile) -> str: + """Detect APIM policies from XML content.""" + head = await file.read(_DETECTION_HEAD) + if not head: + return "unknown" + + text = head.decode("utf-8", errors="replace").strip() + + # Simple heuristic: look for root element + if "=3.5.0", "azure-messaging-webpubsubservice>=1.3.0", + "pyyaml>=6.0.3", ] [dependency-groups] diff --git a/src/backend/shared/blob.py b/src/backend/shared/blob.py index 9ec18b8..f87b2e1 100644 --- a/src/backend/shared/blob.py +++ b/src/backend/shared/blob.py @@ -1,4 +1,5 @@ import logging +from collections.abc import AsyncIterator import structlog from azure.identity.aio import DefaultAzureCredential @@ -9,6 +10,8 @@ logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) +ARTIFACTS_CONTAINER = "artifacts" + class BlobService: """Async wrapper around Azure Blob Storage client.""" @@ -25,6 +28,31 @@ async def _get_client(self) -> BlobServiceClient: ) return self._client + async def upload_blob(self, blob_path: str, data: bytes, content_type: str = "application/octet-stream") -> None: + """Upload data to a blob at the given path in the artifacts container.""" + client = await self._get_client() + container_client = client.get_container_client(ARTIFACTS_CONTAINER) + blob_client = container_client.get_blob_client(blob_path) + await blob_client.upload_blob(data, overwrite=True, content_settings={"content_type": content_type}) + logger.info("blob_uploaded", blob_path=blob_path) + + async def download_blob(self, blob_path: str) -> bytes: + """Download the full contents of a blob from the artifacts container.""" + client = await self._get_client() + container_client = client.get_container_client(ARTIFACTS_CONTAINER) + blob_client = container_client.get_blob_client(blob_path) + downloader = await blob_client.download_blob() + return await downloader.readall() + + async def download_blob_stream(self, blob_path: str) -> AsyncIterator[bytes]: + """Stream blob contents as an async iterator of chunks.""" + client = await self._get_client() + container_client = client.get_container_client(ARTIFACTS_CONTAINER) + blob_client = container_client.get_blob_client(blob_path) + downloader = await blob_client.download_blob() + async for chunk in downloader.chunks(): + yield chunk + async def ping(self) -> bool: """Check connectivity to Azure Blob Storage. Returns True if reachable.""" try: diff --git a/src/backend/shared/events.py b/src/backend/shared/events.py index 63a9f0f..7948ec2 100644 --- a/src/backend/shared/events.py +++ b/src/backend/shared/events.py @@ -1,6 +1,8 @@ import logging +from typing import Any import structlog +from azure.core.messaging import CloudEvent from azure.core.rest import HttpRequest from azure.eventgrid.aio import EventGridPublisherClient from azure.identity.aio import DefaultAzureCredential @@ -10,6 +12,28 @@ logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) +# --------------------------------------------------------------------------- +# Event type constants +# --------------------------------------------------------------------------- + +ARTIFACT_UPLOADED = "com.integration-copilot.artifact.uploaded.v1" + + +def build_cloud_event( + *, + event_type: str, + subject: str, + data: dict[str, Any], + source: str = "/integration-copilot/api", +) -> CloudEvent: + """Build an Azure SDK ``CloudEvent`` instance in CloudEvents v1.0 format.""" + return CloudEvent( + type=event_type, + source=source, + subject=subject, + data=data, + ) + class EventGridPublisher: """Wrapper around Event Grid Namespace publish API.""" @@ -27,6 +51,23 @@ async def _get_client(self) -> EventGridPublisherClient: ) return self._client + async def publish_event(self, event: CloudEvent) -> None: + """Publish a single CloudEvent to the configured Event Grid topic. + + If Event Grid is not configured (empty endpoint), the event is + silently skipped with a warning log. + """ + if not settings.event_grid_namespace_endpoint: + logger.warning("event_grid_not_configured", event_type=event.type) + return + + try: + client = await self._get_client() + await client.send(event, namespace_topic=settings.event_grid_topic) + logger.info("event_published", event_type=event.type, subject=event.subject) + except Exception: + logger.warning("event_publish_failed", event_type=event.type, exc_info=True) + async def ping(self) -> bool: """Check connectivity to Azure Event Grid Namespace. Returns True if reachable.""" try: diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 416e5db..db49940 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -770,6 +770,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, + { name = "pyyaml" }, { name = "structlog" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -799,6 +800,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "structlog" }, { name = "uvicorn", extras = ["standard"] }, ] From 607129c68931184277faef2f5de04c6762668bf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:33:22 +0000 Subject: [PATCH 3/6] feat: add backend tests and frontend artifact upload UI - Add test_type_detection.py (11 tests for type detection) - Add test_artifact_upload.py (6 tests for upload endpoint) - Add test_artifact_download.py (3 tests for download endpoint) - Add artifact-status-badge.tsx (color-coded status badges) - Add artifact-list.tsx (table showing artifacts) - Add artifact-upload.tsx (drag-and-drop upload dropzone) - Add use-artifacts.ts (React Query hooks) - Add artifacts page at projects/[projectId]/artifacts Agent-Logs-Url: https://github.com/christopherhouse/Azure-Integration-Copilot/sessions/4cb396ca-06ce-4a66-999a-7a41a96ba68f Co-authored-by: christopherhouse <748998+christopherhouse@users.noreply.github.com> --- .../projects/[projectId]/artifacts/page.tsx | 54 +++++ .../components/artifacts/artifact-list.tsx | 79 ++++++ .../artifacts/artifact-status-badge.tsx | 78 ++++++ .../components/artifacts/artifact-upload.tsx | 75 ++++++ src/frontend/src/hooks/use-artifacts.ts | 78 ++++++ tests/backend/test_artifact_download.py | 127 ++++++++++ tests/backend/test_artifact_upload.py | 226 ++++++++++++++++++ tests/backend/test_type_detection.py | 157 ++++++++++++ 8 files changed, 874 insertions(+) create mode 100644 src/frontend/src/app/(dashboard)/projects/[projectId]/artifacts/page.tsx create mode 100644 src/frontend/src/components/artifacts/artifact-list.tsx create mode 100644 src/frontend/src/components/artifacts/artifact-status-badge.tsx create mode 100644 src/frontend/src/components/artifacts/artifact-upload.tsx create mode 100644 src/frontend/src/hooks/use-artifacts.ts create mode 100644 tests/backend/test_artifact_download.py create mode 100644 tests/backend/test_artifact_upload.py create mode 100644 tests/backend/test_type_detection.py diff --git a/src/frontend/src/app/(dashboard)/projects/[projectId]/artifacts/page.tsx b/src/frontend/src/app/(dashboard)/projects/[projectId]/artifacts/page.tsx new file mode 100644 index 0000000..80fa67a --- /dev/null +++ b/src/frontend/src/app/(dashboard)/projects/[projectId]/artifacts/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { toast } from "sonner"; +import { ArtifactUpload } from "@/components/artifacts/artifact-upload"; +import { ArtifactList } from "@/components/artifacts/artifact-list"; +import { useArtifacts, useUploadArtifact } from "@/hooks/use-artifacts"; + +export default function ArtifactsPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + + const { data, isLoading, error } = useArtifacts(projectId); + const uploadMutation = useUploadArtifact(projectId); + + const handleUpload = (file: File) => { + uploadMutation.mutate(file, { + onSuccess: () => { + toast.success(`Uploaded ${file.name}`); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Upload failed"); + }, + }); + }; + + return ( +
+
+

Artifacts

+

+ Upload and manage integration artifacts for this project. +

+
+ + + + {isLoading && ( +

Loading artifacts…

+ )} + + {error && ( +

+ Failed to load artifacts. Please try again. +

+ )} + + {data && } +
+ ); +} diff --git a/src/frontend/src/components/artifacts/artifact-list.tsx b/src/frontend/src/components/artifacts/artifact-list.tsx new file mode 100644 index 0000000..3aa1879 --- /dev/null +++ b/src/frontend/src/components/artifacts/artifact-list.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ArtifactStatusBadge } from "./artifact-status-badge"; +import type { Artifact } from "@/hooks/use-artifacts"; + +function formatBytes(bytes: number | null): string { + if (bytes === null || bytes === undefined) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function artifactTypeLabel(type: string | null): string { + if (!type) return "—"; + const labels: Record = { + logic_app_workflow: "Logic App Workflow", + openapi_spec: "OpenAPI Spec", + apim_policy: "APIM Policy", + terraform: "Terraform", + bicep: "Bicep", + unknown: "Unknown", + }; + return labels[type] ?? type; +} + +export function ArtifactList({ artifacts }: { artifacts: Artifact[] }) { + if (artifacts.length === 0) { + return ( +

+ No artifacts uploaded yet. Drag and drop files above to get started. +

+ ); + } + + return ( +
+ + + + + + + + + + + + {artifacts.map((a) => ( + + + + + + + + ))} + +
NameTypeStatusSizeUploaded
{a.name} + {artifactTypeLabel(a.artifactType)} + + + + {formatBytes(a.fileSizeBytes)} + + {formatDate(a.createdAt)} +
+
+ ); +} diff --git a/src/frontend/src/components/artifacts/artifact-status-badge.tsx b/src/frontend/src/components/artifacts/artifact-status-badge.tsx new file mode 100644 index 0000000..54d0b58 --- /dev/null +++ b/src/frontend/src/components/artifacts/artifact-status-badge.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import type { ArtifactStatus } from "@/types/api"; + +const statusConfig: Record< + ArtifactStatus, + { label: string; className: string } +> = { + uploading: { + label: "Uploading", + className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + }, + uploaded: { + label: "Uploaded", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + scanning: { + label: "Scanning", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + scan_passed: { + label: "Scan Passed", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + scan_failed: { + label: "Scan Failed", + className: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }, + parsing: { + label: "Parsing", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + parsed: { + label: "Parsed", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + parse_failed: { + label: "Parse Failed", + className: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }, + graph_building: { + label: "Building Graph", + className: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }, + graph_built: { + label: "Graph Built", + className: + "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + }, + graph_failed: { + label: "Graph Failed", + className: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }, + unsupported: { + label: "Unsupported", + className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + }, +}; + +export function ArtifactStatusBadge({ status }: { status: ArtifactStatus }) { + const config = statusConfig[status] ?? { + label: status, + className: "bg-gray-100 text-gray-600", + }; + + return ( + + {config.label} + + ); +} diff --git a/src/frontend/src/components/artifacts/artifact-upload.tsx b/src/frontend/src/components/artifacts/artifact-upload.tsx new file mode 100644 index 0000000..a52caae --- /dev/null +++ b/src/frontend/src/components/artifacts/artifact-upload.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const ACCEPTED_EXTENSIONS = [".json", ".yaml", ".yml", ".xml"]; + +interface ArtifactUploadProps { + onUpload: (file: File) => void; + isUploading?: boolean; +} + +export function ArtifactUpload({ onUpload, isUploading }: ArtifactUploadProps) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleFiles = useCallback( + (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + onUpload(file); + }, + [onUpload], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + handleFiles(e.dataTransfer.files); + }, + [handleFiles], + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/hooks/use-artifacts.ts b/src/frontend/src/hooks/use-artifacts.ts new file mode 100644 index 0000000..0103f98 --- /dev/null +++ b/src/frontend/src/hooks/use-artifacts.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ArtifactStatus } from "@/types/api"; + +/** Artifact data returned from the API. */ +export interface Artifact { + id: string; + name: string; + artifactType: string | null; + status: ArtifactStatus; + fileSizeBytes: number | null; + contentHash: string | null; + createdAt: string; + updatedAt: string; +} + +interface ArtifactListResponse { + data: Artifact[]; + pagination: { + page: number; + page_size: number; + total_count: number; + total_pages: number; + has_next_page: boolean; + }; +} + +async function fetchArtifacts( + projectId: string, + page = 1, + pageSize = 20, +): Promise { + const res = await fetch( + `/api/v1/projects/${projectId}/artifacts?page=${page}&pageSize=${pageSize}`, + ); + if (!res.ok) throw new Error("Failed to fetch artifacts"); + return res.json(); +} + +async function uploadArtifact( + projectId: string, + file: File, +): Promise { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`/api/v1/projects/${projectId}/artifacts`, { + method: "POST", + body: form, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error?.message ?? "Upload failed"); + } + const json = await res.json(); + return json.data; +} + +/** Hook to list artifacts for a project. */ +export function useArtifacts(projectId: string, page = 1, pageSize = 20) { + return useQuery({ + queryKey: ["artifacts", projectId, page, pageSize], + queryFn: () => fetchArtifacts(projectId, page, pageSize), + enabled: !!projectId, + }); +} + +/** Hook to upload an artifact file. */ +export function useUploadArtifact(projectId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (file: File) => uploadArtifact(projectId, file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["artifacts", projectId] }); + }, + }); +} diff --git a/tests/backend/test_artifact_download.py b/tests/backend/test_artifact_download.py new file mode 100644 index 0000000..f9d417e --- /dev/null +++ b/tests/backend/test_artifact_download.py @@ -0,0 +1,127 @@ +"""Tests for artifact download endpoint.""" + +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src" / "backend")) + +_test_env = { + "ENVIRONMENT": "test", + "COSMOS_DB_ENDPOINT": "https://fake-cosmos.documents.azure.com:443/", + "BLOB_STORAGE_ENDPOINT": "https://fake-blob.blob.core.windows.net/", + "EVENT_GRID_NAMESPACE_ENDPOINT": "", + "WEB_PUBSUB_ENDPOINT": "", + "AZURE_CLIENT_ID": "", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "SKIP_AUTH": "true", +} + +with patch.dict(os.environ, _test_env): + from main import app # noqa: E402 + +from domains.tenants.models import Tenant, TenantStatus, Usage, User, UserRole, UserStatus + + +def _make_tenant(tenant_id: str = "t-001") -> Tenant: + now = datetime.now(UTC) + return Tenant( + id=tenant_id, + partitionKey=tenant_id, + displayName="Test Tenant", + ownerId="u-001", + tierId="tier_free", + status=TenantStatus.ACTIVE, + usage=Usage(daily_analysis_reset_at=now), + createdAt=now, + updatedAt=now, + ) + + +def _make_user(tenant_id: str = "t-001") -> User: + return User( + id="u-001", + partitionKey=tenant_id, + tenantId=tenant_id, + externalId="dev-user-001", + role=UserRole.OWNER, + status=UserStatus.ACTIVE, + createdAt=datetime.now(UTC), + ) + + +def _setup_context_mocks(tenant, user=None): + if user is None: + user = _make_user(tenant.id if tenant else "t-001") + return ( + patch("middleware.tenant_context.user_service", **{ + "get_user_by_external_id": AsyncMock(return_value=user), + }), + patch("middleware.tenant_context.tenant_service", **{ + "get_tenant": AsyncMock(return_value=tenant), + }), + patch("middleware.tenant_context.settings", **{ + "skip_auth": True, + "cosmos_db_endpoint": "https://fake.documents.azure.com/", + }), + ) + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --------------------------------------------------------------------------- +# GET /api/v1/projects/{projectId}/artifacts/{artifactId}/download +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_download_artifact_returns_content(client): + """GET download returns the raw file content with Content-Disposition.""" + tenant = _make_tenant() + file_content = b'{"definition": {"triggers": {}}}' + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.download_artifact = AsyncMock(return_value=(file_content, "workflow.json")) + + response = await client.get("/api/v1/projects/prj-001/artifacts/art-001/download") + assert response.status_code == 200 + assert response.content == file_content + assert 'attachment; filename="workflow.json"' in response.headers["content-disposition"] + + +@pytest.mark.asyncio +async def test_download_artifact_returns_404_when_not_found(client): + """GET download returns 404 for missing artifact.""" + tenant = _make_tenant() + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.download_artifact = AsyncMock(return_value=None) + + response = await client.get("/api/v1/projects/prj-001/artifacts/art-nonexistent/download") + assert response.status_code == 404 + body = response.json() + assert body["error"]["code"] == "RESOURCE_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_download_artifact_returns_401_without_tenant(client): + """GET download without tenant context returns 401.""" + mock1, mock2, mock3 = _setup_context_mocks(None) + with mock1, mock2, mock3: + response = await client.get("/api/v1/projects/prj-001/artifacts/art-001/download") + assert response.status_code == 401 diff --git a/tests/backend/test_artifact_upload.py b/tests/backend/test_artifact_upload.py new file mode 100644 index 0000000..3fe0b27 --- /dev/null +++ b/tests/backend/test_artifact_upload.py @@ -0,0 +1,226 @@ +"""Tests for artifact upload endpoint.""" + +import io +import json +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src" / "backend")) + +_test_env = { + "ENVIRONMENT": "test", + "COSMOS_DB_ENDPOINT": "https://fake-cosmos.documents.azure.com:443/", + "BLOB_STORAGE_ENDPOINT": "https://fake-blob.blob.core.windows.net/", + "EVENT_GRID_NAMESPACE_ENDPOINT": "", + "WEB_PUBSUB_ENDPOINT": "", + "AZURE_CLIENT_ID": "", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "SKIP_AUTH": "true", +} + +with patch.dict(os.environ, _test_env): + from main import app # noqa: E402 + +from domains.artifacts.models import Artifact, ArtifactStatus +from domains.tenants.models import Tenant, TenantStatus, TierLimits, Usage, User, UserRole, UserStatus + + +def _make_tenant(tenant_id: str = "t-001") -> Tenant: + now = datetime.now(UTC) + return Tenant( + id=tenant_id, + partitionKey=tenant_id, + displayName="Test Tenant", + ownerId="u-001", + tierId="tier_free", + status=TenantStatus.ACTIVE, + usage=Usage(daily_analysis_reset_at=now), + createdAt=now, + updatedAt=now, + ) + + +def _make_user(tenant_id: str = "t-001") -> User: + return User( + id="u-001", + partitionKey=tenant_id, + tenantId=tenant_id, + externalId="dev-user-001", + role=UserRole.OWNER, + status=UserStatus.ACTIVE, + createdAt=datetime.now(UTC), + ) + + +def _make_artifact( + artifact_id: str = "art_abc123", + tenant_id: str = "t-001", + project_id: str = "prj-001", + name: str = "workflow.json", + status: ArtifactStatus = ArtifactStatus.UPLOADED, + blob_path: str | None = None, +) -> Artifact: + now = datetime.now(UTC) + return Artifact( + id=artifact_id, + partitionKey=tenant_id, + tenantId=tenant_id, + projectId=project_id, + name=name, + artifactType="logic_app_workflow", + status=status, + fileSizeBytes=1024, + blobPath=blob_path or f"tenants/{tenant_id}/projects/{project_id}/artifacts/{artifact_id}/{name}", + contentHash="sha256:abc123def456", + createdAt=now, + updatedAt=now, + ) + + +def _setup_context_mocks(tenant, user=None): + if user is None: + user = _make_user(tenant.id if tenant else "t-001") + return ( + patch("middleware.tenant_context.user_service", **{ + "get_user_by_external_id": AsyncMock(return_value=user), + }), + patch("middleware.tenant_context.tenant_service", **{ + "get_tenant": AsyncMock(return_value=tenant), + }), + patch("middleware.tenant_context.settings", **{ + "skip_auth": True, + "cosmos_db_endpoint": "https://fake.documents.azure.com/", + }), + ) + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --------------------------------------------------------------------------- +# POST /api/v1/projects/{projectId}/artifacts — Upload +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_upload_artifact_returns_202(client): + """POST upload returns 202 with artifact metadata.""" + tenant = _make_tenant() + artifact = _make_artifact() + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.upload_artifact = AsyncMock(return_value=artifact) + + response = await client.post( + "/api/v1/projects/prj-001/artifacts", + files={"file": ("workflow.json", b'{"definition": {"triggers": {}}}', "application/json")}, + ) + assert response.status_code == 202 + body = response.json() + assert body["data"]["id"] == "art_abc123" + assert body["data"]["name"] == "workflow.json" + assert body["data"]["artifactType"] == "logic_app_workflow" + assert body["data"]["status"] == "uploaded" + assert "meta" in body + + +@pytest.mark.asyncio +async def test_upload_artifact_returns_413_for_large_file(client): + """POST upload returns 413 when file exceeds size limit.""" + tenant = _make_tenant() + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.upload_artifact = AsyncMock( + side_effect=ValueError("File size 20971520 exceeds maximum 10485760 bytes (10 MB).") + ) + + response = await client.post( + "/api/v1/projects/prj-001/artifacts", + files={"file": ("big.json", b"x" * 100, "application/json")}, + ) + assert response.status_code == 413 + body = response.json() + assert body["error"]["code"] == "FILE_TOO_LARGE" + + +@pytest.mark.asyncio +async def test_upload_artifact_with_type_override(client): + """POST upload with artifact_type form field passes override to service.""" + tenant = _make_tenant() + artifact = _make_artifact() + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.upload_artifact = AsyncMock(return_value=artifact) + + response = await client.post( + "/api/v1/projects/prj-001/artifacts", + files={"file": ("workflow.json", b'{}', "application/json")}, + data={"artifact_type": "openapi_spec"}, + ) + assert response.status_code == 202 + # Verify the override was passed + call_kwargs = mock_svc.upload_artifact.call_args + assert call_kwargs.kwargs.get("artifact_type_override") == "openapi_spec" + + +@pytest.mark.asyncio +async def test_upload_artifact_returns_401_without_tenant(client): + """POST upload without tenant context returns 401.""" + mock1, mock2, mock3 = _setup_context_mocks(None) + with mock1, mock2, mock3: + response = await client.post( + "/api/v1/projects/prj-001/artifacts", + files={"file": ("test.json", b'{}', "application/json")}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_upload_unknown_type_sets_unsupported(client): + """Upload of unknown file type returns artifact with unsupported status.""" + tenant = _make_tenant() + artifact = _make_artifact(status=ArtifactStatus.UNSUPPORTED) + artifact.artifact_type = "unknown" + + mock1, mock2, mock3 = _setup_context_mocks(tenant) + with mock1, mock2, mock3: + with patch("domains.artifacts.router.artifact_service") as mock_svc: + mock_svc.upload_artifact = AsyncMock(return_value=artifact) + + response = await client.post( + "/api/v1/projects/prj-001/artifacts", + files={"file": ("readme.txt", b"Hello", "text/plain")}, + ) + assert response.status_code == 202 + body = response.json() + assert body["data"]["status"] == "unsupported" + assert body["data"]["artifactType"] == "unknown" + + +# --------------------------------------------------------------------------- +# Route registration +# --------------------------------------------------------------------------- + + +def test_upload_and_download_routes_registered(): + """Upload and download routes are registered in the app.""" + routes = [route.path for route in app.routes if hasattr(route, "path")] + assert "/api/v1/projects/{project_id}/artifacts" in routes + assert "/api/v1/projects/{project_id}/artifacts/{artifact_id}/download" in routes diff --git a/tests/backend/test_type_detection.py b/tests/backend/test_type_detection.py new file mode 100644 index 0000000..c8d8e91 --- /dev/null +++ b/tests/backend/test_type_detection.py @@ -0,0 +1,157 @@ +"""Tests for artifact type detection.""" + +import io +import json +import sys +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src" / "backend")) + +from domains.artifacts.type_detector import detect_artifact_type # noqa: E402 +from fastapi import UploadFile # noqa: E402 + + +def _make_upload(content: bytes, filename: str) -> UploadFile: + """Create a FastAPI UploadFile backed by an in-memory BytesIO.""" + return UploadFile(filename=filename, file=io.BytesIO(content)) + + +# --------------------------------------------------------------------------- +# Logic App workflow detection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_detect_logic_app_workflow_json(): + """JSON file with definition.triggers should be detected as logic_app_workflow.""" + workflow = json.dumps({ + "definition": { + "triggers": {"manual": {"type": "Request"}}, + "actions": {"response": {"type": "Response"}}, + } + }).encode() + upload = _make_upload(workflow, "workflow.json") + result = await detect_artifact_type("workflow.json", upload) + assert result == "logic_app_workflow" + + +@pytest.mark.asyncio +async def test_detect_logic_app_workflow_yaml(): + """YAML file with definition.triggers should be detected as logic_app_workflow.""" + workflow = yaml.dump({ + "definition": { + "triggers": {"manual": {"type": "Request"}}, + "actions": {"response": {"type": "Response"}}, + } + }).encode() + upload = _make_upload(workflow, "workflow.yaml") + result = await detect_artifact_type("workflow.yaml", upload) + assert result == "logic_app_workflow" + + +# --------------------------------------------------------------------------- +# OpenAPI spec detection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_detect_openapi_json(): + """JSON file with 'openapi' key should be detected as openapi_spec.""" + spec = json.dumps({"openapi": "3.0.0", "info": {"title": "Test"}}).encode() + upload = _make_upload(spec, "api.json") + result = await detect_artifact_type("api.json", upload) + assert result == "openapi_spec" + + +@pytest.mark.asyncio +async def test_detect_swagger_yaml(): + """YAML file with 'swagger' key should be detected as openapi_spec.""" + spec = yaml.dump({"swagger": "2.0", "info": {"title": "Test"}}).encode() + upload = _make_upload(spec, "api.yml") + result = await detect_artifact_type("api.yml", upload) + assert result == "openapi_spec" + + +# --------------------------------------------------------------------------- +# APIM policy detection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_detect_apim_policy_xml(): + """XML file with root should be detected as apim_policy.""" + xml = b'' + upload = _make_upload(xml, "policy.xml") + result = await detect_artifact_type("policy.xml", upload) + assert result == "apim_policy" + + +@pytest.mark.asyncio +async def test_detect_xml_without_policies(): + """XML file without should be unknown.""" + xml = b'' + upload = _make_upload(xml, "config.xml") + result = await detect_artifact_type("config.xml", upload) + assert result == "unknown" + + +# --------------------------------------------------------------------------- +# Unknown / unsupported file types +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_detect_unknown_txt(): + """A .txt file should be detected as unknown.""" + upload = _make_upload(b"Hello world", "readme.txt") + result = await detect_artifact_type("readme.txt", upload) + assert result == "unknown" + + +@pytest.mark.asyncio +async def test_detect_unknown_json_without_markers(): + """A JSON file without workflow or OpenAPI markers should be unknown.""" + data = json.dumps({"name": "test", "value": 42}).encode() + upload = _make_upload(data, "data.json") + result = await detect_artifact_type("data.json", upload) + assert result == "unknown" + + +# --------------------------------------------------------------------------- +# File position reset +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_file_position_reset_after_detection(): + """File position should be at 0 after detection, regardless of type.""" + content = json.dumps({"openapi": "3.0.0"}).encode() + upload = _make_upload(content, "spec.json") + await detect_artifact_type("spec.json", upload) + # Verify the full content is still readable from position 0 + data = await upload.read() + assert data == content + + +# --------------------------------------------------------------------------- +# Extension-only detection (stretch types) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_detect_terraform(): + """.tf files should be detected as terraform.""" + upload = _make_upload(b'resource "azurerm_resource_group" {}', "main.tf") + result = await detect_artifact_type("main.tf", upload) + assert result == "terraform" + + +@pytest.mark.asyncio +async def test_detect_bicep(): + """.bicep files should be detected as bicep.""" + upload = _make_upload(b"param location string", "main.bicep") + result = await detect_artifact_type("main.bicep", upload) + assert result == "bicep" From d136ae0102be66e6915dcffa44de7b68c1bd6452 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:34:37 +0000 Subject: [PATCH 4/6] fix: use ContentSettings object for blob upload content_type Agent-Logs-Url: https://github.com/christopherhouse/Azure-Integration-Copilot/sessions/4cb396ca-06ce-4a66-999a-7a41a96ba68f Co-authored-by: christopherhouse <748998+christopherhouse@users.noreply.github.com> --- src/backend/shared/blob.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/shared/blob.py b/src/backend/shared/blob.py index f87b2e1..b3ae729 100644 --- a/src/backend/shared/blob.py +++ b/src/backend/shared/blob.py @@ -3,6 +3,7 @@ import structlog from azure.identity.aio import DefaultAzureCredential +from azure.storage.blob import ContentSettings from azure.storage.blob.aio import BlobServiceClient from config import settings @@ -33,7 +34,9 @@ async def upload_blob(self, blob_path: str, data: bytes, content_type: str = "ap client = await self._get_client() container_client = client.get_container_client(ARTIFACTS_CONTAINER) blob_client = container_client.get_blob_client(blob_path) - await blob_client.upload_blob(data, overwrite=True, content_settings={"content_type": content_type}) + await blob_client.upload_blob( + data, overwrite=True, content_settings=ContentSettings(content_type=content_type) + ) logger.info("blob_uploaded", blob_path=blob_path) async def download_blob(self, blob_path: str) -> bytes: From e6767049d992dfeacdc27aaf6d30e997d82be243 Mon Sep 17 00:00:00 2001 From: Christopher House Date: Tue, 31 Mar 2026 17:39:11 -0500 Subject: [PATCH 5/6] fix for code scanning alert no. 276: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/backend/domains/artifacts/router.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/backend/domains/artifacts/router.py b/src/backend/domains/artifacts/router.py index 3c4a12c..5dd5d68 100644 --- a/src/backend/domains/artifacts/router.py +++ b/src/backend/domains/artifacts/router.py @@ -73,12 +73,19 @@ async def upload_artifact( artifact_type_override=artifact_type, ) except ValueError as exc: + logger.warning( + "File upload rejected due to ValueError", + request_id=req_id, + tenant_id=_get_tenant_id(request), + project_id=project_id, + error=str(exc), + ) return JSONResponse( status_code=413, content={ "error": { "code": "FILE_TOO_LARGE", - "message": str(exc), + "message": "Uploaded file is too large.", "request_id": req_id, } }, From da504b0d82808a9885b71e9301443390d9af5ab4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:49:06 +0000 Subject: [PATCH 6/6] fix: update 401 tests to work with auto-provisioning tenant middleware The tenant context middleware was updated on main to auto-provision tenants. The _setup_context_mocks(None) pattern triggered a 500 'Tenant not found' instead of reaching the route handler's 401 check. Fix: for no-tenant tests, patch settings with empty cosmos_db_endpoint to trigger the dev-mode-no-cosmos path, which sets tenant=None and passes through to the route handler. Agent-Logs-Url: https://github.com/christopherhouse/Azure-Integration-Copilot/sessions/608f2dbb-8eef-4fd8-b20f-c664bc4a09b4 Co-authored-by: christopherhouse <748998+christopherhouse@users.noreply.github.com> --- tests/backend/test_artifact_download.py | 6 ++++-- tests/backend/test_artifact_upload.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/backend/test_artifact_download.py b/tests/backend/test_artifact_download.py index f9d417e..5dc0979 100644 --- a/tests/backend/test_artifact_download.py +++ b/tests/backend/test_artifact_download.py @@ -121,7 +121,9 @@ async def test_download_artifact_returns_404_when_not_found(client): @pytest.mark.asyncio async def test_download_artifact_returns_401_without_tenant(client): """GET download without tenant context returns 401.""" - mock1, mock2, mock3 = _setup_context_mocks(None) - with mock1, mock2, mock3: + # Patch settings so the middleware takes the dev-mode-no-cosmos path, + # which sets request.state.tenant = None and lets the route handler + # return 401. + with patch("middleware.tenant_context.settings", skip_auth=True, cosmos_db_endpoint=""): response = await client.get("/api/v1/projects/prj-001/artifacts/art-001/download") assert response.status_code == 401 diff --git a/tests/backend/test_artifact_upload.py b/tests/backend/test_artifact_upload.py index 3fe0b27..2bd9fef 100644 --- a/tests/backend/test_artifact_upload.py +++ b/tests/backend/test_artifact_upload.py @@ -183,8 +183,10 @@ async def test_upload_artifact_with_type_override(client): @pytest.mark.asyncio async def test_upload_artifact_returns_401_without_tenant(client): """POST upload without tenant context returns 401.""" - mock1, mock2, mock3 = _setup_context_mocks(None) - with mock1, mock2, mock3: + # Patch settings so the middleware takes the dev-mode-no-cosmos path, + # which sets request.state.tenant = None and lets the route handler + # return 401. + with patch("middleware.tenant_context.settings", skip_auth=True, cosmos_db_endpoint=""): response = await client.post( "/api/v1/projects/prj-001/artifacts", files={"file": ("test.json", b'{}', "application/json")},