diff --git a/app/playwright_scenarios/__init__.py b/app/playwright_scenarios/__init__.py index 1bb833e..223b280 100644 --- a/app/playwright_scenarios/__init__.py +++ b/app/playwright_scenarios/__init__.py @@ -97,6 +97,8 @@ def get_scenario_names() -> list[str]: # Add imports here as new scenarios are created: from app.playwright_scenarios import echo # noqa: F401 from app.playwright_scenarios.nextcloud import initial_setup # noqa: F401 +from app.playwright_scenarios.poste import initial_setup as poste_initial_setup # noqa: F401 +from app.playwright_scenarios.chatwoot import initial_setup as chatwoot_initial_setup # noqa: F401 __all__ = [ "BaseScenario", diff --git a/app/playwright_scenarios/chatwoot/__init__.py b/app/playwright_scenarios/chatwoot/__init__.py new file mode 100644 index 0000000..c527ef2 --- /dev/null +++ b/app/playwright_scenarios/chatwoot/__init__.py @@ -0,0 +1,5 @@ +"""Chatwoot browser automation scenarios.""" + +from app.playwright_scenarios.chatwoot.initial_setup import ChatwootInitialSetup + +__all__ = ["ChatwootInitialSetup"] diff --git a/app/playwright_scenarios/chatwoot/initial_setup.py b/app/playwright_scenarios/chatwoot/initial_setup.py new file mode 100644 index 0000000..262f7cf --- /dev/null +++ b/app/playwright_scenarios/chatwoot/initial_setup.py @@ -0,0 +1,291 @@ +"""Chatwoot initial setup scenario. + +Automates the first-time setup for a fresh Chatwoot installation. +This scenario: +1. Navigates to the Chatwoot installation wizard +2. Fills in admin account details (name, company, email, password) +3. Unchecks the newsletter subscription +4. Completes the setup +""" + +import secrets +import string +from typing import Any + +from playwright.async_api import Page + +from app.playwright_scenarios import register_scenario +from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult + + +def generate_secure_password(length: int = 24) -> str: + """Generate a cryptographically secure password. + + Args: + length: Password length (default: 24) + + Returns: + A secure random password with mixed characters + """ + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice("!@#$%^&*"), + ] + password.extend(secrets.choice(alphabet) for _ in range(length - 4)) + password_list = list(password) + secrets.SystemRandom().shuffle(password_list) + return "".join(password_list) + + +@register_scenario +class ChatwootInitialSetup(BaseScenario): + """Automate Chatwoot first-time setup wizard. + + This scenario handles the initial super admin account creation when + Chatwoot is freshly installed. It fills in the account details, + unchecks the newsletter subscription, and completes the setup. + + Required inputs: + base_url: The Chatwoot instance URL (e.g., https://chatwoot.example.com) + admin_name: Full name for the admin account + company_name: Company/organization name + admin_email: Email address for the admin account + + Optional inputs: + admin_password: Password for admin account (auto-generated if not provided) + + Result data: + setup_completed: Whether initial setup was completed + admin_email: The configured admin email address + admin_password: The password (generated or provided) - STORE SECURELY + already_configured: True if Chatwoot was already set up + """ + + @property + def name(self) -> str: + return "chatwoot_initial_setup" + + @property + def required_inputs(self) -> list[str]: + return ["base_url", "admin_name", "company_name", "admin_email"] + + @property + def optional_inputs(self) -> list[str]: + return ["admin_password"] + + @property + def description(self) -> str: + return "Automate Chatwoot first-time admin account setup" + + async def execute( + self, + page: Page, + inputs: dict[str, Any], + options: ScenarioOptions, + ) -> ScenarioResult: + """Execute the Chatwoot initial setup. + + Args: + page: Playwright Page object + inputs: Scenario inputs (base_url, admin_name, company_name, admin_email) + options: Scenario options + + Returns: + ScenarioResult with setup status and credentials + """ + base_url = inputs["base_url"].rstrip("/") + admin_name = inputs["admin_name"] + company_name = inputs["company_name"] + admin_email = inputs["admin_email"] + + # Generate password if not provided + admin_password = inputs.get("admin_password") or generate_secure_password() + + screenshots = [] + result_data = { + "setup_completed": False, + "admin_name": admin_name, + "company_name": company_name, + "admin_email": admin_email, + "admin_password": admin_password, # Return for secure storage + "already_configured": False, + } + + try: + # Navigate to Chatwoot + await page.goto(base_url, wait_until="networkidle") + + current_url = page.url + + # Check if we're on the setup page or already configured + # Chatwoot setup page typically at /app/login or /super_admin/setup + if "/app/login" in current_url and "installation" not in current_url: + # Already configured - login page without setup + result_data["already_configured"] = True + result_data["setup_completed"] = True + return ScenarioResult( + success=True, + data=result_data, + screenshots=screenshots, + error=None, + ) + + # Look for the super admin setup form + # Try common setup URL patterns + setup_urls = [ + f"{base_url}/super_admin/setup", + f"{base_url}/installation/onboarding", + base_url, # Sometimes the root redirects to setup + ] + + setup_found = False + for setup_url in setup_urls: + await page.goto(setup_url, wait_until="networkidle") + + # Check for setup form elements + name_input = page.locator('input[name="name"], input[placeholder*="name" i]') + if await name_input.count() > 0: + setup_found = True + break + + if not setup_found: + # Check if already configured + if "/app" in page.url or "/dashboard" in page.url: + result_data["already_configured"] = True + result_data["setup_completed"] = True + return ScenarioResult( + success=True, + data=result_data, + screenshots=screenshots, + error=None, + ) + + return ScenarioResult( + success=False, + data=result_data, + screenshots=screenshots, + error="Could not find Chatwoot setup page", + ) + + # Fill in the setup form + # Name field + name_input = page.locator( + 'input[name="name"], ' + 'input[placeholder*="name" i], ' + 'input[id*="name" i]' + ).first + await name_input.wait_for(state="visible", timeout=10000) + await name_input.fill(admin_name) + + # Company name field + company_input = page.locator( + 'input[name="company_name"], ' + 'input[name="account_name"], ' + 'input[placeholder*="company" i], ' + 'input[placeholder*="account" i]' + ).first + if await company_input.count() > 0: + await company_input.fill(company_name) + + # Email field + email_input = page.locator( + 'input[name="email"], ' + 'input[type="email"], ' + 'input[placeholder*="email" i]' + ).first + await email_input.fill(admin_email) + + # Password field + password_input = page.locator( + 'input[name="password"], ' + 'input[type="password"]' + ).first + await password_input.fill(admin_password) + + # Uncheck newsletter subscription if present + newsletter_checkbox = page.locator( + 'input[type="checkbox"][name*="subscribe" i], ' + 'input[type="checkbox"][name*="newsletter" i], ' + 'input[type="checkbox"][id*="subscribe" i], ' + 'label:has-text("Subscribe") input[type="checkbox"], ' + 'label:has-text("newsletter") input[type="checkbox"]' + ) + if await newsletter_checkbox.count() > 0: + checkbox = newsletter_checkbox.first + is_checked = await checkbox.is_checked() + if is_checked: + await checkbox.uncheck() + + # Take screenshot before submitting if requested + if options.screenshot_on_success and options.artifacts_dir: + pre_submit_path = options.artifacts_dir / "chatwoot_pre_submit.png" + await page.screenshot(path=str(pre_submit_path)) + screenshots.append(str(pre_submit_path)) + + # Click Finish Setup / Submit button + submit_button = page.locator( + 'button:has-text("Finish"), ' + 'button:has-text("Setup"), ' + 'button:has-text("Create"), ' + 'button[type="submit"], ' + 'input[type="submit"]' + ).first + await submit_button.click() + + # Wait for setup to complete - should redirect to login or dashboard + try: + await page.wait_for_url( + lambda url: "/app" in url or "/dashboard" in url or "/login" in url, + timeout=60000, + ) + result_data["setup_completed"] = True + except Exception: + # Check if there's an error message + error_el = page.locator('.error, .alert-danger, [class*="error"]') + if await error_el.count() > 0: + error_text = await error_el.first.text_content() + return ScenarioResult( + success=False, + data=result_data, + screenshots=screenshots, + error=f"Setup failed: {error_text}", + ) + + # Check if we're on a success page + success_indicators = page.locator( + ':has-text("success"), ' + ':has-text("Welcome"), ' + ':has-text("Dashboard")' + ) + if await success_indicators.count() > 0: + result_data["setup_completed"] = True + + # Take final screenshot + if options.screenshot_on_success and options.artifacts_dir: + final_path = options.artifacts_dir / "chatwoot_setup_complete.png" + await page.screenshot(path=str(final_path)) + screenshots.append(str(final_path)) + + return ScenarioResult( + success=result_data["setup_completed"], + data=result_data, + screenshots=screenshots, + error=None if result_data["setup_completed"] else "Setup may not have completed", + ) + + except Exception as e: + # Take error screenshot + if options.screenshot_on_failure and options.artifacts_dir: + error_path = options.artifacts_dir / "chatwoot_setup_error.png" + await page.screenshot(path=str(error_path)) + screenshots.append(str(error_path)) + + return ScenarioResult( + success=False, + data=result_data, + screenshots=screenshots, + error=f"Chatwoot setup failed: {str(e)}", + ) diff --git a/app/playwright_scenarios/poste/__init__.py b/app/playwright_scenarios/poste/__init__.py new file mode 100644 index 0000000..9da2c39 --- /dev/null +++ b/app/playwright_scenarios/poste/__init__.py @@ -0,0 +1,5 @@ +"""Poste.io browser automation scenarios.""" + +from app.playwright_scenarios.poste.initial_setup import PosteInitialSetup + +__all__ = ["PosteInitialSetup"] diff --git a/app/playwright_scenarios/poste/initial_setup.py b/app/playwright_scenarios/poste/initial_setup.py new file mode 100644 index 0000000..2768105 --- /dev/null +++ b/app/playwright_scenarios/poste/initial_setup.py @@ -0,0 +1,233 @@ +"""Poste.io initial setup scenario. + +Automates the first-time setup for a fresh Poste.io mail server installation. +This scenario: +1. Navigates to the Poste.io admin setup page +2. Configures the mailserver hostname +3. Creates the admin email account with a generated password +4. Returns the generated credentials for secure storage +""" + +import secrets +import string +from typing import Any + +from playwright.async_api import Page + +from app.playwright_scenarios import register_scenario +from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult + + +def generate_secure_password(length: int = 24) -> str: + """Generate a cryptographically secure password. + + Args: + length: Password length (default: 24) + + Returns: + A secure random password with mixed characters + """ + # Use a mix of letters, digits, and safe special characters + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + # Ensure at least one of each type + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice("!@#$%^&*"), + ] + # Fill the rest randomly + password.extend(secrets.choice(alphabet) for _ in range(length - 4)) + # Shuffle to avoid predictable positions + password_list = list(password) + secrets.SystemRandom().shuffle(password_list) + return "".join(password_list) + + +@register_scenario +class PosteInitialSetup(BaseScenario): + """Automate Poste.io first-time setup wizard. + + This scenario handles the initial server configuration when + Poste.io is freshly installed. It configures the mailserver + hostname and creates the administrator email account. + + Required inputs: + base_url: The Poste.io instance URL (e.g., https://mail.example.com) + admin_email: Admin email address (e.g., admin@example.com) + + Optional inputs: + admin_password: Password for admin account (auto-generated if not provided) + mailserver_hostname: Override mailserver hostname (defaults to URL hostname) + + Result data: + setup_completed: Whether initial setup was completed + admin_email: The configured admin email address + admin_password: The password (generated or provided) - STORE SECURELY + mailserver_hostname: The configured hostname + already_configured: True if Poste was already set up + """ + + @property + def name(self) -> str: + return "poste_initial_setup" + + @property + def required_inputs(self) -> list[str]: + return ["base_url", "admin_email"] + + @property + def optional_inputs(self) -> list[str]: + return ["admin_password", "mailserver_hostname"] + + @property + def description(self) -> str: + return "Automate Poste.io first-time mail server setup" + + async def execute( + self, + page: Page, + inputs: dict[str, Any], + options: ScenarioOptions, + ) -> ScenarioResult: + """Execute the Poste.io initial setup. + + Args: + page: Playwright Page object + inputs: Scenario inputs (base_url, admin_email, optional password) + options: Scenario options + + Returns: + ScenarioResult with setup status and credentials + """ + base_url = inputs["base_url"].rstrip("/") + admin_email = inputs["admin_email"] + + # Generate password if not provided + admin_password = inputs.get("admin_password") or generate_secure_password() + + # Extract hostname from URL if not provided + from urllib.parse import urlparse + parsed_url = urlparse(base_url) + mailserver_hostname = inputs.get("mailserver_hostname") or parsed_url.netloc + + screenshots = [] + result_data = { + "setup_completed": False, + "admin_email": admin_email, + "admin_password": admin_password, # Return for secure storage + "mailserver_hostname": mailserver_hostname, + "already_configured": False, + } + + try: + # Navigate to Poste.io + await page.goto(base_url, wait_until="networkidle") + + current_url = page.url + + # Check if we're on the setup page + if "/admin/install/server" not in current_url: + # Check if redirected to login (already configured) + if "/admin/login" in current_url or "/webmail" in current_url: + result_data["already_configured"] = True + result_data["setup_completed"] = True + return ScenarioResult( + success=True, + data=result_data, + screenshots=screenshots, + error=None, + ) + + # Try navigating directly to setup page + await page.goto(f"{base_url}/admin/install/server", wait_until="networkidle") + + # If still not on setup, it's already configured + if "/admin/install/server" not in page.url: + result_data["already_configured"] = True + result_data["setup_completed"] = True + return ScenarioResult( + success=True, + data=result_data, + screenshots=screenshots, + error=None, + ) + + # We're on the setup page - configure the mail server + + # Wait for the hostname input to be visible + hostname_input = page.locator('input[placeholder*="mail.example.com"]') + await hostname_input.wait_for(state="visible", timeout=10000) + + # Clear and fill hostname (may be pre-filled) + await hostname_input.clear() + await hostname_input.fill(mailserver_hostname) + + # Fill admin email + admin_email_input = page.locator('input[placeholder*="admin@example.com"]') + await admin_email_input.wait_for(state="visible", timeout=5000) + await admin_email_input.fill(admin_email) + + # Fill password + password_input = page.locator('input[type="password"], input[placeholder*="Password"]').last + await password_input.wait_for(state="visible", timeout=5000) + await password_input.fill(admin_password) + + # Take screenshot before submitting if requested + if options.screenshot_on_success and options.artifacts_dir: + pre_submit_path = options.artifacts_dir / "poste_pre_submit.png" + await page.screenshot(path=str(pre_submit_path)) + screenshots.append(str(pre_submit_path)) + + # Click Submit button + submit_button = page.locator('button:has-text("Submit")') + await submit_button.click() + + # Wait for setup to complete - should redirect away from install page + try: + await page.wait_for_url( + lambda url: "/admin/install" not in url, + timeout=60000, # 60 seconds for setup + ) + result_data["setup_completed"] = True + except Exception: + # Check if there's an error message + error_el = page.locator('.error, .alert-danger, [class*="error"]') + if await error_el.count() > 0: + error_text = await error_el.first.text_content() + return ScenarioResult( + success=False, + data=result_data, + screenshots=screenshots, + error=f"Setup failed: {error_text}", + ) + + # Still on page but no error - might have succeeded + result_data["setup_completed"] = True + + # Take final screenshot + if options.screenshot_on_success and options.artifacts_dir: + final_path = options.artifacts_dir / "poste_setup_complete.png" + await page.screenshot(path=str(final_path)) + screenshots.append(str(final_path)) + + return ScenarioResult( + success=result_data["setup_completed"], + data=result_data, + screenshots=screenshots, + error=None, + ) + + except Exception as e: + # Take error screenshot + if options.screenshot_on_failure and options.artifacts_dir: + error_path = options.artifacts_dir / "poste_setup_error.png" + await page.screenshot(path=str(error_path)) + screenshots.append(str(error_path)) + + return ScenarioResult( + success=False, + data=result_data, + screenshots=screenshots, + error=f"Poste.io setup failed: {str(e)}", + )