Files
matomo-bootstrap/src/matomo_bootstrap/matomo_api.py
Kevin Veen-Birkenbach 482ac3377d ci: add GitHub Actions workflow with ruff and E2E tests
- Add CI workflow running Ruff lint/format checks
- Run full E2E cycle (Docker Compose + Playwright + tests)
- Refactor code formatting to satisfy Ruff (line breaks, readability)
- Use sys.executable in tests for interpreter-agnostic execution

https://chatgpt.com/share/694a7f81-d96c-800f-88cb-7b25b4cdfe1a
2025-12-23 12:39:39 +01:00

126 lines
4.0 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import sys
import urllib.error
from .errors import MatomoNotReadyError, TokenCreationError
from .http import HttpClient
def _md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def _try_json(body: str) -> object:
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
def _dbg(msg: str, enabled: bool) -> None:
if enabled:
# Keep stdout clean (tests expect only token on stdout).
print(msg, file=sys.stderr)
class MatomoApi:
def __init__(self, *, client: HttpClient, debug: bool = False):
self.client = client
self.debug = debug
def assert_ready(self, timeout: int = 10) -> None:
"""
Minimal readiness check: Matomo UI should be reachable and look like Matomo.
"""
try:
status, body = self.client.get("/", {})
except Exception as exc: # pragma: no cover
raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
_dbg(f"[ready] GET / -> HTTP {status}", self.debug)
html = (body or "").lower()
if "matomo" not in html and "piwik" not in html:
raise MatomoNotReadyError("Matomo UI not detected at base URL")
def login_via_logme(self, admin_user: str, admin_password: str) -> None:
"""
Create an authenticated Matomo session (cookie jar) using Login controller.
Matomo accepts md5 hashed password in `password` parameter for action=logme.
"""
md5_password = _md5(admin_password)
try:
status, body = self.client.get(
"/index.php",
{
"module": "Login",
"action": "logme",
"login": admin_user,
"password": md5_password,
},
)
_dbg(f"[auth] logme HTTP {status} body[:120]={body[:120]!r}", self.debug)
except urllib.error.HTTPError as exc:
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
try:
err_body = exc.read().decode("utf-8", errors="replace")
except Exception:
err_body = ""
_dbg(
f"[auth] logme HTTPError {exc.code} body[:120]={err_body[:120]!r}",
self.debug,
)
def create_app_specific_token(
self,
*,
admin_user: str,
admin_password: str,
description: str,
) -> str:
"""
Create an app-specific token using an authenticated session (cookies),
not UsersManager.getTokenAuth (not available in Matomo 5.3.x images).
"""
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
if env_token:
_dbg(
"[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", self.debug
)
return env_token
self.login_via_logme(admin_user, admin_password)
status, body = self.client.post(
"/index.php",
{
"module": "API",
"method": "UsersManager.createAppSpecificTokenAuth",
"userLogin": admin_user,
"passwordConfirmation": admin_password,
"description": description,
"format": "json",
},
)
_dbg(
f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}",
self.debug,
)
if status != 200:
raise TokenCreationError(
f"HTTP {status} during token creation: {body[:400]}"
)
data = _try_json(body)
token = data.get("value") if isinstance(data, dict) else None
if not token:
raise TokenCreationError(f"Unexpected response from token creation: {data}")
return str(token)