From 3594bcf297759b9949c3c5248921e5c3edae62e2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 6 Jan 2026 12:40:26 +0100 Subject: [PATCH] chore: Remove old Python files and update CI for Next.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Python tests, alembic migrations, and requirements.txt - Update CI workflow to use Node.js instead of Python - CI now runs TypeScript check and lint before Docker build 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitea/workflows/build.yml | 26 +- alembic.ini | 58 ----- alembic/env.py | 89 ------- alembic/script.py.mako | 26 -- alembic/versions/001_initial_hub_schema.py | 142 ----------- alembic/versions/002_add_telemetry_samples.py | 63 ----- requirements.txt | 23 -- tests/__init__.py | 1 - .../test_activation.cpython-313.pyc | Bin 5950 -> 0 bytes tests/__pycache__/test_admin.cpython-313.pyc | Bin 8822 -> 0 bytes .../__pycache__/test_redactor.cpython-313.pyc | Bin 6962 -> 0 bytes tests/conftest.py | 82 ------ tests/test_activation.py | 163 ------------ tests/test_admin.py | 233 ------------------ tests/test_redactor.py | 133 ---------- 15 files changed, 15 insertions(+), 1024 deletions(-) delete mode 100644 alembic.ini delete mode 100644 alembic/env.py delete mode 100644 alembic/script.py.mako delete mode 100644 alembic/versions/001_initial_hub_schema.py delete mode 100644 alembic/versions/002_add_telemetry_samples.py delete mode 100644 requirements.txt delete mode 100644 tests/__init__.py delete mode 100644 tests/__pycache__/test_activation.cpython-313.pyc delete mode 100644 tests/__pycache__/test_admin.cpython-313.pyc delete mode 100644 tests/__pycache__/test_redactor.cpython-313.pyc delete mode 100644 tests/conftest.py delete mode 100644 tests/test_activation.py delete mode 100644 tests/test_admin.py delete mode 100644 tests/test_redactor.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 523b023..30a25db 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -17,29 +17,33 @@ env: IMAGE_NAME: letsbe/hub jobs: - test: + lint-and-typecheck: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - python-version: '3.11' + node-version: '20' + cache: 'npm' - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-asyncio aiosqlite + run: npm ci - - name: Run tests - run: pytest -v --tb=short + - name: Generate Prisma client + run: npx prisma generate + + - name: Run TypeScript check + run: npm run typecheck + + - name: Run linter + run: npm run lint --if-present build: runs-on: ubuntu-latest - needs: test + needs: lint-and-typecheck steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index ea2852e..0000000 --- a/alembic.ini +++ /dev/null @@ -1,58 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration file names -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -prepend_sys_path = . - -# version path separator -version_path_separator = os - -# Database URL - will be overridden by env.py from environment variable -sqlalchemy.url = postgresql+asyncpg://hub:hub@localhost:5432/hub - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 1fd9f66..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Alembic migration environment configuration for async SQLAlchemy.""" - -import asyncio -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config - -from app.config import settings -from app.models import Base - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Override sqlalchemy.url with environment variable -config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -target_metadata = Base.metadata - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection: Connection) -> None: - """Run migrations with a connection.""" - context.configure( - connection=connection, - target_metadata=target_metadata, - compare_type=True, - compare_server_default=True, - ) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - """Run migrations in 'online' mode with async engine.""" - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index fbc4b07..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_hub_schema.py b/alembic/versions/001_initial_hub_schema.py deleted file mode 100644 index 08d206a..0000000 --- a/alembic/versions/001_initial_hub_schema.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Initial Hub schema with clients, instances, and usage samples. - -Revision ID: 001 -Revises: -Create Date: 2024-12-09 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create clients table - op.create_table( - "clients", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("contact_email", sa.String(length=255), nullable=True), - sa.Column("billing_plan", sa.String(length=50), nullable=False, server_default="free"), - sa.Column("status", sa.String(length=50), nullable=False, server_default="active"), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("now()"), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("now()"), - ), - sa.PrimaryKeyConstraint("id"), - ) - - # Create instances table - op.create_table( - "instances", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("client_id", sa.UUID(), nullable=False), - sa.Column("instance_id", sa.String(length=255), nullable=False), - # Licensing - sa.Column("license_key_hash", sa.String(length=64), nullable=False), - sa.Column("license_key_prefix", sa.String(length=12), nullable=False), - sa.Column("license_status", sa.String(length=50), nullable=False, server_default="active"), - sa.Column("license_issued_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("license_expires_at", sa.DateTime(timezone=True), nullable=True), - # Activation state - sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("last_activation_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("activation_count", sa.Integer(), nullable=False, server_default="0"), - # Telemetry - sa.Column("hub_api_key_hash", sa.String(length=64), nullable=True), - # Metadata - sa.Column("region", sa.String(length=50), nullable=True), - sa.Column("version", sa.String(length=50), nullable=True), - sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("status", sa.String(length=50), nullable=False, server_default="pending"), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("now()"), - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("now()"), - ), - sa.ForeignKeyConstraint( - ["client_id"], - ["clients.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_instances_instance_id"), - "instances", - ["instance_id"], - unique=True, - ) - - # Create usage_samples table - op.create_table( - "usage_samples", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("instance_id", sa.UUID(), nullable=False), - # Time window - sa.Column("window_start", sa.DateTime(timezone=True), nullable=False), - sa.Column("window_end", sa.DateTime(timezone=True), nullable=False), - sa.Column("window_type", sa.String(length=20), nullable=False), - # Tool (ONLY name) - sa.Column("tool_name", sa.String(length=255), nullable=False), - # Counts - sa.Column("call_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("rate_limited_count", sa.Integer(), nullable=False, server_default="0"), - # Duration stats - sa.Column("total_duration_ms", sa.Integer(), nullable=False, server_default="0"), - sa.Column("min_duration_ms", sa.Integer(), nullable=False, server_default="0"), - sa.Column("max_duration_ms", sa.Integer(), nullable=False, server_default="0"), - sa.ForeignKeyConstraint( - ["instance_id"], - ["instances.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_usage_samples_instance_id"), - "usage_samples", - ["instance_id"], - unique=False, - ) - op.create_index( - op.f("ix_usage_samples_tool_name"), - "usage_samples", - ["tool_name"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_usage_samples_tool_name"), table_name="usage_samples") - op.drop_index(op.f("ix_usage_samples_instance_id"), table_name="usage_samples") - op.drop_table("usage_samples") - op.drop_index(op.f("ix_instances_instance_id"), table_name="instances") - op.drop_table("instances") - op.drop_table("clients") diff --git a/alembic/versions/002_add_telemetry_samples.py b/alembic/versions/002_add_telemetry_samples.py deleted file mode 100644 index 1605d31..0000000 --- a/alembic/versions/002_add_telemetry_samples.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Add telemetry_samples table for aggregated orchestrator metrics. - -Revision ID: 002 -Revises: 001 -Create Date: 2024-12-17 - -This table stores aggregated telemetry from orchestrator instances. -Uses a unique constraint on (instance_id, window_start) for de-duplication. -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - - -# revision identifiers, used by Alembic. -revision: str = "002" -down_revision: Union[str, None] = "001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create telemetry_samples table - op.create_table( - "telemetry_samples", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("instance_id", sa.UUID(), nullable=False), - # Time window - sa.Column("window_start", sa.DateTime(timezone=True), nullable=False), - sa.Column("window_end", sa.DateTime(timezone=True), nullable=False), - # Orchestrator uptime - sa.Column("uptime_seconds", sa.Integer(), nullable=False), - # Aggregated metrics stored as JSONB - sa.Column("metrics", postgresql.JSONB(astext_type=sa.Text()), nullable=False), - # Foreign key and primary key - sa.ForeignKeyConstraint( - ["instance_id"], - ["instances.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - # Unique constraint for de-duplication - # Prevents double-counting if orchestrator retries submissions - sa.UniqueConstraint( - "instance_id", - "window_start", - name="uq_telemetry_instance_window", - ), - ) - # Index on instance_id for efficient queries - op.create_index( - op.f("ix_telemetry_samples_instance_id"), - "telemetry_samples", - ["instance_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_telemetry_samples_instance_id"), table_name="telemetry_samples") - op.drop_table("telemetry_samples") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0c9fd9e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Web Framework -fastapi>=0.109.0 -uvicorn[standard]>=0.27.0 - -# Database -sqlalchemy[asyncio]>=2.0.25 -asyncpg>=0.29.0 -alembic>=1.13.0 - -# Serialization & Validation -pydantic[email]>=2.5.0 -pydantic-settings>=2.1.0 - -# Utilities -python-dotenv>=1.0.0 - -# HTTP Client -httpx>=0.26.0 - -# Testing -pytest>=8.0.0 -pytest-asyncio>=0.23.0 -aiosqlite>=0.19.0 diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index fa05bb0..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Hub test package.""" diff --git a/tests/__pycache__/test_activation.cpython-313.pyc b/tests/__pycache__/test_activation.cpython-313.pyc deleted file mode 100644 index ed60eacb492c33a34f733eb2c770b5bd190a1478..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5950 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
  • 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 F{{b{%4O9RC diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 6592c8f..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Pytest fixtures for Hub tests.""" - -import asyncio -from collections.abc import AsyncGenerator -from typing import Generator - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.config import settings -from app.db import get_db -from app.main import app -from app.models import Base - -# Use SQLite for testing -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - - -@pytest.fixture(scope="session") -def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: - """Create event loop for async tests.""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest_asyncio.fixture -async def db_engine(): - """Create test database engine.""" - engine = create_async_engine( - TEST_DATABASE_URL, - echo=False, - ) - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - yield engine - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - - await engine.dispose() - - -@pytest_asyncio.fixture -async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]: - """Create test database session.""" - async_session = async_sessionmaker( - db_engine, - class_=AsyncSession, - expire_on_commit=False, - ) - - async with async_session() as session: - yield session - - -@pytest_asyncio.fixture -async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: - """Create test HTTP client.""" - - async def override_get_db(): - yield db_session - - app.dependency_overrides[get_db] = override_get_db - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as ac: - yield ac - - app.dependency_overrides.clear() - - -@pytest.fixture -def admin_headers() -> dict[str, str]: - """Return admin authentication headers.""" - return {"X-Admin-Api-Key": settings.ADMIN_API_KEY} diff --git a/tests/test_activation.py b/tests/test_activation.py deleted file mode 100644 index b60c168..0000000 --- a/tests/test_activation.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for instance activation endpoint.""" - -from datetime import datetime, timedelta, timezone - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_activate_success(client: AsyncClient, admin_headers: dict): - """Test successful activation.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Activation Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - instance_response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "activation-test"}, - headers=admin_headers, - ) - license_key = instance_response.json()["license_key"] - - # Activate - response = await client.post( - "/api/v1/instances/activate", - json={ - "license_key": license_key, - "instance_id": "activation-test", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "ok" - assert data["instance_id"] == "activation-test" - # Should return USE_EXISTING since key was pre-generated - assert data["hub_api_key"] == "USE_EXISTING" - assert "config" in data - - -@pytest.mark.asyncio -async def test_activate_increments_count(client: AsyncClient, admin_headers: dict): - """Test that activation increments count.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Count Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - instance_response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "count-test"}, - headers=admin_headers, - ) - license_key = instance_response.json()["license_key"] - - # Activate multiple times - for i in range(3): - await client.post( - "/api/v1/instances/activate", - json={ - "license_key": license_key, - "instance_id": "count-test", - }, - ) - - # Check count - get_response = await client.get( - "/api/v1/admin/instances/count-test", - headers=admin_headers, - ) - assert get_response.json()["activation_count"] == 3 - - -@pytest.mark.asyncio -async def test_activate_invalid_license(client: AsyncClient, admin_headers: dict): - """Test activation with invalid license key.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Invalid Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "invalid-license-test"}, - headers=admin_headers, - ) - - # Try with wrong license - response = await client.post( - "/api/v1/instances/activate", - json={ - "license_key": "lb_inst_wrongkey123456789012345678901234", - "instance_id": "invalid-license-test", - }, - ) - - assert response.status_code == 400 - data = response.json()["detail"] - assert data["code"] == "invalid_license" - - -@pytest.mark.asyncio -async def test_activate_unknown_instance(client: AsyncClient): - """Test activation with unknown instance_id.""" - response = await client.post( - "/api/v1/instances/activate", - json={ - "license_key": "lb_inst_somekey1234567890123456789012", - "instance_id": "unknown-instance", - }, - ) - - assert response.status_code == 400 - data = response.json()["detail"] - assert data["code"] == "instance_not_found" - - -@pytest.mark.asyncio -async def test_activate_suspended_license(client: AsyncClient, admin_headers: dict): - """Test activation with suspended license.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Suspended Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - instance_response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "suspended-license-test"}, - headers=admin_headers, - ) - license_key = instance_response.json()["license_key"] - - # Suspend instance - await client.post( - "/api/v1/admin/instances/suspended-license-test/suspend", - headers=admin_headers, - ) - - # Try to activate - response = await client.post( - "/api/v1/instances/activate", - json={ - "license_key": license_key, - "instance_id": "suspended-license-test", - }, - ) - - assert response.status_code == 400 - data = response.json()["detail"] - assert data["code"] == "suspended" diff --git a/tests/test_admin.py b/tests/test_admin.py deleted file mode 100644 index c50d7de..0000000 --- a/tests/test_admin.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Tests for admin endpoints.""" - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_client(client: AsyncClient, admin_headers: dict): - """Test creating a new client.""" - response = await client.post( - "/api/v1/admin/clients", - json={ - "name": "Acme Corp", - "contact_email": "admin@acme.com", - "billing_plan": "pro", - }, - headers=admin_headers, - ) - - assert response.status_code == 201 - data = response.json() - assert data["name"] == "Acme Corp" - assert data["contact_email"] == "admin@acme.com" - assert data["billing_plan"] == "pro" - assert data["status"] == "active" - assert "id" in data - - -@pytest.mark.asyncio -async def test_create_client_unauthorized(client: AsyncClient): - """Test creating client without auth fails.""" - response = await client.post( - "/api/v1/admin/clients", - json={"name": "Test Corp"}, - ) - - assert response.status_code == 422 # Missing header - - -@pytest.mark.asyncio -async def test_create_client_invalid_key(client: AsyncClient): - """Test creating client with invalid key fails.""" - response = await client.post( - "/api/v1/admin/clients", - json={"name": "Test Corp"}, - headers={"X-Admin-Api-Key": "invalid-key"}, - ) - - assert response.status_code == 401 - - -@pytest.mark.asyncio -async def test_list_clients(client: AsyncClient, admin_headers: dict): - """Test listing clients.""" - # Create a client first - await client.post( - "/api/v1/admin/clients", - json={"name": "Test Corp 1"}, - headers=admin_headers, - ) - await client.post( - "/api/v1/admin/clients", - json={"name": "Test Corp 2"}, - headers=admin_headers, - ) - - response = await client.get( - "/api/v1/admin/clients", - headers=admin_headers, - ) - - assert response.status_code == 200 - data = response.json() - assert len(data) >= 2 - - -@pytest.mark.asyncio -async def test_create_instance(client: AsyncClient, admin_headers: dict): - """Test creating an instance for a client.""" - # Create client first - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Instance Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - # Create instance - response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={ - "instance_id": "test-orchestrator", - "region": "eu-west-1", - }, - headers=admin_headers, - ) - - assert response.status_code == 201 - data = response.json() - assert data["instance_id"] == "test-orchestrator" - assert data["region"] == "eu-west-1" - assert data["license_status"] == "active" - assert data["status"] == "pending" - # Keys should be returned on creation - assert "license_key" in data - assert data["license_key"].startswith("lb_inst_") - assert "hub_api_key" in data - assert data["hub_api_key"].startswith("hk_") - - -@pytest.mark.asyncio -async def test_create_duplicate_instance(client: AsyncClient, admin_headers: dict): - """Test that duplicate instance_id fails.""" - # Create client - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Duplicate Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - # Create first instance - await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "duplicate-test"}, - headers=admin_headers, - ) - - # Try to create duplicate - response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "duplicate-test"}, - headers=admin_headers, - ) - - assert response.status_code == 409 - - -@pytest.mark.asyncio -async def test_rotate_license_key(client: AsyncClient, admin_headers: dict): - """Test rotating a license key.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Rotate Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - instance_response = await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "rotate-test"}, - headers=admin_headers, - ) - original_key = instance_response.json()["license_key"] - - # Rotate license - response = await client.post( - "/api/v1/admin/instances/rotate-test/rotate-license", - headers=admin_headers, - ) - - assert response.status_code == 200 - data = response.json() - assert data["license_key"].startswith("lb_inst_") - assert data["license_key"] != original_key - - -@pytest.mark.asyncio -async def test_suspend_instance(client: AsyncClient, admin_headers: dict): - """Test suspending an instance.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Suspend Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "suspend-test"}, - headers=admin_headers, - ) - - # Suspend - response = await client.post( - "/api/v1/admin/instances/suspend-test/suspend", - headers=admin_headers, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "suspended" - - # Verify status - get_response = await client.get( - "/api/v1/admin/instances/suspend-test", - headers=admin_headers, - ) - assert get_response.json()["license_status"] == "suspended" - - -@pytest.mark.asyncio -async def test_reactivate_instance(client: AsyncClient, admin_headers: dict): - """Test reactivating a suspended instance.""" - # Create client and instance - client_response = await client.post( - "/api/v1/admin/clients", - json={"name": "Reactivate Test Corp"}, - headers=admin_headers, - ) - client_id = client_response.json()["id"] - - await client.post( - f"/api/v1/admin/clients/{client_id}/instances", - json={"instance_id": "reactivate-test"}, - headers=admin_headers, - ) - - # Suspend - await client.post( - "/api/v1/admin/instances/reactivate-test/suspend", - headers=admin_headers, - ) - - # Reactivate - response = await client.post( - "/api/v1/admin/instances/reactivate-test/reactivate", - headers=admin_headers, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "pending" # Not activated yet diff --git a/tests/test_redactor.py b/tests/test_redactor.py deleted file mode 100644 index 07f93c7..0000000 --- a/tests/test_redactor.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for telemetry redactor.""" - -import pytest - -from app.services.redactor import redact_metadata, sanitize_error_code, validate_tool_name - - -class TestRedactMetadata: - """Tests for redact_metadata function.""" - - def test_allows_safe_fields(self): - """Test that allowed fields pass through.""" - metadata = { - "tool_name": "sysadmin.env_update", - "duration_ms": 150, - "status": "success", - "error_code": "E001", - } - result = redact_metadata(metadata) - - assert result == metadata - - def test_removes_unknown_fields(self): - """Test that unknown fields are removed.""" - metadata = { - "tool_name": "sysadmin.env_update", - "password": "secret123", - "file_content": "sensitive data", - "custom_field": "value", - } - result = redact_metadata(metadata) - - assert "password" not in result - assert "file_content" not in result - assert "custom_field" not in result - assert result["tool_name"] == "sysadmin.env_update" - - def test_removes_nested_objects(self): - """Test that nested objects are removed.""" - metadata = { - "tool_name": "sysadmin.env_update", - "nested": {"password": "secret"}, - } - result = redact_metadata(metadata) - - assert "nested" not in result - - def test_handles_none(self): - """Test handling of None input.""" - assert redact_metadata(None) == {} - - def test_handles_empty(self): - """Test handling of empty dict.""" - assert redact_metadata({}) == {} - - def test_truncates_long_strings(self): - """Test that very long strings are removed.""" - metadata = { - "tool_name": "a" * 200, # Too long - "status": "success", - } - result = redact_metadata(metadata) - - assert "tool_name" not in result - assert result["status"] == "success" - - def test_defense_in_depth_patterns(self): - """Test that sensitive patterns in field names are caught.""" - # Even if somehow in allowed list, sensitive patterns should be caught - metadata = { - "status": "success", - "password_hash": "abc123", # Contains 'password' - } - result = redact_metadata(metadata) - - assert "password_hash" not in result - - -class TestValidateToolName: - """Tests for validate_tool_name function.""" - - def test_valid_sysadmin_tool(self): - """Test valid sysadmin tool name.""" - assert validate_tool_name("sysadmin.env_update") is True - assert validate_tool_name("sysadmin.file_write") is True - - def test_valid_browser_tool(self): - """Test valid browser tool name.""" - assert validate_tool_name("browser.navigate") is True - assert validate_tool_name("browser.click") is True - - def test_valid_gateway_tool(self): - """Test valid gateway tool name.""" - assert validate_tool_name("gateway.proxy") is True - - def test_invalid_prefix(self): - """Test that unknown prefixes are rejected.""" - assert validate_tool_name("unknown.tool") is False - assert validate_tool_name("custom.action") is False - - def test_too_long(self): - """Test that very long names are rejected.""" - assert validate_tool_name("sysadmin." + "a" * 100) is False - - def test_suspicious_chars(self): - """Test that suspicious characters are rejected.""" - assert validate_tool_name("sysadmin.tool;drop table") is False - assert validate_tool_name("sysadmin.tool'or'1'='1") is False - assert validate_tool_name("sysadmin.tool\ninjection") is False - - -class TestSanitizeErrorCode: - """Tests for sanitize_error_code function.""" - - def test_valid_codes(self): - """Test valid error codes.""" - assert sanitize_error_code("E001") == "E001" - assert sanitize_error_code("connection_timeout") == "connection_timeout" - assert sanitize_error_code("AUTH-FAILED") == "AUTH-FAILED" - - def test_none_input(self): - """Test None input.""" - assert sanitize_error_code(None) is None - - def test_too_long(self): - """Test that long codes are rejected.""" - assert sanitize_error_code("a" * 60) is None - - def test_invalid_chars(self): - """Test that invalid characters are rejected.""" - assert sanitize_error_code("error code") is None # space - assert sanitize_error_code("error;drop") is None # semicolon - assert sanitize_error_code("error\ntable") is None # newline