feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)

Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:19:14 +10:00
parent 86c602cf07
commit 563b836acc
32 changed files with 1916 additions and 45 deletions
+1
View File
@@ -223,6 +223,7 @@ def test_notify_daily_report_bypasses_min_severity():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
assert APP_VERSION == "0.8.3"
assert APP_VERSION == "0.9.0"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.8.3"
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.0"
+150
View File
@@ -0,0 +1,150 @@
"""Mobile / Seaca API."""
from datetime import datetime, timedelta, timezone
from app.models.mobile_device import MobileDevice
from app.models.mobile_enrollment_code import MobileEnrollmentCode
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
from app.services.mobile_tokens import hash_mobile_secret
def _enable_mobile(db_session):
row = db_session.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
if row is None:
row = MobileSettings(id=MOBILE_SETTINGS_ROW_ID, devices_allowed=True, max_devices_per_user=3)
db_session.add(row)
else:
row.devices_allowed = True
db_session.commit()
def test_mobile_settings_admin(jwt_headers, client, db_session):
_enable_mobile(db_session)
response = client.get("/api/v1/mobile/settings", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["devices_allowed"] is True
assert body["max_devices_per_user"] == 3
def test_create_enrollment_code_and_enroll(jwt_headers, client, db_session):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"label": "test phone", "login_mode": "password", "expires_in_hours": 1},
)
assert create.status_code == 200
created = create.json()
assert created["enrollment_code"].startswith("sacmob_")
assert created["code_prefix"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": created["enrollment_code"],
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-test-device-001",
"display_name": "Pixel Test",
"fcm_token": "fcm-token-test",
},
)
assert enroll.status_code == 200
body = enroll.json()
assert body["username"] == "test-monitor"
assert body["role"] == "monitor"
assert body["device_id"] > 0
assert body["access_token"]
assert body["refresh_token"]
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert len(devices.json()) == 1
assert devices.json()[0]["display_name"] == "Pixel Test"
def test_enroll_blocked_when_disabled(client, db_session, jwt_headers):
code_plain = "sacmob_testcode123456"
db_session.add(
MobileEnrollmentCode(
label="x",
code_hash=hash_mobile_secret(code_plain),
code_prefix=code_plain[:12],
login_mode="password",
max_uses=1,
use_count=0,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
)
db_session.commit()
response = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code_plain,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-disabled-001",
},
)
assert response.status_code == 403
def test_refresh_token_rotation(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-admin",
"password": "test-admin-password",
"device_uuid": "uuid-refresh-001",
},
)
data = enroll.json()
refresh = client.post(
"/api/v1/mobile/auth/refresh",
json={"refresh_token": data["refresh_token"], "device_uuid": "uuid-refresh-001"},
)
assert refresh.status_code == 200
refreshed = refresh.json()
assert refreshed["refresh_token"] != data["refresh_token"]
assert refreshed["device_id"] == data["device_id"]
def test_revoke_device_blocks_token(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-revoke-001",
},
)
data = enroll.json()
headers = {"Authorization": f"Bearer {data['access_token']}"}
revoke = client.delete(f"/api/v1/mobile/devices/{data['device_id']}", headers=jwt_headers)
assert revoke.status_code == 200
me = client.put(
"/api/v1/mobile/devices/me/fcm",
headers=headers,
json={"fcm_token": "new-token"},
)
assert me.status_code == 401
@@ -25,6 +25,7 @@ def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch):
"use_telegram": True,
"use_webhook": True,
"use_email": False,
"use_mobile": False,
},
)
assert response.status_code == 200
@@ -58,6 +59,7 @@ def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch):
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
)
row = db_session.get(NotificationChannel, "telegram")
assert row.min_severity == "critical"
+10
View File
@@ -25,6 +25,7 @@ def test_notify_event_calls_selected_channels():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -50,6 +51,7 @@ def test_notify_event_calls_selected_channels():
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -80,6 +82,7 @@ def test_notify_event_skipped_by_cooldown():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -107,6 +110,7 @@ def test_notify_auth_login_bypasses_min_severity():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -134,6 +138,7 @@ def test_notify_rdg_connection_bypasses_min_severity():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -161,6 +166,7 @@ def test_notify_auth_login_skips_telegram_when_via_agent():
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -190,6 +196,7 @@ def test_notify_lifecycle_bypasses_min_severity():
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -217,6 +224,7 @@ def test_notify_lifecycle_skips_telegram_when_via_agent():
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -246,6 +254,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent():
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
@@ -275,6 +284,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent():
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
+1
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from app.models import Host, Problem
from tests.test_ingest import VALID_EVENT
def _event_payload(**overrides):