Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
62 files changed
+4510
-1406
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,112 @@
|
||||
"""Shared pytest fixtures: fakeredis + app overrides.
|
||||
|
||||
Sets env vars before app modules import settings, swaps the Redis
|
||||
client in app.storage for a fakeredis instance, and mocks apcaccess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Configure env BEFORE importing any app modules
|
||||
os.environ.setdefault("SESSION_SECRET", "test-session-secret-for-pytest-use-only-32bytes")
|
||||
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD_HASH", "")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
||||
os.environ.setdefault("ALLOW_PRIVATE_IPS", "true")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
||||
os.environ.setdefault("LOG_LEVEL", "WARNING")
|
||||
|
||||
import fakeredis # noqa: E402
|
||||
|
||||
# Ensure fresh settings after env vars set
|
||||
from app import settings as settings_mod # noqa: E402
|
||||
|
||||
settings_mod.get_settings.cache_clear()
|
||||
settings_mod.settings = settings_mod.get_settings()
|
||||
|
||||
from app import storage as storage_mod # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis():
|
||||
"""Provide a clean fakeredis client installed into storage module."""
|
||||
client = fakeredis.FakeRedis(decode_responses=True)
|
||||
storage_mod._redis = client
|
||||
yield client
|
||||
client.flushall()
|
||||
storage_mod._redis = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_config_cache():
|
||||
"""Reset module-level config cache to force reload from (fake) Redis."""
|
||||
from app import config as config_mod
|
||||
config_mod._cached = None
|
||||
yield
|
||||
config_mod._cached = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(fake_redis, clean_config_cache):
|
||||
"""FastAPI TestClient with fakeredis wired in."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(app_client, fake_redis):
|
||||
"""Test client with admin set up and logged in."""
|
||||
from app.auth import store_admin
|
||||
store_admin("admin", "testpassword123")
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "admin", "password": "testpassword123"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
csrf = r.json()["csrf_token"]
|
||||
app_client.headers.update({"X-CSRF-Token": csrf})
|
||||
return app_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_apcaccess():
|
||||
"""Patch apc_cli.fetch_status to return a canned snapshot."""
|
||||
from app import apc_cli
|
||||
|
||||
async def _fake_fetch(host, port):
|
||||
return {
|
||||
"STATUS": "ONLINE",
|
||||
"LOADPCT": "25.0",
|
||||
"NOMPOWER": "900",
|
||||
"BCHARGE": "100.0",
|
||||
"TIMELEFT": "45.0 Minutes",
|
||||
"LINEV": "120.0",
|
||||
"NOMINV": "120",
|
||||
"ITEMP": "30.5",
|
||||
"BATTV": "13.5",
|
||||
"NOMBATTV": "12.0",
|
||||
"SELFTEST": "NO",
|
||||
"REPLACEBATT": "NO",
|
||||
"LASTXFER": "No transfers since turnon",
|
||||
}
|
||||
|
||||
with patch.object(apc_cli, "fetch_status", _fake_fetch):
|
||||
yield _fake_fetch
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items): # noqa: ARG001
|
||||
"""Mark all async tests as asyncio automatically."""
|
||||
for item in items:
|
||||
if asyncio.iscoroutinefunction(item.function):
|
||||
item.add_marker(pytest.mark.asyncio)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Alert rules, cooldown, silent hours, deferred drain."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alerts import (
|
||||
SEV_CRITICAL,
|
||||
SEV_INFO,
|
||||
SEV_WARNING,
|
||||
Alert,
|
||||
_defer_for_silent_hours,
|
||||
_in_silent_hours,
|
||||
drain_deferred,
|
||||
emit_info,
|
||||
evaluate_alerts,
|
||||
process_alerts,
|
||||
)
|
||||
from app.config import SMTPConfig, UPSConfig
|
||||
|
||||
|
||||
def _ups(**overrides):
|
||||
base = dict(
|
||||
name="u1", host="192.168.1.10", port=3551, interval_seconds=30,
|
||||
alert_on_battery=True,
|
||||
alert_runtime_low_minutes=10.0,
|
||||
alert_bcharge_low=20.0,
|
||||
alert_loadpct_high=80.0,
|
||||
alert_itemp_high=45.0,
|
||||
)
|
||||
base.update(overrides)
|
||||
return UPSConfig(**base)
|
||||
|
||||
|
||||
def _snap(**overrides):
|
||||
base = dict(
|
||||
STATUS="ONLINE", LOADPCT="25.0", BCHARGE="100.0",
|
||||
TIMELEFT="60.0 Minutes", ITEMP="30.0",
|
||||
SELFTEST="NO", REPLACEBATT="NO",
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_no_alerts_normal_state(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap())
|
||||
assert alerts == []
|
||||
|
||||
|
||||
def test_onbatt_critical(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||
assert any(a.code == "ONBATT" and a.severity == SEV_CRITICAL for a in alerts)
|
||||
|
||||
|
||||
def test_runtime_low_critical(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(TIMELEFT="5.0 Minutes"))
|
||||
assert any(a.code == "RUNTIME_LOW" and a.severity == SEV_CRITICAL for a in alerts)
|
||||
|
||||
|
||||
def test_bcharge_low_critical(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(BCHARGE="15.0"))
|
||||
assert any(a.code == "BCHARGE_LOW" and a.severity == SEV_CRITICAL for a in alerts)
|
||||
|
||||
|
||||
def test_load_high_warning(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(LOADPCT="90.0"))
|
||||
assert any(a.code == "LOAD_HIGH" and a.severity == SEV_WARNING for a in alerts)
|
||||
|
||||
|
||||
def test_replacebatt_warning(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(REPLACEBATT="YES"))
|
||||
assert any(a.code == "REPLACEBATT" and a.severity == SEV_WARNING for a in alerts)
|
||||
|
||||
|
||||
def test_selftest_fail_warning(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(SELFTEST="FAIL"))
|
||||
assert any(a.code == "SELFTEST_FAIL" and a.severity == SEV_WARNING for a in alerts)
|
||||
|
||||
|
||||
def test_temp_high_warning(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(_ups(), _snap(ITEMP="55.0"))
|
||||
assert any(a.code == "TEMP_HIGH" and a.severity == SEV_WARNING for a in alerts)
|
||||
|
||||
|
||||
def test_multiple_alerts_coalesced(fake_redis, clean_config_cache):
|
||||
alerts = evaluate_alerts(
|
||||
_ups(),
|
||||
_snap(STATUS="ONBATT", BCHARGE="10.0", LOADPCT="95.0"),
|
||||
)
|
||||
codes = {a.code for a in alerts}
|
||||
assert "ONBATT" in codes
|
||||
assert "BCHARGE_LOW" in codes
|
||||
assert "LOAD_HIGH" in codes
|
||||
|
||||
|
||||
def test_alert_hash_stable():
|
||||
a1 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 100)
|
||||
a2 = Alert(SEV_CRITICAL, "msg", "CODE", "u1", 999)
|
||||
assert a1.hash() == a2.hash() # hash doesn't depend on ts
|
||||
|
||||
|
||||
def test_silent_hours_same_day():
|
||||
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=23)
|
||||
now = datetime(2024, 1, 1, 22, 30)
|
||||
assert _in_silent_hours(smtp, now) is True
|
||||
now2 = datetime(2024, 1, 1, 23, 30)
|
||||
assert _in_silent_hours(smtp, now2) is False
|
||||
|
||||
|
||||
def test_silent_hours_wrap_midnight():
|
||||
smtp = SMTPConfig(host="x", port=25, silent_hours_start=22, silent_hours_end=7)
|
||||
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 23, 0)) is True
|
||||
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 3, 0)) is True
|
||||
assert _in_silent_hours(smtp, datetime(2024, 1, 1, 10, 0)) is False
|
||||
|
||||
|
||||
def test_silent_hours_unset():
|
||||
smtp = SMTPConfig(host="x", port=25)
|
||||
assert _in_silent_hours(smtp) is False
|
||||
|
||||
|
||||
def test_defer_and_drain(fake_redis):
|
||||
a = Alert(SEV_WARNING, "msg1", "CODE1", "u1", 100)
|
||||
b = Alert(SEV_WARNING, "msg2", "CODE2", "u1", 101)
|
||||
_defer_for_silent_hours([a, b])
|
||||
drained = drain_deferred("u1")
|
||||
assert len(drained) == 2
|
||||
codes = {x.code for x in drained}
|
||||
assert codes == {"CODE1", "CODE2"}
|
||||
# Second drain is empty
|
||||
assert drain_deferred("u1") == []
|
||||
|
||||
|
||||
def test_cooldown_suppresses_second_call(fake_redis, clean_config_cache):
|
||||
# Avoid real SMTP: no smtp in config
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
fresh = process_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||
assert any(a.code == "ONBATT" for a in fresh)
|
||||
# Immediate second call: cooled down
|
||||
fresh2 = process_alerts(_ups(), _snap(STATUS="ONBATT"))
|
||||
assert all(a.code != "ONBATT" for a in fresh2)
|
||||
|
||||
|
||||
def test_emit_info_writes_history(fake_redis, clean_config_cache):
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
emit_info("u1", "REACHABLE", "u1 came back online")
|
||||
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||
assert any("REACHABLE" in e for e in entries)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Integration tests for UPS/alerts/events API routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
def _add_ups(client, name="u1", host="192.168.1.10"):
|
||||
return client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": name, "host": host, "port": 3551, "interval_seconds": 30},
|
||||
)
|
||||
|
||||
|
||||
def test_ups_list_empty(authed_client):
|
||||
r = authed_client.get("/api/ups")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_ups_list_after_add(authed_client):
|
||||
_add_ups(authed_client)
|
||||
r = authed_client.get("/api/ups")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["name"] == "u1"
|
||||
|
||||
|
||||
def test_fleet_overview_empty(authed_client):
|
||||
r = authed_client.get("/api/ups/fleet/overview")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 0
|
||||
assert body["counts"]["online"] == 0
|
||||
|
||||
|
||||
def test_fleet_overview_with_data(authed_client, fake_redis):
|
||||
_add_ups(authed_client, name="u1")
|
||||
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "DERIVED_WATTS": "200", "TIMELEFT": "40"})
|
||||
fake_redis.hset("ups:snap:u2", mapping={"STATUS": "ONBATT", "DERIVED_WATTS": "150", "TIMELEFT": "10"})
|
||||
r = authed_client.get("/api/ups/fleet/overview")
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert body["counts"]["online"] == 1
|
||||
assert body["counts"]["on_battery"] == 1
|
||||
assert body["total_watts"] == 350.0
|
||||
assert body["min_timeleft_minutes"] == 10.0
|
||||
|
||||
|
||||
def test_fleet_overview_detects_offline(authed_client, fake_redis):
|
||||
_add_ups(authed_client, name="u1")
|
||||
fake_redis.set("ups:health:offline:u1", "1")
|
||||
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE"})
|
||||
r = authed_client.get("/api/ups/fleet/overview")
|
||||
body = r.json()
|
||||
assert body["counts"]["offline"] == 1
|
||||
|
||||
|
||||
def test_ups_status(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
fake_redis.hset("ups:snap:u1", mapping={"STATUS": "ONLINE", "_ts": "100"})
|
||||
r = authed_client.get("/api/ups/u1")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["STATUS"] == "ONLINE"
|
||||
|
||||
|
||||
def test_ups_history(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
now = int(time.time())
|
||||
fake_redis.rpush(
|
||||
"ups:hist:u1",
|
||||
json.dumps({"ts": now, "data": {"STATUS": "ONLINE", "LOADPCT": "50"}}),
|
||||
)
|
||||
r = authed_client.get("/api/ups/u1/history")
|
||||
body = r.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["data"]["STATUS"] == "ONLINE"
|
||||
|
||||
|
||||
def test_ups_metric(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
now = int(time.time())
|
||||
for v in (10, 20, 30):
|
||||
fake_redis.rpush(
|
||||
"ups:hist:u1",
|
||||
json.dumps({"ts": now, "data": {"LOADPCT": str(v)}}),
|
||||
)
|
||||
r = authed_client.get("/api/ups/u1/metric/LOADPCT")
|
||||
body = r.json()
|
||||
assert len(body) == 3
|
||||
assert body[0]["value"] == 10.0
|
||||
|
||||
|
||||
def test_ups_events(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
fake_redis.lpush("ups:event:list:u1", f"{int(time.time())}|STATUS|ONLINE")
|
||||
r = authed_client.get("/api/ups/u1/events")
|
||||
body = r.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["type"] == "STATUS"
|
||||
|
||||
|
||||
def test_ups_energy_empty(authed_client):
|
||||
_add_ups(authed_client)
|
||||
r = authed_client.get("/api/ups/u1/energy")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["kwh_today"] is None
|
||||
|
||||
|
||||
def test_ups_energy_with_data(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
day_str = time.strftime("%Y%m%d")
|
||||
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 1 kWh
|
||||
r = authed_client.get("/api/ups/u1/energy")
|
||||
body = r.json()
|
||||
assert body["kwh_today"] == 1.0
|
||||
|
||||
|
||||
def test_ups_health(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
fake_redis.set("ups:health:last_ok:u1", "12345")
|
||||
r = authed_client.get("/api/ups/u1/health")
|
||||
body = r.json()
|
||||
assert body["online"] is True
|
||||
assert body["last_ok_ts"] == 12345
|
||||
|
||||
|
||||
def test_ups_health_offline(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
fake_redis.set("ups:health:offline:u1", "1")
|
||||
fake_redis.set("ups:health:fail_count:u1", "3")
|
||||
r = authed_client.get("/api/ups/u1/health")
|
||||
body = r.json()
|
||||
assert body["online"] is False
|
||||
assert body["fail_count"] == 3
|
||||
|
||||
|
||||
def test_battery_health_empty(authed_client):
|
||||
_add_ups(authed_client)
|
||||
r = authed_client.get("/api/ups/u1/battery_health")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_events_list_empty(authed_client):
|
||||
r = authed_client.get("/api/events")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_events_list_all(authed_client, fake_redis):
|
||||
_add_ups(authed_client, name="u1")
|
||||
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||
now = int(time.time())
|
||||
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||
fake_redis.lpush("ups:event:list:u2", f"{now - 10}|XFER|Low voltage")
|
||||
r = authed_client.get("/api/events")
|
||||
body = r.json()
|
||||
assert len(body) == 2
|
||||
# Sorted by ts desc
|
||||
assert body[0]["ts"] >= body[1]["ts"]
|
||||
|
||||
|
||||
def test_events_filter_by_ups(authed_client, fake_redis):
|
||||
_add_ups(authed_client, name="u1")
|
||||
_add_ups(authed_client, name="u2", host="192.168.1.20")
|
||||
now = int(time.time())
|
||||
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||
fake_redis.lpush("ups:event:list:u2", f"{now}|STATUS|ONBATT")
|
||||
r = authed_client.get("/api/events?ups=u1")
|
||||
body = r.json()
|
||||
assert all(e["ups"] == "u1" for e in body)
|
||||
|
||||
|
||||
def test_events_filter_by_kind(authed_client, fake_redis):
|
||||
_add_ups(authed_client)
|
||||
now = int(time.time())
|
||||
fake_redis.lpush("ups:event:list:u1", f"{now}|STATUS|ONLINE")
|
||||
fake_redis.lpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
|
||||
r = authed_client.get("/api/events?kind=XFER")
|
||||
body = r.json()
|
||||
assert all(e["type"] == "XFER" for e in body)
|
||||
|
||||
|
||||
def test_alerts_list_empty(authed_client):
|
||||
r = authed_client.get("/api/alerts")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_alerts_list_with_data(authed_client, fake_redis):
|
||||
now = int(time.time())
|
||||
fake_redis.lpush(
|
||||
"ups:alerts:history:all",
|
||||
f"{now}|CRITICAL|u1|ONBATT|On battery power|abc123",
|
||||
)
|
||||
r = authed_client.get("/api/alerts")
|
||||
body = r.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["severity"] == "CRITICAL"
|
||||
assert body[0]["ups"] == "u1"
|
||||
assert body[0]["acked_ts"] is None
|
||||
|
||||
|
||||
def test_alerts_filter_by_severity(authed_client, fake_redis):
|
||||
now = int(time.time())
|
||||
fake_redis.lpush("ups:alerts:history:all", f"{now}|CRITICAL|u1|C|x|a")
|
||||
fake_redis.lpush("ups:alerts:history:all", f"{now}|WARNING|u1|W|y|b")
|
||||
r = authed_client.get("/api/alerts?severity=warning")
|
||||
body = r.json()
|
||||
assert all(a["severity"] == "WARNING" for a in body)
|
||||
|
||||
|
||||
def test_alerts_active_filters_acked(authed_client, fake_redis):
|
||||
now = int(time.time())
|
||||
fake_redis.lpush(
|
||||
"ups:alerts:history:all",
|
||||
f"{now}|CRITICAL|u1|A|msg|alert1",
|
||||
)
|
||||
fake_redis.lpush(
|
||||
"ups:alerts:history:all",
|
||||
f"{now}|CRITICAL|u1|B|msg2|alert2",
|
||||
)
|
||||
fake_redis.set("ups:alerts:ack:alert1", str(now))
|
||||
r = authed_client.get("/api/alerts/active")
|
||||
body = r.json()
|
||||
assert all(a["id"] != "alert1" for a in body)
|
||||
|
||||
|
||||
def test_alerts_ack(authed_client, fake_redis):
|
||||
r = authed_client.post("/api/alerts/myid/ack")
|
||||
assert r.status_code == 200
|
||||
assert fake_redis.get("ups:alerts:ack:myid") is not None
|
||||
|
||||
|
||||
def test_healthz_no_auth_required(app_client):
|
||||
r = app_client.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_readyz_reports_redis_status(app_client, fake_redis):
|
||||
r = app_client.get("/readyz")
|
||||
assert r.status_code in (200, 503)
|
||||
|
||||
|
||||
def test_metrics_endpoint(app_client, fake_redis):
|
||||
r = app_client.get("/metrics")
|
||||
assert r.status_code == 200
|
||||
assert "ups_" in r.text or "# HELP" in r.text
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Auth: hash/verify, session token, login/logout, CSRF, setup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth import (
|
||||
create_session_token,
|
||||
get_stored_admin,
|
||||
hash_password,
|
||||
is_admin_configured,
|
||||
store_admin,
|
||||
verify_password,
|
||||
verify_session_token,
|
||||
)
|
||||
|
||||
|
||||
def test_hash_and_verify_password(fake_redis):
|
||||
h = hash_password("supersecret")
|
||||
assert h.startswith("$argon2")
|
||||
assert verify_password("supersecret", h) is True
|
||||
assert verify_password("wrong", h) is False
|
||||
|
||||
|
||||
def test_verify_password_bad_hash_returns_false(fake_redis):
|
||||
assert verify_password("anything", "not-a-valid-hash") is False
|
||||
|
||||
|
||||
def test_session_token_roundtrip(fake_redis):
|
||||
tok = create_session_token("admin")
|
||||
assert verify_session_token(tok) == "admin"
|
||||
|
||||
|
||||
def test_session_token_rejects_tampered(fake_redis):
|
||||
tok = create_session_token("admin")
|
||||
bad = tok[:-4] + "abcd"
|
||||
assert verify_session_token(bad) is None
|
||||
|
||||
|
||||
def test_store_and_get_admin(fake_redis):
|
||||
store_admin("alice", "pw-strong-1")
|
||||
user, h = get_stored_admin()
|
||||
assert user == "alice"
|
||||
assert h and h.startswith("$argon2")
|
||||
assert is_admin_configured() is True
|
||||
|
||||
|
||||
def test_login_rejects_before_setup(app_client, fake_redis):
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "admin", "password": "test"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_login_happy_path(app_client, fake_redis):
|
||||
store_admin("admin", "testpassword123")
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "admin", "password": "testpassword123"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert "csrf_token" in body
|
||||
# Cookies set
|
||||
assert "ups_session" in r.cookies
|
||||
assert "csrf_token" in r.cookies
|
||||
|
||||
|
||||
def test_login_bad_password(app_client, fake_redis):
|
||||
store_admin("admin", "testpassword123")
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_bad_username(app_client, fake_redis):
|
||||
store_admin("admin", "testpassword123")
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "notadmin", "password": "testpassword123"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_logout_clears_cookies(authed_client):
|
||||
r = authed_client.post("/api/logout")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_setup_creates_admin(app_client, fake_redis):
|
||||
r = app_client.post(
|
||||
"/api/setup",
|
||||
json={"username": "newadmin", "password": "setup-pw-1234"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert is_admin_configured() is True
|
||||
|
||||
|
||||
def test_setup_rejects_when_configured(app_client, fake_redis):
|
||||
store_admin("admin", "already-set-password")
|
||||
r = app_client.post(
|
||||
"/api/setup",
|
||||
json={"username": "newadmin", "password": "setup-pw-1234"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_setup_rejects_short_password(app_client, fake_redis):
|
||||
r = app_client.post(
|
||||
"/api/setup",
|
||||
json={"username": "admin", "password": "short"},
|
||||
)
|
||||
assert r.status_code == 422 # pydantic validation
|
||||
|
||||
|
||||
def test_setup_rejects_bad_username(app_client, fake_redis):
|
||||
r = app_client.post(
|
||||
"/api/setup",
|
||||
json={"username": "bad user!", "password": "good-password-1"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_protected_endpoint_requires_auth(app_client):
|
||||
r = app_client.get("/api/config/ups")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_csrf_required_for_mutations(app_client, fake_redis):
|
||||
store_admin("admin", "testpassword123")
|
||||
r = app_client.post(
|
||||
"/api/login",
|
||||
json={"username": "admin", "password": "testpassword123"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# DELETE without CSRF header
|
||||
r2 = app_client.delete("/api/config/ups/nonexistent")
|
||||
assert r2.status_code == 403
|
||||
|
||||
|
||||
def test_mutations_work_with_csrf(authed_client):
|
||||
# Authed client already has CSRF header set in fixture
|
||||
r = authed_client.delete("/api/config/ups/nonexistent")
|
||||
# Expected 404 because UPS doesn't exist (NOT 401/403)
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Config CRUD API: auth, CSRF, create/list/update/delete."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_unauthed_cannot_list_ups(app_client):
|
||||
r = app_client.get("/api/config/ups")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_unauthed_cannot_add_ups(app_client):
|
||||
r = app_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.10"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_add_and_list_ups(authed_client):
|
||||
r = authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.10", "port": 3551, "interval_seconds": 30},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r2 = authed_client.get("/api/config/ups")
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["name"] == "u1"
|
||||
assert body[0]["host"] == "192.168.1.10"
|
||||
|
||||
|
||||
def test_add_duplicate_rejects(authed_client):
|
||||
authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.10"},
|
||||
)
|
||||
r = authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.11"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_add_ssrf_host_rejected(authed_client):
|
||||
r = authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "bad", "host": "127.0.0.1"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_update_ups(authed_client):
|
||||
authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.10"},
|
||||
)
|
||||
r = authed_client.put(
|
||||
"/api/config/ups/u1",
|
||||
json={"host": "192.168.2.20", "interval_seconds": 60},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r2 = authed_client.get("/api/config/ups/u1")
|
||||
body = r2.json()
|
||||
assert body["host"] == "192.168.2.20"
|
||||
assert body["interval_seconds"] == 60
|
||||
|
||||
|
||||
def test_update_nonexistent(authed_client):
|
||||
r = authed_client.put(
|
||||
"/api/config/ups/missing",
|
||||
json={"host": "192.168.2.20"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete_ups(authed_client):
|
||||
authed_client.post(
|
||||
"/api/config/ups",
|
||||
json={"name": "u1", "host": "192.168.1.10"},
|
||||
)
|
||||
r = authed_client.delete("/api/config/ups/u1")
|
||||
assert r.status_code == 200
|
||||
r2 = authed_client.get("/api/config/ups")
|
||||
assert r2.json() == []
|
||||
|
||||
|
||||
def test_delete_nonexistent(authed_client):
|
||||
r = authed_client.delete("/api/config/ups/missing")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_get_smtp_none(authed_client):
|
||||
r = authed_client.get("/api/config/smtp")
|
||||
assert r.status_code == 200
|
||||
assert r.json() is None
|
||||
|
||||
|
||||
def test_smtp_password_always_redacted(authed_client, monkeypatch):
|
||||
from app import settings as settings_mod
|
||||
monkeypatch.setattr(settings_mod.settings, "smtp_password", "supersecret")
|
||||
r = authed_client.put(
|
||||
"/api/config/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "user@example.com",
|
||||
"use_tls": True,
|
||||
"from_addr": "alerts@example.com",
|
||||
"to_addrs": ["ops@example.com"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r2 = authed_client.get("/api/config/smtp")
|
||||
body = r2.json()
|
||||
# Never raw password
|
||||
assert body.get("password") in ("***", None)
|
||||
assert body["host"] == "smtp.example.com"
|
||||
|
||||
|
||||
def test_ui_config_default(authed_client):
|
||||
r = authed_client.get("/api/config/ui")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "show_events" in body
|
||||
|
||||
|
||||
def test_ui_config_update(authed_client):
|
||||
r = authed_client.put(
|
||||
"/api/config/ui",
|
||||
json={"show_energy": True, "energy_cost_per_kwh": 0.15},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ui"]["show_energy"] is True
|
||||
assert body["ui"]["energy_cost_per_kwh"] == 0.15
|
||||
@@ -0,0 +1,74 @@
|
||||
"""CSV exports shape and content."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exports import export_energy_csv, export_events_csv, export_history_csv
|
||||
|
||||
|
||||
async def _collect(gen):
|
||||
return [chunk async for chunk in gen]
|
||||
|
||||
|
||||
async def test_history_export_header_only_when_empty(fake_redis):
|
||||
rows = await _collect(export_history_csv("u1", since_days=1))
|
||||
assert len(rows) == 1
|
||||
assert rows[0].startswith("ts,status,loadpct,bcharge,timeleft,linev,derived_watts")
|
||||
|
||||
|
||||
async def test_history_export_with_data(fake_redis):
|
||||
now = int(time.time())
|
||||
entry = {"ts": now, "data": {
|
||||
"STATUS": "ONLINE", "LOADPCT": "25.0",
|
||||
"BCHARGE": "100", "TIMELEFT": "45",
|
||||
"LINEV": "120", "DERIVED_WATTS": "225",
|
||||
}}
|
||||
fake_redis.rpush("ups:hist:u1", json.dumps(entry))
|
||||
rows = await _collect(export_history_csv("u1", since_days=1))
|
||||
assert len(rows) == 2 # header + 1
|
||||
data_row = rows[1]
|
||||
assert "ONLINE" in data_row
|
||||
assert "225" in data_row
|
||||
|
||||
|
||||
async def test_events_export_header_only_empty(fake_redis):
|
||||
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||
assert len(rows) == 1
|
||||
assert rows[0].startswith("ts,type,detail")
|
||||
|
||||
|
||||
async def test_events_export_with_data(fake_redis):
|
||||
now = int(time.time())
|
||||
fake_redis.rpush("ups:event:list:u1", f"{now}|STATUS|ONBATT")
|
||||
fake_redis.rpush("ups:event:list:u1", f"{now}|XFER|Low voltage")
|
||||
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||
assert len(rows) == 3
|
||||
body = "\n".join(rows)
|
||||
assert "STATUS" in body
|
||||
assert "ONBATT" in body
|
||||
assert "XFER" in body
|
||||
|
||||
|
||||
async def test_events_export_applies_cutoff(fake_redis):
|
||||
# Event older than 1 day should be filtered out
|
||||
old_ts = int(time.time()) - (2 * 24 * 3600)
|
||||
fake_redis.rpush("ups:event:list:u1", f"{old_ts}|STATUS|OLD")
|
||||
rows = await _collect(export_events_csv("u1", since_days=1))
|
||||
assert len(rows) == 1 # header only
|
||||
|
||||
|
||||
async def test_energy_export_header(fake_redis):
|
||||
rows = await _collect(export_energy_csv("u1", since_days=3))
|
||||
assert rows[0].startswith("date,kwh")
|
||||
|
||||
|
||||
async def test_energy_export_with_data(fake_redis):
|
||||
day_str = time.strftime("%Y%m%d")
|
||||
fake_redis.set(f"ups:energy:u1:{day_str}", "3600000") # 3,600,000 Ws = 1 kWh
|
||||
rows = await _collect(export_energy_csv("u1", since_days=1))
|
||||
# header + today's data
|
||||
assert len(rows) >= 2
|
||||
assert "1.0000" in "\n".join(rows)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Connection health transitions + battery history append."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from app.poller import (
|
||||
HEALTH_OFFLINE_MIN_SECONDS,
|
||||
_get_offline_threshold,
|
||||
_record_poll_failure,
|
||||
_record_poll_success,
|
||||
)
|
||||
|
||||
|
||||
def test_offline_threshold_floor():
|
||||
assert _get_offline_threshold(30) == HEALTH_OFFLINE_MIN_SECONDS
|
||||
assert _get_offline_threshold(60) == 180
|
||||
|
||||
|
||||
def test_offline_threshold_scales_with_interval():
|
||||
assert _get_offline_threshold(120) == 360
|
||||
|
||||
|
||||
def test_poll_success_sets_health_key(fake_redis):
|
||||
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
|
||||
_record_poll_success("u1", snap, 30)
|
||||
assert fake_redis.get("ups:health:last_ok:u1") is not None
|
||||
assert fake_redis.get("ups:health:fail_count:u1") is None
|
||||
|
||||
|
||||
def test_poll_success_clears_fail_count(fake_redis):
|
||||
fake_redis.set("ups:health:fail_count:u1", "5")
|
||||
_record_poll_success("u1", {}, 30)
|
||||
assert fake_redis.get("ups:health:fail_count:u1") is None
|
||||
|
||||
|
||||
def test_poll_success_recovery_emits_info(fake_redis, clean_config_cache):
|
||||
from unittest.mock import patch
|
||||
fake_redis.set("ups:health:offline:u1", "1")
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
_record_poll_success("u1", {}, 30)
|
||||
# Offline flag cleared
|
||||
assert fake_redis.get("ups:health:offline:u1") is None
|
||||
# History has recovery entry
|
||||
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||
assert any("REACHABLE" in e for e in entries)
|
||||
|
||||
|
||||
def test_battery_history_appended(fake_redis):
|
||||
snap = {"BCHARGE": "100", "TIMELEFT": "45 Minutes", "BATTV": "13.5", "NOMBATTV": "12"}
|
||||
_record_poll_success("u1", snap, 30)
|
||||
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
|
||||
assert len(entries) == 1
|
||||
parsed = json.loads(entries[0])
|
||||
assert parsed["bcharge"] == 100.0
|
||||
assert parsed["battv"] == 13.5
|
||||
|
||||
|
||||
def test_battery_history_throttled_at_60s(fake_redis):
|
||||
snap = {"BCHARGE": "100", "TIMELEFT": "45", "BATTV": "13", "NOMBATTV": "12"}
|
||||
_record_poll_success("u1", snap, 30)
|
||||
# Immediately call again: no new sample (60s gate)
|
||||
_record_poll_success("u1", snap, 30)
|
||||
entries = fake_redis.lrange("ups:battery:history:u1", 0, -1)
|
||||
assert len(entries) == 1
|
||||
|
||||
|
||||
def test_poll_failure_increments_count(fake_redis):
|
||||
_record_poll_failure("u1", 30, "timeout")
|
||||
assert fake_redis.get("ups:health:fail_count:u1") == "1"
|
||||
_record_poll_failure("u1", 30, "timeout")
|
||||
assert fake_redis.get("ups:health:fail_count:u1") == "2"
|
||||
|
||||
|
||||
def test_poll_failure_emits_offline_after_threshold(fake_redis, clean_config_cache):
|
||||
from unittest.mock import patch
|
||||
# Simulate an old last_ok so we cross the threshold
|
||||
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
_record_poll_failure("u1", 30, "connection refused")
|
||||
# Offline flag now set
|
||||
assert fake_redis.get("ups:health:offline:u1") == "1"
|
||||
# Alert recorded in global history
|
||||
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||
assert any("COMMLOST" in e for e in entries)
|
||||
|
||||
|
||||
def test_poll_failure_idempotent_while_offline(fake_redis, clean_config_cache):
|
||||
from unittest.mock import patch
|
||||
fake_redis.set("ups:health:last_ok:u1", str(int(time.time()) - 1000))
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
_record_poll_failure("u1", 30, "x")
|
||||
_record_poll_failure("u1", 30, "x")
|
||||
# Only one COMMLOST alert
|
||||
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||
assert sum("COMMLOST" in e for e in entries) == 1
|
||||
|
||||
|
||||
def test_poll_failure_below_threshold_no_alert(fake_redis, clean_config_cache):
|
||||
from unittest.mock import patch
|
||||
# Recent last_ok -> under threshold
|
||||
fake_redis.set("ups:health:last_ok:u1", str(int(time.time())))
|
||||
with patch("app.alerts.dispatch_alerts"):
|
||||
_record_poll_failure("u1", 30, "transient")
|
||||
assert fake_redis.get("ups:health:offline:u1") is None
|
||||
entries = fake_redis.lrange("ups:alerts:history:all", 0, -1)
|
||||
assert not any("COMMLOST" in e for e in entries)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""SSRF / config validators + SMTP password redaction."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import SMTPConfig, UPSConfig
|
||||
from app.config_manager import smtp_redacted_dict
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def test_valid_ups_config():
|
||||
u = UPSConfig(name="rack1", host="192.168.1.10", port=3551, interval_seconds=30)
|
||||
assert u.host == "192.168.1.10"
|
||||
assert u.interval_seconds == 30
|
||||
|
||||
|
||||
def test_reject_loopback_literal():
|
||||
with pytest.raises(Exception, match="loopback"):
|
||||
UPSConfig(name="bad", host="localhost")
|
||||
|
||||
|
||||
def test_reject_loopback_ip():
|
||||
with pytest.raises(Exception, match="not a valid polling target"):
|
||||
UPSConfig(name="bad", host="127.0.0.1")
|
||||
|
||||
|
||||
def test_reject_ipv6_loopback():
|
||||
with pytest.raises(Exception, match="not a valid polling target"):
|
||||
UPSConfig(name="bad", host="::1")
|
||||
|
||||
|
||||
def test_reject_link_local():
|
||||
with pytest.raises(Exception, match="not a valid polling target"):
|
||||
UPSConfig(name="bad", host="169.254.1.1")
|
||||
|
||||
|
||||
def test_reject_private_ip_when_disabled(monkeypatch):
|
||||
monkeypatch.setattr(settings, "allow_private_ips", False)
|
||||
with pytest.raises(Exception, match="private IP"):
|
||||
UPSConfig(name="bad", host="10.0.0.5")
|
||||
|
||||
|
||||
def test_accept_private_ip_when_enabled(monkeypatch):
|
||||
monkeypatch.setattr(settings, "allow_private_ips", True)
|
||||
u = UPSConfig(name="good", host="10.0.0.5")
|
||||
assert u.host == "10.0.0.5"
|
||||
|
||||
|
||||
def test_reject_empty_name():
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="", host="192.168.1.10")
|
||||
|
||||
|
||||
def test_reject_name_too_long():
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="a" * 33, host="192.168.1.10")
|
||||
|
||||
|
||||
def test_reject_bad_name_chars():
|
||||
with pytest.raises(Exception, match="alphanumeric"):
|
||||
UPSConfig(name="bad name!", host="192.168.1.10")
|
||||
|
||||
|
||||
def test_accept_valid_name_chars():
|
||||
u = UPSConfig(name="ups_rack-01", host="192.168.1.10")
|
||||
assert u.name == "ups_rack-01"
|
||||
|
||||
|
||||
def test_reject_invalid_port():
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="x", host="192.168.1.10", port=0)
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="x", host="192.168.1.10", port=70000)
|
||||
|
||||
|
||||
def test_reject_invalid_interval():
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="x", host="192.168.1.10", interval_seconds=1)
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="x", host="192.168.1.10", interval_seconds=99999)
|
||||
|
||||
|
||||
def test_accept_valid_hostname():
|
||||
u = UPSConfig(name="dns", host="ups.lan.example.com")
|
||||
assert u.host == "ups.lan.example.com"
|
||||
|
||||
|
||||
def test_reject_bad_hostname():
|
||||
with pytest.raises(Exception):
|
||||
UPSConfig(name="x", host="not valid host")
|
||||
|
||||
|
||||
def test_smtp_model_has_no_password_field():
|
||||
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||
assert not hasattr(s, "password")
|
||||
|
||||
|
||||
def test_smtp_redacted_none():
|
||||
assert smtp_redacted_dict(None) is None
|
||||
|
||||
|
||||
def test_smtp_redacted_env_set(monkeypatch):
|
||||
monkeypatch.setattr(settings, "smtp_password", "secret")
|
||||
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||
d = smtp_redacted_dict(s)
|
||||
assert d["password"] == "***"
|
||||
|
||||
|
||||
def test_smtp_redacted_env_unset(monkeypatch):
|
||||
monkeypatch.setattr(settings, "smtp_password", None)
|
||||
s = SMTPConfig(host="smtp.example.com", port=587)
|
||||
d = smtp_redacted_dict(s)
|
||||
assert d["password"] is None
|
||||
Reference in new issue
Block a user