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