diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 0000000..8ebeb1c --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -0,0 +1,90 @@ +name: Docker CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + smoke-and-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # ── Generate a bcrypt hash for the CI test password ──────────────────── + - name: Generate CI password hash + id: pwhash + run: | + pip install --quiet passlib bcrypt + HASH=$(python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('ci-password-123'))") + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + + # ── Write a minimal .env consumed by docker-compose.yml ─────────────── + - name: Write .env + run: | + cat > .env <<'EOF' + SECRET_KEY=ci-only-secret-key-not-for-production + SQLITE_PATH=/app/data/homelab.db + CORS_ORIGINS=["http://localhost:3000"] + SCANNER_RANGES=["127.0.0.1/32"] + STATUS_CHECKER_INTERVAL=300 + MCP_API_KEY=ci-mcp-key + MCP_SERVICE_KEY=ci-svc-key + AUTH_USERNAME=admin + EOF + echo "AUTH_PASSWORD_HASH=${{ steps.pwhash.outputs.hash }}" >> .env + + # ── Build + start backend and frontend (skip mcp) ───────────────────── + - name: Build images + run: docker compose build backend frontend + + - name: Start stack + run: docker compose up -d backend frontend + + # ── Wait for backend to be healthy (max 60 s) ───────────────────────── + - name: Wait for backend health + run: | + echo "Waiting for backend..." + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; then + echo "Backend is up after ${i}s" + exit 0 + fi + sleep 2 + done + echo "Backend did not become healthy in time" >&2 + docker compose logs backend + exit 1 + + # ── Smoke: frontend serves HTML ──────────────────────────────────────── + - name: Smoke — frontend returns 200 + run: | + STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:3000/) + [ "$STATUS" = "200" ] || { echo "Frontend returned $STATUS"; exit 1; } + + # ── Tier 3: integration tests against the live stack ────────────────── + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install backend test deps + run: pip install --quiet -r backend/requirements.txt + + - name: Run integration tests + env: + INTEGRATION_BASE_URL: http://localhost:8000 + INTEGRATION_USERNAME: admin + INTEGRATION_PASSWORD: ci-password-123 + run: | + cd backend + pytest tests/test_integration.py -v + + # ── Teardown ─────────────────────────────────────────────────────────── + - name: Dump logs on failure + if: failure() + run: docker compose logs + + - name: Stop stack + if: always() + run: docker compose down -v diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f173891..75b79d1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -7,6 +7,23 @@ on: branches: [main] jobs: + lint-scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: ShellCheck — lxc-install.sh + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: './scripts' + - name: Hadolint — Dockerfile.backend + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile.backend + - name: Hadolint — Dockerfile.frontend + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile.frontend + frontend: runs-on: ubuntu-latest defaults: diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py new file mode 100644 index 0000000..67a802f --- /dev/null +++ b/backend/tests/test_integration.py @@ -0,0 +1,198 @@ +""" +Integration tests — run against a live Docker stack. + +Skipped unless INTEGRATION_BASE_URL is set (done automatically in docker-ci.yml). + +Usage (local): + INTEGRATION_BASE_URL=http://localhost:8000 \ + INTEGRATION_USERNAME=admin \ + INTEGRATION_PASSWORD=your-password \ + pytest backend/tests/test_integration.py -v +""" + +import os + +import httpx +import pytest + +BASE_URL = os.environ.get("INTEGRATION_BASE_URL", "") +USERNAME = os.environ.get("INTEGRATION_USERNAME", "admin") +PASSWORD = os.environ.get("INTEGRATION_PASSWORD", "") + +pytestmark = pytest.mark.skipif( + not BASE_URL, + reason="INTEGRATION_BASE_URL not set — skipping live-stack tests", +) + + +@pytest.fixture(scope="module") +def token() -> str: + res = httpx.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": USERNAME, "password": PASSWORD}, + timeout=10, + ) + assert res.status_code == 200, f"Login failed: {res.text}" + return res.json()["access_token"] + + +@pytest.fixture(scope="module") +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ── Health ──────────────────────────────────────────────────────────────────── + +def test_health_endpoint(): + res = httpx.get(f"{BASE_URL}/api/v1/health", timeout=10) + assert res.status_code == 200 + + +# ── Auth ────────────────────────────────────────────────────────────────────── + +def test_login_returns_token(): + res = httpx.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": USERNAME, "password": PASSWORD}, + timeout=10, + ) + assert res.status_code == 200 + data = res.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +def test_login_bad_credentials(): + res = httpx.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": USERNAME, "password": "wrong-password"}, + timeout=10, + ) + assert res.status_code == 401 + + +def test_protected_route_without_token(): + res = httpx.get(f"{BASE_URL}/api/v1/canvas", timeout=10) + assert res.status_code == 401 + + +# ── Canvas round-trip ───────────────────────────────────────────────────────── + +def test_canvas_load_returns_valid_structure(auth): + res = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10) + assert res.status_code == 200 + data = res.json() + assert "nodes" in data + assert "edges" in data + assert isinstance(data["nodes"], list) + assert isinstance(data["edges"], list) + + +def test_canvas_save_and_reload(auth): + payload = { + "nodes": [ + { + "id": "integ-node-1", + "type": "server", + "position": {"x": 100, "y": 200}, + "data": { + "label": "CI Server", + "type": "server", + "status": "unknown", + "services": [], + }, + "width": 240, + "height": 100, + } + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + save_res = httpx.post( + f"{BASE_URL}/api/v1/canvas/save", + json=payload, + headers=auth, + timeout=10, + ) + assert save_res.status_code == 200 + + load_res = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10) + assert load_res.status_code == 200 + nodes = load_res.json()["nodes"] + assert len(nodes) == 1 + + node = nodes[0] + assert node["label"] == "CI Server" + assert node["type"] == "server" + assert node["x"] == pytest.approx(100) + assert node["y"] == pytest.approx(200) + + +def test_canvas_save_preserves_node_dimensions(auth): + """Width/height survive a save→reload cycle through the real DB.""" + payload = { + "nodes": [ + { + "id": "resized-node", + "type": "router", + "position": {"x": 50, "y": 50}, + "data": { + "label": "Big Router", + "type": "router", + "status": "unknown", + "services": [], + }, + "width": 320, + "height": 150, + } + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=payload, headers=auth, timeout=10) + + nodes = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()["nodes"] + node = next((n for n in nodes if n["id"] == "resized-node"), None) + assert node is not None + assert node["width"] == pytest.approx(320) + assert node["height"] == pytest.approx(150) + + +def test_canvas_save_with_edge(auth): + payload = { + "nodes": [ + { + "id": "n-src", + "type": "router", + "position": {"x": 0, "y": 0}, + "data": {"label": "Router", "type": "router", "status": "unknown", "services": []}, + }, + { + "id": "n-dst", + "type": "server", + "position": {"x": 200, "y": 0}, + "data": {"label": "Server", "type": "server", "status": "unknown", "services": []}, + }, + ], + "edges": [ + { + "id": "e-eth", + "source": "n-src", + "target": "n-dst", + "type": "ethernet", + "data": {"type": "ethernet"}, + } + ], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + save_res = httpx.post( + f"{BASE_URL}/api/v1/canvas/save", json=payload, headers=auth, timeout=10 + ) + assert save_res.status_code == 200 + + data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json() + assert len(data["edges"]) == 1 + edge = data["edges"][0] + assert edge["source_id"] == "n-src" + assert edge["target_id"] == "n-dst" + assert edge["type"] == "ethernet" diff --git a/docker-compose.yml b/docker-compose.yml index 631125c..613d572 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,12 @@ services: # Required for ping-based status checks cap_add: - NET_RAW + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s mcp: build: