Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
1
letsbe-orchestrator/tests/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for route modules."""
|
||||
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
361
letsbe-orchestrator/tests/routes/test_agent_auth.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Tests for agent authentication endpoints and dependencies."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies.auth import get_current_agent, get_current_agent_compat
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.agents import (
|
||||
_register_agent_legacy,
|
||||
_register_agent_secure,
|
||||
agent_heartbeat,
|
||||
)
|
||||
from app.schemas.agent import AgentRegisterRequest, AgentRegisterRequestLegacy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgent:
|
||||
"""Tests for the get_current_agent dependency (new auth scheme)."""
|
||||
|
||||
async def test_valid_credentials(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully authenticate with valid X-Agent-Id and X-Agent-Secret."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret=secret
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
assert result.tenant_id == test_tenant.id
|
||||
|
||||
async def test_invalid_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for non-existent agent ID."""
|
||||
fake_agent_id = str(uuid.uuid4())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=fake_agent_id, x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_invalid_secret(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 401 for wrong secret."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id=str(agent.id), x_agent_secret="wrong-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid agent credentials" in str(exc_info.value.detail)
|
||||
|
||||
async def test_malformed_agent_id(self, db: AsyncSession):
|
||||
"""Returns 401 for malformed UUID."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent(
|
||||
db=db, x_agent_id="not-a-uuid", x_agent_secret="any-secret"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid Agent ID format" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentAgentCompat:
|
||||
"""Tests for the backward-compatible auth dependency."""
|
||||
|
||||
async def test_new_scheme_preferred(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""New X-Agent-* headers take precedence over Bearer."""
|
||||
agent, secret = test_agent_with_secret
|
||||
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=str(agent.id),
|
||||
x_agent_secret=secret,
|
||||
authorization="Bearer wrong-token", # Should be ignored
|
||||
)
|
||||
|
||||
assert result.id == agent.id
|
||||
|
||||
async def test_legacy_bearer_fallback(
|
||||
self, db: AsyncSession, test_agent_legacy: Agent
|
||||
):
|
||||
"""Falls back to Bearer token when X-Agent-* not provided."""
|
||||
result = await get_current_agent_compat(
|
||||
db=db,
|
||||
x_agent_id=None,
|
||||
x_agent_secret=None,
|
||||
authorization=f"Bearer {test_agent_legacy.token}",
|
||||
agent_id=test_agent_legacy.id, # Legacy auth requires agent_id
|
||||
)
|
||||
|
||||
assert result.id == test_agent_legacy.id
|
||||
|
||||
async def test_no_credentials_provided(self, db: AsyncSession):
|
||||
"""Returns 401 when no auth credentials provided."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_agent_compat(
|
||||
db=db, x_agent_id=None, x_agent_secret=None, authorization=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Missing authentication credentials" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSecureAgentRegistration:
|
||||
"""Tests for the new secure registration flow."""
|
||||
|
||||
async def test_registration_with_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully register agent with valid registration token."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="new-agent-host",
|
||||
version="2.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.agent_secret is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
|
||||
# Verify agent was created
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.name == "new-agent-host"
|
||||
assert agent.tenant_id == test_tenant.id
|
||||
|
||||
async def test_registration_increments_use_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Registration increments the token's use_count."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
initial_count = token_record.use_count
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
await db.refresh(token_record)
|
||||
assert token_record.use_count == initial_count + 1
|
||||
|
||||
async def test_registration_stores_secret_hash(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Agent secret is stored as hash, not plaintext."""
|
||||
_, plaintext_token = test_registration_token
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
response = await _register_agent_secure(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
|
||||
expected_hash = hashlib.sha256(response.agent_secret.encode()).hexdigest()
|
||||
assert agent.secret_hash == expected_hash
|
||||
|
||||
async def test_registration_with_invalid_token(self, db: AsyncSession):
|
||||
"""Returns 401 for invalid registration token."""
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token="invalid-token",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid registration token" in str(exc_info.value.detail)
|
||||
|
||||
async def test_registration_with_revoked_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for revoked registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "revoked" in str(exc_info.value.detail).lower()
|
||||
|
||||
async def test_registration_with_exhausted_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 401 for exhausted registration token."""
|
||||
token_record, plaintext_token = test_registration_token
|
||||
token_record.max_uses = 1
|
||||
token_record.use_count = 1
|
||||
await db.commit()
|
||||
|
||||
request = AgentRegisterRequest(
|
||||
hostname="test-host",
|
||||
version="1.0.0",
|
||||
registration_token=plaintext_token,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_secure(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "exhausted" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLegacyAgentRegistration:
|
||||
"""Tests for the legacy registration flow."""
|
||||
|
||||
async def test_legacy_registration_success(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Successfully register agent using legacy flow."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
assert response.agent_id is not None
|
||||
assert response.token is not None
|
||||
|
||||
# Verify agent was created with both token and secret_hash
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.token == response.token
|
||||
assert agent.secret_hash == hashlib.sha256(response.token.encode()).hexdigest()
|
||||
|
||||
async def test_legacy_registration_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 for non-existent tenant in legacy flow."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="legacy-host",
|
||||
version="1.0.0",
|
||||
tenant_id=fake_tenant_id,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _register_agent_legacy(request, db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_legacy_registration_without_tenant(self, db: AsyncSession):
|
||||
"""Legacy registration without tenant_id creates shared agent."""
|
||||
request = AgentRegisterRequestLegacy(
|
||||
hostname="shared-host",
|
||||
version="1.0.0",
|
||||
tenant_id=None,
|
||||
)
|
||||
|
||||
response = await _register_agent_legacy(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == response.agent_id)
|
||||
)
|
||||
agent = result.scalar_one()
|
||||
assert agent.tenant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentHeartbeat:
|
||||
"""Tests for the agent heartbeat endpoint."""
|
||||
|
||||
async def test_heartbeat_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully send heartbeat updates timestamp and status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
old_heartbeat = agent.last_heartbeat
|
||||
|
||||
response = await agent_heartbeat(
|
||||
agent_id=agent.id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
|
||||
await db.refresh(agent)
|
||||
assert agent.status == AgentStatus.ONLINE.value
|
||||
assert agent.last_heartbeat >= old_heartbeat
|
||||
|
||||
async def test_heartbeat_agent_id_mismatch(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when path agent_id doesn't match authenticated agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
wrong_agent_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await agent_heartbeat(
|
||||
agent_id=wrong_agent_id, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Agent ID mismatch" in str(exc_info.value.detail)
|
||||
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
213
letsbe-orchestrator/tests/routes/test_env_routes.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Tests for env management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.env import inspect_env, update_env
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectEnv:
|
||||
"""Tests for the inspect_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_INSPECT task."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=["FRONTEND_URL", "BACKEND_URL"],
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
|
||||
|
||||
async def test_without_keys(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request without keys omits keys from payload."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=None,
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert "keys" not in task.payload
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateEnv:
|
||||
"""Tests for the update_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_UPDATE task."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"FRONTEND_URL": "https://new.domain.com"},
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_updates_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only updates omits remove_keys from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
remove_keys=None,
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.payload["updates"] == {"KEY": "VALUE"}
|
||||
assert "remove_keys" not in task.payload
|
||||
|
||||
async def test_remove_keys_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only remove_keys omits updates from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates=None,
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert "updates" not in task.payload
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_UPDATE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
116
letsbe-orchestrator/tests/routes/test_files_routes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for file management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.files import inspect_file
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectFile:
|
||||
"""Tests for the inspect_file endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates a FILE_INSPECT task."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_default_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request uses default max_bytes of 4096."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_custom_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with custom max_bytes is respected."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/large_file.txt",
|
||||
max_bytes=1048576, # 1MB
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["max_bytes"] == 1048576
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "FILE_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
492
letsbe-orchestrator/tests/routes/test_nextcloud_routes.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""Tests for Nextcloud playbook routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR
|
||||
from app.routes.playbooks import (
|
||||
NextcloudInitialSetupRequest,
|
||||
NextcloudSetDomainRequest,
|
||||
nextcloud_initial_setup,
|
||||
nextcloud_set_domain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudSetDomainEndpoint:
|
||||
"""Tests for the nextcloud_set_domain endpoint."""
|
||||
|
||||
async def test_happy_path_creates_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/set-domain returns 201 with COMPOSITE task."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent_for_tenant.id
|
||||
assert task.type == "COMPOSITE"
|
||||
assert task.status == "pending"
|
||||
|
||||
async def test_task_has_both_steps(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify response task payload contains both steps in correct order."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=True,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert "steps" in task.payload
|
||||
assert len(task.payload["steps"]) == 2
|
||||
|
||||
# First step: NEXTCLOUD_SET_DOMAIN
|
||||
step0 = task.payload["steps"][0]
|
||||
assert step0["type"] == "NEXTCLOUD_SET_DOMAIN"
|
||||
assert step0["payload"]["public_url"] == "https://cloud.example.com"
|
||||
|
||||
# Second step: DOCKER_RELOAD
|
||||
step1 = task.payload["steps"][1]
|
||||
assert step1["type"] == "DOCKER_RELOAD"
|
||||
assert step1["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
|
||||
assert step1["payload"]["pull"] is True
|
||||
|
||||
async def test_pull_flag_default_false(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify pull defaults to False when not specified."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
step = task.payload["steps"][1]
|
||||
assert step["payload"]["pull"] is False
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_offline_agent_not_resolved(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Offline agent should not be auto-resolved."""
|
||||
# Create an offline agent for the tenant
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "COMPOSITE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant.id
|
||||
|
||||
async def test_auto_resolves_first_online_agent(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the first online agent is auto-resolved."""
|
||||
# Create two agents for the tenant - one offline, one online
|
||||
offline_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="offline-agent",
|
||||
version="1.0.0",
|
||||
status="offline",
|
||||
token="offline-token",
|
||||
)
|
||||
online_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant.id,
|
||||
name="online-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="online-token",
|
||||
)
|
||||
db.add(offline_agent)
|
||||
db.add(online_agent)
|
||||
await db.commit()
|
||||
|
||||
request = NextcloudSetDomainRequest(
|
||||
public_url="https://cloud.example.com",
|
||||
pull=False,
|
||||
)
|
||||
|
||||
task = await nextcloud_set_domain(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Should have resolved to the online agent
|
||||
assert task.agent_id == online_agent.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures for Initial Setup Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_tenant_with_domain(db: AsyncSession) -> Tenant:
|
||||
"""Create a test tenant with domain configured."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant With Domain",
|
||||
domain="example.com",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent_for_tenant_with_domain(
|
||||
db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
) -> Agent:
|
||||
"""Create a test agent linked to test_tenant_with_domain with online status."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=test_tenant_with_domain.id,
|
||||
name="test-agent-for-tenant-domain",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-domain",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Nextcloud Initial Setup Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNextcloudInitialSetupEndpoint:
|
||||
"""Tests for the nextcloud_initial_setup endpoint."""
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_happy_path_creates_task(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""POST /tenants/{id}/nextcloud/setup returns 201 with PLAYWRIGHT task."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant_with_domain.id
|
||||
assert task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == "pending"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_has_correct_payload(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify response task payload contains scenario and inputs."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="myadmin",
|
||||
admin_password="mypassword123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud_initial_setup"
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "myadmin"
|
||||
assert inputs["admin_password"] == "mypassword123"
|
||||
# allowed_domains should be in options, not inputs
|
||||
assert task.payload["options"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
|
||||
"""Non-existent tenant_id returns 404."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_tenant_without_domain_returns_400(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Tenant without domain configured returns 400."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "no domain configured" in str(exc_info.value.detail)
|
||||
|
||||
async def test_no_online_agent_returns_404(
|
||||
self, db: AsyncSession, test_tenant_with_domain: Tenant
|
||||
):
|
||||
"""Tenant with no online agent returns 404."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "No online agent found" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_nextcloud_unavailable_returns_409(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Health check failure returns 409."""
|
||||
mock_health_check.return_value = False
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Nextcloud not available" in str(exc_info.value.detail)
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_health_check_called_with_correct_url(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify health check is called with correct URL."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
mock_health_check.assert_called_once_with("https://cloud.example.com")
|
||||
|
||||
def test_username_too_short_raises_validation_error(self):
|
||||
"""Username less than 3 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="ab", # Too short
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_invalid_chars_raises_validation_error(self):
|
||||
"""Username with invalid characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin@user", # Invalid char @
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_username_starting_with_number_raises_validation_error(self):
|
||||
"""Username starting with number raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="123admin", # Starts with number
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
assert "admin_username" in str(exc_info.value)
|
||||
|
||||
def test_password_too_short_raises_validation_error(self):
|
||||
"""Password less than 8 characters raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="short", # Too short
|
||||
)
|
||||
|
||||
assert "admin_password" in str(exc_info.value)
|
||||
|
||||
def test_valid_username_with_underscore(self):
|
||||
"""Username with underscore is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin_user",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin_user"
|
||||
|
||||
def test_valid_username_with_numbers(self):
|
||||
"""Username with numbers (not at start) is valid."""
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin123",
|
||||
admin_password="password123",
|
||||
)
|
||||
assert request.admin_username == "admin123"
|
||||
|
||||
@patch("app.routes.playbooks.check_nextcloud_availability")
|
||||
async def test_task_persisted_to_database(
|
||||
self,
|
||||
mock_health_check: AsyncMock,
|
||||
db: AsyncSession,
|
||||
test_tenant_with_domain: Tenant,
|
||||
test_agent_for_tenant_with_domain: Agent,
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
mock_health_check.return_value = True
|
||||
|
||||
request = NextcloudInitialSetupRequest(
|
||||
admin_username="admin",
|
||||
admin_password="password123",
|
||||
)
|
||||
|
||||
task = await nextcloud_initial_setup(
|
||||
tenant_id=test_tenant_with_domain.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant_with_domain.id
|
||||
assert retrieved_task.agent_id == test_agent_for_tenant_with_domain.id
|
||||
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
300
letsbe-orchestrator/tests/routes/test_registration_tokens.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for registration token endpoints."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.base import utc_now
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.registration_tokens import (
|
||||
create_registration_token,
|
||||
get_registration_token,
|
||||
list_registration_tokens,
|
||||
revoke_registration_token,
|
||||
)
|
||||
from app.schemas.registration_token import RegistrationTokenCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateRegistrationToken:
|
||||
"""Tests for the create_registration_token endpoint."""
|
||||
|
||||
async def test_create_token_success(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Successfully create a registration token."""
|
||||
request = RegistrationTokenCreate(
|
||||
description="Test token",
|
||||
max_uses=5,
|
||||
expires_in_hours=24,
|
||||
)
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.id is not None
|
||||
assert response.tenant_id == test_tenant.id
|
||||
assert response.description == "Test token"
|
||||
assert response.max_uses == 5
|
||||
assert response.use_count == 0
|
||||
assert response.revoked is False
|
||||
assert response.token is not None # Plaintext token returned once
|
||||
assert response.expires_at is not None
|
||||
|
||||
async def test_create_token_stores_hash(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token is stored as hash, not plaintext."""
|
||||
request = RegistrationTokenCreate(description="Hash test")
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
# Retrieve from database
|
||||
result = await db.execute(
|
||||
select(RegistrationToken).where(RegistrationToken.id == response.id)
|
||||
)
|
||||
token_record = result.scalar_one()
|
||||
|
||||
# Verify hash is stored, not plaintext
|
||||
expected_hash = hashlib.sha256(response.token.encode()).hexdigest()
|
||||
assert token_record.token_hash == expected_hash
|
||||
assert token_record.token_hash != response.token
|
||||
|
||||
async def test_create_token_default_values(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Default values are applied correctly."""
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
response = await create_registration_token(
|
||||
tenant_id=test_tenant.id, request=request, db=db
|
||||
)
|
||||
|
||||
assert response.max_uses == 1 # Default
|
||||
assert response.description is None
|
||||
assert response.expires_at is None
|
||||
|
||||
async def test_create_token_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = RegistrationTokenCreate()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_registration_token(
|
||||
tenant_id=fake_tenant_id, request=request, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListRegistrationTokens:
|
||||
"""Tests for the list_registration_tokens endpoint."""
|
||||
|
||||
async def test_list_tokens_empty(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns empty list when no tokens exist."""
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert response.tokens == []
|
||||
assert response.total == 0
|
||||
|
||||
async def test_list_tokens_multiple(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns all tokens for tenant."""
|
||||
# Create multiple tokens
|
||||
for i in range(3):
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash=f"hash-{i}",
|
||||
description=f"Token {i}",
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 3
|
||||
assert response.total == 3
|
||||
|
||||
async def test_list_tokens_tenant_isolation(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Only returns tokens for the specified tenant."""
|
||||
# Create another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create token for test_tenant
|
||||
token1 = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="hash-1", description="Tenant 1"
|
||||
)
|
||||
# Create token for other_tenant
|
||||
token2 = RegistrationToken(
|
||||
tenant_id=other_tenant.id, token_hash="hash-2", description="Tenant 2"
|
||||
)
|
||||
db.add_all([token1, token2])
|
||||
await db.commit()
|
||||
|
||||
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
|
||||
|
||||
assert len(response.tokens) == 1
|
||||
assert response.tokens[0].description == "Tenant 1"
|
||||
|
||||
async def test_list_tokens_tenant_not_found(self, db: AsyncSession):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await list_registration_tokens(tenant_id=fake_tenant_id, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetRegistrationToken:
|
||||
"""Tests for the get_registration_token endpoint."""
|
||||
|
||||
async def test_get_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully retrieve a token."""
|
||||
token_record, _ = test_registration_token
|
||||
|
||||
response = await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert response.id == token_record.id
|
||||
assert response.description == token_record.description
|
||||
|
||||
async def test_get_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_get_token_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Returns 404 when token belongs to different tenant."""
|
||||
token_record, _ = test_registration_token
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_registration_token(
|
||||
tenant_id=other_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRevokeRegistrationToken:
|
||||
"""Tests for the revoke_registration_token endpoint."""
|
||||
|
||||
async def test_revoke_token_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Successfully revoke a token."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.revoked is False
|
||||
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=token_record.id, db=db
|
||||
)
|
||||
|
||||
# Refresh to see updated value
|
||||
await db.refresh(token_record)
|
||||
assert token_record.revoked is True
|
||||
|
||||
async def test_revoke_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when token doesn't exist."""
|
||||
fake_token_id = uuid.uuid4()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await revoke_registration_token(
|
||||
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRegistrationTokenIsValid:
|
||||
"""Tests for the RegistrationToken.is_valid() method."""
|
||||
|
||||
async def test_valid_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Token is valid when not revoked, not expired, and not exhausted."""
|
||||
token_record, _ = test_registration_token
|
||||
assert token_record.is_valid() is True
|
||||
|
||||
async def test_revoked_token_invalid(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_registration_token: tuple[RegistrationToken, str],
|
||||
):
|
||||
"""Revoked token is invalid."""
|
||||
token_record, _ = test_registration_token
|
||||
token_record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
assert token_record.is_valid() is False
|
||||
|
||||
async def test_expired_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Expired token is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id,
|
||||
token_hash="test-hash",
|
||||
expires_at=utc_now() - timedelta(hours=1),
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_exhausted_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token that reached max_uses is invalid."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=3, use_count=3
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is False
|
||||
|
||||
async def test_unlimited_uses_token(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Token with max_uses=0 has unlimited uses."""
|
||||
token = RegistrationToken(
|
||||
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=0, use_count=1000
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
|
||||
assert token.is_valid() is True
|
||||
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
396
letsbe-orchestrator/tests/routes/test_tasks_auth.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Tests for task endpoints with authentication."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.tasks import (
|
||||
get_next_pending_task,
|
||||
get_next_task_endpoint,
|
||||
update_task_endpoint,
|
||||
)
|
||||
from app.schemas.task import TaskUpdate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextPendingTask:
|
||||
"""Tests for the get_next_pending_task helper function."""
|
||||
|
||||
async def test_returns_pending_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns oldest pending task for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tasks
|
||||
task1 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 1},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
task2 = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={"order": 2},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add_all([task1, task2])
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should return oldest (task1)
|
||||
assert result is not None
|
||||
assert result.payload["order"] == 1
|
||||
|
||||
async def test_filters_by_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks for agent's tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create tenant for another tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
|
||||
db.add(other_tenant)
|
||||
|
||||
# Create task for other tenant
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no pending tasks exist."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_skips_non_pending_tasks(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Only returns tasks with PENDING status."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create running task
|
||||
running_task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
)
|
||||
db.add(running_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_pending_task(db, agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNextTaskEndpoint:
|
||||
"""Tests for the GET /tasks/next endpoint."""
|
||||
|
||||
async def test_claims_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully claims a pending task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == TaskStatus.RUNNING.value
|
||||
assert result.agent_id == agent.id
|
||||
|
||||
async def test_returns_none_when_empty(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns None when no tasks available."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_tenant_isolation(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Agent can only claim tasks from its tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=agent)
|
||||
|
||||
# Should not see other tenant's task
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateTaskEndpoint:
|
||||
"""Tests for the PATCH /tasks/{task_id} endpoint."""
|
||||
|
||||
async def test_update_task_success(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Successfully update an assigned task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Assigned to this agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED, result={"success": True})
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
assert result.result == {"success": True}
|
||||
|
||||
async def test_update_task_not_found(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 404 for non-existent task."""
|
||||
agent, _ = test_agent_with_secret
|
||||
fake_task_id = uuid.uuid4()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=fake_task_id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_update_task_wrong_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task belongs to different tenant."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task for different tenant
|
||||
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
|
||||
db.add(other_tenant)
|
||||
|
||||
other_task = Task(
|
||||
tenant_id=other_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=agent.id, # Even if assigned to agent
|
||||
)
|
||||
db.add(other_task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=other_task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "does not belong to this tenant" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_not_assigned(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task is not assigned to requesting agent."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
# Create task assigned to different agent
|
||||
other_agent_id = uuid.uuid4()
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=other_agent_id, # Different agent
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "not assigned to this agent" in str(exc_info.value.detail)
|
||||
|
||||
async def test_update_task_unassigned_forbidden(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_with_secret: tuple[Agent, str],
|
||||
):
|
||||
"""Returns 403 when task has no agent_id assigned."""
|
||||
agent, _ = test_agent_with_secret
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
agent_id=None, # Not assigned
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=agent
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSharedAgentBehavior:
|
||||
"""Tests for agents without tenant_id (shared agents)."""
|
||||
|
||||
async def test_shared_agent_can_claim_any_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent (no tenant_id) can claim tasks from any tenant."""
|
||||
# Create shared agent (no tenant_id)
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
result = await get_next_task_endpoint(db=db, current_agent=shared_agent)
|
||||
|
||||
assert result is not None
|
||||
assert result.agent_id == shared_agent.id
|
||||
|
||||
async def test_shared_agent_can_update_any_tenant_task(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Shared agent can update tasks from any tenant if assigned."""
|
||||
shared_agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="shared-agent",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="shared-token",
|
||||
secret_hash="dummy-hash",
|
||||
tenant_id=None,
|
||||
)
|
||||
db.add(shared_agent)
|
||||
|
||||
task = Task(
|
||||
tenant_id=test_tenant.id,
|
||||
type="TEST",
|
||||
payload={},
|
||||
status=TaskStatus.RUNNING.value,
|
||||
agent_id=shared_agent.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
update = TaskUpdate(status=TaskStatus.COMPLETED)
|
||||
|
||||
result = await update_task_endpoint(
|
||||
task_id=task.id, task_update=update, db=db, current_agent=shared_agent
|
||||
)
|
||||
|
||||
assert result.status == TaskStatus.COMPLETED.value
|
||||
Reference in New Issue
Block a user