292 lines
10 KiB
Python
292 lines
10 KiB
Python
|
|
"""Umami initial setup scenario.
|
||
|
|
|
||
|
|
Automates the first-time setup for a fresh Umami installation.
|
||
|
|
This scenario:
|
||
|
|
1. Navigates to the Umami login page
|
||
|
|
2. Logs in with default credentials (admin / umami)
|
||
|
|
3. Changes the admin password
|
||
|
|
4. Optionally adds the first website to track
|
||
|
|
"""
|
||
|
|
|
||
|
|
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 UmamiInitialSetup(BaseScenario):
|
||
|
|
"""Automate Umami first-time setup.
|
||
|
|
|
||
|
|
This scenario handles the initial configuration after Umami is deployed.
|
||
|
|
Umami ships with default credentials (admin / umami). This scenario
|
||
|
|
logs in with those defaults, changes the password, and optionally
|
||
|
|
adds the first website to track.
|
||
|
|
|
||
|
|
Required inputs:
|
||
|
|
base_url: The Umami instance URL (e.g., https://analytics.example.com)
|
||
|
|
|
||
|
|
Optional inputs:
|
||
|
|
admin_password: New password for admin (auto-generated if not provided)
|
||
|
|
website_name: Name of the first website to add
|
||
|
|
website_url: URL of the first website to track
|
||
|
|
|
||
|
|
Result data:
|
||
|
|
setup_completed: Whether initial setup was completed
|
||
|
|
admin_password: The new admin password - STORE SECURELY
|
||
|
|
password_changed: Whether the default password was changed
|
||
|
|
website_added: Whether a website was added
|
||
|
|
already_configured: True if default password no longer works
|
||
|
|
"""
|
||
|
|
|
||
|
|
@property
|
||
|
|
def name(self) -> str:
|
||
|
|
return "umami_initial_setup"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def required_inputs(self) -> list[str]:
|
||
|
|
return ["base_url"]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def optional_inputs(self) -> list[str]:
|
||
|
|
return ["admin_password", "website_name", "website_url"]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def description(self) -> str:
|
||
|
|
return "Automate Umami first-time password change and website setup"
|
||
|
|
|
||
|
|
async def execute(
|
||
|
|
self,
|
||
|
|
page: Page,
|
||
|
|
inputs: dict[str, Any],
|
||
|
|
options: ScenarioOptions,
|
||
|
|
) -> ScenarioResult:
|
||
|
|
"""Execute the Umami initial setup.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
page: Playwright Page object
|
||
|
|
inputs: Scenario inputs (base_url)
|
||
|
|
options: Scenario options
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
ScenarioResult with setup status and credentials
|
||
|
|
"""
|
||
|
|
base_url = inputs["base_url"].rstrip("/")
|
||
|
|
new_password = inputs.get("admin_password") or generate_secure_password()
|
||
|
|
website_name = inputs.get("website_name")
|
||
|
|
website_url = inputs.get("website_url")
|
||
|
|
|
||
|
|
screenshots = []
|
||
|
|
result_data = {
|
||
|
|
"setup_completed": False,
|
||
|
|
"admin_password": new_password,
|
||
|
|
"password_changed": False,
|
||
|
|
"website_added": False,
|
||
|
|
"already_configured": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Navigate to Umami login page
|
||
|
|
login_url = f"{base_url}/login"
|
||
|
|
await page.goto(login_url, wait_until="networkidle")
|
||
|
|
|
||
|
|
# Look for login form
|
||
|
|
username_input = page.locator(
|
||
|
|
'input[name="username"], '
|
||
|
|
'input[id="username"], '
|
||
|
|
'input[placeholder*="username" i]'
|
||
|
|
)
|
||
|
|
await username_input.wait_for(state="visible", timeout=10000)
|
||
|
|
|
||
|
|
# Try default credentials: admin / umami
|
||
|
|
await username_input.fill("admin")
|
||
|
|
|
||
|
|
password_input = page.locator(
|
||
|
|
'input[name="password"], '
|
||
|
|
'input[type="password"]'
|
||
|
|
).first
|
||
|
|
await password_input.fill("umami")
|
||
|
|
|
||
|
|
# Click login
|
||
|
|
login_button = page.locator(
|
||
|
|
'button:has-text("Login"), '
|
||
|
|
'button:has-text("Sign in"), '
|
||
|
|
'button[type="submit"]'
|
||
|
|
).first
|
||
|
|
await login_button.click()
|
||
|
|
|
||
|
|
# Wait for navigation
|
||
|
|
await page.wait_for_timeout(3000)
|
||
|
|
|
||
|
|
# Check if login succeeded
|
||
|
|
current_url = page.url
|
||
|
|
if "/login" in current_url:
|
||
|
|
# Default password may have already been changed
|
||
|
|
error_el = page.locator(
|
||
|
|
'.error, [class*="error"], [class*="alert"]'
|
||
|
|
)
|
||
|
|
if await error_el.count() > 0:
|
||
|
|
result_data["already_configured"] = True
|
||
|
|
result_data["setup_completed"] = True
|
||
|
|
return ScenarioResult(
|
||
|
|
success=True,
|
||
|
|
data=result_data,
|
||
|
|
screenshots=screenshots,
|
||
|
|
error=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
# Logged in successfully with default password - change it
|
||
|
|
# Navigate to profile/settings to change password
|
||
|
|
settings_url = f"{base_url}/settings/profile"
|
||
|
|
await page.goto(settings_url, wait_until="networkidle")
|
||
|
|
|
||
|
|
# Look for password change form
|
||
|
|
current_password_input = page.locator(
|
||
|
|
'input[name="currentPassword"], '
|
||
|
|
'input[name="current_password"], '
|
||
|
|
'input[placeholder*="current" i]'
|
||
|
|
).first
|
||
|
|
|
||
|
|
if await current_password_input.count() > 0:
|
||
|
|
await current_password_input.wait_for(state="visible", timeout=10000)
|
||
|
|
await current_password_input.fill("umami")
|
||
|
|
|
||
|
|
new_password_input = page.locator(
|
||
|
|
'input[name="newPassword"], '
|
||
|
|
'input[name="new_password"], '
|
||
|
|
'input[placeholder*="new" i]'
|
||
|
|
).first
|
||
|
|
await new_password_input.fill(new_password)
|
||
|
|
|
||
|
|
confirm_password_input = page.locator(
|
||
|
|
'input[name="confirmPassword"], '
|
||
|
|
'input[name="confirm_password"], '
|
||
|
|
'input[placeholder*="confirm" i]'
|
||
|
|
).first
|
||
|
|
if await confirm_password_input.count() > 0:
|
||
|
|
await confirm_password_input.fill(new_password)
|
||
|
|
|
||
|
|
# Save password
|
||
|
|
save_button = page.locator(
|
||
|
|
'button:has-text("Save"), '
|
||
|
|
'button:has-text("Change"), '
|
||
|
|
'button:has-text("Update"), '
|
||
|
|
'button[type="submit"]'
|
||
|
|
).first
|
||
|
|
await save_button.click()
|
||
|
|
|
||
|
|
await page.wait_for_timeout(2000)
|
||
|
|
|
||
|
|
# Check for success
|
||
|
|
success_el = page.locator(
|
||
|
|
'[class*="success"], '
|
||
|
|
':has-text("saved"), '
|
||
|
|
':has-text("updated")'
|
||
|
|
)
|
||
|
|
if await success_el.count() > 0:
|
||
|
|
result_data["password_changed"] = True
|
||
|
|
else:
|
||
|
|
# Assume success if no error visible
|
||
|
|
error_el = page.locator('[class*="error"]')
|
||
|
|
if await error_el.count() == 0:
|
||
|
|
result_data["password_changed"] = True
|
||
|
|
|
||
|
|
# Optionally add first website
|
||
|
|
if website_name and website_url:
|
||
|
|
websites_url = f"{base_url}/settings/websites"
|
||
|
|
await page.goto(websites_url, wait_until="networkidle")
|
||
|
|
|
||
|
|
# Click Add Website button
|
||
|
|
add_button = page.locator(
|
||
|
|
'button:has-text("Add website"), '
|
||
|
|
'button:has-text("Add"), '
|
||
|
|
'a:has-text("Add website")'
|
||
|
|
).first
|
||
|
|
|
||
|
|
if await add_button.count() > 0:
|
||
|
|
await add_button.click()
|
||
|
|
await page.wait_for_timeout(1000)
|
||
|
|
|
||
|
|
# Fill website name
|
||
|
|
name_input = page.locator(
|
||
|
|
'input[name="name"], '
|
||
|
|
'input[placeholder*="name" i]'
|
||
|
|
).first
|
||
|
|
if await name_input.count() > 0:
|
||
|
|
await name_input.fill(website_name)
|
||
|
|
|
||
|
|
# Fill website URL/domain
|
||
|
|
url_input = page.locator(
|
||
|
|
'input[name="domain"], '
|
||
|
|
'input[name="url"], '
|
||
|
|
'input[placeholder*="domain" i], '
|
||
|
|
'input[placeholder*="url" i]'
|
||
|
|
).first
|
||
|
|
if await url_input.count() > 0:
|
||
|
|
await url_input.fill(website_url)
|
||
|
|
|
||
|
|
# Save
|
||
|
|
save_button = page.locator(
|
||
|
|
'button:has-text("Save"), '
|
||
|
|
'button:has-text("Create"), '
|
||
|
|
'button[type="submit"]'
|
||
|
|
).first
|
||
|
|
await save_button.click()
|
||
|
|
await page.wait_for_timeout(2000)
|
||
|
|
|
||
|
|
result_data["website_added"] = True
|
||
|
|
|
||
|
|
result_data["setup_completed"] = True
|
||
|
|
|
||
|
|
# Take final screenshot
|
||
|
|
if options.screenshot_on_success and options.artifacts_dir:
|
||
|
|
final_path = options.artifacts_dir / "umami_setup_complete.png"
|
||
|
|
await page.screenshot(path=str(final_path))
|
||
|
|
screenshots.append(str(final_path))
|
||
|
|
|
||
|
|
return ScenarioResult(
|
||
|
|
success=True,
|
||
|
|
data=result_data,
|
||
|
|
screenshots=screenshots,
|
||
|
|
error=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if options.screenshot_on_failure and options.artifacts_dir:
|
||
|
|
error_path = options.artifacts_dir / "umami_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"Umami setup failed: {str(e)}",
|
||
|
|
)
|