chore: Remove old Python files and update CI for Next.js
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
a79b79efd2
commit
3594bcf297
|
|
@ -17,29 +17,33 @@ env:
|
||||||
IMAGE_NAME: letsbe/hub
|
IMAGE_NAME: letsbe/hub
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
lint-and-typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Node.js
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: npm ci
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install pytest pytest-asyncio aiosqlite
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Generate Prisma client
|
||||||
run: pytest -v --tb=short
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Run TypeScript check
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint --if-present
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: lint-and-typecheck
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
|
||||||
58
alembic.ini
58
alembic.ini
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Hub test package."""
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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}
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in New Issue