Compare commits
2 Commits
911d9ed683
...
be24e7c071
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be24e7c071 | ||
|
|
18f2ec2937 |
@@ -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
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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`)
|
||||||
|
|||||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.5
|
||||||
|
httpx==0.28.1
|
||||||
125
tests/test_api.py
Normal file
125
tests/test_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user