feat: add Nextcloud initial setup endpoint via Playwright
Add POST /api/v1/tenants/{tenant_id}/nextcloud/setup endpoint that:
- Creates a PLAYWRIGHT task for Nextcloud initial admin setup
- Validates tenant has domain configured
- Auto-resolves online agent for tenant
- Performs health check against Nextcloud before creating task
- Returns 409 if Nextcloud is unavailable
Changes:
- Add httpx for health checks
- Add build_nextcloud_initial_setup_step() and create_nextcloud_initial_setup_task()
- Add NextcloudInitialSetupRequest schema with username/password validation
- Add check_nextcloud_availability() helper for health checks
- Add comprehensive unit tests (42 tests total)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4dac6d7e4e
commit
4bafefdfaf
|
|
@ -1,13 +1,15 @@
|
|||
"""Nextcloud deployment playbook.
|
||||
|
||||
Defines the steps required to set Nextcloud domain on a tenant server
|
||||
that already has stacks and env templates under /opt/letsbe.
|
||||
Defines the steps required to:
|
||||
1. Set Nextcloud domain on a tenant server (v2: via NEXTCLOUD_SET_DOMAIN task)
|
||||
2. Perform initial setup via Playwright automation (create admin account)
|
||||
|
||||
v2: Configures Nextcloud via NEXTCLOUD_SET_DOMAIN task, then reloads the stack.
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -28,6 +30,95 @@ class CompositeStep(BaseModel):
|
|||
NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_nextcloud_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_username: str,
|
||||
admin_password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Nextcloud initial setup.
|
||||
|
||||
This creates the admin account on a fresh Nextcloud installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com")
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc # e.g., "cloud.example.com"
|
||||
|
||||
return {
|
||||
"scenario": "nextcloud.initial_setup",
|
||||
"inputs": {
|
||||
"base_url": base_url,
|
||||
"admin_username": admin_username,
|
||||
"admin_password": admin_password,
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_nextcloud_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_username: str,
|
||||
admin_password: str,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Nextcloud initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Nextcloud
|
||||
admin_username: Username for the admin account
|
||||
admin_password: Password for the admin account
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_username=admin_username,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Set Domain via NEXTCLOUD_SET_DOMAIN
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]:
|
||||
"""
|
||||
Build the sequence of steps required to set Nextcloud domain (v2).
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Playbook endpoints for triggering infrastructure automation."""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
|
|
@ -11,11 +13,17 @@ from app.models.agent import Agent
|
|||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.chatwoot import create_chatwoot_setup_task
|
||||
from app.playbooks.nextcloud import create_nextcloud_set_domain_task
|
||||
from app.playbooks.nextcloud import (
|
||||
create_nextcloud_initial_setup_task,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"])
|
||||
|
||||
# Health check timeout for Nextcloud availability
|
||||
NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class ChatwootSetupRequest(BaseModel):
|
||||
"""Request body for Chatwoot setup playbook."""
|
||||
|
|
@ -42,6 +50,32 @@ class NextcloudSetDomainRequest(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class NextcloudInitialSetupRequest(BaseModel):
|
||||
"""Request body for Nextcloud initial setup via Playwright."""
|
||||
|
||||
admin_username: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=64,
|
||||
description="Username for the Nextcloud admin account",
|
||||
)
|
||||
admin_password: str = Field(
|
||||
...,
|
||||
min_length=8,
|
||||
description="Password for the Nextcloud admin account (minimum 8 characters)",
|
||||
)
|
||||
|
||||
@field_validator("admin_username")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
"""Validate that username is alphanumeric (with underscores allowed)."""
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", v):
|
||||
raise ValueError(
|
||||
"Username must start with a letter and contain only letters, numbers, and underscores"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
|
|
@ -70,6 +104,46 @@ async def get_online_agent_for_tenant(
|
|||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_nextcloud_availability(base_url: str) -> bool:
|
||||
"""
|
||||
Check if Nextcloud is available at the given URL.
|
||||
|
||||
Performs a health check against {base_url}/status.php which returns
|
||||
JSON with installation status on a healthy Nextcloud instance.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com")
|
||||
|
||||
Returns:
|
||||
True if Nextcloud responds with a valid status, False otherwise
|
||||
"""
|
||||
status_url = f"{base_url.rstrip('/')}/status.php"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(status_url)
|
||||
|
||||
# Nextcloud status.php returns 200 with JSON when healthy
|
||||
if response.status_code == 200:
|
||||
# Try to parse as JSON - valid Nextcloud returns {"installed":true,...}
|
||||
try:
|
||||
data = response.json()
|
||||
# If we get valid JSON with "installed" key, Nextcloud is available
|
||||
return "installed" in data
|
||||
except (ValueError, KeyError):
|
||||
# Not valid JSON, but 200 OK might still indicate availability
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except (httpx.RequestError, httpx.TimeoutException):
|
||||
# Connection failed, timeout, or other network error
|
||||
return False
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
|
|
@ -181,3 +255,91 @@ async def nextcloud_set_domain(
|
|||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post(
|
||||
"/nextcloud/setup",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def nextcloud_initial_setup(
|
||||
tenant_id: uuid.UUID,
|
||||
request: NextcloudInitialSetupRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Perform initial Nextcloud setup via Playwright browser automation.
|
||||
|
||||
Creates a PLAYWRIGHT task that navigates to the Nextcloud installation page
|
||||
and creates the admin account. This should only be called once on a fresh
|
||||
Nextcloud installation.
|
||||
|
||||
**Prerequisites:**
|
||||
- Tenant must have a domain configured
|
||||
- Nextcloud must be deployed and accessible at `https://cloud.{domain}`
|
||||
- An online agent must be registered for the tenant
|
||||
|
||||
**Health Check:**
|
||||
Before creating the task, the endpoint checks if Nextcloud is available
|
||||
at `https://cloud.{domain}/status.php`. If not reachable, returns 409.
|
||||
|
||||
## Request Body
|
||||
- **admin_username**: Username for the admin account (3-64 chars, alphanumeric)
|
||||
- **admin_password**: Password for the admin account (minimum 8 characters)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="PLAYWRIGHT" and status="pending".
|
||||
|
||||
## Errors
|
||||
- **404**: Tenant not found
|
||||
- **400**: Tenant has no domain configured
|
||||
- **404**: No online agent found for tenant
|
||||
- **409**: Nextcloud not available (health check failed)
|
||||
- **422**: Invalid username or password format
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate tenant has domain configured
|
||||
if not tenant.domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tenant {tenant_id} has no domain configured",
|
||||
)
|
||||
|
||||
# Auto-resolve agent (first online agent for tenant)
|
||||
agent = await get_online_agent_for_tenant(db, tenant_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No online agent found for tenant {tenant_id}",
|
||||
)
|
||||
|
||||
# Construct Nextcloud base URL from tenant domain
|
||||
base_url = f"https://cloud.{tenant.domain}"
|
||||
|
||||
# Health check: verify Nextcloud is accessible
|
||||
is_available = await check_nextcloud_availability(base_url)
|
||||
if not is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Nextcloud not available at {base_url}. "
|
||||
"Ensure Nextcloud is deployed and accessible before running setup.",
|
||||
)
|
||||
|
||||
# Create the PLAYWRIGHT task
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
base_url=base_url,
|
||||
admin_username=request.admin_username,
|
||||
admin_password=request.admin_password,
|
||||
)
|
||||
|
||||
return task
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ pydantic-settings>=2.1.0
|
|||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
"""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
|
||||
|
||||
|
|
@ -12,7 +15,9 @@ 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,
|
||||
)
|
||||
|
||||
|
|
@ -202,3 +207,285 @@ class TestNextcloudSetDomainEndpoint:
|
|||
|
||||
# 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"
|
||||
assert inputs["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
|
||||
|
|
|
|||
|
|
@ -12,11 +12,162 @@ from app.models.tenant import Tenant
|
|||
from app.playbooks.nextcloud import (
|
||||
NEXTCLOUD_STACK_DIR,
|
||||
CompositeStep,
|
||||
build_nextcloud_initial_setup_step,
|
||||
build_nextcloud_set_domain_steps,
|
||||
create_nextcloud_initial_setup_task,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Playwright Initial Setup
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudInitialSetupStep:
|
||||
"""Tests for the build_nextcloud_initial_setup_step function."""
|
||||
|
||||
def test_returns_playwright_payload(self):
|
||||
"""Verify that build_nextcloud_initial_setup_step returns correct structure."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert payload["scenario"] == "nextcloud.initial_setup"
|
||||
assert "inputs" in payload
|
||||
assert "timeout" in payload
|
||||
|
||||
def test_inputs_contain_required_fields(self):
|
||||
"""Verify that inputs contain all required fields."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
inputs = payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "admin"
|
||||
assert inputs["admin_password"] == "securepassword123"
|
||||
assert "allowed_domains" in inputs
|
||||
|
||||
def test_allowed_domains_extracted_from_url(self):
|
||||
"""Verify that allowed_domains is extracted from base_url."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["inputs"]["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
def test_allowed_domains_with_port(self):
|
||||
"""Verify that allowed_domains handles URLs with ports."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com:8443",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["inputs"]["allowed_domains"] == ["cloud.example.com:8443"]
|
||||
|
||||
def test_timeout_is_set(self):
|
||||
"""Verify that timeout is set in the payload."""
|
||||
payload = build_nextcloud_initial_setup_step(
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert payload["timeout"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateNextcloudInitialSetupTask:
|
||||
"""Tests for the create_nextcloud_initial_setup_task function."""
|
||||
|
||||
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Verify that create_nextcloud_initial_setup_task persists a PLAYWRIGHT task."""
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=agent_id,
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="securepassword123",
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == agent_id
|
||||
assert task.type == "PLAYWRIGHT"
|
||||
assert task.status == TaskStatus.PENDING.value
|
||||
|
||||
async def test_task_payload_contains_scenario(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the scenario field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
assert task.payload["scenario"] == "nextcloud.initial_setup"
|
||||
|
||||
async def test_task_payload_contains_inputs(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify that the task payload contains the inputs field."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="testadmin",
|
||||
admin_password="testpassword123",
|
||||
)
|
||||
|
||||
inputs = task.payload["inputs"]
|
||||
assert inputs["base_url"] == "https://cloud.example.com"
|
||||
assert inputs["admin_username"] == "testadmin"
|
||||
assert inputs["admin_password"] == "testpassword123"
|
||||
assert inputs["allowed_domains"] == ["cloud.example.com"]
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
task = await create_nextcloud_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=test_tenant.id,
|
||||
agent_id=uuid.uuid4(),
|
||||
base_url="https://cloud.example.com",
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "PLAYWRIGHT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Set Domain Playbook
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildNextcloudSetDomainSteps:
|
||||
"""Tests for the build_nextcloud_set_domain_steps function."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue