diff --git a/tests/e2e/test_compose_stack_bootstrap.py b/tests/e2e/test_compose_stack_bootstrap.py deleted file mode 100644 index 4acb93f..0000000 --- a/tests/e2e/test_compose_stack_bootstrap.py +++ /dev/null @@ -1,106 +0,0 @@ -import subprocess -import time -import unittest - - -COMPOSE_FILE = "tests/e2e/docker-compose.yml" - - -def run(cmd: list[str], check: bool = False) -> subprocess.CompletedProcess: - return subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=check, - ) - - -def wait_http_any(url: str, timeout_s: int = 180) -> None: - # "any HTTP code" reachability via curl: - # curl exits 0 on 2xx, non-zero on 4xx/5xx; so we just wait for any response code != 000 - deadline = time.time() + timeout_s - while time.time() < deadline: - p = run( - [ - "curl", - "-sS", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "--max-time", - "2", - url, - ] - ) - code = (p.stdout or "").strip() - if code and code != "000": - return - time.sleep(1) - raise RuntimeError(f"Matomo did not become reachable: {url}") - - -class TestComposeBootstrapExit0(unittest.TestCase): - def test_bootstrap_exits_zero(self) -> None: - compose = ["docker", "compose", "-f", COMPOSE_FILE] - - try: - # clean slate - run(compose + ["down", "-v"]) - - # start db + matomo (+ nix if present) - up = run(compose + ["up", "-d"]) - self.assertEqual( - up.returncode, - 0, - msg=f"compose up failed\nSTDOUT:\n{up.stdout}\nSTDERR:\n{up.stderr}", - ) - - # wait for host-published matomo port - wait_http_any("http://127.0.0.1:8080/", timeout_s=180) - - # IMPORTANT: - # Run bootstrap via Nix container already defined in tests/e2e/docker-compose.yml - # (this avoids host python/venv completely). - script = r"""set -euo pipefail - -export NIX_CONFIG='experimental-features = nix-command flakes' -export TERM='xterm' -mkdir -p "$HOME" "$HOME/.cache" "$HOME/.config" "$HOME/.local/share" - -# Mark repo safe (root in container) -git config --global --add safe.directory /work - -# Install browsers (cached in container volumes) -nix run --no-write-lock-file -L .#matomo-bootstrap-playwright-install >/dev/null - -# Run bootstrap (must exit 0; stdout should be token-only) -nix run --no-write-lock-file -L .#matomo-bootstrap -- \ - --base-url 'http://127.0.0.1:8080' \ - --admin-user 'administrator' \ - --admin-password 'AdminSecret123!' \ - --admin-email 'administrator@example.org' \ - --token-description 'e2e-compose-exit0' -""" - - boot = run(compose + ["exec", "-T", "nix", "sh", "-lc", script]) - self.assertEqual( - boot.returncode, - 0, - msg=f"bootstrap failed\nSTDOUT:\n{boot.stdout}\nSTDERR:\n{boot.stderr}", - ) - - token = (boot.stdout or "").strip() - self.assertRegex( - token, - r"^[a-f0-9]{32,64}$", - msg=f"expected token on stdout; got: {token!r}\nSTDERR:\n{boot.stderr}", - ) - - finally: - run(compose + ["down", "-v"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/e2e/test_docker_compose_stack.py b/tests/e2e/test_docker_compose_stack.py new file mode 100644 index 0000000..6c0afd2 --- /dev/null +++ b/tests/e2e/test_docker_compose_stack.py @@ -0,0 +1,107 @@ +import json +import os +import re +import subprocess +import time +import unittest +import urllib.request + + +COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.yml") +MATOMO_HOST_URL = os.environ.get("MATOMO_STACK_URL", "http://127.0.0.1:8080") + +# How long we wait for Matomo HTTP to respond at all (seconds) +WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180")) + + +def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def _compose_cmd(*args: str) -> list[str]: + return ["docker", "compose", "-f", COMPOSE_FILE, *args] + + +def _wait_for_http_any_status(url: str, timeout_s: int) -> None: + """ + Consider the service "up" once the HTTP server answers anything. + urllib raises HTTPError on 4xx/5xx, but that's still "reachable". + """ + deadline = time.time() + timeout_s + last_exc: Exception | None = None + + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as resp: + _ = resp.read(64) + return + except Exception as exc: # includes HTTPError + last_exc = exc + time.sleep(1) + + raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})") + + +class TestRootDockerComposeStack(unittest.TestCase): + """ + E2E test for repository root docker-compose.yml: + + 1) docker compose down -v + 2) docker compose build bootstrap + 3) docker compose up -d db matomo + 4) wait for Matomo HTTP on host port (default 8080) + 5) docker compose run --rm bootstrap -> token on stdout + 6) validate token via Matomo API call + 7) docker compose down -v (cleanup) + """ + + def setUp(self) -> None: + # Always start from a clean slate (also clears volumes) + _run(_compose_cmd("down", "-v"), check=False) + + def tearDown(self) -> None: + # Cleanup even if assertions fail + _run(_compose_cmd("down", "-v"), check=False) + + def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None: + # Build bootstrap image from Dockerfile (as defined in docker-compose.yml) + build = _run(_compose_cmd("build", "bootstrap"), check=True) + self.assertEqual(build.returncode, 0, build.stderr) + + # Start db + matomo (bootstrap is one-shot and started via "run") + up = _run(_compose_cmd("up", "-d", "db", "matomo"), check=True) + self.assertEqual(up.returncode, 0, up.stderr) + + # Wait until Matomo answers on the published port + _wait_for_http_any_status(MATOMO_HOST_URL + "/", WAIT_TIMEOUT_SECONDS) + + # Run bootstrap: it should print ONLY the token to stdout + boot = _run(_compose_cmd("run", "--rm", "bootstrap"), check=True) + + token = (boot.stdout or "").strip() + self.assertRegex( + token, + r"^[a-f0-9]{32,64}$", + f"Expected token_auth on stdout, got stdout={boot.stdout!r} stderr={boot.stderr!r}", + ) + + # Verify token works against Matomo API + api_url = ( + f"{MATOMO_HOST_URL}/index.php" + f"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess" + f"&format=json&token_auth={token}" + ) + with urllib.request.urlopen(api_url, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8", errors="replace")) + + self.assertIsInstance(data, list) + + +if __name__ == "__main__": + unittest.main()