From 68c7c772c06a6f2d23ff5d0d91ea2bdc96950122 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 26 Oct 2025 01:30:52 -0600 Subject: [PATCH] Add Engine API tests for Stage 4 --- Borealis.ps1 | 21 ++ Borealis.sh | 19 ++ Data/Engine/CODE_MIGRATION_TRACKER.md | 12 +- Data/Engine/Unit_Tests/__init__.py | 1 + Data/Engine/Unit_Tests/conftest.py | 130 +++++++++++ Data/Engine/Unit_Tests/test_enrollment_api.py | 206 ++++++++++++++++++ Data/Engine/Unit_Tests/test_tokens_api.py | 92 ++++++++ 7 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 Data/Engine/Unit_Tests/__init__.py create mode 100644 Data/Engine/Unit_Tests/conftest.py create mode 100644 Data/Engine/Unit_Tests/test_enrollment_api.py create mode 100644 Data/Engine/Unit_Tests/test_tokens_api.py diff --git a/Borealis.ps1 b/Borealis.ps1 index 1c2786f..6c1d373 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -9,6 +9,7 @@ param( [switch]$Vite, [switch]$Flask, [switch]$Quick, + [switch]$EngineTests, [string]$InstallerCode = '' ) @@ -17,6 +18,26 @@ $choice = $null $modeChoice = $null $agentSubChoice = $null +$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent + +if ($EngineTests) { + Set-Location -Path $scriptDir + $env:BOREALIS_PROJECT_ROOT = $scriptDir + + $python = Get-Command python3 -ErrorAction SilentlyContinue + if (-not $python) { + $python = Get-Command python -ErrorAction SilentlyContinue + } + + if (-not $python) { + Write-Host "Python interpreter not found. Install Python 3 to run Engine tests." -ForegroundColor Red + exit 1 + } + + & $python.Source -m pytest 'Data/Engine/Unit_Tests' + exit $LASTEXITCODE +} + if ($Server -and $Agent) { Write-Host "Cannot use -Server and -Agent together." -ForegroundColor Red exit 1 diff --git a/Borealis.sh b/Borealis.sh index ac87508..1235f13 100644 --- a/Borealis.sh +++ b/Borealis.sh @@ -25,6 +25,7 @@ AGENT_ACTION="" VITE_FLAG=0 FLASK_FLAG=0 QUICK_FLAG=0 +ENGINE_TESTS_FLAG=0 while (( "$#" )); do case "$1" in @@ -34,11 +35,29 @@ while (( "$#" )); do -Vite|--vite) VITE_FLAG=1 ;; -Flask|--flask) FLASK_FLAG=1 ;; -Quick|--quick) QUICK_FLAG=1 ;; + -EngineTests|--engine-tests) ENGINE_TESTS_FLAG=1 ;; *) ;; # ignore unknown for flexibility esac shift || true done +if (( ENGINE_TESTS_FLAG )); then + cd "$SCRIPT_DIR" + export BOREALIS_PROJECT_ROOT="${SCRIPT_DIR}" + + if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" + elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python)" + else + echo -e "${RED}Python interpreter not found. Install Python 3 to run Engine tests.${RESET}" >&2 + exit 1 + fi + + "$PYTHON_BIN" -m pytest Data/Engine/Unit_Tests + exit $? +fi + # ---- Banner ---- clear || true echo -e "${BOREALIS_BLUE}" diff --git a/Data/Engine/CODE_MIGRATION_TRACKER.md b/Data/Engine/CODE_MIGRATION_TRACKER.md index e202fb3..6309850 100644 --- a/Data/Engine/CODE_MIGRATION_TRACKER.md +++ b/Data/Engine/CODE_MIGRATION_TRACKER.md @@ -22,11 +22,11 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [x] Create domain-focused API blueprints and register_api entry point. - [x] Mirror route behaviour from the legacy server via service adapters. - [x] Add configuration toggles for enabling API groups incrementally. -- [ ] **Stage 4 — Build unit and smoke tests for Engine APIs** - - [ ] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints. - - [ ] Provide fixtures that mirror the legacy SQLite schema and seed data. - - [ ] Assert HTTP status codes, payloads, and side effects for parity. - - [ ] Integrate Engine API tests into CI/local workflows. +- [x] **Stage 4 — Build unit and smoke tests for Engine APIs** + - [x] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints. + - [x] Provide fixtures that mirror the legacy SQLite schema and seed data. + - [x] Assert HTTP status codes, payloads, and side effects for parity. + - [x] Integrate Engine API tests into CI/local workflows. - [ ] **Stage 5 — Bridge the legacy server to Engine APIs** - [ ] Delegate API blueprint registration to the Engine factory from the legacy server. - [ ] Replace legacy API routes with Engine-provided blueprints gated by a flag. @@ -43,5 +43,5 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [ ] Update legacy server to consume Engine WebSocket registration. ## Current Status -- **Stage:** Stage 3 — Introduce API blueprints and service adapters (completed) +- **Stage:** Stage 4 — Build unit and smoke tests for Engine APIs (completed) - **Active Task:** Awaiting next stage instructions. diff --git a/Data/Engine/Unit_Tests/__init__.py b/Data/Engine/Unit_Tests/__init__.py new file mode 100644 index 0000000..495e0e9 --- /dev/null +++ b/Data/Engine/Unit_Tests/__init__.py @@ -0,0 +1 @@ +"""Unit and smoke tests for the Borealis Engine runtime.""" diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py new file mode 100644 index 0000000..7f1a01b --- /dev/null +++ b/Data/Engine/Unit_Tests/conftest.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +import pytest +from flask import Flask + +from Data.Engine.server import create_app + + +_SCHEMA_DEFINITION = """ +CREATE TABLE IF NOT EXISTS devices ( + guid TEXT PRIMARY KEY, + hostname TEXT, + created_at INTEGER, + last_seen INTEGER, + ssl_key_fingerprint TEXT, + token_version INTEGER, + status TEXT, + key_added_at TEXT +); +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + guid TEXT, + token_hash TEXT, + dpop_jkt TEXT, + created_at TEXT, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT +); +CREATE TABLE IF NOT EXISTS enrollment_install_codes ( + id TEXT PRIMARY KEY, + code TEXT UNIQUE, + expires_at TEXT, + used_at TEXT, + used_by_guid TEXT, + max_uses INTEGER, + use_count INTEGER, + last_used_at TEXT +); +CREATE TABLE IF NOT EXISTS device_approvals ( + id TEXT PRIMARY KEY, + approval_reference TEXT UNIQUE, + guid TEXT, + hostname_claimed TEXT, + ssl_key_fingerprint_claimed TEXT, + enrollment_code_id TEXT, + status TEXT, + client_nonce TEXT, + server_nonce TEXT, + agent_pubkey_der BLOB, + created_at TEXT, + updated_at TEXT, + approved_by_user_id TEXT +); +CREATE TABLE IF NOT EXISTS device_keys ( + id TEXT PRIMARY KEY, + guid TEXT, + ssl_key_fingerprint TEXT, + added_at TEXT, + retired_at TEXT +); +""" + + +@dataclass +class EngineTestHarness: + app: Flask + db_path: Path + bundle_contents: str + + +def _initialise_legacy_schema(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + conn.executescript(_SCHEMA_DEFINITION) + conn.commit() + finally: + conn.close() + + +@pytest.fixture() +def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[EngineTestHarness]: + project_root = Path(__file__).resolve().parents[3] + monkeypatch.setenv("BOREALIS_PROJECT_ROOT", str(project_root)) + + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + cert_root = tmp_path / "certificates" + cert_root.mkdir() + + monkeypatch.setenv("BOREALIS_SERVER_ROOT", str(runtime_dir)) + monkeypatch.setenv("BOREALIS_CERT_ROOT", str(cert_root)) + monkeypatch.setenv("BOREALIS_SERVER_CERT_ROOT", str(cert_root / "Server")) + monkeypatch.setenv("BOREALIS_AGENT_CERT_ROOT", str(cert_root / "Agent")) + + db_path = tmp_path / "database" / "engine.sqlite3" + _initialise_legacy_schema(db_path) + + tls_dir = tmp_path / "tls" + tls_dir.mkdir() + bundle_contents = "-----BEGIN CERTIFICATE-----\nengine-test\n-----END CERTIFICATE-----\n" + cert_path = tls_dir / "server-cert.pem" + key_path = tls_dir / "server-key.pem" + bundle_path = tls_dir / "server-bundle.pem" + cert_path.write_text(bundle_contents, encoding="utf-8") + key_path.write_text("test-key", encoding="utf-8") + bundle_path.write_text(bundle_contents, encoding="utf-8") + + log_path = tmp_path / "logs" / "server.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + + config = { + "DATABASE_PATH": str(db_path), + "TLS_CERT_PATH": str(cert_path), + "TLS_KEY_PATH": str(key_path), + "TLS_BUNDLE_PATH": str(bundle_path), + "LOG_FILE": str(log_path), + "API_GROUPS": ("tokens", "enrollment"), + } + + app, _socketio, _context = create_app(config) + app.config.update(TESTING=True) + + yield EngineTestHarness(app=app, db_path=db_path, bundle_contents=bundle_contents) diff --git a/Data/Engine/Unit_Tests/test_enrollment_api.py b/Data/Engine/Unit_Tests/test_enrollment_api.py new file mode 100644 index 0000000..1fb30df --- /dev/null +++ b/Data/Engine/Unit_Tests/test_enrollment_api.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import base64 +import os +import sqlite3 +import uuid +from datetime import datetime, timedelta, timezone + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +from flask.testing import FlaskClient + +from Modules.crypto import keys as crypto_keys + +from .conftest import EngineTestHarness + + +def _now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _seed_install_code(db_path: os.PathLike[str], code: str) -> str: + record_id = str(uuid.uuid4()) + expires_at = _iso(_now() + timedelta(days=1)) + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + INSERT INTO enrollment_install_codes ( + id, code, expires_at, used_at, used_by_guid, max_uses, use_count, last_used_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (record_id, code, expires_at, None, None, 1, 0, None), + ) + conn.commit() + return record_id + + +def _generate_agent_material() -> tuple[ed25519.Ed25519PrivateKey, bytes, str]: + private_key = ed25519.Ed25519PrivateKey.generate() + public_der = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_b64 = base64.b64encode(public_der).decode("ascii") + return private_key, public_der, public_b64 + + +def test_enrollment_request_creates_pending_approval(engine_harness: EngineTestHarness) -> None: + harness = engine_harness + client: FlaskClient = harness.app.test_client() + + install_code = "INSTALL-CODE-001" + install_code_id = _seed_install_code(harness.db_path, install_code) + private_key, public_der, public_b64 = _generate_agent_material() + client_nonce_bytes = os.urandom(32) + client_nonce_b64 = base64.b64encode(client_nonce_bytes).decode("ascii") + + response = client.post( + "/api/agent/enroll/request", + json={ + "hostname": "agent-node-01", + "enrollment_code": install_code, + "agent_pubkey": public_b64, + "client_nonce": client_nonce_b64, + }, + headers={"X-Borealis-Agent-Context": "interactive"}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["status"] == "pending" + assert payload["server_certificate"] == harness.bundle_contents + approval_reference = payload["approval_reference"] + + with sqlite3.connect(str(harness.db_path)) as conn: + cur = conn.cursor() + cur.execute( + """ + SELECT hostname_claimed, ssl_key_fingerprint_claimed, client_nonce, status, enrollment_code_id + FROM device_approvals + WHERE approval_reference = ? + """, + (approval_reference,), + ) + row = cur.fetchone() + + assert row is not None + hostname_claimed, fingerprint, stored_client_nonce, status, stored_code_id = row + assert hostname_claimed == "agent-node-01" + assert stored_client_nonce == client_nonce_b64 + assert status == "pending" + assert stored_code_id == install_code_id + expected_fingerprint = crypto_keys.fingerprint_from_spki_der(public_der) + assert fingerprint == expected_fingerprint + + +def test_enrollment_poll_finalizes_when_approved(engine_harness: EngineTestHarness) -> None: + harness = engine_harness + client: FlaskClient = harness.app.test_client() + + install_code = "INSTALL-CODE-002" + install_code_id = _seed_install_code(harness.db_path, install_code) + private_key, public_der, public_b64 = _generate_agent_material() + client_nonce_bytes = os.urandom(32) + client_nonce_b64 = base64.b64encode(client_nonce_bytes).decode("ascii") + + request_response = client.post( + "/api/agent/enroll/request", + json={ + "hostname": "agent-node-02", + "enrollment_code": install_code, + "agent_pubkey": public_b64, + "client_nonce": client_nonce_b64, + }, + headers={"X-Borealis-Agent-Context": "system"}, + ) + assert request_response.status_code == 200 + request_payload = request_response.get_json() + approval_reference = request_payload["approval_reference"] + server_nonce_b64 = request_payload["server_nonce"] + + approved_at = _iso(_now()) + with sqlite3.connect(str(harness.db_path)) as conn: + conn.execute( + """ + UPDATE device_approvals + SET status = 'approved', + updated_at = ?, + approved_by_user_id = 'operator' + WHERE approval_reference = ? + """, + (approved_at, approval_reference), + ) + conn.commit() + + message = base64.b64decode(server_nonce_b64, validate=True) + approval_reference.encode("utf-8") + client_nonce_bytes + proof_sig = private_key.sign(message) + proof_sig_b64 = base64.b64encode(proof_sig).decode("ascii") + + poll_response = client.post( + "/api/agent/enroll/poll", + json={ + "approval_reference": approval_reference, + "client_nonce": client_nonce_b64, + "proof_sig": proof_sig_b64, + }, + ) + + assert poll_response.status_code == 200 + poll_payload = poll_response.get_json() + assert poll_payload["status"] == "approved" + assert poll_payload["token_type"] == "Bearer" + assert poll_payload["server_certificate"] == harness.bundle_contents + + final_guid = poll_payload["guid"] + assert isinstance(final_guid, str) and len(final_guid) == 36 + + with sqlite3.connect(str(harness.db_path)) as conn: + cur = conn.cursor() + cur.execute( + "SELECT guid, status FROM device_approvals WHERE approval_reference = ?", + (approval_reference,), + ) + approval_row = cur.fetchone() + cur.execute( + "SELECT hostname, ssl_key_fingerprint, token_version FROM devices WHERE guid = ?", + (final_guid,), + ) + device_row = cur.fetchone() + cur.execute( + "SELECT COUNT(*) FROM refresh_tokens WHERE guid = ?", + (final_guid,), + ) + refresh_count = cur.fetchone()[0] + cur.execute( + "SELECT use_count, used_by_guid FROM enrollment_install_codes WHERE id = ?", + (install_code_id,), + ) + install_row = cur.fetchone() + cur.execute( + "SELECT COUNT(*) FROM device_keys WHERE guid = ?", + (final_guid,), + ) + key_count = cur.fetchone()[0] + + assert approval_row is not None + approval_guid, approval_status = approval_row + assert approval_status == "completed" + assert approval_guid == final_guid + + assert device_row is not None + hostname, fingerprint, token_version = device_row + assert hostname == "agent-node-02" + assert fingerprint == crypto_keys.fingerprint_from_spki_der(public_der) + assert token_version >= 1 + + assert refresh_count == 1 + assert install_row is not None + use_count, used_by_guid = install_row + assert use_count == 1 + assert used_by_guid == final_guid + assert key_count == 1 diff --git a/Data/Engine/Unit_Tests/test_tokens_api.py b/Data/Engine/Unit_Tests/test_tokens_api.py new file mode 100644 index 0000000..5344825 --- /dev/null +++ b/Data/Engine/Unit_Tests/test_tokens_api.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import hashlib +import sqlite3 +from datetime import datetime, timedelta, timezone + +from flask.testing import FlaskClient + +from .conftest import EngineTestHarness + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def test_refresh_token_success(engine_harness: EngineTestHarness) -> None: + harness = engine_harness + client: FlaskClient = harness.app.test_client() + + guid = "54E8C9E2-6B3D-4B51-A456-4ACB94C45F00" + refresh_token = "refresh-token-value" + token_hash = hashlib.sha256(refresh_token.encode("utf-8")).hexdigest() + now = datetime.now(tz=timezone.utc) + expires_at = now + timedelta(days=1) + + with sqlite3.connect(str(harness.db_path)) as conn: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO devices (guid, hostname, created_at, last_seen, ssl_key_fingerprint, + token_version, status, key_added_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + guid, + "device-one", + int(now.timestamp()), + int(now.timestamp()), + "fingerprint", + 1, + "active", + _iso(now), + ), + ) + cur.execute( + """ + INSERT INTO refresh_tokens (id, guid, token_hash, created_at, expires_at, revoked_at, last_used_at) + VALUES (?, ?, ?, ?, ?, NULL, NULL) + """, + ( + "token-row", + guid, + token_hash, + _iso(now), + _iso(expires_at), + ), + ) + conn.commit() + + response = client.post( + "/api/agent/token/refresh", + json={"guid": guid, "refresh_token": refresh_token}, + ) + assert response.status_code == 200 + payload = response.get_json() + assert payload["token_type"] == "Bearer" + assert payload["expires_in"] == 900 + assert isinstance(payload["access_token"], str) and payload["access_token"] + + with sqlite3.connect(str(harness.db_path)) as conn: + cur = conn.cursor() + cur.execute( + "SELECT last_used_at, revoked_at FROM refresh_tokens WHERE guid = ?", + (guid,), + ) + row = cur.fetchone() + assert row is not None + last_used_at, revoked_at = row + assert last_used_at is not None + assert revoked_at is None + + +def test_refresh_token_requires_payload(engine_harness: EngineTestHarness) -> None: + client: FlaskClient = engine_harness.app.test_client() + + response = client.post( + "/api/agent/token/refresh", + json={"guid": "", "refresh_token": ""}, + ) + assert response.status_code == 400 + payload = response.get_json() + assert payload["error"] == "invalid_request"