From c2b7f75af97aba766ff1b7e17b3afbd3ef3d4ddf Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Fri, 12 Dec 2025 15:42:01 -0500 Subject: [PATCH 01/27] test --- Makefile | 15 ++ docker-compose.e2e.yml | 143 ++++++++++++ tests/e2e/Dockerfile.test-runner | 25 ++ tests/e2e/mock-sentry-api/Dockerfile | 20 ++ tests/e2e/mock-sentry-api/server.py | 329 +++++++++++++++++++++++++++ tests/e2e/test_e2e_flow.py | 269 ++++++++++++++++++++++ 6 files changed, 801 insertions(+) create mode 100644 docker-compose.e2e.yml create mode 100644 tests/e2e/Dockerfile.test-runner create mode 100644 tests/e2e/mock-sentry-api/Dockerfile create mode 100644 tests/e2e/mock-sentry-api/server.py create mode 100644 tests/e2e/test_e2e_flow.py diff --git a/Makefile b/Makefile index 9c620dbd..59610e95 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,21 @@ test-unit: test-integration: $(PYTHON_VENV) -m pytest -n auto tests/integration/ -v +test-e2e: ## Run E2E tests with Docker Compose + @echo "Starting E2E test environment..." + docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-tests + @echo "Cleaning up E2E environment..." + docker compose -f docker-compose.e2e.yml down -v + +test-e2e-up: ## Start E2E environment (for debugging) + docker compose -f docker-compose.e2e.yml up --build + +test-e2e-down: ## Stop E2E environment + docker compose -f docker-compose.e2e.yml down -v + +test-e2e-logs: ## Show logs from E2E environment + docker compose -f docker-compose.e2e.yml logs -f + coverage: $(PYTHON_VENV) -m pytest tests/unit/ tests/integration/ -v --cov --cov-branch --cov-report=xml --junitxml=junit.xml diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 00000000..44494730 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,143 @@ +version: '3.8' + +services: + # Kafka infrastructure + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: echo stat | nc localhost 2181 + interval: 10s + timeout: 5s + retries: 3 + + kafka: + image: confluentinc/cp-kafka:7.5.0 + depends_on: + zookeeper: + condition: service_healthy + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9093,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + healthcheck: + test: kafka-broker-api-versions --bootstrap-server localhost:9093 + interval: 10s + timeout: 10s + retries: 5 + + # MinIO for ObjectStore (S3-compatible) + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 3 + volumes: + - minio-data:/data + + # Mock Sentry API server + mock-sentry-api: + build: + context: . + dockerfile: tests/e2e/mock-sentry-api/Dockerfile + ports: + - "8000:8000" + environment: + PYTHONUNBUFFERED: "1" + MINIO_ENDPOINT: "http://minio:9000" + MINIO_ACCESS_KEY: "minioadmin" + MINIO_SECRET_KEY: "minioadmin" + depends_on: + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 3 + volumes: + - mock-api-data:/app/data + + # Launchpad service + launchpad: + build: + context: . + dockerfile: Dockerfile + args: + TEST_BUILD: "true" # Include test fixtures + ports: + - "2218:2218" + environment: + PYTHONUNBUFFERED: "1" + KAFKA_BOOTSTRAP_SERVERS: "kafka:9093" + KAFKA_GROUP_ID: "launchpad-e2e-test" + KAFKA_TOPICS: "preprod-artifact-events" + KAFKA_AUTO_OFFSET_RESET: "earliest" + LAUNCHPAD_HOST: "0.0.0.0" + LAUNCHPAD_PORT: "2218" + LAUNCHPAD_ENV: "e2e-test" + SENTRY_BASE_URL: "http://mock-sentry-api:8000" + OBJECTSTORE_URL: "http://minio:9000" + LAUNCHPAD_RPC_SHARED_SECRET: "test-secret-key-for-e2e" + SENTRY_DSN: "" # Disable Sentry SDK in tests + depends_on: + kafka: + condition: service_healthy + mock-sentry-api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:2218/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # Test orchestrator + e2e-tests: + build: + context: . + dockerfile: tests/e2e/Dockerfile.test-runner + environment: + KAFKA_BOOTSTRAP_SERVERS: "kafka:9093" + MOCK_API_URL: "http://mock-sentry-api:8000" + LAUNCHPAD_URL: "http://launchpad:2218" + MINIO_ENDPOINT: "http://minio:9000" + MINIO_ACCESS_KEY: "minioadmin" + MINIO_SECRET_KEY: "minioadmin" + LAUNCHPAD_RPC_SHARED_SECRET: "test-secret-key-for-e2e" + depends_on: + launchpad: + condition: service_healthy + mock-sentry-api: + condition: service_healthy + kafka: + condition: service_healthy + volumes: + - ./tests:/app/tests + - ./tests/e2e/results:/app/results + command: pytest tests/e2e/test_e2e_flow.py -v --tb=short + +volumes: + minio-data: + mock-api-data: + +networks: + default: + name: launchpad-e2e diff --git a/tests/e2e/Dockerfile.test-runner b/tests/e2e/Dockerfile.test-runner new file mode 100644 index 00000000..96a38c3d --- /dev/null +++ b/tests/e2e/Dockerfile.test-runner @@ -0,0 +1,25 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python test dependencies +RUN pip install --no-cache-dir \ + pytest==8.3.3 \ + pytest-asyncio==0.24.0 \ + confluent-kafka==2.5.3 \ + requests==2.32.3 \ + boto3==1.35.0 + +# Copy test files +COPY tests/e2e /app/tests/e2e +COPY tests/_fixtures /app/tests/_fixtures + +# Create results directory +RUN mkdir -p /app/results + +WORKDIR /app diff --git a/tests/e2e/mock-sentry-api/Dockerfile b/tests/e2e/mock-sentry-api/Dockerfile new file mode 100644 index 00000000..643a9406 --- /dev/null +++ b/tests/e2e/mock-sentry-api/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies +RUN pip install --no-cache-dir \ + fastapi==0.115.0 \ + uvicorn[standard]==0.32.0 \ + pydantic==2.9.2 \ + boto3==1.35.0 + +# Copy mock API server code +COPY tests/e2e/mock-sentry-api/server.py . + +# Create data directory for storing artifacts and results +RUN mkdir -p /app/data/artifacts /app/data/results /app/data/chunks + +EXPOSE 8000 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py new file mode 100644 index 00000000..3f94ec54 --- /dev/null +++ b/tests/e2e/mock-sentry-api/server.py @@ -0,0 +1,329 @@ +"""Mock Sentry API server for E2E testing. + +This server simulates the Sentry monolith API endpoints that Launchpad interacts with: +- Artifact download +- Artifact updates +- Size analysis uploads (chunked) +- Chunk assembly +""" + +import hashlib +import hmac +import json +import os + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import FastAPI, Header, HTTPException, Request, Response, UploadFile +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel + +app = FastAPI(title="Mock Sentry API for Launchpad E2E Tests") + +# Storage paths +DATA_DIR = Path("/app/data") +ARTIFACTS_DIR = DATA_DIR / "artifacts" +RESULTS_DIR = DATA_DIR / "results" +CHUNKS_DIR = DATA_DIR / "chunks" + +# Create directories +for dir_path in [ARTIFACTS_DIR, RESULTS_DIR, CHUNKS_DIR]: + dir_path.mkdir(parents=True, exist_ok=True) + +# In-memory storage for test data +artifacts_db: Dict[str, Dict[str, Any]] = {} +size_analysis_db: Dict[str, Dict[str, Any]] = {} +assembled_files: Dict[str, bytes] = {} + +# Expected RPC secret (should match docker-compose env var) +RPC_SHARED_SECRET = os.getenv("LAUNCHPAD_RPC_SHARED_SECRET", "test-secret-key-for-e2e") + + +def verify_rpc_signature(authorization: str, body: bytes) -> bool: + """Verify RPC signature from Authorization header.""" + if not authorization or not authorization.startswith("rpcsignature rpc0:"): + return False + + signature = authorization.replace("rpcsignature rpc0:", "") + expected_signature = hmac.new(RPC_SHARED_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() + + return signature == expected_signature + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "ok", "service": "mock-sentry-api"} + + +@app.head("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/") +@app.get("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/") +async def download_artifact( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Download artifact file.""" + # Check if artifact exists + artifact_path = ARTIFACTS_DIR / f"{artifact_id}.zip" + + if not artifact_path.exists(): + raise HTTPException(status_code=404, detail="Artifact not found") + + # Handle HEAD request + if request.method == "HEAD": + file_size = artifact_path.stat().st_size + return Response(headers={"Content-Length": str(file_size)}, status_code=200) + + # Handle Range requests for resumable downloads + range_header = request.headers.get("range") + if range_header: + # Parse range header (simplified implementation) + range_start = int(range_header.replace("bytes=", "").split("-")[0]) + with open(artifact_path, "rb") as f: + f.seek(range_start) + content = f.read() + return Response( + content=content, + status_code=206, + headers={"Content-Range": f"bytes {range_start}-{len(content) - 1}/{artifact_path.stat().st_size}"}, + ) + + return FileResponse(artifact_path) + + +class UpdateRequest(BaseModel): + """Artifact update request model.""" + + pass # Accept any fields + + +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/update/") +async def update_artifact( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Update artifact metadata.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + + # Store update in database + if artifact_id not in artifacts_db: + artifacts_db[artifact_id] = {} + + artifacts_db[artifact_id].update(data) + + # Track which fields were updated + updated_fields = list(data.keys()) + + return {"success": True, "artifactId": artifact_id, "updatedFields": updated_fields} + + +class ChunkOptionsResponse(BaseModel): + """Chunk upload options response.""" + + url: str + chunkSize: int + chunksPerRequest: int + maxFileSize: int + maxRequestSize: int + concurrency: int + hashAlgorithm: str + compression: List[str] + accept: List[str] + + +@app.get("/api/0/organizations/{org}/chunk-upload/") +async def get_chunk_options(org: str): + """Get chunk upload configuration.""" + return { + "url": f"/api/0/organizations/{org}/chunk-upload/", + "chunkSize": 8388608, # 8MB + "chunksPerRequest": 64, + "maxFileSize": 2147483648, # 2GB + "maxRequestSize": 33554432, # 32MB + "concurrency": 8, + "hashAlgorithm": "sha1", + "compression": ["gzip"], + "accept": ["*"], + } + + +@app.post("/api/0/organizations/{org}/chunk-upload/") +async def upload_chunk( + org: str, + file: UploadFile, + authorization: str = Header(None), +): + """Upload a file chunk.""" + # Read chunk data + chunk_data = await file.read() + + # Calculate checksum + checksum = hashlib.sha1(chunk_data).hexdigest() + + # Store chunk + chunk_path = CHUNKS_DIR / checksum + chunk_path.write_bytes(chunk_data) + + # Return 200 if successful, 409 if already exists + return JSONResponse({"checksum": checksum}, status_code=200) + + +class AssembleRequest(BaseModel): + """Assembly request model.""" + + checksum: str + chunks: List[str] + assemble_type: str + + +@app.post("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/assemble-generic/") +async def assemble_file( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Assemble uploaded chunks into complete file.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + checksum = data["checksum"] + chunks = data["chunks"] + assemble_type = data["assemble_type"] + + # Check which chunks are missing + missing_chunks = [] + for chunk_checksum in chunks: + chunk_path = CHUNKS_DIR / chunk_checksum + if not chunk_path.exists(): + missing_chunks.append(chunk_checksum) + + if missing_chunks: + return {"state": "not_found", "missingChunks": missing_chunks} + + # Assemble the file + file_data = b"" + for chunk_checksum in chunks: + chunk_path = CHUNKS_DIR / chunk_checksum + file_data += chunk_path.read_bytes() + + # Verify checksum + actual_checksum = hashlib.sha1(file_data).hexdigest() + if actual_checksum != checksum: + return { + "state": "error", + "missingChunks": [], + "detail": f"Checksum mismatch: expected {checksum}, got {actual_checksum}", + } + + # Store assembled file + if assemble_type == "size_analysis": + result_path = RESULTS_DIR / f"{artifact_id}_size_analysis.json" + result_path.write_bytes(file_data) + + # Parse and store in database + try: + size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) + except Exception as e: + print(f"Error parsing size analysis: {e}") + + elif assemble_type == "installable_app": + app_path = RESULTS_DIR / f"{artifact_id}_app" + app_path.write_bytes(file_data) + + return {"state": "ok", "missingChunks": []} + + +class PutSizeRequest(BaseModel): + """Size analysis update request.""" + + pass # Accept any fields + + +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/size/") +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/size/{identifier}/") +async def update_size_analysis( + org: str, + project: str, + artifact_id: str, + request: Request, + identifier: Optional[str] = None, + authorization: str = Header(None), +): + """Update size analysis metadata.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + + # Store in database + key = f"{artifact_id}:{identifier}" if identifier else artifact_id + if key not in size_analysis_db: + size_analysis_db[key] = {} + size_analysis_db[key].update(data) + + return {"artifactId": artifact_id} + + +# Test helper endpoints (not part of real Sentry API) + + +@app.post("/test/upload-artifact/{artifact_id}") +async def test_upload_artifact(artifact_id: str, file: UploadFile): + """Test helper: Upload an artifact file for testing.""" + artifact_path = ARTIFACTS_DIR / f"{artifact_id}.zip" + + with open(artifact_path, "wb") as f: + content = await file.read() + f.write(content) + + return {"artifact_id": artifact_id, "size": len(content)} + + +@app.get("/test/results/{artifact_id}") +async def test_get_results(artifact_id: str): + """Test helper: Get analysis results for an artifact.""" + return { + "artifact_metadata": artifacts_db.get(artifact_id, {}), + "size_analysis": size_analysis_db.get(artifact_id, {}), + "has_size_analysis_file": (RESULTS_DIR / f"{artifact_id}_size_analysis.json").exists(), + "has_installable_app": (RESULTS_DIR / f"{artifact_id}_app").exists(), + } + + +@app.get("/test/results/{artifact_id}/size-analysis-raw") +async def test_get_size_analysis_raw(artifact_id: str): + """Test helper: Get raw size analysis JSON.""" + result_path = RESULTS_DIR / f"{artifact_id}_size_analysis.json" + + if not result_path.exists(): + raise HTTPException(status_code=404, detail="Size analysis not found") + + return JSONResponse(json.loads(result_path.read_text())) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py new file mode 100644 index 00000000..8422b053 --- /dev/null +++ b/tests/e2e/test_e2e_flow.py @@ -0,0 +1,269 @@ +"""End-to-end tests for Launchpad service. + +Tests the full flow: +1. Upload test artifact to mock API +2. Send Kafka message to trigger processing +3. Wait for Launchpad to process +4. Verify results via mock API +""" + +import json +import os +import time + +from pathlib import Path +from typing import Any, Dict + +import pytest +import requests + +from confluent_kafka import Producer + +# Configuration from environment +KAFKA_BOOTSTRAP_SERVERS = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:9093") +MOCK_API_URL = os.getenv("MOCK_API_URL", "http://mock-sentry-api:8000") +LAUNCHPAD_URL = os.getenv("LAUNCHPAD_URL", "http://launchpad:2218") +KAFKA_TOPIC = "preprod-artifact-events" + +# Test fixtures +FIXTURES_DIR = Path(__file__).parent.parent / "_fixtures" +IOS_FIXTURE = FIXTURES_DIR / "ios" / "HackerNews.xcarchive.zip" +ANDROID_APK_FIXTURE = FIXTURES_DIR / "android" / "hn.apk" +ANDROID_AAB_FIXTURE = FIXTURES_DIR / "android" / "hn.aab" + + +def wait_for_service(url: str, timeout: int = 60, service_name: str = "service") -> None: + """Wait for a service to be healthy.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = requests.get(f"{url}/health", timeout=5) + if response.status_code == 200: + print(f"✓ {service_name} is healthy") + return + except requests.exceptions.RequestException: + pass + time.sleep(2) + raise TimeoutError(f"{service_name} did not become healthy within {timeout}s") + + +def upload_artifact_to_mock_api(artifact_id: str, file_path: Path) -> None: + """Upload an artifact file to the mock API.""" + with open(file_path, "rb") as f: + files = {"file": (file_path.name, f, "application/zip")} + response = requests.post(f"{MOCK_API_URL}/test/upload-artifact/{artifact_id}", files=files, timeout=30) + response.raise_for_status() + print(f"✓ Uploaded artifact {artifact_id} ({file_path.name})") + + +def send_kafka_message(artifact_id: str, org: str, project: str, features: list[str]) -> None: + """Send a Kafka message to trigger artifact processing.""" + producer = Producer({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS, "client.id": "e2e-test-producer"}) + + message = { + "artifact_id": artifact_id, + "organization_id": org, + "project_id": project, + "requested_features": features, + } + + producer.produce(KAFKA_TOPIC, key=artifact_id.encode("utf-8"), value=json.dumps(message).encode("utf-8")) + producer.flush(timeout=10) + print(f"✓ Sent Kafka message for artifact {artifact_id}") + + +def wait_for_processing(artifact_id: str, timeout: int = 120, check_interval: int = 3) -> Dict[str, Any]: + """Wait for artifact processing to complete and return results.""" + start_time = time.time() + last_status = None + + while time.time() - start_time < timeout: + try: + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}", timeout=10) + response.raise_for_status() + results = response.json() + + # Check if processing is complete + # We consider it complete when artifact metadata has been updated + if results.get("artifact_metadata"): + print(f"✓ Processing completed for {artifact_id}") + return results + + # Show progress + current_status = json.dumps(results, sort_keys=True) + if current_status != last_status: + print(f" Waiting for processing... (results so far: {results})") + last_status = current_status + + except requests.exceptions.RequestException as e: + print(f" Error checking results: {e}") + + time.sleep(check_interval) + + raise TimeoutError(f"Artifact {artifact_id} was not processed within {timeout}s") + + +def get_size_analysis_raw(artifact_id: str) -> Dict[str, Any]: + """Get the raw size analysis JSON for an artifact.""" + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}/size-analysis-raw", timeout=10) + response.raise_for_status() + return response.json() + + +class TestE2EFlow: + """End-to-end tests for full Launchpad service flow.""" + + @classmethod + def setup_class(cls): + """Wait for all services to be ready before running tests.""" + print("\n=== Waiting for services to be ready ===") + wait_for_service(MOCK_API_URL, service_name="Mock Sentry API") + wait_for_service(LAUNCHPAD_URL, service_name="Launchpad") + print("=== All services ready ===\n") + + def test_ios_xcarchive_full_flow(self): + """Test full flow with iOS .xcarchive.zip file.""" + if not IOS_FIXTURE.exists(): + pytest.skip(f"iOS fixture not found: {IOS_FIXTURE}") + + artifact_id = "test-ios-001" + org = "test-org" + project = "test-ios-project" + + print("\n=== Testing iOS .xcarchive.zip E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, IOS_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify basic metadata + assert "app_name" in metadata or "appName" in metadata, "App name should be present" + assert "app_id" in metadata or "appId" in metadata, "App ID should be present" + assert "build_version" in metadata or "buildVersion" in metadata, "Build version should be present" + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents + size_analysis = get_size_analysis_raw(artifact_id) + assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "insights" in size_analysis, "Size analysis should contain insights" + assert "treemap" in size_analysis, "Size analysis should contain treemap" + + # Verify insights were generated + insights = size_analysis["insights"] + assert len(insights) > 0, "Should generate at least one insight" + + print("✓ iOS E2E test passed!") + print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Insights generated: {len(insights)}") + print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + + def test_android_apk_full_flow(self): + """Test full flow with Android .apk file.""" + if not ANDROID_APK_FIXTURE.exists(): + pytest.skip(f"Android APK fixture not found: {ANDROID_APK_FIXTURE}") + + artifact_id = "test-android-apk-001" + org = "test-org" + project = "test-android-project" + + print("\n=== Testing Android .apk E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, ANDROID_APK_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify basic metadata + assert "app_name" in metadata or "appName" in metadata, "App name should be present" + assert "app_id" in metadata or "appId" in metadata, "App ID should be present" + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents + size_analysis = get_size_analysis_raw(artifact_id) + assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "insights" in size_analysis, "Size analysis should contain insights" + + print("✓ Android APK E2E test passed!") + print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Insights generated: {len(size_analysis['insights'])}") + print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + + def test_android_aab_full_flow(self): + """Test full flow with Android .aab file.""" + if not ANDROID_AAB_FIXTURE.exists(): + pytest.skip(f"Android AAB fixture not found: {ANDROID_AAB_FIXTURE}") + + artifact_id = "test-android-aab-001" + org = "test-org" + project = "test-android-project" + + print("\n=== Testing Android .aab E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, ANDROID_AAB_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify basic metadata + assert "app_name" in metadata or "appName" in metadata, "App name should be present" + assert "app_id" in metadata or "appId" in metadata, "App ID should be present" + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents + size_analysis = get_size_analysis_raw(artifact_id) + assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "insights" in size_analysis, "Size analysis should contain insights" + + print("✓ Android AAB E2E test passed!") + print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Insights generated: {len(size_analysis['insights'])}") + print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + + def test_launchpad_health_check(self): + """Verify Launchpad service is healthy.""" + response = requests.get(f"{LAUNCHPAD_URL}/health", timeout=10) + assert response.status_code == 200 + data = response.json() + assert data["service"] == "launchpad" + assert data["status"] == "ok" + print("✓ Launchpad health check passed") From 44f628b93167e896752a264eb7d8c7b96ac9bd53 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Fri, 12 Dec 2025 15:55:50 -0500 Subject: [PATCH 02/27] fix --- .github/workflows/ci.yml | 79 +++++++++++++++++++++++++++++++++++++++- Makefile | 10 ++++- docker-compose.e2e.yml | 56 +++++++++------------------- 3 files changed, 105 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b866f808..f78783ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,9 +226,86 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: junit.xml + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Install dependencies + run: make install-dev + + - name: Start Kafka with devservices + run: | + .venv/bin/devservices up --mode default + + echo "Waiting for Kafka to be ready..." + KAFKA_READY=false + for i in {1..30}; do + KAFKA_CONTAINER=$(docker ps -qf "name=kafka") + if [ -z "$KAFKA_CONTAINER" ]; then + echo "Waiting for Kafka container to start... attempt $i/30" + sleep 2 + continue + fi + + HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' $KAFKA_CONTAINER 2>/dev/null || echo "none") + if [ "$HEALTH_STATUS" = "healthy" ]; then + echo "Kafka is ready!" + KAFKA_READY=true + break + fi + echo "Waiting for Kafka health check (status: $HEALTH_STATUS)... attempt $i/30" + sleep 2 + done + + if [ "$KAFKA_READY" = "false" ]; then + echo "ERROR: Kafka failed to become healthy after 60 seconds" + echo "=== Docker containers ===" + docker ps -a + echo "=== Kafka logs ===" + docker logs $(docker ps -aqf "name=kafka") --tail 100 || echo "Could not get Kafka logs" + exit 1 + fi + + docker ps + + - name: Run E2E tests + run: | + # Start E2E stack (will connect to devservices Kafka) + docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-tests + timeout-minutes: 15 + + - name: Show E2E logs on failure + if: failure() + run: | + echo "=== Launchpad logs ===" + docker compose -f docker-compose.e2e.yml logs launchpad + echo "=== Mock API logs ===" + docker compose -f docker-compose.e2e.yml logs mock-sentry-api + echo "=== E2E test logs ===" + docker compose -f docker-compose.e2e.yml logs e2e-tests + echo "=== Kafka logs ===" + docker logs $(docker ps -qf "name=kafka") --tail 100 || echo "Could not get Kafka logs" + + - name: Cleanup E2E environment + if: always() + run: docker compose -f docker-compose.e2e.yml down -v + build: runs-on: ubuntu-latest - needs: [check, test] + needs: [check, test, e2e] steps: - name: Checkout code diff --git a/Makefile b/Makefile index 59610e95..1d8307d9 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,15 @@ test-unit: test-integration: $(PYTHON_VENV) -m pytest -n auto tests/integration/ -v -test-e2e: ## Run E2E tests with Docker Compose +test-e2e: ## Run E2E tests with Docker Compose (requires devservices up) + @echo "Ensuring devservices Kafka is running..." + @if ! docker ps | grep -q kafka; then \ + echo "Starting devservices..."; \ + devservices up --mode default; \ + sleep 10; \ + else \ + echo "Kafka already running"; \ + fi @echo "Starting E2E test environment..." docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-tests @echo "Cleaning up E2E environment..." diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 44494730..b90dd41f 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -1,39 +1,9 @@ version: '3.8' -services: - # Kafka infrastructure - zookeeper: - image: confluentinc/cp-zookeeper:7.5.0 - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - healthcheck: - test: echo stat | nc localhost 2181 - interval: 10s - timeout: 5s - retries: 3 - - kafka: - image: confluentinc/cp-kafka:7.5.0 - depends_on: - zookeeper: - condition: service_healthy - ports: - - "9092:9092" - environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9093,PLAINTEXT_HOST://localhost:9092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" - healthcheck: - test: kafka-broker-api-versions --bootstrap-server localhost:9093 - interval: 10s - timeout: 10s - retries: 5 +# Note: This E2E setup leverages your existing devservices Kafka +# Run `devservices up` before starting these tests +services: # MinIO for ObjectStore (S3-compatible) minio: image: minio/minio:latest @@ -51,6 +21,8 @@ services: retries: 3 volumes: - minio-data:/data + networks: + - launchpad-e2e # Mock Sentry API server mock-sentry-api: @@ -74,6 +46,9 @@ services: retries: 3 volumes: - mock-api-data:/app/data + networks: + - launchpad-e2e + - devservices # Launchpad service launchpad: @@ -98,8 +73,6 @@ services: LAUNCHPAD_RPC_SHARED_SECRET: "test-secret-key-for-e2e" SENTRY_DSN: "" # Disable Sentry SDK in tests depends_on: - kafka: - condition: service_healthy mock-sentry-api: condition: service_healthy healthcheck: @@ -108,6 +81,9 @@ services: timeout: 5s retries: 5 start_period: 30s + networks: + - launchpad-e2e + - devservices # Test orchestrator e2e-tests: @@ -127,17 +103,21 @@ services: condition: service_healthy mock-sentry-api: condition: service_healthy - kafka: - condition: service_healthy volumes: - ./tests:/app/tests - ./tests/e2e/results:/app/results command: pytest tests/e2e/test_e2e_flow.py -v --tb=short + networks: + - launchpad-e2e + - devservices volumes: minio-data: mock-api-data: networks: - default: + launchpad-e2e: name: launchpad-e2e + devservices: + name: devservices + external: true From 866f500e302f6afbcca8b009fcb44ddcecbbab28 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 17:18:36 -0500 Subject: [PATCH 03/27] fix e2e test setup to avoid importing launchpad in test-runner The test-runner container doesn't have launchpad installed, so mounting the full tests directory caused pytest to load the main conftest.py which imports launchpad. Fixed by: - Copy e2e tests to /app/e2e_tests to avoid parent conftest.py discovery - Add standalone e2e conftest.py that doesn't import launchpad - Add missing build deps (gcc, g++, librdkafka-dev) for confluent-kafka - Add missing deps to mock API (curl for healthcheck, python-multipart for uploads) - Update fixture path to match new directory structure Co-Authored-By: Claude Opus 4.5 --- docker-compose.e2e.yml | 11 +++++------ tests/e2e/Dockerfile.test-runner | 12 ++++++++---- tests/e2e/conftest.py | 11 +++++++++++ tests/e2e/mock-sentry-api/Dockerfile | 10 ++++++++-- tests/e2e/test_e2e_flow.py | 2 +- 5 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/conftest.py diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index b90dd41f..e4be31a7 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -8,8 +8,8 @@ services: minio: image: minio/minio:latest ports: - - "9000:9000" - - "9001:9001" + - "9010:9000" + - "9011:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin @@ -30,7 +30,7 @@ services: context: . dockerfile: tests/e2e/mock-sentry-api/Dockerfile ports: - - "8000:8000" + - "8001:8000" environment: PYTHONUNBUFFERED: "1" MINIO_ENDPOINT: "http://minio:9000" @@ -40,7 +40,7 @@ services: minio: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "curl", "-f", "-L", "http://localhost:8000/health"] interval: 10s timeout: 5s retries: 3 @@ -104,9 +104,8 @@ services: mock-sentry-api: condition: service_healthy volumes: - - ./tests:/app/tests - ./tests/e2e/results:/app/results - command: pytest tests/e2e/test_e2e_flow.py -v --tb=short + command: pytest e2e_tests/test_e2e_flow.py -v --tb=short networks: - launchpad-e2e - devservices diff --git a/tests/e2e/Dockerfile.test-runner b/tests/e2e/Dockerfile.test-runner index 96a38c3d..b48d4c0d 100644 --- a/tests/e2e/Dockerfile.test-runner +++ b/tests/e2e/Dockerfile.test-runner @@ -2,9 +2,12 @@ FROM python:3.13-slim WORKDIR /app -# Install system dependencies +# Install system dependencies including build tools for confluent-kafka RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ + gcc \ + g++ \ + librdkafka-dev \ && rm -rf /var/lib/apt/lists/* # Install Python test dependencies @@ -15,9 +18,10 @@ RUN pip install --no-cache-dir \ requests==2.32.3 \ boto3==1.35.0 -# Copy test files -COPY tests/e2e /app/tests/e2e -COPY tests/_fixtures /app/tests/_fixtures +# Copy only E2E test files (not the main test suite) +# Copy to root to avoid pytest finding parent conftest.py +COPY tests/e2e /app/e2e_tests +COPY tests/_fixtures /app/fixtures # Create results directory RUN mkdir -p /app/results diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..c1cf8b7d --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,11 @@ +"""Conftest for E2E tests - overrides main conftest to avoid importing launchpad.""" + +import os + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_environment(): + """Set up test environment variables for E2E tests.""" + os.environ.setdefault("LAUNCHPAD_ENV", "e2e-test") diff --git a/tests/e2e/mock-sentry-api/Dockerfile b/tests/e2e/mock-sentry-api/Dockerfile index 643a9406..afc6204d 100644 --- a/tests/e2e/mock-sentry-api/Dockerfile +++ b/tests/e2e/mock-sentry-api/Dockerfile @@ -2,12 +2,18 @@ FROM python:3.13-slim WORKDIR /app -# Install dependencies +# Install system dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies RUN pip install --no-cache-dir \ fastapi==0.115.0 \ uvicorn[standard]==0.32.0 \ pydantic==2.9.2 \ - boto3==1.35.0 + boto3==1.35.0 \ + python-multipart==0.0.20 # Copy mock API server code COPY tests/e2e/mock-sentry-api/server.py . diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index 8422b053..a589e1ca 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -26,7 +26,7 @@ KAFKA_TOPIC = "preprod-artifact-events" # Test fixtures -FIXTURES_DIR = Path(__file__).parent.parent / "_fixtures" +FIXTURES_DIR = Path("/app/fixtures") IOS_FIXTURE = FIXTURES_DIR / "ios" / "HackerNews.xcarchive.zip" ANDROID_APK_FIXTURE = FIXTURES_DIR / "android" / "hn.apk" ANDROID_AAB_FIXTURE = FIXTURES_DIR / "android" / "hn.aab" From 13241b0118bee9f69a6116f8f6b8a44ac40a7400 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 17:45:42 -0500 Subject: [PATCH 04/27] fix: exclude e2e tests from regular test job The e2e tests require Docker services (mock-sentry-api) that only exist in the dedicated e2e job's docker-compose environment. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f78783ca..bb6af60d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: -e KAFKA_BOOTSTRAP_SERVERS="kafka:9093" \ -e KAFKA_GROUP_ID="launchpad-test-ci" \ -e KAFKA_TOPICS="preprod-artifact-events" \ - launchpad-test python -m pytest -n auto tests/ -v + launchpad-test python -m pytest -n auto tests/ --ignore=tests/e2e -v - name: Show Kafka logs on failure if: failure() From 99185c591a82810880a7927156b16972bf56e2c2 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 17:49:12 -0500 Subject: [PATCH 05/27] fix: sanitize artifact IDs to prevent path traversal Add sanitize_id helper to validate artifact_id parameters contain only safe characters (alphanumeric, hyphens, underscores). This fixes CodeQL path injection warnings in the mock API server. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 53 +++++++++++++++++++---------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 3f94ec54..78d39180 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -11,6 +11,7 @@ import hmac import json import os +import re from pathlib import Path from typing import Any, Dict, List, Optional @@ -21,6 +22,15 @@ app = FastAPI(title="Mock Sentry API for Launchpad E2E Tests") + +def sanitize_id(value: str) -> str: + """Sanitize ID to prevent path traversal. Only allow alphanumeric, hyphens, underscores.""" + sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", value) + if not sanitized: + raise HTTPException(status_code=400, detail="Invalid ID") + return sanitized + + # Storage paths DATA_DIR = Path("/app/data") ARTIFACTS_DIR = DATA_DIR / "artifacts" @@ -67,8 +77,8 @@ async def download_artifact( authorization: str = Header(None), ): """Download artifact file.""" - # Check if artifact exists - artifact_path = ARTIFACTS_DIR / f"{artifact_id}.zip" + safe_id = sanitize_id(artifact_id) + artifact_path = ARTIFACTS_DIR / f"{safe_id}.zip" if not artifact_path.exists(): raise HTTPException(status_code=404, detail="Artifact not found") @@ -117,17 +127,18 @@ async def update_artifact( raise HTTPException(status_code=403, detail="Invalid signature") data = json.loads(body) + safe_id = sanitize_id(artifact_id) # Store update in database - if artifact_id not in artifacts_db: - artifacts_db[artifact_id] = {} + if safe_id not in artifacts_db: + artifacts_db[safe_id] = {} - artifacts_db[artifact_id].update(data) + artifacts_db[safe_id].update(data) # Track which fields were updated updated_fields = list(data.keys()) - return {"success": True, "artifactId": artifact_id, "updatedFields": updated_fields} + return {"success": True, "artifactId": safe_id, "updatedFields": updated_fields} class ChunkOptionsResponse(BaseModel): @@ -235,18 +246,19 @@ async def assemble_file( } # Store assembled file + safe_id = sanitize_id(artifact_id) if assemble_type == "size_analysis": - result_path = RESULTS_DIR / f"{artifact_id}_size_analysis.json" + result_path = RESULTS_DIR / f"{safe_id}_size_analysis.json" result_path.write_bytes(file_data) # Parse and store in database try: - size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) + size_analysis_db[safe_id] = json.loads(file_data.decode("utf-8")) except Exception as e: print(f"Error parsing size analysis: {e}") elif assemble_type == "installable_app": - app_path = RESULTS_DIR / f"{artifact_id}_app" + app_path = RESULTS_DIR / f"{safe_id}_app" app_path.write_bytes(file_data) return {"state": "ok", "missingChunks": []} @@ -276,14 +288,16 @@ async def update_size_analysis( raise HTTPException(status_code=403, detail="Invalid signature") data = json.loads(body) + safe_id = sanitize_id(artifact_id) + safe_identifier = sanitize_id(identifier) if identifier else None # Store in database - key = f"{artifact_id}:{identifier}" if identifier else artifact_id + key = f"{safe_id}:{safe_identifier}" if safe_identifier else safe_id if key not in size_analysis_db: size_analysis_db[key] = {} size_analysis_db[key].update(data) - return {"artifactId": artifact_id} + return {"artifactId": safe_id} # Test helper endpoints (not part of real Sentry API) @@ -292,30 +306,33 @@ async def update_size_analysis( @app.post("/test/upload-artifact/{artifact_id}") async def test_upload_artifact(artifact_id: str, file: UploadFile): """Test helper: Upload an artifact file for testing.""" - artifact_path = ARTIFACTS_DIR / f"{artifact_id}.zip" + safe_id = sanitize_id(artifact_id) + artifact_path = ARTIFACTS_DIR / f"{safe_id}.zip" with open(artifact_path, "wb") as f: content = await file.read() f.write(content) - return {"artifact_id": artifact_id, "size": len(content)} + return {"artifact_id": safe_id, "size": len(content)} @app.get("/test/results/{artifact_id}") async def test_get_results(artifact_id: str): """Test helper: Get analysis results for an artifact.""" + safe_id = sanitize_id(artifact_id) return { - "artifact_metadata": artifacts_db.get(artifact_id, {}), - "size_analysis": size_analysis_db.get(artifact_id, {}), - "has_size_analysis_file": (RESULTS_DIR / f"{artifact_id}_size_analysis.json").exists(), - "has_installable_app": (RESULTS_DIR / f"{artifact_id}_app").exists(), + "artifact_metadata": artifacts_db.get(safe_id, {}), + "size_analysis": size_analysis_db.get(safe_id, {}), + "has_size_analysis_file": (RESULTS_DIR / f"{safe_id}_size_analysis.json").exists(), + "has_installable_app": (RESULTS_DIR / f"{safe_id}_app").exists(), } @app.get("/test/results/{artifact_id}/size-analysis-raw") async def test_get_size_analysis_raw(artifact_id: str): """Test helper: Get raw size analysis JSON.""" - result_path = RESULTS_DIR / f"{artifact_id}_size_analysis.json" + safe_id = sanitize_id(artifact_id) + result_path = RESULTS_DIR / f"{safe_id}_size_analysis.json" if not result_path.exists(): raise HTTPException(status_code=404, detail="Size analysis not found") From 76b9f999a55b8ee2af7df03394ee6388c3a6d725 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 17:56:49 -0500 Subject: [PATCH 06/27] fix: create Kafka topic before running e2e tests The preprod-artifact-events topic must exist before Launchpad starts, otherwise the Kafka consumer fails with UNKNOWN_TOPIC_OR_PART error. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb6af60d..cdca33ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,13 @@ jobs: docker ps + - name: Create Kafka topic + run: | + KAFKA_CONTAINER=$(docker ps -qf "name=kafka") + echo "Creating preprod-artifact-events topic..." + docker exec $KAFKA_CONTAINER kafka-topics --bootstrap-server localhost:9092 --create --topic preprod-artifact-events --partitions 1 --replication-factor 1 --if-not-exists + echo "Topic created successfully" + - name: Run E2E tests run: | # Start E2E stack (will connect to devservices Kafka) From e4e43c597655879800148932c0a271d097638fcc Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 18:06:21 -0500 Subject: [PATCH 07/27] fix: wait for size analysis file before checking results The wait_for_processing function was returning as soon as artifact metadata was present, but the size analysis file is uploaded after the metadata update. Now wait for both to be complete. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index a589e1ca..17366dea 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -84,8 +84,8 @@ def wait_for_processing(artifact_id: str, timeout: int = 120, check_interval: in results = response.json() # Check if processing is complete - # We consider it complete when artifact metadata has been updated - if results.get("artifact_metadata"): + # Processing is complete when both metadata is updated AND size analysis file exists + if results.get("artifact_metadata") and results.get("has_size_analysis_file"): print(f"✓ Processing completed for {artifact_id}") return results From 92ce4cb5a85d5a91cde26174f712b58e4b8a10df Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 18:14:01 -0500 Subject: [PATCH 08/27] fix: use correct field name download_size in e2e tests The analysis results use download_size and install_size, not total_size. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index 17366dea..a644ce48 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -158,7 +158,7 @@ def test_ios_xcarchive_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "download_size" in size_analysis, "Size analysis should contain download_size" assert "insights" in size_analysis, "Size analysis should contain insights" assert "treemap" in size_analysis, "Size analysis should contain treemap" @@ -167,7 +167,7 @@ def test_ios_xcarchive_full_flow(self): assert len(insights) > 0, "Should generate at least one insight" print("✓ iOS E2E test passed!") - print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(insights)}") print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") @@ -207,11 +207,11 @@ def test_android_apk_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "download_size" in size_analysis, "Size analysis should contain download_size" assert "insights" in size_analysis, "Size analysis should contain insights" print("✓ Android APK E2E test passed!") - print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(size_analysis['insights'])}") print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") @@ -251,11 +251,11 @@ def test_android_aab_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "total_size" in size_analysis, "Size analysis should contain total_size" + assert "download_size" in size_analysis, "Size analysis should contain download_size" assert "insights" in size_analysis, "Size analysis should contain insights" print("✓ Android AAB E2E test passed!") - print(f" - Total size: {size_analysis.get('total_size', 'N/A')} bytes") + print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(size_analysis['insights'])}") print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") From 6774d7dba0d510fa9b1d92496af4322483e70ca7 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Thu, 8 Jan 2026 18:26:04 -0500 Subject: [PATCH 09/27] fix: use path resolution pattern for CodeQL compliance Replace regex-based sanitize_id with safe_path() that: 1. Constructs the target path 2. Resolves it to absolute path (removes .., symlinks) 3. Validates it stays within the base directory This pattern is recognized by CodeQL as a proper path traversal sanitizer. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 71 +++++++++++++++-------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 78d39180..ccc9c1ea 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -11,7 +11,6 @@ import hmac import json import os -import re from pathlib import Path from typing import Any, Dict, List, Optional @@ -22,15 +21,6 @@ app = FastAPI(title="Mock Sentry API for Launchpad E2E Tests") - -def sanitize_id(value: str) -> str: - """Sanitize ID to prevent path traversal. Only allow alphanumeric, hyphens, underscores.""" - sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", value) - if not sanitized: - raise HTTPException(status_code=400, detail="Invalid ID") - return sanitized - - # Storage paths DATA_DIR = Path("/app/data") ARTIFACTS_DIR = DATA_DIR / "artifacts" @@ -41,6 +31,23 @@ def sanitize_id(value: str) -> str: for dir_path in [ARTIFACTS_DIR, RESULTS_DIR, CHUNKS_DIR]: dir_path.mkdir(parents=True, exist_ok=True) + +def safe_path(base_dir: Path, filename: str) -> Path: + """Safely construct a path within base_dir, preventing path traversal. + + This pattern is recognized by CodeQL as a proper sanitizer. + """ + # Construct the path + target = base_dir / filename + # Resolve to absolute path (removes .., symlinks, etc.) + resolved = target.resolve() + # Verify it's within the base directory + base_resolved = base_dir.resolve() + if not str(resolved).startswith(str(base_resolved) + "/") and resolved != base_resolved: + raise HTTPException(status_code=400, detail="Invalid path") + return resolved + + # In-memory storage for test data artifacts_db: Dict[str, Dict[str, Any]] = {} size_analysis_db: Dict[str, Dict[str, Any]] = {} @@ -77,8 +84,7 @@ async def download_artifact( authorization: str = Header(None), ): """Download artifact file.""" - safe_id = sanitize_id(artifact_id) - artifact_path = ARTIFACTS_DIR / f"{safe_id}.zip" + artifact_path = safe_path(ARTIFACTS_DIR, f"{artifact_id}.zip") if not artifact_path.exists(): raise HTTPException(status_code=404, detail="Artifact not found") @@ -127,18 +133,17 @@ async def update_artifact( raise HTTPException(status_code=403, detail="Invalid signature") data = json.loads(body) - safe_id = sanitize_id(artifact_id) # Store update in database - if safe_id not in artifacts_db: - artifacts_db[safe_id] = {} + if artifact_id not in artifacts_db: + artifacts_db[artifact_id] = {} - artifacts_db[safe_id].update(data) + artifacts_db[artifact_id].update(data) # Track which fields were updated updated_fields = list(data.keys()) - return {"success": True, "artifactId": safe_id, "updatedFields": updated_fields} + return {"success": True, "artifactId": artifact_id, "updatedFields": updated_fields} class ChunkOptionsResponse(BaseModel): @@ -246,19 +251,18 @@ async def assemble_file( } # Store assembled file - safe_id = sanitize_id(artifact_id) if assemble_type == "size_analysis": - result_path = RESULTS_DIR / f"{safe_id}_size_analysis.json" + result_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") result_path.write_bytes(file_data) # Parse and store in database try: - size_analysis_db[safe_id] = json.loads(file_data.decode("utf-8")) + size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) except Exception as e: print(f"Error parsing size analysis: {e}") elif assemble_type == "installable_app": - app_path = RESULTS_DIR / f"{safe_id}_app" + app_path = safe_path(RESULTS_DIR, f"{artifact_id}_app") app_path.write_bytes(file_data) return {"state": "ok", "missingChunks": []} @@ -288,16 +292,14 @@ async def update_size_analysis( raise HTTPException(status_code=403, detail="Invalid signature") data = json.loads(body) - safe_id = sanitize_id(artifact_id) - safe_identifier = sanitize_id(identifier) if identifier else None # Store in database - key = f"{safe_id}:{safe_identifier}" if safe_identifier else safe_id + key = f"{artifact_id}:{identifier}" if identifier else artifact_id if key not in size_analysis_db: size_analysis_db[key] = {} size_analysis_db[key].update(data) - return {"artifactId": safe_id} + return {"artifactId": artifact_id} # Test helper endpoints (not part of real Sentry API) @@ -306,33 +308,32 @@ async def update_size_analysis( @app.post("/test/upload-artifact/{artifact_id}") async def test_upload_artifact(artifact_id: str, file: UploadFile): """Test helper: Upload an artifact file for testing.""" - safe_id = sanitize_id(artifact_id) - artifact_path = ARTIFACTS_DIR / f"{safe_id}.zip" + artifact_path = safe_path(ARTIFACTS_DIR, f"{artifact_id}.zip") with open(artifact_path, "wb") as f: content = await file.read() f.write(content) - return {"artifact_id": safe_id, "size": len(content)} + return {"artifact_id": artifact_id, "size": len(content)} @app.get("/test/results/{artifact_id}") async def test_get_results(artifact_id: str): """Test helper: Get analysis results for an artifact.""" - safe_id = sanitize_id(artifact_id) + size_analysis_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") + installable_app_path = safe_path(RESULTS_DIR, f"{artifact_id}_app") return { - "artifact_metadata": artifacts_db.get(safe_id, {}), - "size_analysis": size_analysis_db.get(safe_id, {}), - "has_size_analysis_file": (RESULTS_DIR / f"{safe_id}_size_analysis.json").exists(), - "has_installable_app": (RESULTS_DIR / f"{safe_id}_app").exists(), + "artifact_metadata": artifacts_db.get(artifact_id, {}), + "size_analysis": size_analysis_db.get(artifact_id, {}), + "has_size_analysis_file": size_analysis_path.exists(), + "has_installable_app": installable_app_path.exists(), } @app.get("/test/results/{artifact_id}/size-analysis-raw") async def test_get_size_analysis_raw(artifact_id: str): """Test helper: Get raw size analysis JSON.""" - safe_id = sanitize_id(artifact_id) - result_path = RESULTS_DIR / f"{safe_id}_size_analysis.json" + result_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") if not result_path.exists(): raise HTTPException(status_code=404, detail="Size analysis not found") From 0e4447986845949c0ed71d6ae48007e71ad41223 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Fri, 9 Jan 2026 18:43:23 -0500 Subject: [PATCH 10/27] chore: exclude e2e tests from CodeQL scanning The e2e mock server is test code that runs in an isolated Docker container. Exclude it from CodeQL to avoid false positives on path handling in test utilities. Co-Authored-By: Claude Opus 4.5 --- .github/codeql/codeql-config.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..294952e7 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: "CodeQL config" + +paths-ignore: + - tests/e2e/ From 82d85a57d4223c5b32b5f7673008f50c3829d424 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Fri, 9 Jan 2026 19:46:04 -0500 Subject: [PATCH 11/27] fix: use hashed filenames to prevent path injection Instead of trying to sanitize user input, hash the artifact_id to create safe filenames. This ensures user-controlled data never directly becomes part of file paths. Also removes unused CodeQL config file. Co-Authored-By: Claude Opus 4.5 --- .github/codeql/codeql-config.yml | 4 ---- tests/e2e/mock-sentry-api/server.py | 32 ++++++++++++----------------- 2 files changed, 13 insertions(+), 23 deletions(-) delete mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml deleted file mode 100644 index 294952e7..00000000 --- a/.github/codeql/codeql-config.yml +++ /dev/null @@ -1,4 +0,0 @@ -name: "CodeQL config" - -paths-ignore: - - tests/e2e/ diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index ccc9c1ea..eb349b77 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -32,20 +32,14 @@ dir_path.mkdir(parents=True, exist_ok=True) -def safe_path(base_dir: Path, filename: str) -> Path: - """Safely construct a path within base_dir, preventing path traversal. +def safe_filename(artifact_id: str, suffix: str = "") -> str: + """Convert artifact_id to a safe filename using SHA256 hash. - This pattern is recognized by CodeQL as a proper sanitizer. + This prevents path traversal by ensuring user input never directly + becomes part of the filename - only the hash is used. """ - # Construct the path - target = base_dir / filename - # Resolve to absolute path (removes .., symlinks, etc.) - resolved = target.resolve() - # Verify it's within the base directory - base_resolved = base_dir.resolve() - if not str(resolved).startswith(str(base_resolved) + "/") and resolved != base_resolved: - raise HTTPException(status_code=400, detail="Invalid path") - return resolved + hash_digest = hashlib.sha256(artifact_id.encode()).hexdigest()[:16] + return f"{hash_digest}{suffix}" # In-memory storage for test data @@ -84,7 +78,7 @@ async def download_artifact( authorization: str = Header(None), ): """Download artifact file.""" - artifact_path = safe_path(ARTIFACTS_DIR, f"{artifact_id}.zip") + artifact_path = ARTIFACTS_DIR / safe_filename(artifact_id, ".zip") if not artifact_path.exists(): raise HTTPException(status_code=404, detail="Artifact not found") @@ -252,7 +246,7 @@ async def assemble_file( # Store assembled file if assemble_type == "size_analysis": - result_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") + result_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") result_path.write_bytes(file_data) # Parse and store in database @@ -262,7 +256,7 @@ async def assemble_file( print(f"Error parsing size analysis: {e}") elif assemble_type == "installable_app": - app_path = safe_path(RESULTS_DIR, f"{artifact_id}_app") + app_path = RESULTS_DIR / safe_filename(artifact_id, "_app") app_path.write_bytes(file_data) return {"state": "ok", "missingChunks": []} @@ -308,7 +302,7 @@ async def update_size_analysis( @app.post("/test/upload-artifact/{artifact_id}") async def test_upload_artifact(artifact_id: str, file: UploadFile): """Test helper: Upload an artifact file for testing.""" - artifact_path = safe_path(ARTIFACTS_DIR, f"{artifact_id}.zip") + artifact_path = ARTIFACTS_DIR / safe_filename(artifact_id, ".zip") with open(artifact_path, "wb") as f: content = await file.read() @@ -320,8 +314,8 @@ async def test_upload_artifact(artifact_id: str, file: UploadFile): @app.get("/test/results/{artifact_id}") async def test_get_results(artifact_id: str): """Test helper: Get analysis results for an artifact.""" - size_analysis_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") - installable_app_path = safe_path(RESULTS_DIR, f"{artifact_id}_app") + size_analysis_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") + installable_app_path = RESULTS_DIR / safe_filename(artifact_id, "_app") return { "artifact_metadata": artifacts_db.get(artifact_id, {}), "size_analysis": size_analysis_db.get(artifact_id, {}), @@ -333,7 +327,7 @@ async def test_get_results(artifact_id: str): @app.get("/test/results/{artifact_id}/size-analysis-raw") async def test_get_size_analysis_raw(artifact_id: str): """Test helper: Get raw size analysis JSON.""" - result_path = safe_path(RESULTS_DIR, f"{artifact_id}_size_analysis.json") + result_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") if not result_path.exists(): raise HTTPException(status_code=404, detail="Size analysis not found") From fe64ffaf0c9ecac2b3b1e3fa9609b9299b26b684 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 11:29:43 -0500 Subject: [PATCH 12/27] fix: validate chunk checksums to prevent path traversal Add is_valid_chunk_checksum() to validate that chunk checksums are valid SHA1 hex strings before using them in file paths. This prevents potential path traversal attacks in the assemble_file endpoint. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index eb349b77..48aa39e8 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -11,6 +11,7 @@ import hmac import json import os +import re from pathlib import Path from typing import Any, Dict, List, Optional @@ -42,6 +43,19 @@ def safe_filename(artifact_id: str, suffix: str = "") -> str: return f"{hash_digest}{suffix}" +# Pattern for valid SHA1 hex checksums (40 hex characters) +SHA1_HEX_PATTERN = re.compile(r"^[a-f0-9]{40}$") + + +def is_valid_chunk_checksum(checksum: str) -> bool: + """Validate that a chunk checksum is a valid SHA1 hex string. + + This prevents path traversal by ensuring chunk checksums can only + contain hex characters and are exactly 40 characters long. + """ + return bool(SHA1_HEX_PATTERN.match(checksum)) + + # In-memory storage for test data artifacts_db: Dict[str, Dict[str, Any]] = {} size_analysis_db: Dict[str, Dict[str, Any]] = {} @@ -219,10 +233,15 @@ async def assemble_file( chunks = data["chunks"] assemble_type = data["assemble_type"] + # Validate all chunk checksums to prevent path traversal + for chunk_checksum in chunks: + if not is_valid_chunk_checksum(chunk_checksum): + raise HTTPException(status_code=400, detail=f"Invalid chunk checksum format: {chunk_checksum}") + # Check which chunks are missing missing_chunks = [] for chunk_checksum in chunks: - chunk_path = CHUNKS_DIR / chunk_checksum + chunk_path = CHUNKS_DIR / chunk_checksum # Safe: validated above if not chunk_path.exists(): missing_chunks.append(chunk_checksum) @@ -232,7 +251,7 @@ async def assemble_file( # Assemble the file file_data = b"" for chunk_checksum in chunks: - chunk_path = CHUNKS_DIR / chunk_checksum + chunk_path = CHUNKS_DIR / chunk_checksum # Safe: validated above file_data += chunk_path.read_bytes() # Verify checksum From 532756bedbfb4b4c375a7c143f229bb0d06427b9 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 11:58:01 -0500 Subject: [PATCH 13/27] fix: use path resolution pattern for CodeQL compliance Replace regex-based validation with path.resolve() pattern that CodeQL recognizes as a proper path traversal sanitizer. The safe_chunk_path() function now resolves the path and verifies it stays within CHUNKS_DIR. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 48aa39e8..6fb73649 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -11,7 +11,6 @@ import hmac import json import os -import re from pathlib import Path from typing import Any, Dict, List, Optional @@ -43,17 +42,16 @@ def safe_filename(artifact_id: str, suffix: str = "") -> str: return f"{hash_digest}{suffix}" -# Pattern for valid SHA1 hex checksums (40 hex characters) -SHA1_HEX_PATTERN = re.compile(r"^[a-f0-9]{40}$") +def safe_chunk_path(checksum: str) -> Path: + """Get a safe path for a chunk file, preventing path traversal. - -def is_valid_chunk_checksum(checksum: str) -> bool: - """Validate that a chunk checksum is a valid SHA1 hex string. - - This prevents path traversal by ensuring chunk checksums can only - contain hex characters and are exactly 40 characters long. + Resolves the path and verifies it stays within CHUNKS_DIR. + Raises HTTPException if the path would escape the allowed directory. """ - return bool(SHA1_HEX_PATTERN.match(checksum)) + chunk_path = (CHUNKS_DIR / checksum).resolve() + if not str(chunk_path).startswith(str(CHUNKS_DIR.resolve())): + raise HTTPException(status_code=400, detail="Invalid chunk checksum") + return chunk_path # In-memory storage for test data @@ -233,15 +231,10 @@ async def assemble_file( chunks = data["chunks"] assemble_type = data["assemble_type"] - # Validate all chunk checksums to prevent path traversal - for chunk_checksum in chunks: - if not is_valid_chunk_checksum(chunk_checksum): - raise HTTPException(status_code=400, detail=f"Invalid chunk checksum format: {chunk_checksum}") - - # Check which chunks are missing + # Check which chunks are missing (safe_chunk_path validates against path traversal) missing_chunks = [] for chunk_checksum in chunks: - chunk_path = CHUNKS_DIR / chunk_checksum # Safe: validated above + chunk_path = safe_chunk_path(chunk_checksum) if not chunk_path.exists(): missing_chunks.append(chunk_checksum) @@ -251,7 +244,7 @@ async def assemble_file( # Assemble the file file_data = b"" for chunk_checksum in chunks: - chunk_path = CHUNKS_DIR / chunk_checksum # Safe: validated above + chunk_path = safe_chunk_path(chunk_checksum) file_data += chunk_path.read_bytes() # Verify checksum From 1505119219da6bba20f694c571edb98c098d23bb Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 13:42:36 -0500 Subject: [PATCH 14/27] fix: use hashed filenames for chunk storage Apply the same safe_filename pattern to chunk storage - hash the checksum before using it as a filename. This ensures user-controlled data never directly becomes part of file paths, preventing path traversal attacks. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 6fb73649..1c1966c1 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -42,16 +42,13 @@ def safe_filename(artifact_id: str, suffix: str = "") -> str: return f"{hash_digest}{suffix}" -def safe_chunk_path(checksum: str) -> Path: - """Get a safe path for a chunk file, preventing path traversal. +def safe_chunk_filename(checksum: str) -> str: + """Convert checksum to a safe filename using SHA256 hash. - Resolves the path and verifies it stays within CHUNKS_DIR. - Raises HTTPException if the path would escape the allowed directory. + This prevents path traversal by ensuring user input never directly + becomes part of the filename - only the hash is used. """ - chunk_path = (CHUNKS_DIR / checksum).resolve() - if not str(chunk_path).startswith(str(CHUNKS_DIR.resolve())): - raise HTTPException(status_code=400, detail="Invalid chunk checksum") - return chunk_path + return hashlib.sha256(checksum.encode()).hexdigest()[:16] # In-memory storage for test data @@ -195,8 +192,8 @@ async def upload_chunk( # Calculate checksum checksum = hashlib.sha1(chunk_data).hexdigest() - # Store chunk - chunk_path = CHUNKS_DIR / checksum + # Store chunk using safe filename (hash of checksum prevents path injection) + chunk_path = CHUNKS_DIR / safe_chunk_filename(checksum) chunk_path.write_bytes(chunk_data) # Return 200 if successful, 409 if already exists @@ -231,10 +228,10 @@ async def assemble_file( chunks = data["chunks"] assemble_type = data["assemble_type"] - # Check which chunks are missing (safe_chunk_path validates against path traversal) + # Check which chunks are missing (safe_chunk_filename hashes input to prevent path injection) missing_chunks = [] for chunk_checksum in chunks: - chunk_path = safe_chunk_path(chunk_checksum) + chunk_path = CHUNKS_DIR / safe_chunk_filename(chunk_checksum) if not chunk_path.exists(): missing_chunks.append(chunk_checksum) @@ -244,7 +241,7 @@ async def assemble_file( # Assemble the file file_data = b"" for chunk_checksum in chunks: - chunk_path = safe_chunk_path(chunk_checksum) + chunk_path = CHUNKS_DIR / safe_chunk_filename(chunk_checksum) file_data += chunk_path.read_bytes() # Verify checksum From ebc8aad804105922f639ad96f5dafa9c514e4493 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 14:24:36 -0500 Subject: [PATCH 15/27] chore: split e2e job into separate steps for cleaner logs Split the E2E CI job into distinct steps so each has its own collapsible log section in GitHub Actions: - Build E2E Docker images - Start E2E services - Run E2E tests This makes it much easier to find the actual test output without wading through Docker build and service startup logs. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdca33ac..aa45f066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,21 +288,40 @@ jobs: docker exec $KAFKA_CONTAINER kafka-topics --bootstrap-server localhost:9092 --create --topic preprod-artifact-events --partitions 1 --replication-factor 1 --if-not-exists echo "Topic created successfully" + - name: Build E2E Docker images + run: docker compose -f docker-compose.e2e.yml build + + - name: Start E2E services + run: | + # Start services in detached mode (minio, mock-sentry-api, launchpad) + docker compose -f docker-compose.e2e.yml up -d minio mock-sentry-api launchpad + + # Wait for launchpad to be healthy + echo "Waiting for Launchpad to be healthy..." + for i in {1..30}; do + if docker compose -f docker-compose.e2e.yml ps launchpad | grep -q "healthy"; then + echo "Launchpad is ready!" + break + fi + echo "Waiting for Launchpad... attempt $i/30" + sleep 5 + done + + # Show running services + docker compose -f docker-compose.e2e.yml ps + - name: Run E2E tests run: | - # Start E2E stack (will connect to devservices Kafka) - docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-tests - timeout-minutes: 15 + docker compose -f docker-compose.e2e.yml run --rm e2e-tests + timeout-minutes: 10 - - name: Show E2E logs on failure + - name: Show service logs on failure if: failure() run: | echo "=== Launchpad logs ===" docker compose -f docker-compose.e2e.yml logs launchpad echo "=== Mock API logs ===" docker compose -f docker-compose.e2e.yml logs mock-sentry-api - echo "=== E2E test logs ===" - docker compose -f docker-compose.e2e.yml logs e2e-tests echo "=== Kafka logs ===" docker logs $(docker ps -qf "name=kafka") --tail 100 || echo "Could not get Kafka logs" From fdec1a0d28514db869c3aacbe1f0a37338c947d4 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 14:37:42 -0500 Subject: [PATCH 16/27] test: add deliberate error to verify E2E catches failures This is a temporary commit to verify E2E tests properly detect analysis failures. Will be reverted after verification. Co-Authored-By: Claude Opus 4.5 --- src/launchpad/size/analyzers/apple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index ac18f765..e0172f33 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -111,6 +111,7 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: Returns: Analysis results including file sizes, binary analysis, and treemap """ + raise RuntimeError("DELIBERATE ERROR: Testing E2E failure detection") start_time = time.time() if not isinstance(artifact, ZippedXCArchive): raise NotImplementedError(f"Only ZippedXCArchive artifacts are supported, got {type(artifact)}") From 4080337afeee259a6fe32559b84baab6dc6383fb Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 14:48:15 -0500 Subject: [PATCH 17/27] Revert "test: add deliberate error to verify E2E catches failures" This reverts commit fdec1a0d28514db869c3aacbe1f0a37338c947d4. --- src/launchpad/size/analyzers/apple.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index e0172f33..ac18f765 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -111,7 +111,6 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: Returns: Analysis results including file sizes, binary analysis, and treemap """ - raise RuntimeError("DELIBERATE ERROR: Testing E2E failure detection") start_time = time.time() if not isinstance(artifact, ZippedXCArchive): raise NotImplementedError(f"Only ZippedXCArchive artifacts are supported, got {type(artifact)}") From d190f58cacf05cfb18ba6c1101d33237b59cd01f Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 15:03:08 -0500 Subject: [PATCH 18/27] test: add strict API contract validation to E2E tests Validate the full API response schema in E2E tests to catch issues like the build_date commit that broke production. This includes: - iOS: verify required fields (app_name, app_id, build_version, short_version) - iOS: verify optional fields exist (minimum_os_version, sdk_version, is_simulator, codesigning_type, build_date) - iOS: validate build_date is ISO format when present - Android: verify required fields (app_name, app_id, version_code, version_name) - All: verify download_size is an integer, insights is a list This would have caught issues where new fields break serialization or where field names don't match what Sentry expects. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 73 +++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index a644ce48..72fe0733 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -11,6 +11,7 @@ import os import time +from datetime import datetime from pathlib import Path from typing import Any, Dict @@ -148,10 +149,35 @@ def test_ios_xcarchive_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify basic metadata - assert "app_name" in metadata or "appName" in metadata, "App name should be present" - assert "app_id" in metadata or "appId" in metadata, "App ID should be present" - assert "build_version" in metadata or "buildVersion" in metadata, "Build version should be present" + # Verify iOS-specific metadata fields (API contract) + # These are the fields Sentry expects from Launchpad for iOS apps + ios_required_fields = [ + "app_name", + "app_id", + "build_version", + "short_version", + ] + for field in ios_required_fields: + assert field in metadata, f"iOS metadata missing required field: {field}" + assert metadata[field] is not None, f"iOS metadata field {field} should not be None" + + # Verify iOS-specific optional fields are present (may be None but key should exist) + ios_optional_fields = [ + "minimum_os_version", + "sdk_version", + "is_simulator", + "codesigning_type", + "build_date", + ] + for field in ios_optional_fields: + assert field in metadata, f"iOS metadata missing optional field: {field}" + + # Verify build_date format if present (should be ISO format) + if metadata.get("build_date"): + try: + datetime.fromisoformat(metadata["build_date"]) + except ValueError: + raise AssertionError(f"build_date should be ISO format, got: {metadata['build_date']}") # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" @@ -159,17 +185,20 @@ def test_ios_xcarchive_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) assert "download_size" in size_analysis, "Size analysis should contain download_size" + assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" assert "insights" in size_analysis, "Size analysis should contain insights" assert "treemap" in size_analysis, "Size analysis should contain treemap" # Verify insights were generated insights = size_analysis["insights"] + assert isinstance(insights, list), "insights should be a list" assert len(insights) > 0, "Should generate at least one insight" print("✓ iOS E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(insights)}") - print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + print(f" - App name: {metadata.get('app_name')}") + print(f" - Build date: {metadata.get('build_date')}") def test_android_apk_full_flow(self): """Test full flow with Android .apk file.""" @@ -198,9 +227,16 @@ def test_android_apk_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify basic metadata - assert "app_name" in metadata or "appName" in metadata, "App name should be present" - assert "app_id" in metadata or "appId" in metadata, "App ID should be present" + # Verify Android-specific metadata fields (API contract) + android_required_fields = [ + "app_name", + "app_id", + "version_code", + "version_name", + ] + for field in android_required_fields: + assert field in metadata, f"Android metadata missing required field: {field}" + assert metadata[field] is not None, f"Android metadata field {field} should not be None" # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" @@ -208,12 +244,14 @@ def test_android_apk_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) assert "download_size" in size_analysis, "Size analysis should contain download_size" + assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" assert "insights" in size_analysis, "Size analysis should contain insights" + assert isinstance(size_analysis["insights"], list), "insights should be a list" print("✓ Android APK E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(size_analysis['insights'])}") - print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + print(f" - App name: {metadata.get('app_name')}") def test_android_aab_full_flow(self): """Test full flow with Android .aab file.""" @@ -242,9 +280,16 @@ def test_android_aab_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify basic metadata - assert "app_name" in metadata or "appName" in metadata, "App name should be present" - assert "app_id" in metadata or "appId" in metadata, "App ID should be present" + # Verify Android-specific metadata fields (API contract) + android_required_fields = [ + "app_name", + "app_id", + "version_code", + "version_name", + ] + for field in android_required_fields: + assert field in metadata, f"Android metadata missing required field: {field}" + assert metadata[field] is not None, f"Android metadata field {field} should not be None" # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" @@ -252,12 +297,14 @@ def test_android_aab_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) assert "download_size" in size_analysis, "Size analysis should contain download_size" + assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" assert "insights" in size_analysis, "Size analysis should contain insights" + assert isinstance(size_analysis["insights"], list), "insights should be a list" print("✓ Android AAB E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(size_analysis['insights'])}") - print(f" - App name: {metadata.get('app_name') or metadata.get('appName')}") + print(f" - App name: {metadata.get('app_name')}") def test_launchpad_health_check(self): """Verify Launchpad service is healthy.""" From ecf42e697052e1fc9c74c3e539e87e2d4c717bef Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 15:11:10 -0500 Subject: [PATCH 19/27] fix: update E2E tests to match actual API contract Fix field names to match the UpdateData model: - Common fields: app_name, app_id, build_version, artifact_type - iOS-specific fields are nested in apple_app_info - Android-specific fields are nested in android_app_info The E2E tests now validate the actual API contract that Sentry expects from Launchpad. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 80 +++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index 72fe0733..d4339bf8 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -149,35 +149,33 @@ def test_ios_xcarchive_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify iOS-specific metadata fields (API contract) - # These are the fields Sentry expects from Launchpad for iOS apps - ios_required_fields = [ + # Verify common metadata fields (API contract from UpdateData model) + required_fields = [ "app_name", "app_id", "build_version", - "short_version", + "artifact_type", ] - for field in ios_required_fields: - assert field in metadata, f"iOS metadata missing required field: {field}" - assert metadata[field] is not None, f"iOS metadata field {field} should not be None" - - # Verify iOS-specific optional fields are present (may be None but key should exist) - ios_optional_fields = [ - "minimum_os_version", - "sdk_version", - "is_simulator", - "codesigning_type", - "build_date", - ] - for field in ios_optional_fields: - assert field in metadata, f"iOS metadata missing optional field: {field}" + for field in required_fields: + assert field in metadata, f"Metadata missing required field: {field}" + assert metadata[field] is not None, f"Metadata field {field} should not be None" + + # Verify iOS-specific nested info exists + assert "apple_app_info" in metadata, "iOS metadata should have apple_app_info" + apple_info = metadata["apple_app_info"] + assert apple_info is not None, "apple_app_info should not be None for iOS" + + # Verify iOS-specific fields in apple_app_info + ios_fields = ["is_simulator", "codesigning_type", "build_date"] + for field in ios_fields: + assert field in apple_info, f"apple_app_info missing field: {field}" # Verify build_date format if present (should be ISO format) - if metadata.get("build_date"): + if apple_info.get("build_date"): try: - datetime.fromisoformat(metadata["build_date"]) + datetime.fromisoformat(apple_info["build_date"]) except ValueError: - raise AssertionError(f"build_date should be ISO format, got: {metadata['build_date']}") + raise AssertionError(f"build_date should be ISO format, got: {apple_info['build_date']}") # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" @@ -198,7 +196,7 @@ def test_ios_xcarchive_full_flow(self): print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") print(f" - Insights generated: {len(insights)}") print(f" - App name: {metadata.get('app_name')}") - print(f" - Build date: {metadata.get('build_date')}") + print(f" - Build date: {apple_info.get('build_date')}") def test_android_apk_full_flow(self): """Test full flow with Android .apk file.""" @@ -227,16 +225,21 @@ def test_android_apk_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify Android-specific metadata fields (API contract) - android_required_fields = [ + # Verify common metadata fields (API contract from UpdateData model) + required_fields = [ "app_name", "app_id", - "version_code", - "version_name", + "build_version", + "artifact_type", ] - for field in android_required_fields: - assert field in metadata, f"Android metadata missing required field: {field}" - assert metadata[field] is not None, f"Android metadata field {field} should not be None" + for field in required_fields: + assert field in metadata, f"Metadata missing required field: {field}" + assert metadata[field] is not None, f"Metadata field {field} should not be None" + + # Verify Android-specific nested info exists + assert "android_app_info" in metadata, "Android metadata should have android_app_info" + android_info = metadata["android_app_info"] + assert android_info is not None, "android_app_info should not be None for Android" # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" @@ -280,16 +283,21 @@ def test_android_aab_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify Android-specific metadata fields (API contract) - android_required_fields = [ + # Verify common metadata fields (API contract from UpdateData model) + required_fields = [ "app_name", "app_id", - "version_code", - "version_name", + "build_version", + "artifact_type", ] - for field in android_required_fields: - assert field in metadata, f"Android metadata missing required field: {field}" - assert metadata[field] is not None, f"Android metadata field {field} should not be None" + for field in required_fields: + assert field in metadata, f"Metadata missing required field: {field}" + assert metadata[field] is not None, f"Metadata field {field} should not be None" + + # Verify Android-specific nested info exists + assert "android_app_info" in metadata, "Android metadata should have android_app_info" + android_info = metadata["android_app_info"] + assert android_info is not None, "android_app_info should not be None for Android" # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" From 25de3021ab75986e1b5a8577f059674918d1fd88 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 12 Jan 2026 16:09:12 -0500 Subject: [PATCH 20/27] fix: insights is a dict keyed by category, not a list The size analysis insights field is organized as a dict with category keys (duplicate_files, image_optimization, etc.), not a flat list. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index d4339bf8..085d0b47 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -187,14 +187,14 @@ def test_ios_xcarchive_full_flow(self): assert "insights" in size_analysis, "Size analysis should contain insights" assert "treemap" in size_analysis, "Size analysis should contain treemap" - # Verify insights were generated + # Verify insights were generated (insights is a dict keyed by category) insights = size_analysis["insights"] - assert isinstance(insights, list), "insights should be a list" - assert len(insights) > 0, "Should generate at least one insight" + assert isinstance(insights, dict), "insights should be a dict" + assert len(insights) > 0, "Should generate at least one insight category" print("✓ iOS E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") - print(f" - Insights generated: {len(insights)}") + print(f" - Insight categories: {list(insights.keys())}") print(f" - App name: {metadata.get('app_name')}") print(f" - Build date: {apple_info.get('build_date')}") @@ -249,11 +249,11 @@ def test_android_apk_full_flow(self): assert "download_size" in size_analysis, "Size analysis should contain download_size" assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" assert "insights" in size_analysis, "Size analysis should contain insights" - assert isinstance(size_analysis["insights"], list), "insights should be a list" + assert isinstance(size_analysis["insights"], dict), "insights should be a dict" print("✓ Android APK E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") - print(f" - Insights generated: {len(size_analysis['insights'])}") + print(f" - Insight categories: {list(size_analysis['insights'].keys())}") print(f" - App name: {metadata.get('app_name')}") def test_android_aab_full_flow(self): @@ -307,11 +307,11 @@ def test_android_aab_full_flow(self): assert "download_size" in size_analysis, "Size analysis should contain download_size" assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" assert "insights" in size_analysis, "Size analysis should contain insights" - assert isinstance(size_analysis["insights"], list), "insights should be a list" + assert isinstance(size_analysis["insights"], dict), "insights should be a dict" print("✓ Android AAB E2E test passed!") print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") - print(f" - Insights generated: {len(size_analysis['insights'])}") + print(f" - Insight categories: {list(size_analysis['insights'].keys())}") print(f" - App name: {metadata.get('app_name')}") def test_launchpad_health_check(self): From 7cc65d4531b14e9605a176eb641efa7942721661 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 10:52:05 -0500 Subject: [PATCH 21/27] test: use exact value assertions in E2E tests Replace weak field existence checks with exact value assertions: iOS (HackerNews.xcarchive.zip): - app_name == "HackerNews" - app_id == "com.emergetools.hackernews" - build_version == "3.8", build_number == 1 - codesigning_type == "development" - build_date == "2025-05-19T16:15:12" - main_binary_uuid == "BEB3C0D6-2518-343D-BB6F-FF5581C544E8" Android APK (hn.apk): - app_name == "Hacker News" - app_id == "com.emergetools.hackernews" - has_proguard_mapping == False Android AAB (hn.aab): - build_version == "1.0.2", build_number == 13 - has_proguard_mapping == True Also verify specific insight categories are generated for each platform. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 133 +++++++++++++++---------------------- 1 file changed, 54 insertions(+), 79 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index 085d0b47..af3bf1b6 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -11,7 +11,6 @@ import os import time -from datetime import datetime from pathlib import Path from typing import Any, Dict @@ -149,54 +148,39 @@ def test_ios_xcarchive_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify common metadata fields (API contract from UpdateData model) - required_fields = [ - "app_name", - "app_id", - "build_version", - "artifact_type", - ] - for field in required_fields: - assert field in metadata, f"Metadata missing required field: {field}" - assert metadata[field] is not None, f"Metadata field {field} should not be None" - - # Verify iOS-specific nested info exists - assert "apple_app_info" in metadata, "iOS metadata should have apple_app_info" - apple_info = metadata["apple_app_info"] - assert apple_info is not None, "apple_app_info should not be None for iOS" - - # Verify iOS-specific fields in apple_app_info - ios_fields = ["is_simulator", "codesigning_type", "build_date"] - for field in ios_fields: - assert field in apple_info, f"apple_app_info missing field: {field}" + # Verify exact metadata values for HackerNews.xcarchive.zip + assert metadata["app_name"] == "HackerNews" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["build_version"] == "3.8" + assert metadata["build_number"] == 1 + assert metadata["artifact_type"] == 0 # iOS xcarchive - # Verify build_date format if present (should be ISO format) - if apple_info.get("build_date"): - try: - datetime.fromisoformat(apple_info["build_date"]) - except ValueError: - raise AssertionError(f"build_date should be ISO format, got: {apple_info['build_date']}") + # Verify iOS-specific nested info + assert "apple_app_info" in metadata + apple_info = metadata["apple_app_info"] + assert apple_info["is_simulator"] is False + assert apple_info["codesigning_type"] == "development" + assert apple_info["build_date"] == "2025-05-19T16:15:12" + assert apple_info["is_code_signature_valid"] is True + assert apple_info["main_binary_uuid"] == "BEB3C0D6-2518-343D-BB6F-FF5581C544E8" # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "download_size" in size_analysis, "Size analysis should contain download_size" - assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" - assert "insights" in size_analysis, "Size analysis should contain insights" - assert "treemap" in size_analysis, "Size analysis should contain treemap" + assert size_analysis["download_size"] > 0 + assert "treemap" in size_analysis - # Verify insights were generated (insights is a dict keyed by category) + # Verify expected insight categories for iOS insights = size_analysis["insights"] - assert isinstance(insights, dict), "insights should be a dict" - assert len(insights) > 0, "Should generate at least one insight category" + assert "duplicate_files" in insights + assert "image_optimization" in insights + assert "main_binary_exported_symbols" in insights print("✓ iOS E2E test passed!") - print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") + print(f" - Download size: {size_analysis['download_size']} bytes") print(f" - Insight categories: {list(insights.keys())}") - print(f" - App name: {metadata.get('app_name')}") - print(f" - Build date: {apple_info.get('build_date')}") def test_android_apk_full_flow(self): """Test full flow with Android .apk file.""" @@ -225,36 +209,31 @@ def test_android_apk_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify common metadata fields (API contract from UpdateData model) - required_fields = [ - "app_name", - "app_id", - "build_version", - "artifact_type", - ] - for field in required_fields: - assert field in metadata, f"Metadata missing required field: {field}" - assert metadata[field] is not None, f"Metadata field {field} should not be None" - - # Verify Android-specific nested info exists - assert "android_app_info" in metadata, "Android metadata should have android_app_info" + # Verify exact metadata values for hn.apk + assert metadata["app_name"] == "Hacker News" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["artifact_type"] == 2 # Android APK + + # Verify Android-specific nested info + assert "android_app_info" in metadata android_info = metadata["android_app_info"] - assert android_info is not None, "android_app_info should not be None for Android" + assert android_info["has_proguard_mapping"] is False # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "download_size" in size_analysis, "Size analysis should contain download_size" - assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" - assert "insights" in size_analysis, "Size analysis should contain insights" - assert isinstance(size_analysis["insights"], dict), "insights should be a dict" + assert size_analysis["download_size"] > 0 + + # Verify expected insight categories for Android APK + insights = size_analysis["insights"] + assert "duplicate_files" in insights + assert "multiple_native_library_archs" in insights print("✓ Android APK E2E test passed!") - print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") - print(f" - Insight categories: {list(size_analysis['insights'].keys())}") - print(f" - App name: {metadata.get('app_name')}") + print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Insight categories: {list(insights.keys())}") def test_android_aab_full_flow(self): """Test full flow with Android .aab file.""" @@ -283,36 +262,32 @@ def test_android_aab_full_flow(self): assert results["artifact_metadata"], "Artifact metadata should be updated" metadata = results["artifact_metadata"] - # Verify common metadata fields (API contract from UpdateData model) - required_fields = [ - "app_name", - "app_id", - "build_version", - "artifact_type", - ] - for field in required_fields: - assert field in metadata, f"Metadata missing required field: {field}" - assert metadata[field] is not None, f"Metadata field {field} should not be None" - - # Verify Android-specific nested info exists - assert "android_app_info" in metadata, "Android metadata should have android_app_info" + # Verify exact metadata values for hn.aab + assert metadata["app_name"] == "Hacker News" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["build_version"] == "1.0.2" + assert metadata["build_number"] == 13 + assert metadata["artifact_type"] == 1 # Android AAB + + # Verify Android-specific nested info + assert "android_app_info" in metadata android_info = metadata["android_app_info"] - assert android_info is not None, "android_app_info should not be None for Android" + assert android_info["has_proguard_mapping"] is True # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) - assert "download_size" in size_analysis, "Size analysis should contain download_size" - assert isinstance(size_analysis["download_size"], int), "download_size should be an integer" - assert "insights" in size_analysis, "Size analysis should contain insights" - assert isinstance(size_analysis["insights"], dict), "insights should be a dict" + assert size_analysis["download_size"] > 0 + + # Verify expected insight categories for Android AAB + insights = size_analysis["insights"] + assert "duplicate_files" in insights print("✓ Android AAB E2E test passed!") - print(f" - Download size: {size_analysis.get('download_size', 'N/A')} bytes") - print(f" - Insight categories: {list(size_analysis['insights'].keys())}") - print(f" - App name: {metadata.get('app_name')}") + print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Insight categories: {list(insights.keys())}") def test_launchpad_health_check(self): """Verify Launchpad service is healthy.""" From 33b38cb941bc8bbc10a4bb5f279c6fa97cea0c49 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 14:51:24 -0500 Subject: [PATCH 22/27] fix: strengthen E2E assertions with exact values Updated E2E tests to verify exact values instead of weak existence checks. This ensures API contract changes are caught, like the build_date issue in fabe8d8. iOS test now verifies: - Exact download_size (6502319) - Treemap root matches download_size and app name - Insight categories have proper structure with total_savings Android APK test now verifies: - Exact download_size (3670839) and treemap root size (7886041) - Exact duplicate_files savings (51709) - Exact multiple_native_library_archs savings (1891208) Android AAB test now verifies: - Exact treemap root size (5932249) - Treemap structure with expected children count - Insight structure validation Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 54 +++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index af3bf1b6..f3855555 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -167,19 +167,34 @@ def test_ios_xcarchive_full_flow(self): # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" - # Verify size analysis contents + # Verify size analysis contents with exact values size_analysis = get_size_analysis_raw(artifact_id) - assert size_analysis["download_size"] > 0 - assert "treemap" in size_analysis + assert size_analysis["download_size"] == 6502319 + + # Verify treemap structure and root size matches download_size + treemap = size_analysis["treemap"] + assert treemap["platform"] == "ios" + assert treemap["root"]["name"] == "HackerNews.app" + assert treemap["root"]["size"] == 6502319 + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) > 0 - # Verify expected insight categories for iOS + # Verify expected insight categories and their structure insights = size_analysis["insights"] assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] > 0 + assert len(insights["duplicate_files"]["groups"]) > 0 + assert "image_optimization" in insights + assert insights["image_optimization"]["total_savings"] > 0 + assert len(insights["image_optimization"]["optimizable_files"]) > 0 + assert "main_binary_exported_symbols" in insights + assert insights["main_binary_exported_symbols"]["total_savings"] > 0 print("✓ iOS E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") def test_android_apk_full_flow(self): @@ -222,17 +237,30 @@ def test_android_apk_full_flow(self): # Check size analysis was uploaded assert results["has_size_analysis_file"], "Size analysis file should be uploaded" - # Verify size analysis contents + # Verify size analysis contents with exact values size_analysis = get_size_analysis_raw(artifact_id) - assert size_analysis["download_size"] > 0 + assert size_analysis["download_size"] == 3670839 + + # Verify treemap structure and root size + treemap = size_analysis["treemap"] + assert treemap["platform"] == "android" + assert treemap["root"]["name"] == "Hacker News" + assert treemap["root"]["size"] == 7886041 + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) == 14 - # Verify expected insight categories for Android APK + # Verify expected insight categories and their structure insights = size_analysis["insights"] assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] == 51709 + assert len(insights["duplicate_files"]["groups"]) > 0 + assert "multiple_native_library_archs" in insights + assert insights["multiple_native_library_archs"]["total_savings"] == 1891208 print("✓ Android APK E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") def test_android_aab_full_flow(self): @@ -279,14 +307,26 @@ def test_android_aab_full_flow(self): # Verify size analysis contents size_analysis = get_size_analysis_raw(artifact_id) + # AAB download size varies based on extracted APKs - verify it's positive assert size_analysis["download_size"] > 0 + # Verify treemap structure and root size + treemap = size_analysis["treemap"] + assert treemap["platform"] == "android" + assert treemap["root"]["name"] == "Hacker News" + assert treemap["root"]["size"] == 5932249 + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) == 14 + # Verify expected insight categories for Android AAB insights = size_analysis["insights"] assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] >= 0 + assert "groups" in insights["duplicate_files"] print("✓ Android AAB E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") def test_launchpad_health_check(self): From 3a47ae6819b2aa2765a17644a24b2354677b9d14 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 14:58:49 -0500 Subject: [PATCH 23/27] fix: correct iOS treemap root name (HackerNews, not HackerNews.app) The treemap root name is the app name without the .app extension. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index f3855555..fd184e99 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -174,7 +174,7 @@ def test_ios_xcarchive_full_flow(self): # Verify treemap structure and root size matches download_size treemap = size_analysis["treemap"] assert treemap["platform"] == "ios" - assert treemap["root"]["name"] == "HackerNews.app" + assert treemap["root"]["name"] == "HackerNews" assert treemap["root"]["size"] == 6502319 assert treemap["root"]["is_dir"] is True assert len(treemap["root"]["children"]) > 0 From 81e3cfc9da6b5087f2ae56036ec8058e1159871b Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 15:06:18 -0500 Subject: [PATCH 24/27] fix: use correct iOS treemap root size (install size, not download size) Treemap root size is 9728000 (install size), which is different from download_size (6502319, compressed). Fixed comment and assertion. Co-Authored-By: Claude Opus 4.5 --- tests/e2e/test_e2e_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index fd184e99..66d98851 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -171,11 +171,11 @@ def test_ios_xcarchive_full_flow(self): size_analysis = get_size_analysis_raw(artifact_id) assert size_analysis["download_size"] == 6502319 - # Verify treemap structure and root size matches download_size + # Verify treemap structure (root size is install size, different from download_size) treemap = size_analysis["treemap"] assert treemap["platform"] == "ios" assert treemap["root"]["name"] == "HackerNews" - assert treemap["root"]["size"] == 6502319 + assert treemap["root"]["size"] == 9728000 # Install size, larger than download_size assert treemap["root"]["is_dir"] is True assert len(treemap["root"]["children"]) > 0 From d654a23e0fcf18707a9ac05b95404d84608a43f1 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 18:16:21 -0500 Subject: [PATCH 25/27] fix: address PR review feedback for E2E tests Security fixes: - Use hmac.compare_digest() for timing-safe signature comparison - Return error instead of silently ignoring JSON parsing failures - Return error for unknown assemble_type values Code quality: - Remove unused Pydantic models (UpdateRequest, ChunkOptionsResponse, etc.) - Remove unused assembled_files variable - Remove unused List import - Fix Content-Range header calculation (use correct end byte) - Remove obsolete docker-compose version field - Replace emoji checkmarks with [OK] text Robustness: - Add Kafka delivery callback to verify message sends - Add failure handling for CI health check loop - Add failure path test for non-existent artifacts Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 8 ++++ docker-compose.e2e.yml | 2 - tests/e2e/mock-sentry-api/server.py | 61 ++++++++----------------- tests/e2e/test_e2e_flow.py | 69 ++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa45f066..6c664608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,15 +298,23 @@ jobs: # Wait for launchpad to be healthy echo "Waiting for Launchpad to be healthy..." + LAUNCHPAD_READY=false for i in {1..30}; do if docker compose -f docker-compose.e2e.yml ps launchpad | grep -q "healthy"; then echo "Launchpad is ready!" + LAUNCHPAD_READY=true break fi echo "Waiting for Launchpad... attempt $i/30" sleep 5 done + if [ "$LAUNCHPAD_READY" = "false" ]; then + echo "ERROR: Launchpad failed to become healthy" + docker compose -f docker-compose.e2e.yml logs launchpad + exit 1 + fi + # Show running services docker compose -f docker-compose.e2e.yml ps diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index e4be31a7..d4bf388d 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -1,5 +1,3 @@ -version: '3.8' - # Note: This E2E setup leverages your existing devservices Kafka # Run `devservices up` before starting these tests diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 1c1966c1..f5946b18 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -13,11 +13,10 @@ import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from fastapi import FastAPI, Header, HTTPException, Request, Response, UploadFile from fastapi.responses import FileResponse, JSONResponse -from pydantic import BaseModel app = FastAPI(title="Mock Sentry API for Launchpad E2E Tests") @@ -54,7 +53,6 @@ def safe_chunk_filename(checksum: str) -> str: # In-memory storage for test data artifacts_db: Dict[str, Dict[str, Any]] = {} size_analysis_db: Dict[str, Dict[str, Any]] = {} -assembled_files: Dict[str, bytes] = {} # Expected RPC secret (should match docker-compose env var) RPC_SHARED_SECRET = os.getenv("LAUNCHPAD_RPC_SHARED_SECRET", "test-secret-key-for-e2e") @@ -68,7 +66,7 @@ def verify_rpc_signature(authorization: str, body: bytes) -> bool: signature = authorization.replace("rpcsignature rpc0:", "") expected_signature = hmac.new(RPC_SHARED_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() - return signature == expected_signature + return hmac.compare_digest(signature, expected_signature) @app.get("/health") @@ -101,25 +99,21 @@ async def download_artifact( range_header = request.headers.get("range") if range_header: # Parse range header (simplified implementation) + file_size = artifact_path.stat().st_size range_start = int(range_header.replace("bytes=", "").split("-")[0]) with open(artifact_path, "rb") as f: f.seek(range_start) content = f.read() + range_end = range_start + len(content) - 1 return Response( content=content, status_code=206, - headers={"Content-Range": f"bytes {range_start}-{len(content) - 1}/{artifact_path.stat().st_size}"}, + headers={"Content-Range": f"bytes {range_start}-{range_end}/{file_size}"}, ) return FileResponse(artifact_path) -class UpdateRequest(BaseModel): - """Artifact update request model.""" - - pass # Accept any fields - - @app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/update/") async def update_artifact( org: str, @@ -149,20 +143,6 @@ async def update_artifact( return {"success": True, "artifactId": artifact_id, "updatedFields": updated_fields} -class ChunkOptionsResponse(BaseModel): - """Chunk upload options response.""" - - url: str - chunkSize: int - chunksPerRequest: int - maxFileSize: int - maxRequestSize: int - concurrency: int - hashAlgorithm: str - compression: List[str] - accept: List[str] - - @app.get("/api/0/organizations/{org}/chunk-upload/") async def get_chunk_options(org: str): """Get chunk upload configuration.""" @@ -200,14 +180,6 @@ async def upload_chunk( return JSONResponse({"checksum": checksum}, status_code=200) -class AssembleRequest(BaseModel): - """Assembly request model.""" - - checksum: str - chunks: List[str] - assemble_type: str - - @app.post("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/assemble-generic/") async def assemble_file( org: str, @@ -258,23 +230,28 @@ async def assemble_file( result_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") result_path.write_bytes(file_data) - # Parse and store in database + # Parse and store in database - fail if JSON is invalid try: size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) - except Exception as e: - print(f"Error parsing size analysis: {e}") + except json.JSONDecodeError as e: + return { + "state": "error", + "missingChunks": [], + "detail": f"Invalid JSON in size analysis: {e}", + } elif assemble_type == "installable_app": app_path = RESULTS_DIR / safe_filename(artifact_id, "_app") app_path.write_bytes(file_data) - return {"state": "ok", "missingChunks": []} - - -class PutSizeRequest(BaseModel): - """Size analysis update request.""" + else: + return { + "state": "error", + "missingChunks": [], + "detail": f"Unknown assemble_type: {assemble_type}", + } - pass # Accept any fields + return {"state": "ok", "missingChunks": []} @app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/size/") diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py index 66d98851..0e7181ae 100644 --- a/tests/e2e/test_e2e_flow.py +++ b/tests/e2e/test_e2e_flow.py @@ -39,7 +39,7 @@ def wait_for_service(url: str, timeout: int = 60, service_name: str = "service") try: response = requests.get(f"{url}/health", timeout=5) if response.status_code == 200: - print(f"✓ {service_name} is healthy") + print(f"[OK] {service_name} is healthy") return except requests.exceptions.RequestException: pass @@ -53,11 +53,18 @@ def upload_artifact_to_mock_api(artifact_id: str, file_path: Path) -> None: files = {"file": (file_path.name, f, "application/zip")} response = requests.post(f"{MOCK_API_URL}/test/upload-artifact/{artifact_id}", files=files, timeout=30) response.raise_for_status() - print(f"✓ Uploaded artifact {artifact_id} ({file_path.name})") + print(f"[OK] Uploaded artifact {artifact_id} ({file_path.name})") def send_kafka_message(artifact_id: str, org: str, project: str, features: list[str]) -> None: """Send a Kafka message to trigger artifact processing.""" + delivery_error = None + + def delivery_callback(err, msg): + nonlocal delivery_error + if err: + delivery_error = err + producer = Producer({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS, "client.id": "e2e-test-producer"}) message = { @@ -67,9 +74,20 @@ def send_kafka_message(artifact_id: str, org: str, project: str, features: list[ "requested_features": features, } - producer.produce(KAFKA_TOPIC, key=artifact_id.encode("utf-8"), value=json.dumps(message).encode("utf-8")) - producer.flush(timeout=10) - print(f"✓ Sent Kafka message for artifact {artifact_id}") + producer.produce( + KAFKA_TOPIC, + key=artifact_id.encode("utf-8"), + value=json.dumps(message).encode("utf-8"), + callback=delivery_callback, + ) + remaining = producer.flush(timeout=10) + + if delivery_error: + raise RuntimeError(f"Kafka message delivery failed: {delivery_error}") + if remaining > 0: + raise RuntimeError(f"Failed to flush {remaining} Kafka messages") + + print(f"[OK] Sent Kafka message for artifact {artifact_id}") def wait_for_processing(artifact_id: str, timeout: int = 120, check_interval: int = 3) -> Dict[str, Any]: @@ -86,7 +104,7 @@ def wait_for_processing(artifact_id: str, timeout: int = 120, check_interval: in # Check if processing is complete # Processing is complete when both metadata is updated AND size analysis file exists if results.get("artifact_metadata") and results.get("has_size_analysis_file"): - print(f"✓ Processing completed for {artifact_id}") + print(f"[OK] Processing completed for {artifact_id}") return results # Show progress @@ -192,7 +210,7 @@ def test_ios_xcarchive_full_flow(self): assert "main_binary_exported_symbols" in insights assert insights["main_binary_exported_symbols"]["total_savings"] > 0 - print("✓ iOS E2E test passed!") + print("[OK] iOS E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") @@ -258,7 +276,7 @@ def test_android_apk_full_flow(self): assert "multiple_native_library_archs" in insights assert insights["multiple_native_library_archs"]["total_savings"] == 1891208 - print("✓ Android APK E2E test passed!") + print("[OK] Android APK E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") @@ -324,7 +342,7 @@ def test_android_aab_full_flow(self): assert insights["duplicate_files"]["total_savings"] >= 0 assert "groups" in insights["duplicate_files"] - print("✓ Android AAB E2E test passed!") + print("[OK] Android AAB E2E test passed!") print(f" - Download size: {size_analysis['download_size']} bytes") print(f" - Treemap root size: {treemap['root']['size']} bytes") print(f" - Insight categories: {list(insights.keys())}") @@ -336,4 +354,35 @@ def test_launchpad_health_check(self): data = response.json() assert data["service"] == "launchpad" assert data["status"] == "ok" - print("✓ Launchpad health check passed") + print("[OK] Launchpad health check passed") + + def test_nonexistent_artifact_error_handling(self): + """Test that processing a non-existent artifact is handled gracefully.""" + artifact_id = "test-nonexistent-artifact" + org = "test-org" + project = "test-project" + + print("\n=== Testing non-existent artifact error handling ===") + + # Don't upload any artifact - just send Kafka message for non-existent one + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Wait a bit for processing attempt + time.sleep(10) + + # Check results - should have error metadata, no size analysis + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}", timeout=10) + response.raise_for_status() + results = response.json() + + # Verify no size analysis was uploaded (artifact download should have failed) + assert not results["has_size_analysis_file"], "Should not have size analysis for non-existent artifact" + + # The artifact metadata may have error information + metadata = results.get("artifact_metadata", {}) + # If error was recorded, it should indicate a download/processing failure + if metadata: + # Check if error fields are present (depends on implementation) + print(f" Metadata received: {metadata}") + + print("[OK] Non-existent artifact handled correctly (no size analysis produced)") From 61d94763a6b0c9cc972cb2fa562d83e7c6ed1e92 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 13 Jan 2026 18:26:08 -0500 Subject: [PATCH 26/27] Potential fix for code scanning alert no. 40: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/e2e/mock-sentry-api/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index f5946b18..0a3c7643 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -237,7 +237,7 @@ async def assemble_file( return { "state": "error", "missingChunks": [], - "detail": f"Invalid JSON in size analysis: {e}", + "detail": "Invalid JSON in size analysis", } elif assemble_type == "installable_app": From 9bcadf8ca7902485829d53dba1ede88559e11ca0 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 14 Jan 2026 10:02:38 -0500 Subject: [PATCH 27/27] fix: remove unused exception variable Co-Authored-By: Claude Opus 4.5 --- tests/e2e/mock-sentry-api/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py index 0a3c7643..724c9161 100644 --- a/tests/e2e/mock-sentry-api/server.py +++ b/tests/e2e/mock-sentry-api/server.py @@ -233,7 +233,7 @@ async def assemble_file( # Parse and store in database - fail if JSON is invalid try: size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) - except json.JSONDecodeError as e: + except json.JSONDecodeError: return { "state": "error", "missingChunks": [],