mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-26 15:41:58 -06:00 
			
		
		
		
	Restore agent detail ingestion and device description updates
This commit is contained in:
		| @@ -228,6 +228,10 @@ def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]: | ||||
|         "agent_guid": record.get("guid") or record.get("agent_guid") or "", | ||||
|         "connection_type": record.get("connection_type") or "", | ||||
|         "connection_endpoint": record.get("connection_endpoint") or "", | ||||
|         "ssl_key_fingerprint": record.get("ssl_key_fingerprint") or "", | ||||
|         "status": record.get("status") or "", | ||||
|         "token_version": record.get("token_version") or 0, | ||||
|         "key_added_at": record.get("key_added_at") or "", | ||||
|         "created_at": record.get("created_at") or 0, | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,10 @@ from flask import Blueprint, Flask, current_app, g, jsonify, request | ||||
| from Data.Engine.builders.device_auth import DeviceAuthRequestBuilder | ||||
| from Data.Engine.domain.device_auth import DeviceAuthContext, DeviceAuthFailure | ||||
| from Data.Engine.services.container import EngineServiceContainer | ||||
| from Data.Engine.services.devices.device_inventory_service import DeviceHeartbeatError | ||||
| from Data.Engine.services.devices.device_inventory_service import ( | ||||
|     DeviceDetailsError, | ||||
|     DeviceHeartbeatError, | ||||
| ) | ||||
|  | ||||
| AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context" | ||||
|  | ||||
| @@ -110,4 +113,36 @@ def script_request() -> Any: | ||||
|     return jsonify(response) | ||||
|  | ||||
|  | ||||
| __all__ = ["register", "blueprint", "heartbeat", "script_request", "require_device_auth"] | ||||
| @blueprint.route("/api/agent/details", methods=["POST"]) | ||||
| @require_device_auth | ||||
| def save_details() -> Any: | ||||
|     services = _services() | ||||
|     payload = request.get_json(force=True, silent=True) or {} | ||||
|     context = cast(DeviceAuthContext, g.device_auth) | ||||
|  | ||||
|     try: | ||||
|         services.device_inventory.save_agent_details(context=context, payload=payload) | ||||
|     except DeviceDetailsError as exc: | ||||
|         error_payload = {"error": exc.code} | ||||
|         if exc.code == "invalid_payload": | ||||
|             return jsonify(error_payload), 400 | ||||
|         if exc.code in {"fingerprint_mismatch", "guid_mismatch"}: | ||||
|             return jsonify(error_payload), 403 | ||||
|         if exc.code == "device_not_registered": | ||||
|             return jsonify(error_payload), 404 | ||||
|         current_app.logger.exception( | ||||
|             "device-details-error guid=%s code=%s", context.identity.guid.value, exc.code | ||||
|         ) | ||||
|         return jsonify(error_payload), 500 | ||||
|  | ||||
|     return jsonify({"status": "ok"}) | ||||
|  | ||||
|  | ||||
| __all__ = [ | ||||
|     "register", | ||||
|     "blueprint", | ||||
|     "heartbeat", | ||||
|     "script_request", | ||||
|     "save_details", | ||||
|     "require_device_auth", | ||||
| ] | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from ipaddress import ip_address | ||||
| from flask import Blueprint, Flask, current_app, jsonify, request, session | ||||
|  | ||||
| from Data.Engine.services.container import EngineServiceContainer | ||||
| from Data.Engine.services.devices import RemoteDeviceError | ||||
| from Data.Engine.services.devices import DeviceDescriptionError, RemoteDeviceError | ||||
|  | ||||
| blueprint = Blueprint("engine_devices", __name__) | ||||
|  | ||||
| @@ -64,6 +64,24 @@ def get_device_by_guid(guid: str) -> object: | ||||
|     return jsonify(device) | ||||
|  | ||||
|  | ||||
| @blueprint.route("/api/device/description/<hostname>", methods=["POST"]) | ||||
| def set_device_description(hostname: str) -> object: | ||||
|     payload = request.get_json(silent=True) or {} | ||||
|     description = payload.get("description") | ||||
|     try: | ||||
|         _inventory().update_device_description(hostname, description) | ||||
|     except DeviceDescriptionError as exc: | ||||
|         if exc.code == "invalid_hostname": | ||||
|             return jsonify({"error": "invalid hostname"}), 400 | ||||
|         if exc.code == "not_found": | ||||
|             return jsonify({"error": "not found"}), 404 | ||||
|         current_app.logger.exception( | ||||
|             "device-description-error host=%s code=%s", hostname, exc.code | ||||
|         ) | ||||
|         return jsonify({"error": "internal error"}), 500 | ||||
|     return jsonify({"status": "ok"}) | ||||
|  | ||||
|  | ||||
| @blueprint.route("/api/agent_devices", methods=["GET"]) | ||||
| def list_agent_devices() -> object: | ||||
|     guard = _require_admin() | ||||
|   | ||||
| @@ -5,6 +5,7 @@ from __future__ import annotations | ||||
| import logging | ||||
| import sqlite3 | ||||
| import time | ||||
| import uuid | ||||
| from contextlib import closing | ||||
| from typing import Any, Dict, List, Optional, Tuple | ||||
|  | ||||
| @@ -158,8 +159,12 @@ class SQLiteDeviceInventoryRepository: | ||||
|                 agent_id, | ||||
|                 ansible_ee_ver, | ||||
|                 connection_type, | ||||
|                 connection_endpoint | ||||
|             ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) | ||||
|                 connection_endpoint, | ||||
|                 ssl_key_fingerprint, | ||||
|                 token_version, | ||||
|                 status, | ||||
|                 key_added_at | ||||
|             ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) | ||||
|             ON CONFLICT(hostname) DO UPDATE SET | ||||
|                 description=excluded.description, | ||||
|                 created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at), | ||||
| @@ -182,7 +187,11 @@ class SQLiteDeviceInventoryRepository: | ||||
|                 agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id), | ||||
|                 ansible_ee_ver=COALESCE(NULLIF(excluded.ansible_ee_ver, ''), {DEVICE_TABLE}.ansible_ee_ver), | ||||
|                 connection_type=COALESCE(NULLIF(excluded.connection_type, ''), {DEVICE_TABLE}.connection_type), | ||||
|                 connection_endpoint=COALESCE(NULLIF(excluded.connection_endpoint, ''), {DEVICE_TABLE}.connection_endpoint) | ||||
|                 connection_endpoint=COALESCE(NULLIF(excluded.connection_endpoint, ''), {DEVICE_TABLE}.connection_endpoint), | ||||
|                 ssl_key_fingerprint=COALESCE(NULLIF(excluded.ssl_key_fingerprint, ''), {DEVICE_TABLE}.ssl_key_fingerprint), | ||||
|                 token_version=COALESCE(NULLIF(excluded.token_version, 0), {DEVICE_TABLE}.token_version), | ||||
|                 status=COALESCE(NULLIF(excluded.status, ''), {DEVICE_TABLE}.status), | ||||
|                 key_added_at=COALESCE(NULLIF(excluded.key_added_at, ''), {DEVICE_TABLE}.key_added_at) | ||||
|         """ | ||||
|  | ||||
|         params: List[Any] = [ | ||||
| @@ -209,6 +218,10 @@ class SQLiteDeviceInventoryRepository: | ||||
|             column_values.get("ansible_ee_ver"), | ||||
|             column_values.get("connection_type"), | ||||
|             column_values.get("connection_endpoint"), | ||||
|             column_values.get("ssl_key_fingerprint"), | ||||
|             column_values.get("token_version"), | ||||
|             column_values.get("status"), | ||||
|             column_values.get("key_added_at"), | ||||
|         ] | ||||
|  | ||||
|         with closing(self._connections()) as conn: | ||||
| @@ -223,6 +236,42 @@ class SQLiteDeviceInventoryRepository: | ||||
|             cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (hostname,)) | ||||
|             conn.commit() | ||||
|  | ||||
|     def record_device_fingerprint(self, guid: Optional[str], fingerprint: Optional[str], added_at: str) -> None: | ||||
|         normalized_guid = clean_device_str(guid) | ||||
|         normalized_fp = clean_device_str(fingerprint) | ||||
|         if not normalized_guid or not normalized_fp: | ||||
|             return | ||||
|  | ||||
|         with closing(self._connections()) as conn: | ||||
|             cur = conn.cursor() | ||||
|             cur.execute( | ||||
|                 """ | ||||
|                 INSERT OR IGNORE INTO device_keys (id, guid, ssl_key_fingerprint, added_at) | ||||
|                 VALUES (?, ?, ?, ?) | ||||
|                 """, | ||||
|                 (str(uuid.uuid4()), normalized_guid, normalized_fp.lower(), added_at), | ||||
|             ) | ||||
|             cur.execute( | ||||
|                 """ | ||||
|                 UPDATE device_keys | ||||
|                    SET retired_at = ? | ||||
|                  WHERE guid = ? | ||||
|                    AND ssl_key_fingerprint != ? | ||||
|                    AND retired_at IS NULL | ||||
|                 """, | ||||
|                 (added_at, normalized_guid, normalized_fp.lower()), | ||||
|             ) | ||||
|             cur.execute( | ||||
|                 """ | ||||
|                 UPDATE devices | ||||
|                    SET ssl_key_fingerprint = COALESCE(LOWER(?), ssl_key_fingerprint), | ||||
|                        key_added_at = COALESCE(key_added_at, ?) | ||||
|                  WHERE LOWER(guid) = LOWER(?) | ||||
|                 """, | ||||
|                 (normalized_fp, added_at, normalized_guid), | ||||
|             ) | ||||
|             conn.commit() | ||||
|  | ||||
|     def _extract_device_columns(self, details: Dict[str, Any]) -> Dict[str, Any]: | ||||
|         summary = details.get("summary") or {} | ||||
|         payload: Dict[str, Any] = {} | ||||
| @@ -250,4 +299,8 @@ class SQLiteDeviceInventoryRepository: | ||||
|         payload["connection_endpoint"] = clean_device_str( | ||||
|             summary.get("connection_endpoint") or summary.get("endpoint") | ||||
|         ) | ||||
|         payload["ssl_key_fingerprint"] = clean_device_str(summary.get("ssl_key_fingerprint")) | ||||
|         payload["token_version"] = coerce_int(summary.get("token_version")) or 0 | ||||
|         payload["status"] = clean_device_str(summary.get("status")) | ||||
|         payload["key_added_at"] = clean_device_str(summary.get("key_added_at")) | ||||
|         return payload | ||||
|   | ||||
| @@ -1,4 +1,15 @@ | ||||
| from .device_inventory_service import DeviceInventoryService, RemoteDeviceError | ||||
| from .device_inventory_service import ( | ||||
|     DeviceDescriptionError, | ||||
|     DeviceDetailsError, | ||||
|     DeviceInventoryService, | ||||
|     RemoteDeviceError, | ||||
| ) | ||||
| from .device_view_service import DeviceViewService | ||||
|  | ||||
| __all__ = ["DeviceInventoryService", "RemoteDeviceError", "DeviceViewService"] | ||||
| __all__ = [ | ||||
|     "DeviceInventoryService", | ||||
|     "RemoteDeviceError", | ||||
|     "DeviceViewService", | ||||
|     "DeviceDetailsError", | ||||
|     "DeviceDescriptionError", | ||||
| ] | ||||
|   | ||||
| @@ -2,19 +2,27 @@ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import json | ||||
| import logging | ||||
| import sqlite3 | ||||
| import time | ||||
| from datetime import datetime, timezone | ||||
| from collections.abc import Mapping | ||||
| from typing import Any, Dict, List, Optional | ||||
|  | ||||
| from Data.Engine.repositories.sqlite.device_inventory_repository import ( | ||||
|     SQLiteDeviceInventoryRepository, | ||||
| ) | ||||
| from Data.Engine.domain.device_auth import DeviceAuthContext | ||||
| from Data.Engine.domain.device_auth import DeviceAuthContext, normalize_guid | ||||
| from Data.Engine.domain.devices import clean_device_str, coerce_int | ||||
|  | ||||
| __all__ = ["DeviceInventoryService", "RemoteDeviceError", "DeviceHeartbeatError"] | ||||
| __all__ = [ | ||||
|     "DeviceInventoryService", | ||||
|     "RemoteDeviceError", | ||||
|     "DeviceHeartbeatError", | ||||
|     "DeviceDetailsError", | ||||
|     "DeviceDescriptionError", | ||||
| ] | ||||
|  | ||||
|  | ||||
| class RemoteDeviceError(Exception): | ||||
| @@ -29,6 +37,18 @@ class DeviceHeartbeatError(Exception): | ||||
|         self.code = code | ||||
|  | ||||
|  | ||||
| class DeviceDetailsError(Exception): | ||||
|     def __init__(self, code: str, message: Optional[str] = None) -> None: | ||||
|         super().__init__(message or code) | ||||
|         self.code = code | ||||
|  | ||||
|  | ||||
| class DeviceDescriptionError(Exception): | ||||
|     def __init__(self, code: str, message: Optional[str] = None) -> None: | ||||
|         super().__init__(message or code) | ||||
|         self.code = code | ||||
|  | ||||
|  | ||||
| class DeviceInventoryService: | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -220,7 +240,7 @@ class DeviceInventoryService: | ||||
|         summary["hostname"] = hostname | ||||
|  | ||||
|         if metrics: | ||||
|             last_user = metrics.get("last_user") or metrics.get("username") | ||||
|             last_user = metrics.get("last_user") | ||||
|             if last_user: | ||||
|                 cleaned_user = clean_device_str(last_user) | ||||
|                 if cleaned_user: | ||||
| @@ -300,3 +320,182 @@ class DeviceInventoryService: | ||||
|             ) | ||||
|             raise DeviceHeartbeatError("storage_error", "failed to persist heartbeat") from exc | ||||
|  | ||||
|     # ------------------------------------------------------------------ | ||||
|     # Agent details | ||||
|     # ------------------------------------------------------------------ | ||||
|     @staticmethod | ||||
|     def _is_empty(value: Any) -> bool: | ||||
|         return value in (None, "", [], {}) | ||||
|  | ||||
|     @classmethod | ||||
|     def _deep_merge_preserve(cls, prev: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]: | ||||
|         merged: Dict[str, Any] = dict(prev or {}) | ||||
|         for key, value in (incoming or {}).items(): | ||||
|             if isinstance(value, Mapping): | ||||
|                 existing = merged.get(key) | ||||
|                 if not isinstance(existing, Mapping): | ||||
|                     existing = {} | ||||
|                 merged[key] = cls._deep_merge_preserve(dict(existing), dict(value)) | ||||
|             elif isinstance(value, list): | ||||
|                 if value: | ||||
|                     merged[key] = value | ||||
|             else: | ||||
|                 if cls._is_empty(value): | ||||
|                     continue | ||||
|                 merged[key] = value | ||||
|         return merged | ||||
|  | ||||
|     def save_agent_details( | ||||
|         self, | ||||
|         *, | ||||
|         context: DeviceAuthContext, | ||||
|         payload: Mapping[str, Any], | ||||
|     ) -> None: | ||||
|         hostname = clean_device_str(payload.get("hostname")) | ||||
|         details_raw = payload.get("details") | ||||
|         agent_id = clean_device_str(payload.get("agent_id")) | ||||
|         agent_hash = clean_device_str(payload.get("agent_hash")) | ||||
|  | ||||
|         if not isinstance(details_raw, Mapping): | ||||
|             raise DeviceDetailsError("invalid_payload", "details object required") | ||||
|  | ||||
|         details_dict: Dict[str, Any] | ||||
|         try: | ||||
|             details_dict = json.loads(json.dumps(details_raw)) | ||||
|         except Exception: | ||||
|             details_dict = dict(details_raw) | ||||
|  | ||||
|         incoming_summary = dict(details_dict.get("summary") or {}) | ||||
|         if not hostname: | ||||
|             hostname = clean_device_str(incoming_summary.get("hostname")) | ||||
|         if not hostname: | ||||
|             raise DeviceDetailsError("invalid_payload", "hostname required") | ||||
|  | ||||
|         snapshot = self._repo.load_snapshot(hostname=hostname) | ||||
|         if not snapshot: | ||||
|             snapshot = {} | ||||
|  | ||||
|         previous_details = snapshot.get("details") | ||||
|         if isinstance(previous_details, Mapping): | ||||
|             try: | ||||
|                 prev_details = json.loads(json.dumps(previous_details)) | ||||
|             except Exception: | ||||
|                 prev_details = dict(previous_details) | ||||
|         else: | ||||
|             prev_details = {} | ||||
|  | ||||
|         prev_summary = dict(prev_details.get("summary") or {}) | ||||
|  | ||||
|         existing_guid = clean_device_str(snapshot.get("guid") or snapshot.get("summary", {}).get("agent_guid")) | ||||
|         normalized_existing_guid = normalize_guid(existing_guid) | ||||
|         auth_guid = context.identity.guid.value | ||||
|  | ||||
|         if normalized_existing_guid and normalized_existing_guid != auth_guid: | ||||
|             raise DeviceDetailsError("guid_mismatch", "device guid mismatch") | ||||
|  | ||||
|         fingerprint = context.identity.fingerprint.value.lower() | ||||
|         stored_fp = clean_device_str(snapshot.get("summary", {}).get("ssl_key_fingerprint")) | ||||
|         if stored_fp and stored_fp.lower() != fingerprint: | ||||
|             raise DeviceDetailsError("fingerprint_mismatch", "device fingerprint mismatch") | ||||
|  | ||||
|         incoming_summary.setdefault("hostname", hostname) | ||||
|         if agent_id and not incoming_summary.get("agent_id"): | ||||
|             incoming_summary["agent_id"] = agent_id | ||||
|         if agent_hash: | ||||
|             incoming_summary["agent_hash"] = agent_hash | ||||
|         incoming_summary["agent_guid"] = auth_guid | ||||
|         if fingerprint: | ||||
|             incoming_summary["ssl_key_fingerprint"] = fingerprint | ||||
|         if not incoming_summary.get("last_seen") and prev_summary.get("last_seen"): | ||||
|             incoming_summary["last_seen"] = prev_summary.get("last_seen") | ||||
|  | ||||
|         details_dict["summary"] = incoming_summary | ||||
|         merged_details = self._deep_merge_preserve(prev_details, details_dict) | ||||
|         merged_summary = merged_details.setdefault("summary", {}) | ||||
|  | ||||
|         if not merged_summary.get("last_user") and prev_summary.get("last_user"): | ||||
|             merged_summary["last_user"] = prev_summary.get("last_user") | ||||
|  | ||||
|         created_at = coerce_int(merged_summary.get("created_at")) | ||||
|         if created_at is None: | ||||
|             created_at = coerce_int(snapshot.get("created_at")) | ||||
|         if created_at is None: | ||||
|             created_at = int(time.time()) | ||||
|         merged_summary["created_at"] = created_at | ||||
|  | ||||
|         if fingerprint: | ||||
|             merged_summary["ssl_key_fingerprint"] = fingerprint | ||||
|             if not merged_summary.get("key_added_at"): | ||||
|                 merged_summary["key_added_at"] = datetime.now(timezone.utc).isoformat() | ||||
|         if merged_summary.get("token_version") is None: | ||||
|             merged_summary["token_version"] = 1 | ||||
|         if not merged_summary.get("status") and snapshot.get("summary", {}).get("status"): | ||||
|             merged_summary["status"] = snapshot.get("summary", {}).get("status") | ||||
|  | ||||
|         description = clean_device_str(merged_summary.get("description")) | ||||
|         existing_description = snapshot.get("description") if snapshot else "" | ||||
|         description_to_store = description if description is not None else (existing_description or "") | ||||
|  | ||||
|         existing_hash = clean_device_str(snapshot.get("agent_hash") or snapshot.get("summary", {}).get("agent_hash")) | ||||
|         effective_hash = agent_hash or existing_hash | ||||
|  | ||||
|         try: | ||||
|             self._repo.upsert_device( | ||||
|                 hostname, | ||||
|                 description_to_store, | ||||
|                 merged_details, | ||||
|                 created_at, | ||||
|                 agent_hash=effective_hash, | ||||
|                 guid=auth_guid, | ||||
|             ) | ||||
|         except sqlite3.DatabaseError as exc: | ||||
|             raise DeviceDetailsError("storage_error", str(exc)) from exc | ||||
|  | ||||
|         added_at = merged_summary.get("key_added_at") or datetime.now(timezone.utc).isoformat() | ||||
|         self._repo.record_device_fingerprint(auth_guid, fingerprint, added_at) | ||||
|  | ||||
|     # ------------------------------------------------------------------ | ||||
|     # Description management | ||||
|     # ------------------------------------------------------------------ | ||||
|     def update_device_description(self, hostname: str, description: Optional[str]) -> None: | ||||
|         normalized_host = clean_device_str(hostname) | ||||
|         if not normalized_host: | ||||
|             raise DeviceDescriptionError("invalid_hostname", "invalid hostname") | ||||
|  | ||||
|         snapshot = self._repo.load_snapshot(hostname=normalized_host) | ||||
|         if not snapshot: | ||||
|             raise DeviceDescriptionError("not_found", "device not found") | ||||
|  | ||||
|         details = snapshot.get("details") | ||||
|         if isinstance(details, Mapping): | ||||
|             try: | ||||
|                 existing = json.loads(json.dumps(details)) | ||||
|             except Exception: | ||||
|                 existing = dict(details) | ||||
|         else: | ||||
|             existing = {} | ||||
|  | ||||
|         summary = dict(existing.get("summary") or {}) | ||||
|         summary["description"] = description or "" | ||||
|         existing["summary"] = summary | ||||
|  | ||||
|         created_at = coerce_int(summary.get("created_at")) | ||||
|         if created_at is None: | ||||
|             created_at = coerce_int(snapshot.get("created_at")) | ||||
|         if created_at is None: | ||||
|             created_at = int(time.time()) | ||||
|  | ||||
|         agent_hash = clean_device_str(summary.get("agent_hash") or snapshot.get("agent_hash")) | ||||
|         guid = clean_device_str(summary.get("agent_guid") or snapshot.get("guid")) | ||||
|  | ||||
|         try: | ||||
|             self._repo.upsert_device( | ||||
|                 normalized_host, | ||||
|                 description or (snapshot.get("description") or ""), | ||||
|                 existing, | ||||
|                 created_at, | ||||
|                 agent_hash=agent_hash, | ||||
|                 guid=guid, | ||||
|             ) | ||||
|         except sqlite3.DatabaseError as exc: | ||||
|             raise DeviceDescriptionError("storage_error", str(exc)) from exc | ||||
|   | ||||
| @@ -232,3 +232,101 @@ def test_script_request_reports_status_and_signing_key(prepared_app, monkeypatch | ||||
|     assert resp.get_json()["status"] == "quarantined" | ||||
|     assert resp.get_json()["poll_after_ms"] == 60000 | ||||
|  | ||||
|  | ||||
| def test_agent_details_persists_inventory(prepared_app, monkeypatch): | ||||
|     client = prepared_app.test_client() | ||||
|     guid = "5C9D76E4-4C5A-4A5D-9B5D-1C2E3F4A5B6C" | ||||
|     fingerprint = "aa:bb:cc:dd" | ||||
|     hostname = "device-details" | ||||
|     _insert_device(prepared_app, guid, fingerprint, hostname) | ||||
|  | ||||
|     services = prepared_app.extensions["engine_services"] | ||||
|     context = _build_context(guid, fingerprint) | ||||
|     monkeypatch.setattr(services.device_auth, "authenticate", lambda request, path: context) | ||||
|  | ||||
|     payload = { | ||||
|         "hostname": hostname, | ||||
|         "agent_id": "AGENT-01", | ||||
|         "agent_hash": "hash-value", | ||||
|         "details": { | ||||
|             "summary": { | ||||
|                 "hostname": hostname, | ||||
|                 "device_type": "Laptop", | ||||
|                 "last_user": "BUNNY-LAB\\nicole.rappe", | ||||
|                 "operating_system": "Windows 11", | ||||
|                 "description": "Primary workstation", | ||||
|             }, | ||||
|             "memory": [{"slot": "DIMM0", "capacity": 17179869184}], | ||||
|             "storage": [{"model": "NVMe", "size": 512}], | ||||
|             "network": [{"adapter": "Ethernet", "ips": ["192.168.1.50"]}], | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     resp = client.post( | ||||
|         "/api/agent/details", | ||||
|         json=payload, | ||||
|         headers={"Authorization": "Bearer token"}, | ||||
|     ) | ||||
|  | ||||
|     assert resp.status_code == 200 | ||||
|     assert resp.get_json() == {"status": "ok"} | ||||
|  | ||||
|     db_path = Path(prepared_app.config["ENGINE_DATABASE_PATH"]) | ||||
|     with sqlite3.connect(db_path) as conn: | ||||
|         row = conn.execute( | ||||
|             """ | ||||
|             SELECT device_type, last_user, memory, storage, network, description | ||||
|               FROM devices | ||||
|              WHERE guid = ? | ||||
|             """, | ||||
|             (guid,), | ||||
|         ).fetchone() | ||||
|  | ||||
|     assert row is not None | ||||
|     device_type, last_user, memory_json, storage_json, network_json, description = row | ||||
|     assert device_type == "Laptop" | ||||
|     assert last_user == "BUNNY-LAB\\nicole.rappe" | ||||
|     assert description == "Primary workstation" | ||||
|     assert json.loads(memory_json)[0]["capacity"] == 17179869184 | ||||
|     assert json.loads(storage_json)[0]["model"] == "NVMe" | ||||
|     assert json.loads(network_json)[0]["ips"][0] == "192.168.1.50" | ||||
|  | ||||
|  | ||||
| def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch): | ||||
|     client = prepared_app.test_client() | ||||
|     guid = "7E8F90A1-B2C3-4D5E-8F90-A1B2C3D4E5F6" | ||||
|     fingerprint = "11:22:33:44" | ||||
|     hostname = "device-preserve" | ||||
|     _insert_device(prepared_app, guid, fingerprint, hostname) | ||||
|  | ||||
|     services = prepared_app.extensions["engine_services"] | ||||
|     context = _build_context(guid, fingerprint) | ||||
|     monkeypatch.setattr(services.device_auth, "authenticate", lambda request, path: context) | ||||
|  | ||||
|     client.post( | ||||
|         "/api/agent/details", | ||||
|         json={ | ||||
|             "hostname": hostname, | ||||
|             "details": { | ||||
|                 "summary": {"hostname": hostname, "last_user": "BUNNY-LAB\\nicole.rappe"} | ||||
|             }, | ||||
|         }, | ||||
|         headers={"Authorization": "Bearer token"}, | ||||
|     ) | ||||
|  | ||||
|     client.post( | ||||
|         "/api/agent/heartbeat", | ||||
|         json={"hostname": hostname, "metrics": {"uptime": 120}}, | ||||
|         headers={"Authorization": "Bearer token"}, | ||||
|     ) | ||||
|  | ||||
|     db_path = Path(prepared_app.config["ENGINE_DATABASE_PATH"]) | ||||
|     with sqlite3.connect(db_path) as conn: | ||||
|         row = conn.execute( | ||||
|             "SELECT last_user FROM devices WHERE guid = ?", | ||||
|             (guid,), | ||||
|         ).fetchone() | ||||
|  | ||||
|     assert row is not None | ||||
|     assert row[0] == "BUNNY-LAB\\nicole.rappe" | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import sqlite3 | ||||
| from datetime import datetime, timezone | ||||
| import sqlite3 | ||||
| import time | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| @@ -106,3 +107,45 @@ def test_credentials_list_requires_admin(prepared_app): | ||||
|     resp = client.get("/api/credentials") | ||||
|     assert resp.status_code == 200 | ||||
|     assert resp.get_json() == {"credentials": []} | ||||
|  | ||||
|  | ||||
| def test_device_description_update(prepared_app, engine_settings): | ||||
|     client = prepared_app.test_client() | ||||
|     hostname = "device-desc" | ||||
|     guid = "A3D3F1E5-9B8C-4C6F-80F1-4D5E6F7A8B9C" | ||||
|  | ||||
|     now = int(time.time()) | ||||
|     conn = sqlite3.connect(engine_settings.database.path) | ||||
|     cur = conn.cursor() | ||||
|     cur.execute( | ||||
|         """ | ||||
|         INSERT INTO devices ( | ||||
|             guid, | ||||
|             hostname, | ||||
|             description, | ||||
|             created_at, | ||||
|             last_seen | ||||
|         ) VALUES (?, ?, '', ?, ?) | ||||
|         """, | ||||
|         (guid, hostname, now, now), | ||||
|     ) | ||||
|     conn.commit() | ||||
|     conn.close() | ||||
|  | ||||
|     resp = client.post( | ||||
|         f"/api/device/description/{hostname}", | ||||
|         json={"description": "Primary workstation"}, | ||||
|     ) | ||||
|  | ||||
|     assert resp.status_code == 200 | ||||
|     assert resp.get_json() == {"status": "ok"} | ||||
|  | ||||
|     conn = sqlite3.connect(engine_settings.database.path) | ||||
|     row = conn.execute( | ||||
|         "SELECT description FROM devices WHERE hostname = ?", | ||||
|         (hostname,), | ||||
|     ).fetchone() | ||||
|     conn.close() | ||||
|  | ||||
|     assert row is not None | ||||
|     assert row[0] == "Primary workstation" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user