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