test: add deployment test tiers (shellcheck, hadolint, Docker smoke, integration)
Tier 1 — quality.yml: ShellCheck on lxc-install.sh, hadolint on both Dockerfiles Tier 2 — docker-ci.yml: build images, smoke-test backend health + frontend 200 Tier 3 — test_integration.py: full stack pytest (auth, canvas save/reload, dimensions) Also adds Docker healthcheck to backend service in docker-compose.yml
This commit is contained in:
@@ -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
|
||||||
@@ -7,6 +7,23 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
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:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -17,6 +17,12 @@ services:
|
|||||||
# Required for ping-based status checks
|
# Required for ping-based status checks
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
build:
|
build:
|
||||||
|
|||||||
Reference in New Issue
Block a user