Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
letsbe-orchestrator/tests/__init__.py
Normal file
1
letsbe-orchestrator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for letsbe-orchestrator."""
|
||||
168
letsbe-orchestrator/tests/conftest.py
Normal file
168
letsbe-orchestrator/tests/conftest.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Pytest configuration and fixtures for letsbe-orchestrator tests."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.models.base import Base, utc_now
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.agent import Agent
|
||||
from app.models.registration_token import RegistrationToken
|
||||
|
||||
# Use in-memory SQLite for testing
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def async_engine():
|
||||
"""Create a test async 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(scope="function")
|
||||
async def db(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a test database session."""
|
||||
session_factory = async_sessionmaker(
|
||||
async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_tenant(db: AsyncSession) -> Tenant:
|
||||
"""Create a test tenant."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent(db: AsyncSession) -> Agent:
|
||||
"""Create a test agent."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="test-agent-host",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-12345",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_for_tenant(db: AsyncSession, test_tenant: Tenant) -> Agent:
|
||||
"""Create a test agent linked to test_tenant with online status."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-for-tenant",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-tenant",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
# --- New fixtures for secure auth testing ---
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_registration_token(
|
||||
db: AsyncSession, test_tenant: Tenant
|
||||
) -> tuple[RegistrationToken, str]:
|
||||
"""Create a test registration token and return (token_record, plaintext_token)."""
|
||||
plaintext_token = str(uuid.uuid4())
|
||||
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
|
||||
|
||||
reg_token = RegistrationToken(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash=token_hash,
|
||||
description="Test registration token",
|
||||
max_uses=10,
|
||||
use_count=0,
|
||||
)
|
||||
db.add(reg_token)
|
||||
await db.commit()
|
||||
await db.refresh(reg_token)
|
||||
return reg_token, plaintext_token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_with_secret(
|
||||
db: AsyncSession, test_tenant: Tenant
|
||||
) -> tuple[Agent, str]:
|
||||
"""Create a test agent with secret_hash and return (agent, plaintext_secret)."""
|
||||
plaintext_secret = "test-secret-" + uuid.uuid4().hex[:16]
|
||||
secret_hash = hashlib.sha256(plaintext_secret.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-secure",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="", # Empty for new auth scheme
|
||||
secret_hash=secret_hash,
|
||||
last_heartbeat=utc_now(),
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent, plaintext_secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_legacy(db: AsyncSession, test_tenant: Tenant) -> Agent:
|
||||
"""Create a test agent with legacy token auth (both token and secret_hash set)."""
|
||||
legacy_token = "legacy-token-" + uuid.uuid4().hex[:16]
|
||||
secret_hash = hashlib.sha256(legacy_token.encode()).hexdigest()
|
||||
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="test-agent-legacy",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token=legacy_token, # Legacy field
|
||||
secret_hash=secret_hash, # Also set for new auth
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for route modules."""
|
||||
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Tests for agent authentication endpoints and dependencies."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies.auth import get_current_agent, get_current_agent_compat
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.agents import (
|
||||
_register_agent_legacy,
|
||||
_register_agent_secure,
|
||||
agent_heartbeat,
|
||||
)
|
||||
from app.schemas.agent import AgentRegisterRequest, AgentRegisterRequestLegacy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgent:
|
||||
"""Tests for the get_current_agent dependency (new auth scheme)."""
|
||||
|
||||
async def test_valid_credentials(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully authenticate with valid X-Agent-Id and X-Agent-Secret."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret=secret
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
assert result.tenant_id == test_tenant.id
|
||||
|
||||
async def test_invalid_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for non-existent agent ID."""
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=fake_agent_id, x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_invalid_secret(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 401 for wrong secret."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret="wrong-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_malformed_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for malformed UUID."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id="not-a-uuid", x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid Agent ID format" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgentCompat:
|
||||
"""Tests for the backward-compatible auth dependency."""
|
||||
|
||||
async def test_new_scheme_preferred(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""New X-Agent-* headers take precedence over Bearer."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=str(agent.id),
|
||||
x_agent_secret=secret,
|
||||
authorization="Bearer wrong-token", # Should be ignored
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
|
||||
async def test_legacy_bearer_fallback(
|
||||
self, db: AsyncSession, test_agent_legacy: Agent
|
||||
):
|
||||
"""Falls back to Bearer token when X-Agent-* not provided."""
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=None,
|
||||
x_agent_secret=None,
|
||||
authorization=f"Bearer {test_agent_legacy.token}",
|
||||
agent_id=test_agent_legacy.id, # Legacy auth requires agent_id
|
||||
)
|
||||
|
||||
assert result.id == test_agent_legacy.id
|
||||
|
||||
async def test_no_credentials_provided(self, db: AsyncSession):
|
||||
"""Returns 401 when no auth credentials provided."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent_compat(
|
||||
db=db, x_agent_id=None, x_agent_secret=None, authorization=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Missing authentication credentials" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSecureAgentRegistration:
|
||||
"""Tests for the new secure registration flow."""
|
||||
|
||||
async def test_registration_with_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully register agent with valid registration token."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="new-agent-host",
|
||||
version="2.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.agent_secret is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
|
||||
# Verify agent was created
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.name == "new-agent-host"
|
||||
assert agent.tenant_id == test_tenant.id
|
||||
|
||||
async def test_registration_increments_use_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Registration increments the token's use_count."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
initial_count = token_record.use_count
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
await db.refresh(token_record)
|
||||
assert token_record.use_count == initial_count + 1
|
||||
|
||||
async def test_registration_stores_secret_hash(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Agent secret is stored as hash, not plaintext."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
|
||||
expected_hash = hashlib.sha256(response.agent_secret.encode()).hexdigest()
|
||||
assert agent.secret_hash == expected_hash
|
||||
|
||||
async def test_registration_with_invalid_token(self, db: AsyncSession):
|
||||
"""Returns 401 for invalid registration token."""
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token="invalid-token",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid registration token" in str(exc_info.value.detail)
|
||||
|
||||
async def test_registration_with_revoked_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for revoked registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "revoked" in str(exc_info.value.detail).lower()
|
||||
|
||||
async def test_registration_with_exhausted_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for exhausted registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.max_uses = 1
|
||||
token_record.use_count = 1
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "exhausted" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLegacyAgentRegistration:
|
||||
"""Tests for the legacy registration flow."""
|
||||
|
||||
async def test_legacy_registration_success(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Successfully register agent using legacy flow."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.token is not None
|
||||
|
||||
# Verify agent was created with both token and secret_hash
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.token == response.token
|
||||
assert agent.secret_hash == hashlib.sha256(response.token.encode()).hexdigest()
|
||||
|
||||
async def test_legacy_registration_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 for non-existent tenant in legacy flow."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=fake_tenant_id,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_legacy(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_legacy_registration_without_tenant(self, db: AsyncSession):
|
||||
"""Legacy registration without tenant_id creates shared agent."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="shared-host",
|
||||
version="1.0.0",
|
||||
tenant_id=None,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.tenant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentHeartbeat:
|
||||
"""Tests for the agent heartbeat endpoint."""
|
||||
|
||||
async def test_heartbeat_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully send heartbeat updates timestamp and status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
old_heartbeat = agent.last_heartbeat
|
||||
|
||||
response = await agent_heartbeat(
|
||||
agent_id=agent.id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
|
||||
await db.refresh(agent)
|
||||
assert agent.status == AgentStatus.ONLINE.value
|
||||
assert agent.last_heartbeat >= old_heartbeat
|
||||
|
||||
async def test_heartbeat_agent_id_mismatch(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when path agent_id doesn't match authenticated agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
wrong_agent_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await agent_heartbeat(
|
||||
agent_id=wrong_agent_id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Agent ID mismatch" in str(exc_info.value.detail)
|
||||
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Tests for env management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.env import inspect_env, update_env
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectEnv:
|
||||
"""Tests for the inspect_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_INSPECT task."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=["FRONTEND_URL", "BACKEND_URL"],
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
|
||||
|
||||
async def test_without_keys(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request without keys omits keys from payload."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=None,
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert "keys" not in task.payload
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateEnv:
|
||||
"""Tests for the update_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_UPDATE task."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"FRONTEND_URL": "https://new.domain.com"},
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_updates_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only updates omits remove_keys from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
remove_keys=None,
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.payload["updates"] == {"KEY": "VALUE"}
|
||||
assert "remove_keys" not in task.payload
|
||||
|
||||
async def test_remove_keys_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only remove_keys omits updates from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates=None,
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert "updates" not in task.payload
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_UPDATE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for file management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.files import inspect_file
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectFile:
|
||||
"""Tests for the inspect_file endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates a FILE_INSPECT task."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_default_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request uses default max_bytes of 4096."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_custom_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with custom max_bytes is respected."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/large_file.txt",
|
||||
max_bytes=1048576, # 1MB
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["max_bytes"] == 1048576
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "FILE_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""Tests for Nextcloud playbook routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR
|
||||
from app.routes.playbooks import (
|
||||
NextcloudInitialSetupRequest,
|
||||
NextcloudSetDomainRequest,
|
||||
nextcloud_initial_setup,
|
||||
nextcloud_set_domain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudSetDomainEndpoint:
|
||||
"""Tests for the nextcloud_set_domain endpoint."""
|
||||
|
||||
async def test_happy_path_creates_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/set-domain returns 201 with COMPOSITE task."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent_for_tenant.id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == "pending"
|
||||
|
||||
async def test_task_has_both_steps(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify response task payload contains both steps in correct order."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=True,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
# First step: NEXTCLOUD_SET_DOMAIN
|
||||
step0 = task.payload["steps"][0]
|
||||
assert step0["type"] == "NEXTCLOUD_SET_DOMAIN"
|
||||
assert step0["payload"]["public_url"] == "https://cloud.example.com"
|
||||
|
||||
# Second step: DOCKER_RELOAD
|
||||
step1 = task.payload["steps"][1]
|
||||
assert step1["type"] == "DOCKER_RELOAD"
|
||||
assert step1["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert step1["payload"]["pull"] is True
|
||||
|
||||
async def test_pull_flag_default_false(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify pull defaults to False when not specified."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
step = task.payload["steps"][1]
|
||||
assert step["payload"]["pull"] is False
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_offline_agent_not_resolved(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Offline agent should not be auto-resolved."""
|
||||
# Create an offline agent for the tenant
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant.id
|
||||
|
||||
async def test_auto_resolves_first_online_agent(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the first online agent is auto-resolved."""
|
||||
# Create two agents for the tenant - one offline, one online
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
online_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="online-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="online-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
db.add(online_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Should have resolved to the online agent
|
||||
assert task.agent_id == online_agent.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures for Initial Setup Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_tenant_with_domain(db: AsyncSession) -> Tenant:
|
||||
"""Create a test tenant with domain configured."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant With Domain",
|
||||
domain="example.com",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_for_tenant_with_domain(
|
||||
db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
) -> Agent:
|
||||
"""Create a test agent linked to test_tenant_with_domain with online status."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant_with_domain.id,
|
||||
name="test-agent-for-tenant-domain",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-domain",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Nextcloud Initial Setup Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudInitialSetupEndpoint:
|
||||
"""Tests for the nextcloud_initial_setup endpoint."""
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_happy_path_creates_task(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/setup returns 201 with PLAYWRIGHT task."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant_with_domain.id
|
||||
assert task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == "pending"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_has_correct_payload(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify response task payload contains scenario and inputs."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="myadmin",
|
||||
admin_password="mypassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud_initial_setup"
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "myadmin"
|
||||
assert inputs["admin_password"] == "mypassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert task.payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_tenant_without_domain_returns_400(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant without domain configured returns 400."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "no domain configured" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_nextcloud_unavailable_returns_409(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Health check failure returns 409."""
|
||||
mock_health_check.return_value = False
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Nextcloud not available" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_health_check_called_with_correct_url(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify health check is called with correct URL."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
mock_health_check.assert_called_once_with("https://cloud.example.com")
|
||||
|
||||
def test_username_too_short_raises_validation_error(self):
|
||||
"""Username less than 3 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="ab", # Too short
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_invalid_chars_raises_validation_error(self):
|
||||
"""Username with invalid characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin@user", # Invalid char @
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_starting_with_number_raises_validation_error(self):
|
||||
"""Username starting with number raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="123admin", # Starts with number
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_password_too_short_raises_validation_error(self):
|
||||
"""Password less than 8 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="short", # Too short
|
||||
)
|
||||
|
||||
assert "admin_password" in str(exc_info.value)
|
||||
|
||||
def test_valid_username_with_underscore(self):
|
||||
"""Username with underscore is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin_user",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin_user"
|
||||
|
||||
def test_valid_username_with_numbers(self):
|
||||
"""Username with numbers (not at start) is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin123",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin123"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_persisted_to_database(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant_with_domain.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for registration token endpoints."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.base import utc_now
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.registration_tokens import (
|
||||
create_registration_token,
|
||||
get_registration_token,
|
||||
list_registration_tokens,
|
||||
revoke_registration_token,
|
||||
)
|
||||
from app.schemas.registration_token import RegistrationTokenCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateRegistrationToken:
|
||||
"""Tests for the create_registration_token endpoint."""
|
||||
|
||||
async def test_create_token_success(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Successfully create a registration token."""
|
||||
request = RegistrationTokenCreate(
|
||||
description="Test token",
|
||||
max_uses=5,
|
||||
expires_in_hours=24,
|
||||
)
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.id is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
assert response.description == "Test token"
|
||||
assert response.max_uses == 5
|
||||
assert response.use_count == 0
|
||||
assert response.revoked is False
|
||||
assert response.token is not None # Plaintext token returned once
|
||||
assert response.expires_at is not None
|
||||
|
||||
async def test_create_token_stores_hash(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token is stored as hash, not plaintext."""
|
||||
request = RegistrationTokenCreate(description="Hash test")
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Retrieve from database
|
||||
result = await db.execute(
|
||||
select(RegistrationToken).where(RegistrationToken.id == response.id)
|
||||
)
|
||||
token_record = result.scalar_one()
|
||||
|
||||
# Verify hash is stored, not plaintext
|
||||
expected_hash = hashlib.sha256(response.token.encode()).hexdigest()
|
||||
assert token_record.token_hash == expected_hash
|
||||
assert token_record.token_hash != response.token
|
||||
|
||||
async def test_create_token_default_values(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Default values are applied correctly."""
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.max_uses == 1 # Default
|
||||
assert response.description is None
|
||||
assert response.expires_at is None
|
||||
|
||||
async def test_create_token_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_registration_token(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListRegistrationTokens:
|
||||
"""Tests for the list_registration_tokens endpoint."""
|
||||
|
||||
async def test_list_tokens_empty(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns empty list when no tokens exist."""
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert response.tokens == []
|
||||
assert response.total == 0
|
||||
|
||||
async def test_list_tokens_multiple(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns all tokens for tenant."""
|
||||
# Create multiple tokens
|
||||
for i in range(3):
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash=f"hash-{i}",
|
||||
description=f"Token {i}",
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 3
|
||||
assert response.total == 3
|
||||
|
||||
async def test_list_tokens_tenant_isolation(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Only returns tokens for the specified tenant."""
|
||||
# Create another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create token for test_tenant
|
||||
token1 = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="hash-1", description="Tenant 1"
|
||||
)
|
||||
# Create token for other_tenant
|
||||
token2 = RegistrationToken(
|
||||
tenant_id=other_tenant.id, token_hash="hash-2", description="Tenant 2"
|
||||
)
|
||||
db.add_all([token1, token2])
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 1
|
||||
assert response.tokens[0].description == "Tenant 1"
|
||||
|
||||
async def test_list_tokens_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await list_registration_tokens(tenant_id=fake_tenant_id, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetRegistrationToken:
|
||||
"""Tests for the get_registration_token endpoint."""
|
||||
|
||||
async def test_get_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully retrieve a token."""
|
||||
token_record, _ = test_registration_token
|
||||
|
||||
response = await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert response.id == token_record.id
|
||||
assert response.description == token_record.description
|
||||
|
||||
async def test_get_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_get_token_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 404 when token belongs to different tenant."""
|
||||
token_record, _ = test_registration_token
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=other_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRevokeRegistrationToken:
|
||||
"""Tests for the revoke_registration_token endpoint."""
|
||||
|
||||
async def test_revoke_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully revoke a token."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.revoked is False
|
||||
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
# Refresh to see updated value
|
||||
await db.refresh(token_record)
|
||||
assert token_record.revoked is True
|
||||
|
||||
async def test_revoke_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRegistrationTokenIsValid:
|
||||
"""Tests for the RegistrationToken.is_valid() method."""
|
||||
|
||||
async def test_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Token is valid when not revoked, not expired, and not exhausted."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.is_valid() is True
|
||||
|
||||
async def test_revoked_token_invalid(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Revoked token is invalid."""
|
||||
token_record, _ = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
assert token_record.is_valid() is False
|
||||
|
||||
async def test_expired_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Expired token is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash="test-hash",
|
||||
expires_at=utc_now() - timedelta(hours=1),
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_exhausted_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token that reached max_uses is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=3, use_count=3
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_unlimited_uses_token(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token with max_uses=0 has unlimited uses."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=0, use_count=1000
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is True
|
||||
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Tests for task endpoints with authentication."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.tasks import (
|
||||
get_next_pending_task,
|
||||
get_next_task_endpoint,
|
||||
update_task_endpoint,
|
||||
)
|
||||
from app.schemas.task import TaskUpdate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextPendingTask:
|
||||
"""Tests for the get_next_pending_task helper function."""
|
||||
|
||||
async def test_returns_pending_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns oldest pending task for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tasks
|
||||
task1 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 1},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
task2 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 2},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add_all([task1, task2])
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should return oldest (task1)
|
||||
assert result is not None
|
||||
assert result.payload["order"] == 1
|
||||
|
||||
async def test_filters_by_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tenant for another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create task for other tenant
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no pending tasks exist."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_skips_non_pending_tasks(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks with PENDING status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create running task
|
||||
running_task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
)
|
||||
db.add(running_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextTaskEndpoint:
|
||||
"""Tests for the GET /tasks/next endpoint."""
|
||||
|
||||
async def test_claims_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully claims a pending task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == TaskStatus.RUNNING.value
|
||||
assert result.agent_id == agent.id
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no tasks available."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_tenant_isolation(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Agent can only claim tasks from its tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateTaskEndpoint:
|
||||
"""Tests for the PATCH /tasks/{task_id} endpoint."""
|
||||
|
||||
async def test_update_task_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully update an assigned task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Assigned to this agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED, result={"success": True})
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
assert result.result == {"success": True}
|
||||
|
||||
async def test_update_task_not_found(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 404 for non-existent task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
fake_task_id = uuid.uuid4()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=fake_task_id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_update_task_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task belongs to different tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Even if assigned to agent
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=other_task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "does not belong to this tenant" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_not_assigned(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task is not assigned to requesting agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task assigned to different agent
|
||||
other_agent_id = uuid.uuid4()
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=other_agent_id, # Different agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "not assigned to this agent" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_unassigned_forbidden(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task has no agent_id assigned."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
agent_id=None, # Not assigned
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSharedAgentBehavior:
|
||||
"""Tests for agents without tenant_id (shared agents)."""
|
||||
|
||||
async def test_shared_agent_can_claim_any_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent (no tenant_id) can claim tasks from any tenant."""
|
||||
# Create shared agent (no tenant_id)
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=shared_agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.agent_id == shared_agent.id
|
||||
|
||||
async def test_shared_agent_can_update_any_tenant_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent can update tasks from any tenant if assigned."""
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=shared_agent.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=shared_agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
192
letsbe-orchestrator/tests/test_events.py
Normal file
192
letsbe-orchestrator/tests/test_events.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests for the events route module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.event import Event
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEventModel:
|
||||
"""Tests for Event model creation and retrieval."""
|
||||
|
||||
async def test_create_event(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be created with required fields."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="agent.registered",
|
||||
payload={"agent_name": "test-agent", "version": "1.0.0"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.id is not None
|
||||
assert event.tenant_id == test_tenant.id
|
||||
assert event.event_type == "agent.registered"
|
||||
assert event.payload["agent_name"] == "test-agent"
|
||||
assert event.created_at is not None
|
||||
|
||||
async def test_create_event_with_task_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be created with an optional task_id."""
|
||||
task_id = uuid.uuid4()
|
||||
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
task_id=task_id,
|
||||
event_type="task.completed",
|
||||
payload={"result": "success"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.task_id == task_id
|
||||
|
||||
async def test_create_event_without_task_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that task_id defaults to None."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="system.startup",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.task_id is None
|
||||
|
||||
async def test_create_event_with_empty_payload(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can have empty payloads."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="heartbeat",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.payload == {}
|
||||
|
||||
async def test_create_event_with_complex_payload(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can have complex nested payloads."""
|
||||
payload = {
|
||||
"metrics": {
|
||||
"cpu_usage": 45.2,
|
||||
"memory_mb": 1024,
|
||||
"disk_io": {"read": 100, "write": 200},
|
||||
},
|
||||
"tags": ["production", "high-priority"],
|
||||
"count": 42,
|
||||
}
|
||||
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="metrics.collected",
|
||||
payload=payload,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
assert event.payload["metrics"]["cpu_usage"] == 45.2
|
||||
assert event.payload["tags"] == ["production", "high-priority"]
|
||||
|
||||
async def test_retrieve_event_by_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that an event can be retrieved by its ID."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.retrieve",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
result = await db.execute(select(Event).where(Event.id == event.id))
|
||||
retrieved = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.event_type == "test.retrieve"
|
||||
assert retrieved.payload["key"] == "value"
|
||||
|
||||
async def test_filter_events_by_type(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be filtered by event_type."""
|
||||
# Create events of different types
|
||||
for event_type in ["agent.registered", "task.completed", "agent.registered"]:
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type=event_type,
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event).where(Event.event_type == "agent.registered")
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) == 2
|
||||
|
||||
async def test_filter_events_by_tenant(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be filtered by tenant_id."""
|
||||
# Create event for test tenant
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.tenant_filter",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event).where(Event.tenant_id == test_tenant.id)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) >= 1
|
||||
assert all(e.tenant_id == test_tenant.id for e in events)
|
||||
|
||||
async def test_events_ordered_by_created_at(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that events can be ordered by created_at."""
|
||||
for i in range(3):
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type=f"test.order_{i}",
|
||||
payload={"index": i},
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.where(Event.tenant_id == test_tenant.id)
|
||||
.order_by(Event.created_at.desc())
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
assert len(events) >= 3
|
||||
|
||||
async def test_event_repr(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify the __repr__ method of Event."""
|
||||
event = Event(
|
||||
tenant_id=test_tenant.id,
|
||||
event_type="test.repr",
|
||||
payload={},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
repr_str = repr(event)
|
||||
assert "Event" in repr_str
|
||||
assert "test.repr" in repr_str
|
||||
254
letsbe-orchestrator/tests/test_hub_telemetry.py
Normal file
254
letsbe-orchestrator/tests/test_hub_telemetry.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for the Hub Telemetry service."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.hub_telemetry import HubTelemetryService
|
||||
|
||||
|
||||
class TestHubTelemetryServiceStart:
|
||||
"""Tests for the start/stop lifecycle of HubTelemetryService."""
|
||||
|
||||
def _reset_service(self):
|
||||
"""Reset class state between tests."""
|
||||
HubTelemetryService._task = None
|
||||
HubTelemetryService._shutdown_event = None
|
||||
HubTelemetryService._start_time = None
|
||||
HubTelemetryService._last_sent_at = None
|
||||
HubTelemetryService._client = None
|
||||
HubTelemetryService._consecutive_failures = 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_telemetry_disabled(self):
|
||||
"""Verify start() does nothing when HUB_TELEMETRY_ENABLED is False."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = False
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
assert HubTelemetryService._client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_hub_url_missing(self):
|
||||
"""Verify start() does nothing when HUB_URL is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_hub_api_key_missing(self):
|
||||
"""Verify start() does nothing when HUB_API_KEY is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = "https://hub.example.com"
|
||||
mock_settings.HUB_API_KEY = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_when_instance_id_missing(self):
|
||||
"""Verify start() does nothing when INSTANCE_ID is not set."""
|
||||
self._reset_service()
|
||||
|
||||
with patch("app.services.hub_telemetry.settings") as mock_settings:
|
||||
mock_settings.HUB_TELEMETRY_ENABLED = True
|
||||
mock_settings.HUB_URL = "https://hub.example.com"
|
||||
mock_settings.HUB_API_KEY = "test-key"
|
||||
mock_settings.INSTANCE_ID = None
|
||||
|
||||
await HubTelemetryService.start()
|
||||
|
||||
assert HubTelemetryService._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_start(self):
|
||||
"""Verify stop() handles gracefully when service was never started."""
|
||||
self._reset_service()
|
||||
|
||||
# Should not raise
|
||||
await HubTelemetryService.stop()
|
||||
|
||||
|
||||
class TestHubTelemetryFormatters:
|
||||
"""Tests for the metric formatting class methods."""
|
||||
|
||||
def test_format_agent_counts_empty(self):
|
||||
"""Verify _format_agent_counts handles empty rows."""
|
||||
result = HubTelemetryService._format_agent_counts([])
|
||||
|
||||
assert result == {
|
||||
"online_count": 0,
|
||||
"offline_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
def test_format_agent_counts_with_online_agents(self):
|
||||
"""Verify _format_agent_counts counts online agents correctly."""
|
||||
# Create mock rows that mimic SQLAlchemy result rows
|
||||
from enum import Enum
|
||||
|
||||
class MockAgentStatus(str, Enum):
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
|
||||
# Patch AgentStatus for comparison
|
||||
with patch("app.services.hub_telemetry.AgentStatus") as mock_status:
|
||||
mock_status.ONLINE = MockAgentStatus.ONLINE
|
||||
mock_status.OFFLINE = MockAgentStatus.OFFLINE
|
||||
|
||||
online_row = MagicMock()
|
||||
online_row.status = MockAgentStatus.ONLINE
|
||||
online_row.count = 3
|
||||
|
||||
offline_row = MagicMock()
|
||||
offline_row.status = MockAgentStatus.OFFLINE
|
||||
offline_row.count = 1
|
||||
|
||||
result = HubTelemetryService._format_agent_counts([online_row, offline_row])
|
||||
|
||||
assert result["online_count"] == 3
|
||||
assert result["offline_count"] == 1
|
||||
assert result["total_count"] == 4
|
||||
|
||||
def test_format_task_counts_empty(self):
|
||||
"""Verify _format_task_counts handles empty rows."""
|
||||
result = HubTelemetryService._format_task_counts([])
|
||||
|
||||
assert result == {
|
||||
"by_status": {},
|
||||
"by_type": {},
|
||||
}
|
||||
|
||||
def test_format_task_counts_with_data(self):
|
||||
"""Verify _format_task_counts aggregates correctly."""
|
||||
row1 = MagicMock()
|
||||
row1.status = "completed"
|
||||
row1.type = "SHELL"
|
||||
row1.count = 5
|
||||
row1.avg_duration_ms = 1500.0
|
||||
|
||||
row2 = MagicMock()
|
||||
row2.status = "failed"
|
||||
row2.type = "SHELL"
|
||||
row2.count = 2
|
||||
row2.avg_duration_ms = 3000.0
|
||||
|
||||
row3 = MagicMock()
|
||||
row3.status = "completed"
|
||||
row3.type = "DOCKER_RELOAD"
|
||||
row3.count = 3
|
||||
row3.avg_duration_ms = 5000.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row1, row2, row3])
|
||||
|
||||
# Check by_status aggregation
|
||||
assert result["by_status"]["completed"] == 8 # 5 + 3
|
||||
assert result["by_status"]["failed"] == 2
|
||||
|
||||
# Check by_type aggregation
|
||||
assert result["by_type"]["SHELL"]["count"] == 7 # 5 + 2
|
||||
assert result["by_type"]["DOCKER_RELOAD"]["count"] == 3
|
||||
|
||||
def test_format_task_counts_handles_none_duration(self):
|
||||
"""Verify _format_task_counts handles None avg_duration_ms."""
|
||||
row = MagicMock()
|
||||
row.status = "pending"
|
||||
row.type = "ECHO"
|
||||
row.count = 1
|
||||
row.avg_duration_ms = None
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert result["by_type"]["ECHO"]["count"] == 1
|
||||
assert result["by_type"]["ECHO"]["avg_duration_ms"] == 0
|
||||
|
||||
def test_format_task_counts_rounds_durations(self):
|
||||
"""Verify _format_task_counts rounds avg_duration_ms to 2 decimals."""
|
||||
row = MagicMock()
|
||||
row.status = "completed"
|
||||
row.type = "SHELL"
|
||||
row.count = 1
|
||||
row.avg_duration_ms = 1234.56789
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1234.57
|
||||
|
||||
def test_format_task_counts_weighted_average(self):
|
||||
"""Verify _format_task_counts computes weighted average across same type."""
|
||||
# Two rows for same type: SHELL completed (count=2, avg=1000) and SHELL failed (count=3, avg=2000)
|
||||
row1 = MagicMock()
|
||||
row1.status = "completed"
|
||||
row1.type = "SHELL"
|
||||
row1.count = 2
|
||||
row1.avg_duration_ms = 1000.0
|
||||
|
||||
row2 = MagicMock()
|
||||
row2.status = "failed"
|
||||
row2.type = "SHELL"
|
||||
row2.count = 3
|
||||
row2.avg_duration_ms = 2000.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row1, row2])
|
||||
|
||||
# Weighted avg: (2*1000 + 3*2000) / (2+3) = 8000/5 = 1600.0
|
||||
assert result["by_type"]["SHELL"]["count"] == 5
|
||||
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1600.0
|
||||
|
||||
def test_format_task_counts_with_enum_values(self):
|
||||
"""Verify _format_task_counts handles status/type with .value attribute."""
|
||||
from enum import Enum
|
||||
|
||||
class MockStatus(Enum):
|
||||
COMPLETED = "completed"
|
||||
|
||||
class MockType(Enum):
|
||||
SHELL = "SHELL"
|
||||
|
||||
row = MagicMock()
|
||||
row.status = MockStatus.COMPLETED
|
||||
row.type = MockType.SHELL
|
||||
row.count = 1
|
||||
row.avg_duration_ms = 500.0
|
||||
|
||||
result = HubTelemetryService._format_task_counts([row])
|
||||
|
||||
assert "completed" in result["by_status"]
|
||||
assert "SHELL" in result["by_type"]
|
||||
|
||||
|
||||
class TestHubTelemetryBackoff:
|
||||
"""Tests for backoff and jitter behavior."""
|
||||
|
||||
def test_consecutive_failures_reset_on_init(self):
|
||||
"""Verify consecutive_failures starts at 0."""
|
||||
HubTelemetryService._consecutive_failures = 5
|
||||
HubTelemetryService._consecutive_failures = 0
|
||||
|
||||
assert HubTelemetryService._consecutive_failures == 0
|
||||
|
||||
def test_backoff_calculation(self):
|
||||
"""Verify exponential backoff formula."""
|
||||
# Backoff is min(2^failures, 60)
|
||||
assert min(2**0, 60) == 1
|
||||
assert min(2**1, 60) == 2
|
||||
assert min(2**2, 60) == 4
|
||||
assert min(2**3, 60) == 8
|
||||
assert min(2**6, 60) == 60 # Capped at 60
|
||||
assert min(2**10, 60) == 60 # Still capped
|
||||
314
letsbe-orchestrator/tests/test_local_mode.py
Normal file
314
letsbe-orchestrator/tests/test_local_mode.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Tests for LOCAL_MODE behavior.
|
||||
|
||||
Verifies:
|
||||
1. Multi-tenant mode (LOCAL_MODE=false) is unchanged
|
||||
2. Local mode (LOCAL_MODE=true) bootstrap works correctly
|
||||
3. Meta endpoint is stable in all scenarios
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import app
|
||||
from app.models import Tenant
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
|
||||
class TestMultiTenantModeUnchanged:
|
||||
"""
|
||||
Tests that multi-tenant mode (LOCAL_MODE=false, the default) remains unchanged.
|
||||
|
||||
Key assertions:
|
||||
- No automatic tenant creation on startup
|
||||
- Tenants must be created manually via API
|
||||
- Bootstrap service does nothing
|
||||
"""
|
||||
|
||||
def test_local_mode_default_is_false(self):
|
||||
"""Verify LOCAL_MODE defaults to False."""
|
||||
settings = Settings()
|
||||
assert settings.LOCAL_MODE is False
|
||||
|
||||
def test_bootstrap_service_skips_when_local_mode_false(self):
|
||||
"""Verify bootstrap does nothing when LOCAL_MODE=false."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
# Patch settings in the bootstrap module where it's used
|
||||
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = False
|
||||
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||||
|
||||
# Should not have attempted anything
|
||||
assert LocalBootstrapService._bootstrap_attempted is False
|
||||
assert LocalBootstrapService._local_tenant_id is None
|
||||
|
||||
def test_meta_endpoint_in_multi_tenant_mode(self):
|
||||
"""Verify meta endpoint returns correct values in multi-tenant mode."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.config.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = False
|
||||
mock_settings.INSTANCE_ID = None
|
||||
mock_settings.APP_VERSION = "0.1.0"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["local_mode"] is False
|
||||
assert data["tenant_id"] is None
|
||||
# instance_id may or may not be set depending on config
|
||||
|
||||
|
||||
class TestLocalModeBootstrap:
|
||||
"""
|
||||
Tests for LOCAL_MODE=true bootstrap behavior.
|
||||
"""
|
||||
|
||||
def test_bootstrap_requires_instance_id(self):
|
||||
"""Verify bootstrap fails gracefully without INSTANCE_ID."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.services.local_bootstrap.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.INSTANCE_ID = None
|
||||
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
|
||||
|
||||
assert LocalBootstrapService._bootstrap_attempted is True
|
||||
assert LocalBootstrapService._local_tenant_id is None
|
||||
assert "INSTANCE_ID is required" in LocalBootstrapService._bootstrap_error
|
||||
|
||||
def test_bootstrap_status_tracking(self):
|
||||
"""Verify bootstrap status is correctly tracked."""
|
||||
# Reset class state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
status = LocalBootstrapService.get_bootstrap_status()
|
||||
assert status["attempted"] is False
|
||||
assert status["success"] is False
|
||||
assert status["tenant_id"] is None
|
||||
assert status["error"] is None
|
||||
|
||||
|
||||
class TestMetaEndpointStability:
|
||||
"""
|
||||
Tests that /api/v1/meta/instance is stable in all scenarios.
|
||||
|
||||
Key assertions:
|
||||
- Endpoint works before tenant exists
|
||||
- Endpoint works after tenant bootstrap fails
|
||||
- Endpoint always returns required fields
|
||||
"""
|
||||
|
||||
def test_meta_endpoint_returns_required_fields(self):
|
||||
"""Verify meta endpoint always returns required fields."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# These fields must always be present
|
||||
assert "local_mode" in data
|
||||
assert "version" in data
|
||||
assert "bootstrap_status" in data
|
||||
|
||||
# These fields can be null but must be present
|
||||
assert "instance_id" in data
|
||||
assert "tenant_id" in data
|
||||
|
||||
def test_meta_endpoint_after_failed_bootstrap(self):
|
||||
"""Verify meta endpoint works even after bootstrap failure."""
|
||||
# Simulate failed bootstrap
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = "Test error"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should still return all fields
|
||||
assert data["bootstrap_status"]["attempted"] is True
|
||||
assert data["bootstrap_status"]["success"] is False
|
||||
assert data["bootstrap_status"]["error"] == "Test error"
|
||||
|
||||
def test_meta_endpoint_with_successful_bootstrap(self):
|
||||
"""Verify meta endpoint reflects successful bootstrap."""
|
||||
tenant_id = uuid4()
|
||||
|
||||
# Simulate successful bootstrap
|
||||
LocalBootstrapService._local_tenant_id = tenant_id
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.routes.meta.settings') as mock_settings:
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.INSTANCE_ID = "test-instance-123"
|
||||
mock_settings.APP_VERSION = "0.1.0"
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/meta/instance")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["local_mode"] is True
|
||||
assert data["instance_id"] == "test-instance-123"
|
||||
assert data["tenant_id"] == str(tenant_id)
|
||||
assert data["bootstrap_status"]["success"] is True
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""
|
||||
Tests for configuration defaults related to LOCAL_MODE.
|
||||
"""
|
||||
|
||||
def test_all_local_mode_settings_have_safe_defaults(self):
|
||||
"""Verify all LOCAL_MODE settings have safe defaults that don't break multi-tenant mode."""
|
||||
settings = Settings()
|
||||
|
||||
# Core setting defaults to False (multi-tenant)
|
||||
assert settings.LOCAL_MODE is False
|
||||
|
||||
# Optional settings default to None/False
|
||||
assert settings.INSTANCE_ID is None
|
||||
assert settings.HUB_URL is None
|
||||
assert settings.HUB_API_KEY is None
|
||||
assert settings.HUB_TELEMETRY_ENABLED is False
|
||||
|
||||
# Local tenant domain has a default
|
||||
assert settings.LOCAL_TENANT_DOMAIN == "local.letsbe.cloud"
|
||||
|
||||
# Phase 2: LOCAL_AGENT_KEY defaults to None
|
||||
assert settings.LOCAL_AGENT_KEY is None
|
||||
|
||||
|
||||
class TestLocalAgentRegistration:
|
||||
"""
|
||||
Tests for /api/v1/agents/register-local endpoint (Phase 2).
|
||||
|
||||
HTTP Status Semantics:
|
||||
- 404: Endpoint hidden when LOCAL_MODE=false
|
||||
- 401: Invalid or missing LOCAL_AGENT_KEY
|
||||
- 503: Local tenant not bootstrapped
|
||||
- 201: New agent created
|
||||
- 200: Existing agent returned (idempotent)
|
||||
"""
|
||||
|
||||
def test_register_local_hidden_when_local_mode_false(self):
|
||||
"""Verify /register-local returns 404 when LOCAL_MODE=false (security by obscurity)."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = False
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "any-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_register_local_requires_local_agent_key(self):
|
||||
"""Verify /register-local returns 401 without X-Local-Agent-Key header."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = uuid4()
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "test-key-12345"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
# Missing header
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # FastAPI validation error for missing header
|
||||
|
||||
def test_register_local_rejects_invalid_key(self):
|
||||
"""Verify /register-local returns 401 with wrong LOCAL_AGENT_KEY."""
|
||||
# Reset bootstrap state
|
||||
LocalBootstrapService._local_tenant_id = uuid4()
|
||||
LocalBootstrapService._bootstrap_attempted = True
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "correct-key"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "wrong-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_register_local_503_when_not_bootstrapped(self):
|
||||
"""Verify /register-local returns 503 if local tenant not bootstrapped."""
|
||||
# Bootstrap not complete
|
||||
LocalBootstrapService._local_tenant_id = None
|
||||
LocalBootstrapService._bootstrap_attempted = False
|
||||
LocalBootstrapService._bootstrap_error = None
|
||||
|
||||
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
|
||||
mock_settings = Settings()
|
||||
mock_settings.LOCAL_MODE = True
|
||||
mock_settings.LOCAL_AGENT_KEY = "test-key"
|
||||
mock_get_settings.return_value = mock_settings
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/agents/register-local",
|
||||
json={"hostname": "test-agent", "version": "1.0.0"},
|
||||
headers={"X-Local-Agent-Key": "test-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "Retry-After" in response.headers
|
||||
141
letsbe-orchestrator/tests/test_playbooks_chatwoot.py
Normal file
141
letsbe-orchestrator/tests/test_playbooks_chatwoot.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for the Chatwoot playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.chatwoot import (
|
||||
CHATWOOT_ENV_PATH,
|
||||
CHATWOOT_STACK_DIR,
|
||||
CompositeStep,
|
||||
build_chatwoot_setup_steps,
|
||||
create_chatwoot_setup_task,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildChatwootSetupSteps:
|
||||
"""Tests for the build_chatwoot_setup_steps function."""
|
||||
|
||||
def test_returns_two_steps(self):
|
||||
"""Verify that build_chatwoot_setup_steps returns exactly 2 steps."""
|
||||
steps = build_chatwoot_setup_steps(domain="support.example.com")
|
||||
assert len(steps) == 2
|
||||
assert all(isinstance(step, CompositeStep) for step in steps)
|
||||
|
||||
def test_env_update_payload(self):
|
||||
"""Verify the ENV_UPDATE step has the correct payload structure."""
|
||||
domain = "support.example.com"
|
||||
steps = build_chatwoot_setup_steps(domain=domain)
|
||||
|
||||
env_step = steps[0]
|
||||
assert env_step.type == "ENV_UPDATE"
|
||||
assert env_step.payload["path"] == CHATWOOT_ENV_PATH
|
||||
assert env_step.payload["updates"]["FRONTEND_URL"] == f"https://{domain}"
|
||||
assert env_step.payload["updates"]["BACKEND_URL"] == f"https://{domain}"
|
||||
|
||||
def test_docker_reload_payload(self):
|
||||
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
|
||||
steps = build_chatwoot_setup_steps(domain="support.example.com")
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.type == "DOCKER_RELOAD"
|
||||
assert docker_step.payload["compose_dir"] == CHATWOOT_STACK_DIR
|
||||
assert docker_step.payload["pull"] is True
|
||||
|
||||
def test_domain_url_formatting(self):
|
||||
"""Verify that domain URLs are properly formatted with https."""
|
||||
domain = "chat.mycompany.io"
|
||||
steps = build_chatwoot_setup_steps(domain=domain)
|
||||
|
||||
env_step = steps[0]
|
||||
assert env_step.payload["updates"]["FRONTEND_URL"] == "https://chat.mycompany.io"
|
||||
assert env_step.payload["updates"]["BACKEND_URL"] == "https://chat.mycompany.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateChatwootSetupTask:
|
||||
"""Tests for the create_chatwoot_setup_task function."""
|
||||
|
||||
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_chatwoot_setup_task persists a COMPOSITE task."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_steps(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the task payload contains the steps array."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
async def test_task_steps_structure(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the steps in the payload have the correct structure."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
|
||||
# First step should be ENV_UPDATE
|
||||
assert steps[0]["type"] == "ENV_UPDATE"
|
||||
assert "path" in steps[0]["payload"]
|
||||
assert "updates" in steps[0]["payload"]
|
||||
|
||||
# Second step should be DOCKER_RELOAD
|
||||
assert steps[1]["type"] == "DOCKER_RELOAD"
|
||||
assert "compose_dir" in steps[1]["payload"]
|
||||
assert steps[1]["payload"]["pull"] is True
|
||||
|
||||
async def test_task_with_agent_id(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that agent_id is properly assigned when provided."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
# Note: In a real scenario, the agent would need to exist in the DB
|
||||
# For this test, we're just verifying the task stores the agent_id
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
assert task.agent_id == agent_id
|
||||
|
||||
async def test_task_persisted_to_database(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_chatwoot_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=None,
|
||||
domain="support.example.com",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
317
letsbe-orchestrator/tests/test_playbooks_nextcloud.py
Normal file
317
letsbe-orchestrator/tests/test_playbooks_nextcloud.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Tests for the Nextcloud playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.nextcloud import (
|
||||
NEXTCLOUD_STACK_DIR,
|
||||
CompositeStep,
|
||||
build_nextcloud_initial_setup_step,
|
||||
build_nextcloud_set_domain_steps,
|
||||
create_nextcloud_initial_setup_task,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Playwright Initial Setup
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudInitialSetupStep:
|
||||
"""Tests for the build_nextcloud_initial_setup_step function."""
|
||||
|
||||
def test_returns_playwright_payload(self):
|
||||
"""Verify that build_nextcloud_initial_setup_step returns correct structure."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert payload["scenario"] == "nextcloud_initial_setup"
|
||||
assert "inputs" in payload
|
||||
assert "timeout" in payload
|
||||
|
||||
def test_inputs_contain_required_fields(self):
|
||||
"""Verify that inputs contain all required fields."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
inputs = payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "admin"
|
||||
assert inputs["admin_password"] == "securepassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert "options" in payload
|
||||
assert "allowed_domains" in payload["options"]
|
||||
|
||||
def test_allowed_domains_extracted_from_url(self):
|
||||
"""Verify that allowed_domains is extracted from base_url."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
def test_allowed_domains_with_port(self):
|
||||
"""Verify that allowed_domains handles URLs with ports."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com:8443",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["cloud.example.com:8443"]
|
||||
|
||||
def test_timeout_is_set(self):
|
||||
"""Verify that timeout is set in the payload."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["timeout"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateNextcloudInitialSetupTask:
|
||||
"""Tests for the create_nextcloud_initial_setup_task function."""
|
||||
|
||||
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_nextcloud_initial_setup_task persists a PLAYWRIGHT task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_scenario(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the scenario field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud_initial_setup"
|
||||
|
||||
async def test_task_payload_contains_inputs(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the inputs field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="testadmin",
|
||||
admin_password="testpassword123",
|
||||
)
|
||||
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "testadmin"
|
||||
assert inputs["admin_password"] == "testpassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert task.payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Set Domain Playbook
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudSetDomainSteps:
|
||||
"""Tests for the build_nextcloud_set_domain_steps function."""
|
||||
|
||||
def test_returns_two_steps(self):
|
||||
"""Verify that build_nextcloud_set_domain_steps returns exactly 2 steps."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
assert len(steps) == 2
|
||||
assert all(isinstance(step, CompositeStep) for step in steps)
|
||||
|
||||
def test_first_step_is_nextcloud_set_domain(self):
|
||||
"""Verify the first step is NEXTCLOUD_SET_DOMAIN."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
assert steps[0].type == "NEXTCLOUD_SET_DOMAIN"
|
||||
|
||||
def test_nextcloud_set_domain_payload(self):
|
||||
"""Verify NEXTCLOUD_SET_DOMAIN step has correct payload."""
|
||||
public_url = "https://cloud.example.com"
|
||||
steps = build_nextcloud_set_domain_steps(public_url=public_url, pull=False)
|
||||
assert steps[0].payload["public_url"] == public_url
|
||||
|
||||
def test_docker_reload_payload(self):
|
||||
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.type == "DOCKER_RELOAD"
|
||||
assert docker_step.payload["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert docker_step.payload["pull"] is False
|
||||
|
||||
def test_pull_flag_true(self):
|
||||
"""Verify that pull=True is passed correctly."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=True
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.payload["pull"] is True
|
||||
|
||||
def test_pull_flag_false(self):
|
||||
"""Verify that pull=False is passed correctly."""
|
||||
steps = build_nextcloud_set_domain_steps(
|
||||
public_url="https://cloud.example.com", pull=False
|
||||
)
|
||||
|
||||
docker_step = steps[1]
|
||||
assert docker_step.payload["pull"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateNextcloudSetDomainTask:
|
||||
"""Tests for the create_nextcloud_set_domain_task function."""
|
||||
|
||||
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_nextcloud_set_domain_task persists a COMPOSITE task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_steps(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the steps array."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
async def test_task_steps_structure(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that the steps in the payload have the correct structure."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=True,
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
|
||||
# First step should be NEXTCLOUD_SET_DOMAIN
|
||||
assert steps[0]["type"] == "NEXTCLOUD_SET_DOMAIN"
|
||||
assert steps[0]["payload"]["public_url"] == "https://cloud.example.com"
|
||||
|
||||
# Second step should be DOCKER_RELOAD
|
||||
assert steps[1]["type"] == "DOCKER_RELOAD"
|
||||
assert steps[1]["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert steps[1]["payload"]["pull"] is True
|
||||
|
||||
async def test_task_with_pull_false(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that pull=False is correctly stored in the task payload."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
steps = task.payload["steps"]
|
||||
assert steps[1]["payload"]["pull"] is False
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_nextcloud_set_domain_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
230
letsbe-orchestrator/tests/test_poste_playbook.py
Normal file
230
letsbe-orchestrator/tests/test_poste_playbook.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Tests for the Poste.io playbook module."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.poste import (
|
||||
POSTE_STACK_DIR,
|
||||
build_poste_initial_setup_step,
|
||||
create_poste_initial_setup_task,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildPosteInitialSetupStep:
|
||||
"""Tests for the build_poste_initial_setup_step function."""
|
||||
|
||||
def test_returns_playwright_payload(self):
|
||||
"""Verify that build_poste_initial_setup_step returns correct structure."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["scenario"] == "poste_initial_setup"
|
||||
assert "inputs" in payload
|
||||
assert "options" in payload
|
||||
assert "timeout" in payload
|
||||
|
||||
def test_inputs_contain_required_fields(self):
|
||||
"""Verify that inputs contain base_url and admin_email."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
inputs = payload["inputs"]
|
||||
assert inputs["base_url"] == "https://mail.example.com"
|
||||
assert inputs["admin_email"] == "admin@example.com"
|
||||
|
||||
def test_password_included_when_provided(self):
|
||||
"""Verify that admin_password is included in inputs when provided."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password="secure-password-123",
|
||||
)
|
||||
|
||||
assert payload["inputs"]["admin_password"] == "secure-password-123"
|
||||
|
||||
def test_password_omitted_when_none(self):
|
||||
"""Verify that admin_password is omitted when not provided."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert "admin_password" not in payload["inputs"]
|
||||
|
||||
def test_allowed_domains_extracted_from_url(self):
|
||||
"""Verify that allowed_domains is extracted from base_url."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.example.com"]
|
||||
|
||||
def test_allowed_domains_with_port(self):
|
||||
"""Verify that allowed_domains handles URLs with ports."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com:8443",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.example.com:8443"]
|
||||
|
||||
def test_timeout_is_set(self):
|
||||
"""Verify that timeout is 120 seconds."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert payload["timeout"] == 120
|
||||
|
||||
def test_domain_url_with_subdomain(self):
|
||||
"""Verify correct domain extraction from a subdomain URL."""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.mycompany.io",
|
||||
admin_email="postmaster@mycompany.io",
|
||||
)
|
||||
|
||||
assert payload["options"]["allowed_domains"] == ["mail.mycompany.io"]
|
||||
assert payload["inputs"]["admin_email"] == "postmaster@mycompany.io"
|
||||
|
||||
def test_password_with_special_characters(self):
|
||||
"""Verify that passwords with special characters are handled."""
|
||||
special_password = "p@$$w0rd!#%^&*()"
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password=special_password,
|
||||
)
|
||||
|
||||
assert payload["inputs"]["admin_password"] == special_password
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreatePosteInitialSetupTask:
|
||||
"""Tests for the create_poste_initial_setup_task function."""
|
||||
|
||||
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_poste_initial_setup_task persists a PLAYWRIGHT task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_scenario(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the scenario field."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "poste_initial_setup"
|
||||
|
||||
async def test_task_payload_contains_inputs(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the inputs field."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="postmaster@example.com",
|
||||
)
|
||||
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://mail.example.com"
|
||||
assert inputs["admin_email"] == "postmaster@example.com"
|
||||
|
||||
async def test_task_with_password(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that admin_password is included when provided."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
admin_password="test-password-123",
|
||||
)
|
||||
|
||||
assert task.payload["inputs"]["admin_password"] == "test-password-123"
|
||||
|
||||
async def test_task_without_password(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that admin_password is omitted when not provided."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert "admin_password" not in task.payload["inputs"]
|
||||
|
||||
async def test_task_options_contain_allowed_domains(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that task options include allowed_domains."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
assert task.payload["options"]["allowed_domains"] == ["mail.example.com"]
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://mail.example.com",
|
||||
admin_email="admin@example.com",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
class TestPosteConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_poste_stack_dir(self):
|
||||
"""Verify POSTE_STACK_DIR is set correctly."""
|
||||
assert POSTE_STACK_DIR == "/opt/letsbe/stacks/poste"
|
||||
Reference in New Issue
Block a user