"""Tests for #9 — CLI artifact + install script endpoints.""" import os from pathlib import Path import tempfile def test_cli_install_script_bakes_server_url(monkeypatch): from fastapi.testclient import TestClient from app.main import app client = TestClient(app, base_url="https://agnes.example.com") resp = client.get("/cli/install.sh", headers={"host": "agnes.example.com"}) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/") body = resp.text assert "https://agnes.example.com" in body or "agnes.example.com" in body assert "pip install" in body or "uv tool install" in body def test_cli_download_returns_wheel_or_404(monkeypatch): from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp = client.get("/cli/download") # Either serve the wheel or return a clear 404 telling where to find it. assert resp.status_code in (200, 404) if resp.status_code == 200: assert resp.headers["content-disposition"].startswith("attachment") def test_cli_download_serves_wheel_when_present(monkeypatch, tmp_path): """Put a fake wheel and confirm the endpoint serves it.""" wheel = tmp_path / "agnes_fake-1.0-py3-none-any.whl" wheel.write_bytes(b"PK\x03\x04fake-wheel-bytes") monkeypatch.setenv("AGNES_CLI_DIST_DIR", str(tmp_path)) from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp = client.get("/cli/download") assert resp.status_code == 200 assert resp.content.startswith(b"PK") def test_cli_wheel_versioned_serves_current_wheel(monkeypatch, tmp_path): """`/cli/wheel/{filename}` serves the current wheel and matches `/cli/download` bytes.""" wheel = tmp_path / "agnes_fake-1.0-py3-none-any.whl" wheel.write_bytes(b"PK\x03\x04fake-wheel-bytes-agnes") monkeypatch.setenv("AGNES_CLI_DIST_DIR", str(tmp_path)) from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp = client.get("/cli/wheel/agnes_fake-1.0-py3-none-any.whl") assert resp.status_code == 200 assert resp.headers["content-type"] == "application/octet-stream" assert resp.content == wheel.read_bytes() resp_download = client.get("/cli/download") assert resp_download.status_code == 200 assert resp.content == resp_download.content def test_cli_wheel_versioned_rejects_other_filenames(monkeypatch, tmp_path): """Arbitrary `wheel_name` values must 404 — no filesystem lookup from user input.""" wheel = tmp_path / "agnes_fake-1.0-py3-none-any.whl" wheel.write_bytes(b"PK\x03\x04") monkeypatch.setenv("AGNES_CLI_DIST_DIR", str(tmp_path)) from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp_wrong = client.get("/cli/wheel/other-2.0-py3-none-any.whl") assert resp_wrong.status_code == 404 def test_cli_agnes_whl_alias_is_gone(monkeypatch, tmp_path): """The bareword alias was removed — it never worked with `uv tool install` (uv validates the filename before fetching) and only confused users. The only CLI wheel URL is now `/cli/wheel/{filename}`.""" wheel = tmp_path / "agnes_fake-1.0-py3-none-any.whl" wheel.write_bytes(b"PK\x03\x04") monkeypatch.setenv("AGNES_CLI_DIST_DIR", str(tmp_path)) from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp = client.get("/cli/agnes.whl", follow_redirects=False) assert resp.status_code == 404 def test_install_page_renders_with_server_url(tmp_path, monkeypatch): """Parallel-test isolation: GET /install routes through the shared system.duckdb. Without per-worker DATA_DIR isolation, two xdist workers exercising this test (or any DB-touching test) at the same time hit `Could not set lock on file …system.duckdb` because conftest's default DATA_DIR is shared across the worker pool.""" monkeypatch.setenv("DATA_DIR", str(tmp_path)) (tmp_path / "state").mkdir(parents=True, exist_ok=True) from fastapi.testclient import TestClient from app.main import app client = TestClient(app) resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"}) assert resp.status_code == 200 assert "agnes.test" in resp.text assert "da auth whoami" in resp.text def test_safe_url_re_accepts_reverse_proxy_path_prefix(): """Reverse-proxy deployments have request.base_url with a path segment (e.g. https://host/agnes/). The regex must accept that; the install.sh endpoint previously rejected it with 400.""" from app.api.cli_artifacts import _SAFE_URL_RE # Path prefix (Agnes behind a reverse proxy with location /agnes/) assert _SAFE_URL_RE.match("https://agnes.example.com/agnes") assert _SAFE_URL_RE.match("https://agnes.example.com/agnes/") # Underscores in Docker Compose hostnames assert _SAFE_URL_RE.match("http://agnes_web:8000") # IPv6 literal assert _SAFE_URL_RE.match("http://[::1]:8000") # Still rejects obvious bad shapes assert not _SAFE_URL_RE.match("https://agnes.example.com/agnes;rm -rf /") assert not _SAFE_URL_RE.match("ftp://agnes.example.com/") assert not _SAFE_URL_RE.match("https://agnes.example.com/?x=$(id)") def test_safe_url_re_rejects_trailing_newline_bypass(): """Python's `$` matches immediately before a trailing `\\n`, so a naïve allowlist with `^...$` would accept "good.example.com\\n$(rm -rf /)" and allow shell-injection in the generated install.sh. Anchoring with `\\Z` closes that bypass. Covers both allowlists.""" from app.api.cli_artifacts import _SAFE_URL_RE, _SAFE_VERSION_RE # Trailing newline after an otherwise-valid URL must be rejected. assert not _SAFE_URL_RE.match("https://good.example.com\n") assert not _SAFE_URL_RE.match("https://good.example.com\n$(rm -rf /)") assert not _SAFE_URL_RE.match("http://host:8000\nevil") # Sanity: the clean form still matches. assert _SAFE_URL_RE.match("https://good.example.com") # Version allowlist — same class of bypass. assert not _SAFE_VERSION_RE.match("1.2.3\n") assert not _SAFE_VERSION_RE.match("1.2.3\nrm") assert _SAFE_VERSION_RE.match("1.2.3") def test_cli_install_sh_accepts_base_url_with_path_prefix(monkeypatch): """Reverse-proxy deployments (Caddy/Nginx routing /agnes/* to Agnes) surface a request.base_url like 'https://host/agnes/'. The handler previously 400'd on that. We call the handler directly with a stub request so we don't need a mounted ASGI proxy in tests.""" import asyncio from types import SimpleNamespace from starlette.datastructures import URL from app.api.cli_artifacts import cli_install_script # Minimal Request stub — cli_install_script only needs .base_url. stub = SimpleNamespace(base_url=URL("https://agnes.example.com/agnes/")) result = asyncio.run(cli_install_script(stub)) # returns the script body assert isinstance(result, str) assert "https://agnes.example.com/agnes" in result