merge testing->tomerge/main #7

Open
anti wants to merge 242 commits from testing into tomerge/main
4 changed files with 376 additions and 0 deletions
Showing only changes of commit 187194786f - Show all commits

View File

@@ -0,0 +1,208 @@
"""
Live integration tests for the MySQL dashboard backend.
Requires a real MySQL server. Skipped unless ``DECNET_DB_URL`` (or
``DECNET_MYSQL_TEST_URL``) is exported pointing at a running instance,
e.g. a throw-away docker container:
docker run -d --rm --name decnet-mysql-test \
-e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=decnet \
-e MYSQL_USER=decnet -e MYSQL_PASSWORD=decnet \
-p 3307:3306 mysql:8
# Either url works; the connecting account MUST have CREATE/DROP DATABASE
# privilege because each xdist worker uses its own throwaway schema.
export DECNET_DB_URL='mysql+aiomysql://root:root@127.0.0.1:3307/decnet'
pytest -m live tests/live/test_mysql_backend_live.py
Each worker creates ``test_decnet_<worker>`` on session start and drops it
on session end. ``<worker>`` is ``master`` outside xdist, ``gw0``/``gw1``/…
under it, so parallel runs never clash.
"""
from __future__ import annotations
import json
import os
import uuid as _uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse, urlunparse
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from decnet.web.db.mysql.repository import MySQLRepository
LIVE_URL = os.environ.get("DECNET_MYSQL_TEST_URL") or os.environ.get("DECNET_DB_URL")
pytestmark = [
pytest.mark.live,
pytest.mark.skipif(
not (LIVE_URL and LIVE_URL.startswith("mysql")),
reason="Set DECNET_DB_URL=mysql+aiomysql://... to run MySQL live tests",
),
]
def _worker_id() -> str:
"""Return a stable identifier for the current xdist worker (``master`` when single-process)."""
return os.environ.get("PYTEST_XDIST_WORKER", "master")
def _split_url(url: str) -> tuple[str, str]:
"""Return (server_url_without_db, test_db_name)."""
parsed = urlparse(url)
server_url = urlunparse(parsed._replace(path=""))
db_name = f"test_decnet_{_worker_id()}"
return server_url, db_name
def _url_with_db(server_url: str, db_name: str) -> str:
parsed = urlparse(server_url)
return urlunparse(parsed._replace(path=f"/{db_name}"))
@pytest.fixture(scope="session")
async def mysql_test_db_url():
"""Create a per-worker throwaway database, yield its URL, drop it on teardown.
Uses the configured URL's credentials to CREATE/DROP. If the account
lacks that privilege you'll see a clear SQL error — grant it with::
GRANT ALL PRIVILEGES ON `test\\_decnet\\_%`.* TO 'decnet'@'%';
or point ``DECNET_MYSQL_TEST_URL`` at a root-level URL.
"""
server_url, db_name = _split_url(LIVE_URL)
admin = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
try:
async with admin.connect() as conn:
await conn.execute(text(f"DROP DATABASE IF EXISTS `{db_name}`"))
await conn.execute(text(f"CREATE DATABASE `{db_name}`"))
finally:
await admin.dispose()
yield _url_with_db(server_url, db_name)
# Teardown — always drop, even if tests errored.
admin = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
try:
async with admin.connect() as conn:
await conn.execute(text(f"DROP DATABASE IF EXISTS `{db_name}`"))
finally:
await admin.dispose()
@pytest.fixture
async def mysql_repo(mysql_test_db_url):
"""Fresh schema per test — truncate between tests to keep them isolated."""
repo = MySQLRepository(url=mysql_test_db_url)
await repo.initialize()
yield repo
# Per-test cleanup: truncate with FK checks disabled so order doesn't matter.
async with repo.engine.begin() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
for tbl in ("attacker_behavior", "attackers", "logs", "bounty", "state", "users"):
await conn.execute(text(f"TRUNCATE TABLE `{tbl}`"))
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
await repo.engine.dispose()
async def test_schema_creation_and_admin_seed(mysql_repo):
user = await mysql_repo.get_user_by_username(os.environ.get("DECNET_ADMIN_USER", "admin"))
assert user is not None
assert user["role"] == "admin"
async def test_add_and_query_logs(mysql_repo):
await mysql_repo.add_log({
"decky": "decky-01", "service": "ssh", "event_type": "connect",
"attacker_ip": "10.0.0.7", "raw_line": "connect from 10.0.0.7",
"fields": json.dumps({"port": 22}), "msg": "conn",
})
logs = await mysql_repo.get_logs(limit=10)
assert any(lg["attacker_ip"] == "10.0.0.7" for lg in logs)
assert await mysql_repo.get_total_logs() >= 1
async def test_json_field_search(mysql_repo):
await mysql_repo.add_log({
"decky": "d1", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.2.3.4", "raw_line": "x",
"fields": json.dumps({"username": "root"}), "msg": "",
})
hits = await mysql_repo.get_logs(search="username:root")
assert any("1.2.3.4" == h["attacker_ip"] for h in hits)
async def test_histogram_buckets(mysql_repo):
now = datetime.now(timezone.utc).replace(microsecond=0)
for i in range(3):
await mysql_repo.add_log({
"decky": "h", "service": "ssh", "event_type": "connect",
"attacker_ip": "9.9.9.9",
"raw_line": f"line {i}", "fields": "{}", "msg": "",
"timestamp": (now - timedelta(minutes=i)).isoformat(),
})
buckets = await mysql_repo.get_log_histogram(interval_minutes=5)
assert buckets, "expected at least one histogram bucket"
for b in buckets:
assert "time" in b and "count" in b
assert b["count"] >= 1
async def test_bounty_roundtrip(mysql_repo):
await mysql_repo.add_bounty({
"decky": "decky-01", "service": "ssh", "attacker_ip": "10.0.0.1",
"bounty_type": "credentials",
"payload": {"username": "root", "password": "toor"},
})
out = await mysql_repo.get_bounties()
assert any(b["bounty_type"] == "credentials" for b in out)
async def test_user_crud(mysql_repo):
uid = str(_uuid.uuid4())
await mysql_repo.create_user({
"uuid": uid, "username": "live_tester",
"password_hash": "hashed", "role": "viewer", "must_change_password": True,
})
u = await mysql_repo.get_user_by_uuid(uid)
assert u and u["username"] == "live_tester"
await mysql_repo.update_user_role(uid, "admin")
u2 = await mysql_repo.get_user_by_uuid(uid)
assert u2["role"] == "admin"
ok = await mysql_repo.delete_user(uid)
assert ok
assert await mysql_repo.get_user_by_uuid(uid) is None
async def test_purge_clears_tables(mysql_repo):
await mysql_repo.add_log({
"decky": "p", "service": "ssh", "event_type": "connect",
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": "",
})
await mysql_repo.purge_logs_and_bounties()
assert await mysql_repo.get_total_logs() == 0
async def test_large_commands_blob_round_trips(mysql_repo):
"""Attacker.commands must handle >64 KiB (MEDIUMTEXT) — was 1406 errors on TEXT."""
big_commands = [
{"service": "ssh", "decky": "d", "command": "A" * 512,
"timestamp": "2026-04-15T12:00:00+00:00"}
for _ in range(500) # ~250 KiB
]
ip = "8.8.8.8"
now = datetime.now(timezone.utc)
row_uuid = await mysql_repo.upsert_attacker({
"ip": ip, "first_seen": now, "last_seen": now,
"event_count": 0, "service_count": 0, "decky_count": 0,
"commands": json.dumps(big_commands),
})
got = await mysql_repo.get_attacker_by_uuid(row_uuid)
assert got is not None
assert len(got["commands"]) == 500

20
tests/mysql_spinup.sh Executable file
View File

@@ -0,0 +1,20 @@
# start the instance
docker run -d --rm --name decnet-mysql \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=decnet \
-e MYSQL_USER=decnet \
-e MYSQL_PASSWORD=decnet \
-p 3307:3306 mysql:8
until docker exec decnet-mysql mysqladmin ping -h127.0.0.1 -uroot -proot --silent; do
sleep 1
done
echo "MySQL up."
export DECNET_DB_TYPE=mysql
export DECNET_DB_URL='mysql+aiomysql://decnet:decnet@127.0.0.1:3307/decnet'
source ../.venv/bin/activate
sudo ../.venv/bin/decnet api

View File

@@ -0,0 +1,70 @@
"""
Inspection-level tests for the MySQL-dialect SQL emitted by MySQLRepository.
We compile the SQLAlchemy statements against the MySQL dialect and assert on
the string form — no live MySQL server is required.
"""
import pytest
from sqlalchemy import func, select, literal_column
from sqlalchemy.dialects import mysql
from sqlmodel.sql.expression import SelectOfScalar
from decnet.web.db.models import Log
def _compile(stmt) -> str:
"""Compile a statement to MySQL-dialect SQL with literal values inlined."""
return str(stmt.compile(
dialect=mysql.dialect(),
compile_kwargs={"literal_binds": True},
))
def test_mysql_histogram_uses_from_unixtime_bucket():
"""The MySQL dialect must bucket with UNIX_TIMESTAMP DIV N * N wrapped in FROM_UNIXTIME."""
bucket_seconds = 900 # 15 min
bucket_expr = literal_column(
f"FROM_UNIXTIME((UNIX_TIMESTAMP(timestamp) DIV {bucket_seconds}) * {bucket_seconds})"
).label("bucket_time")
stmt: SelectOfScalar = select(bucket_expr, func.count().label("count")).select_from(Log)
sql = _compile(stmt)
assert "FROM_UNIXTIME" in sql
assert "UNIX_TIMESTAMP" in sql
assert "DIV 900" in sql
# Sanity: SQLite-only strftime must NOT appear in the MySQL-dialect output.
assert "strftime" not in sql
assert "unixepoch" not in sql
def test_mysql_json_unquote_predicate_shape():
"""MySQL JSON filter uses JSON_UNQUOTE(JSON_EXTRACT(...))."""
from decnet.web.db.mysql.repository import MySQLRepository
# Build a dummy instance without touching the engine. We only need _json_field_equals,
# which is a pure function of the key.
repo = MySQLRepository.__new__(MySQLRepository) # bypass __init__ / no DB connection
predicate = repo._json_field_equals("username")
# text() objects carry their literal SQL in .text
assert "JSON_UNQUOTE" in predicate.text
assert "JSON_EXTRACT(fields, '$.username')" in predicate.text
assert ":val" in predicate.text
@pytest.mark.parametrize("key", ["user", "port", "sess_id"])
def test_mysql_json_predicate_safe_for_reasonable_keys(key):
"""Keys matching [A-Za-z0-9_]+ are inserted verbatim; verify no SQL breakage."""
from decnet.web.db.mysql.repository import MySQLRepository
repo = MySQLRepository.__new__(MySQLRepository)
pred = repo._json_field_equals(key)
assert f"'$.{key}'" in pred.text
def test_sqlite_histogram_still_uses_strftime():
"""Regression guard — SQLite implementation must keep its strftime-based bucket."""
from decnet.web.db.sqlite.repository import SQLiteRepository
import inspect
src = inspect.getsource(SQLiteRepository.get_log_histogram)
assert "strftime" in src
assert "unixepoch" in src

View File

@@ -0,0 +1,78 @@
"""
Unit tests for decnet.web.db.mysql.database.build_mysql_url / resolve_url.
No MySQL server is required — these are pure URL-construction tests.
"""
import pytest
from decnet.web.db.mysql.database import build_mysql_url, resolve_url
def test_build_url_defaults(monkeypatch):
for v in ("DECNET_DB_HOST", "DECNET_DB_PORT", "DECNET_DB_NAME",
"DECNET_DB_USER", "DECNET_DB_PASSWORD", "DECNET_DB_URL"):
monkeypatch.delenv(v, raising=False)
# PYTEST_* is set by pytest itself, so empty password is allowed here.
url = build_mysql_url()
assert url == "mysql+aiomysql://decnet:@localhost:3306/decnet"
def test_build_url_from_env(monkeypatch):
monkeypatch.setenv("DECNET_DB_HOST", "db.internal")
monkeypatch.setenv("DECNET_DB_PORT", "3307")
monkeypatch.setenv("DECNET_DB_NAME", "decnet_prod")
monkeypatch.setenv("DECNET_DB_USER", "svc_decnet")
monkeypatch.setenv("DECNET_DB_PASSWORD", "hunter2")
url = build_mysql_url()
assert url == "mysql+aiomysql://svc_decnet:hunter2@db.internal:3307/decnet_prod"
def test_build_url_percent_encodes_password(monkeypatch):
"""Passwords with @ : / # etc must not break URL parsing."""
monkeypatch.setenv("DECNET_DB_PASSWORD", "p@ss:word/!#")
url = build_mysql_url(user="u", host="h", port=3306, database="d")
# @ → %40, : → %3A, / → %2F, # → %23, ! → %21
assert "p%40ss%3Aword%2F%21%23" in url
assert url.startswith("mysql+aiomysql://u:")
assert url.endswith("@h:3306/d")
def test_build_url_component_args_override_env(monkeypatch):
monkeypatch.setenv("DECNET_DB_HOST", "ignored")
monkeypatch.setenv("DECNET_DB_PASSWORD", "env-pw")
url = build_mysql_url(host="arg.host", user="arg-user", password="arg-pw",
port=9999, database="arg-db")
assert url == "mysql+aiomysql://arg-user:arg-pw@arg.host:9999/arg-db"
def test_resolve_url_prefers_explicit_arg(monkeypatch):
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://env-url/x")
assert resolve_url("mysql+aiomysql://explicit/y") == "mysql+aiomysql://explicit/y"
def test_resolve_url_uses_env_url_before_components(monkeypatch):
monkeypatch.setenv("DECNET_DB_URL", "mysql+aiomysql://env-user:env-pw@env-host/env-db")
monkeypatch.setenv("DECNET_DB_HOST", "ignored.host")
assert resolve_url() == "mysql+aiomysql://env-user:env-pw@env-host/env-db"
def test_resolve_url_falls_back_to_components(monkeypatch):
monkeypatch.delenv("DECNET_DB_URL", raising=False)
monkeypatch.setenv("DECNET_DB_HOST", "fallback.host")
monkeypatch.setenv("DECNET_DB_PASSWORD", "pw")
url = resolve_url()
assert "fallback.host" in url
assert url.startswith("mysql+aiomysql://")
def test_build_url_requires_password_outside_pytest(monkeypatch):
"""Without a password and not in a pytest run, construction must fail loudly."""
for v in ("DECNET_DB_URL", "DECNET_DB_PASSWORD"):
monkeypatch.delenv(v, raising=False)
# Strip every PYTEST_* env var so the safety check trips.
import os
for k in list(os.environ):
if k.startswith("PYTEST"):
monkeypatch.delenv(k, raising=False)
with pytest.raises(ValueError, match="DECNET_DB_PASSWORD is not set"):
build_mysql_url()