Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1 @@
"""Test suite for letsbe-orchestrator."""

View 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

View File

@@ -0,0 +1 @@
"""Tests for route modules."""

View 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)

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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"