Compare commits

..

2 Commits

Author SHA1 Message Date
Space-Banane
be24e7c071 Add pytest suite and CI test gate
Some checks failed
docker / test (push) Failing after 1m18s
docker / build-and-push (push) Has been skipped
2026-05-20 22:04:41 +02:00
Space-Banane
18f2ec2937 Update README to match current backend behavior 2026-05-20 22:01:59 +02:00
4 changed files with 177 additions and 1 deletions

View File

@@ -9,7 +9,41 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11
env:
MARIADB_DATABASE: jellomator_test
MARIADB_USER: jellomator
MARIADB_PASSWORD: jellomator
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=20
env:
DB_HOST: 127.0.0.1
DB_PORT: "3306"
DB_USER: jellomator
DB_PASSWORD: jellomator
DB_NAME: jellomator_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install -r backend/requirements-dev.txt
- name: Run backend tests
run: pytest -q
build-and-push: build-and-push:
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -6,23 +6,27 @@ Dark dashboard for Arr* services and custom links.
- First-run admin setup - First-run admin setup
- Cookie-based admin auth - Cookie-based admin auth
- Health endpoint at `/healthz`
- Readiness endpoint at `/readyz` (optional DB write probe)
- Public dashboard with search/filter - Public dashboard with search/filter
- Dedicated protected admin page at `/admin` - Dedicated protected admin page at `/admin`
- Link CRUD backed by MariaDB - Link CRUD backed by MariaDB
- Icon blobs stored in the database - Icon blobs stored in the database
- Single-container deployment - Containerized app deployment (requires MariaDB)
- Admin-managed service links - Admin-managed service links
## Local Dev ## Local Dev
```bash ```bash
npm install npm install
pip install -r backend/requirements.txt
npm run dev npm run dev
``` ```
Backend runs on `http://localhost:6363`. Backend runs on `http://localhost:6363`.
Open `/admin` for the protected management page. Open `/admin` for the protected management page.
Ensure MariaDB is running and reachable by the backend `DB_*` variables.
## Docker ## Docker
@@ -32,6 +36,12 @@ docker compose up --build
The app expects a MariaDB instance configured through environment variables. The app expects a MariaDB instance configured through environment variables.
### Health Endpoints
- `GET /healthz` returns `{"ok": true}` when the app process is up
- `GET /readyz` returns `{"ok": true}` when database checks pass
- `GET /readyz?write_test=true` additionally verifies DB writes using a temporary table
### Session and Cookie Env Vars ### Session and Cookie Env Vars
- `SESSION_TTL_SECONDS` (default: `86400`) - `SESSION_TTL_SECONDS` (default: `86400`)
@@ -41,6 +51,10 @@ The app expects a MariaDB instance configured through environment variables.
- `LOGIN_MAX_ATTEMPTS` (default: `5`) - `LOGIN_MAX_ATTEMPTS` (default: `5`)
- `LOGIN_WINDOW_SECONDS` (default: `300`) - `LOGIN_WINDOW_SECONDS` (default: `300`)
- `LOGIN_LOCKOUT_SECONDS` (default: `900`) - `LOGIN_LOCKOUT_SECONDS` (default: `900`)
- `MAX_NAME_LEN` (default: `255`)
- `MAX_CATEGORY_LEN` (default: `255`)
- `MAX_DESCRIPTION_LEN` (default: `2000`)
- `MAX_ICON_URL_LEN` (default: `2048`)
- `MAX_ICON_BYTES` (default: `2097152`) - `MAX_ICON_BYTES` (default: `2097152`)
- `USERNAME_MAX_LEN` (default: `64`) - `USERNAME_MAX_LEN` (default: `64`)
- `PASSWORD_MIN_LEN` (default: `12`) - `PASSWORD_MIN_LEN` (default: `12`)

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.5
httpx==0.28.1

125
tests/test_api.py Normal file
View File

@@ -0,0 +1,125 @@
import importlib
import os
import time
from typing import Iterator
import pymysql
import pytest
from fastapi.testclient import TestClient
def wait_for_db(host: str, port: int, user: str, password: str, timeout_seconds: int = 60) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
try:
conn = pymysql.connect(host=host, port=port, user=user, password=password, autocommit=True)
conn.close()
return
except Exception:
time.sleep(1)
raise RuntimeError("MariaDB did not become ready in time")
@pytest.fixture(scope="session")
def app_module():
os.environ.setdefault("DB_HOST", "127.0.0.1")
os.environ.setdefault("DB_PORT", "3306")
os.environ.setdefault("DB_USER", "jellomator")
os.environ.setdefault("DB_PASSWORD", "jellomator")
os.environ.setdefault("DB_NAME", "jellomator_test")
wait_for_db(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
)
module = importlib.import_module("backend.main")
return module
@pytest.fixture()
def client(app_module) -> Iterator[TestClient]:
with app_module.db() as conn:
with conn.cursor() as cur:
cur.execute("delete from sessions")
cur.execute("delete from users")
cur.execute("delete from links")
app_module.login_attempts.clear()
app_module.login_lockouts.clear()
with TestClient(app_module.app) as test_client:
yield test_client
def test_healthz(client: TestClient):
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_readyz(client: TestClient):
resp = client.get("/readyz")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_setup_and_login(client: TestClient):
setup_resp = client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
assert setup_resp.status_code == 200
assert setup_resp.json() == {"ok": True}
login_resp = client.post("/api/login", json={"username": "admin", "password": "123456789012"})
assert login_resp.status_code == 200
assert login_resp.json() == {"ok": True}
assert "jellomator_session=" in login_resp.headers.get("set-cookie", "")
def test_link_crud_with_auth(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
login_resp = client.post("/api/login", json={"username": "admin", "password": "123456789012"})
assert login_resp.status_code == 200
create_resp = client.post(
"/api/links",
data={
"name": "Prowlarr",
"url": "https://prowlarr.example.com",
"description": "Indexer manager",
"category": "Arr",
"enabled": "true",
},
)
assert create_resp.status_code == 200
assert create_resp.json() == {"ok": True}
list_resp = client.get("/api/links")
assert list_resp.status_code == 200
rows = list_resp.json()
assert len(rows) == 1
assert rows[0]["name"] == "Prowlarr"
link_id = rows[0]["id"]
patch_resp = client.patch(
f"/api/links/{link_id}",
data={
"name": "Prowlarr Updated",
"url": "https://prowlarr.example.com/new",
"description": "Updated",
"category": "Arr",
"enabled": "true",
},
)
assert patch_resp.status_code == 200
assert patch_resp.json() == {"ok": True}
delete_resp = client.delete(f"/api/links/{link_id}")
assert delete_resp.status_code == 200
assert delete_resp.json() == {"ok": True}
def test_login_rate_limit_lockout(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
for _ in range(5):
resp = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert resp.status_code == 401
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert locked.status_code == 429