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:
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user