From 3d36faa49d102d41199ad33168869044fff45e54 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Sat, 13 Jun 2026 16:36:07 +1000 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B9=20enroll=20=D0=B8=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=20=D0=B2=20UI=20(0.9.10).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При повторной привязке Seaca переиспользуется запись device_uuid вместо INSERT. Добавлены POST /devices/{id}/revoke и DELETE для полного удаления записи в настройках SAC. --- backend/app/api/v1/mobile.py | 19 +++++- backend/app/services/mobile_devices.py | 9 ++- backend/app/services/mobile_enrollment.py | 61 +++++++++++------ backend/app/version.py | 4 +- backend/tests/test_health.py | 6 +- backend/tests/test_mobile_api.py | 81 ++++++++++++++++++++++- frontend/package.json | 4 +- frontend/src/api.ts | 8 ++- frontend/src/version.ts | 4 +- frontend/src/views/SettingsView.vue | 29 +++++++- 10 files changed, 186 insertions(+), 39 deletions(-) diff --git a/backend/app/api/v1/mobile.py b/backend/app/api/v1/mobile.py index d7c53dd..9c1cbcf 100644 --- a/backend/app/api/v1/mobile.py +++ b/backend/app/api/v1/mobile.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field @@ -13,6 +13,7 @@ from app.services.mobile_devices import ( get_device_or_raise, list_mobile_devices, revoke_device, + delete_device_record, touch_device, update_fcm_token, assert_device_active, @@ -257,8 +258,8 @@ def get_devices( return [_device_to_response(item) for item in list_mobile_devices(db)] -@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse) -def delete_device( +@router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse) +def post_revoke_device( device_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), @@ -270,6 +271,18 @@ def delete_device( return _device_to_response(summary) +@router.delete("/devices/{device_id}", status_code=204) +def delete_device( + device_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> None: + try: + delete_device_record(db, device_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/devices/{device_id}/test-push", response_model=TestPushResponse) def test_device_push( device_id: int, diff --git a/backend/app/services/mobile_devices.py b/backend/app/services/mobile_devices.py index 28739cc..4bc4051 100644 --- a/backend/app/services/mobile_devices.py +++ b/backend/app/services/mobile_devices.py @@ -1,4 +1,4 @@ -"""Управление зарегистрированными устройствами Seaca.""" +"""Управление зарегистрированными устройствами Seaca.""" from __future__ import annotations @@ -86,6 +86,13 @@ def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary: return _to_summary(device, username) +def delete_device_record(db: Session, device_id: int) -> None: + device = get_device_or_raise(db, device_id) + revoke_refresh_tokens_for_device(db, device.id) + db.delete(device) + db.commit() + + def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None: cleaned = (fcm_token or "").strip() if not cleaned: diff --git a/backend/app/services/mobile_enrollment.py b/backend/app/services/mobile_enrollment.py index b1bba41..3b91998 100644 --- a/backend/app/services/mobile_enrollment.py +++ b/backend/app/services/mobile_enrollment.py @@ -1,4 +1,4 @@ -"""Создание и проверка кодов регистрации Seaca.""" +"""Создание и проверка кодов регистрации Seaca.""" from __future__ import annotations @@ -223,31 +223,50 @@ def enroll_device( if code_row.target_user_id is not None and user.id != code_row.target_user_id: raise ValueError("enrollment code is bound to another user") + existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid)) + if existing is not None and existing.user_id != user.id: + raise ValueError("device_uuid already registered to another user") + active_count = _count_active_devices(db, user.id) + if ( + existing is not None + and existing.user_id == user.id + and existing.revoked_at is None + ): + active_count -= 1 if active_count >= mobile_cfg.max_devices_per_user: raise ValueError("device limit reached for this user") - existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid)) - if existing is not None: - if existing.revoked_at is None and existing.user_id != user.id: - raise ValueError("device_uuid already registered to another user") - if existing.revoked_at is None: - revoke_refresh_tokens_for_device(db, existing.id) - existing.revoked_at = now - existing = None + cleaned_display = (display_name or "").strip() or "Android" + cleaned_platform = (platform or "android").strip() or "android" + cleaned_app_version = (app_version or "").strip() or None + cleaned_fcm = (fcm_token or "").strip() or None - device = MobileDevice( - user_id=user.id, - device_uuid=cleaned_uuid, - display_name=(display_name or "").strip() or "Android", - platform=(platform or "android").strip() or "android", - app_version=(app_version or "").strip() or None, - fcm_token=(fcm_token or "").strip() or None, - fcm_token_updated_at=now if fcm_token else None, - enrollment_code_id=code_row.id, - last_seen_at=now, - ) - db.add(device) + if existing is not None: + revoke_refresh_tokens_for_device(db, existing.id) + existing.revoked_at = None + existing.user_id = user.id + existing.display_name = cleaned_display + existing.platform = cleaned_platform + existing.app_version = cleaned_app_version + existing.fcm_token = cleaned_fcm + existing.fcm_token_updated_at = now if cleaned_fcm else None + existing.enrollment_code_id = code_row.id + existing.last_seen_at = now + device = existing + else: + device = MobileDevice( + user_id=user.id, + device_uuid=cleaned_uuid, + display_name=cleaned_display, + platform=cleaned_platform, + app_version=cleaned_app_version, + fcm_token=cleaned_fcm, + fcm_token_updated_at=now if cleaned_fcm else None, + enrollment_code_id=code_row.id, + last_seen_at=now, + ) + db.add(device) code_row.use_count += 1 db.flush() diff --git a/backend/app/version.py b/backend/app/version.py index d082a0e..3af75ca 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ -"""Единый источник версии SAC (API, health, логи, OpenAPI).""" +"""Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.9.9" +APP_VERSION = "0.9.10" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 757ec17..cbf17d3 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,9 +1,9 @@ -"""Version and health contract smoke tests (no DB required).""" +"""Version and health contract smoke tests (no DB required).""" from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.9.0" + assert APP_VERSION == "0.9.10" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.9.0" + assert APP_VERSION_LABEL == "Security Alert Center v.0.9.10" diff --git a/backend/tests/test_mobile_api.py b/backend/tests/test_mobile_api.py index bd88da9..023e324 100644 --- a/backend/tests/test_mobile_api.py +++ b/backend/tests/test_mobile_api.py @@ -1,4 +1,4 @@ -"""Mobile / Seaca API.""" +"""Mobile / Seaca API.""" from datetime import datetime, timedelta, timezone @@ -139,7 +139,7 @@ def test_revoke_device_blocks_token(client, db_session, jwt_headers): data = enroll.json() headers = {"Authorization": f"Bearer {data['access_token']}"} - revoke = client.delete(f"/api/v1/mobile/devices/{data['device_id']}", headers=jwt_headers) + revoke = client.post(f"/api/v1/mobile/devices/{data['device_id']}/revoke", headers=jwt_headers) assert revoke.status_code == 200 me = client.put( @@ -148,3 +148,80 @@ def test_revoke_device_blocks_token(client, db_session, jwt_headers): json={"fcm_token": "new-token"}, ) assert me.status_code == 401 + + +def test_reenroll_same_device_after_revoke(client, db_session, jwt_headers): + _enable_mobile(db_session) + device_uuid = "uuid-reenroll-001" + + first_code = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ).json()["enrollment_code"] + first = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": first_code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": device_uuid, + "display_name": "Phone A", + }, + ) + assert first.status_code == 200 + first_device_id = first.json()["device_id"] + + revoke = client.post(f"/api/v1/mobile/devices/{first_device_id}/revoke", headers=jwt_headers) + assert revoke.status_code == 200 + + second_code = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ).json()["enrollment_code"] + second = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": second_code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": device_uuid, + "display_name": "Phone B", + }, + ) + assert second.status_code == 200 + assert second.json()["device_id"] == first_device_id + + 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"] == "Phone B" + assert devices.json()[0]["is_active"] is True + + +def test_delete_device_record(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-delete-001", + }, + ) + device_id = enroll.json()["device_id"] + + deleted = client.delete(f"/api/v1/mobile/devices/{device_id}", headers=jwt_headers) + assert deleted.status_code == 204 + + devices = client.get("/api/v1/mobile/devices", headers=jwt_headers) + assert devices.status_code == 200 + assert devices.json() == [] diff --git a/frontend/package.json b/frontend/package.json index b7afd59..5add440 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ -{ +{ "name": "sac-ui", "private": true, - "version": "0.7.4", + "version": "0.9.10", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c9ae810..76d4665 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -const TOKEN_KEY = "sac_token"; +const TOKEN_KEY = "sac_token"; const ROLE_KEY = "sac_role"; export function getToken(): string | null { @@ -373,7 +373,11 @@ export function fetchMobileDevices(): Promise { } export function revokeMobileDevice(deviceId: number): Promise { - return apiFetch(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" }); + return apiFetch(`/api/v1/mobile/devices/${deviceId}/revoke`, { method: "POST" }); +} + +export function deleteMobileDevice(deviceId: number): Promise { + return apiFetch(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" }); } export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 3d1e145..56f486f 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ -/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ +/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.9.9"; +export const APP_VERSION = "0.9.10"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index edf9012..e0b4b35 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,4 +1,4 @@ -