mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
Document parity plan and add engine unit tests
This commit is contained in:
@@ -63,3 +63,5 @@
|
||||
- 12.1 Stand up Engine end-to-end in a staging environment, exercising enrollment, token refresh, agent connections, and jobs.
|
||||
- 12.2 Document any divergences and address them with follow-up commits.
|
||||
- 12.3 Once satisfied, coordinate cut-over steps (switch entrypoint, deprecate legacy server) as a future initiative.
|
||||
- Documentation and test coverage for this phase now live in `Data/Engine/README.md` and `Data/Engine/tests/` to guide the
|
||||
remaining staging work.
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
The Engine is an additive server stack that will ultimately replace the legacy Flask app under `Data/Server`. It is safe to run the Engine entrypoint (`Data/Engine/bootstrapper.py`) side-by-side with the legacy server while we migrate functionality feature-by-feature.
|
||||
|
||||
## Architectural roles
|
||||
|
||||
The Engine is organized around explicit dependency layers so each concern stays
|
||||
testable and replaceable:
|
||||
|
||||
- **Configuration (`Data/Engine/config/`)** parses environment variables into
|
||||
immutable settings objects that the bootstrapper hands to factories and
|
||||
integrations.
|
||||
- **Builders (`Data/Engine/builders/`)** transform external inputs (HTTP
|
||||
headers, JSON payloads, scheduled job definitions) into validated immutable
|
||||
records that services can trust.
|
||||
- **Domain models (`Data/Engine/domain/`)** house pure value objects, enums, and
|
||||
error types with no I/O so services can express intent without depending on
|
||||
Flask or SQLite.
|
||||
- **Repositories (`Data/Engine/repositories/`)** encapsulate all SQLite access
|
||||
and expose protocol methods that return domain models. They are injected into
|
||||
services through the container so persistence can be swapped or mocked.
|
||||
- **Services (`Data/Engine/services/`)** host business logic such as device
|
||||
authentication, enrollment, job scheduling, GitHub artifact lookups, and
|
||||
real-time agent coordination. Services depend only on repositories,
|
||||
integrations, and builders.
|
||||
- **Integrations (`Data/Engine/integrations/`)** wrap external systems (GitHub
|
||||
today) and keep HTTP/token handling outside the services that consume them.
|
||||
- **Interfaces (`Data/Engine/interfaces/`)** provide thin HTTP/Socket.IO
|
||||
adapters that translate requests to builder/service calls and serialize
|
||||
responses. They contain no business rules of their own.
|
||||
|
||||
The runtime factory (`Data/Engine/runtime.py`) wires these layers together and
|
||||
attaches the resulting container to the Flask app created in
|
||||
`Data/Engine/server.py`.
|
||||
|
||||
## Environment configuration
|
||||
|
||||
The Engine mirrors the legacy defaults so it can boot without additional configuration. These environment variables are read by `Data/Engine/config/environment.py`:
|
||||
@@ -95,3 +126,50 @@ Step 11 migrates the GitHub artifact provider into the Engine:
|
||||
- `Data/Engine/interfaces/http/github.py` exposes `/api/repo/current_hash` and `/api/github/token` through the Engine stack while keeping business logic in the service layer.
|
||||
|
||||
The service container now wires `github_service`, giving other interfaces and background jobs a clean entry point for GitHub functionality.
|
||||
|
||||
## Final parity checklist
|
||||
|
||||
Step 12 tracks the final integration work required before switching over to the
|
||||
Engine entrypoint:
|
||||
|
||||
1. Stand up the Engine in a staging environment and exercise enrollment, token
|
||||
refresh, scheduler operations, and the agent real-time channel side-by-side
|
||||
with the legacy server.
|
||||
2. Capture any behavioural differences uncovered during staging and file them
|
||||
for follow-up fixes before the cut-over.
|
||||
3. When satisfied with parity, coordinate the entrypoint swap (point production
|
||||
tooling at `Data/Engine/bootstrapper.py`) and plan the deprecation of
|
||||
`Data/Server`.
|
||||
|
||||
## Performing unit tests
|
||||
|
||||
Targeted unit tests cover the most important domain, builder, repository, and
|
||||
migration behaviours without requiring Flask or external services. Run them
|
||||
with the standard library test runner:
|
||||
|
||||
```bash
|
||||
python -m unittest discover Data/Engine/tests
|
||||
```
|
||||
|
||||
The suite currently validates:
|
||||
|
||||
- Domain normalization helpers for GUIDs, fingerprints, and authentication
|
||||
failures.
|
||||
- Device authentication and refresh-token builders, including error handling for
|
||||
malformed requests.
|
||||
- SQLite schema migrations to ensure the Engine can provision required tables in
|
||||
a fresh database.
|
||||
|
||||
Successful execution prints a summary similar to:
|
||||
|
||||
```
|
||||
.............
|
||||
----------------------------------------------------------------------
|
||||
Ran 13 tests in <N>.<M>s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
Additional tests should follow the same pattern and live under
|
||||
`Data/Engine/tests/` so this command remains the single entry point for Engine
|
||||
unit verification.
|
||||
|
||||
@@ -8,16 +8,28 @@ from .device_auth import (
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenRequestBuilder,
|
||||
)
|
||||
from .device_enrollment import (
|
||||
EnrollmentRequestBuilder,
|
||||
ProofChallengeBuilder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceAuthRequest",
|
||||
"DeviceAuthRequestBuilder",
|
||||
"RefreshTokenRequest",
|
||||
"RefreshTokenRequestBuilder",
|
||||
"EnrollmentRequestBuilder",
|
||||
"ProofChallengeBuilder",
|
||||
]
|
||||
|
||||
try: # pragma: no cover - optional dependency shim
|
||||
from .device_enrollment import (
|
||||
EnrollmentRequestBuilder,
|
||||
ProofChallengeBuilder,
|
||||
)
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - executed when crypto deps missing
|
||||
_missing_reason = str(exc)
|
||||
|
||||
def _missing_builder(*_args: object, **_kwargs: object) -> None:
|
||||
raise ModuleNotFoundError(
|
||||
"device enrollment builders require optional cryptography dependencies"
|
||||
) from exc
|
||||
|
||||
EnrollmentRequestBuilder = _missing_builder # type: ignore[assignment]
|
||||
ProofChallengeBuilder = _missing_builder # type: ignore[assignment]
|
||||
else:
|
||||
__all__ += ["EnrollmentRequestBuilder", "ProofChallengeBuilder"]
|
||||
|
||||
@@ -45,7 +45,6 @@ def _require(value: Optional[str], field: str) -> str:
|
||||
class EnrollmentCode:
|
||||
"""Installer code metadata loaded from the persistence layer."""
|
||||
|
||||
record_id: Optional[str] = None
|
||||
code: str
|
||||
expires_at: datetime
|
||||
max_uses: int
|
||||
@@ -53,6 +52,7 @@ class EnrollmentCode:
|
||||
used_by_guid: Optional[DeviceGuid]
|
||||
last_used_at: Optional[datetime]
|
||||
used_at: Optional[datetime]
|
||||
record_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
@@ -69,7 +69,6 @@ class EnrollmentCode:
|
||||
used_by = record.get("used_by_guid")
|
||||
used_by_guid = DeviceGuid(used_by) if used_by else None
|
||||
return cls(
|
||||
record_id=str(record.get("id") or "") or None,
|
||||
code=_require(record.get("code"), "code"),
|
||||
expires_at=_parse_iso8601(record.get("expires_at")) or datetime.now(tz=timezone.utc),
|
||||
max_uses=int(record.get("max_uses") or 1),
|
||||
@@ -77,6 +76,7 @@ class EnrollmentCode:
|
||||
used_by_guid=used_by_guid,
|
||||
last_used_at=_parse_iso8601(record.get("last_used_at")),
|
||||
used_at=_parse_iso8601(record.get("used_at")),
|
||||
record_id=str(record.get("id") or "") or None,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -9,12 +9,7 @@ from .connection import (
|
||||
connection_factory,
|
||||
connection_scope,
|
||||
)
|
||||
from .device_repository import SQLiteDeviceRepository
|
||||
from .enrollment_repository import SQLiteEnrollmentRepository
|
||||
from .github_repository import SQLiteGitHubRepository
|
||||
from .job_repository import SQLiteJobRepository
|
||||
from .migrations import apply_all
|
||||
from .token_repository import SQLiteRefreshTokenRepository
|
||||
|
||||
__all__ = [
|
||||
"SQLiteConnectionFactory",
|
||||
@@ -22,10 +17,31 @@ __all__ = [
|
||||
"connect",
|
||||
"connection_factory",
|
||||
"connection_scope",
|
||||
"SQLiteDeviceRepository",
|
||||
"SQLiteRefreshTokenRepository",
|
||||
"SQLiteJobRepository",
|
||||
"SQLiteEnrollmentRepository",
|
||||
"SQLiteGitHubRepository",
|
||||
"apply_all",
|
||||
]
|
||||
|
||||
try: # pragma: no cover - optional dependency shim
|
||||
from .device_repository import SQLiteDeviceRepository
|
||||
from .enrollment_repository import SQLiteEnrollmentRepository
|
||||
from .github_repository import SQLiteGitHubRepository
|
||||
from .job_repository import SQLiteJobRepository
|
||||
from .token_repository import SQLiteRefreshTokenRepository
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - triggered when auth deps missing
|
||||
def _missing_repo(*_args: object, **_kwargs: object) -> None:
|
||||
raise ModuleNotFoundError(
|
||||
"Engine SQLite repositories require optional authentication dependencies"
|
||||
) from exc
|
||||
|
||||
SQLiteDeviceRepository = _missing_repo # type: ignore[assignment]
|
||||
SQLiteEnrollmentRepository = _missing_repo # type: ignore[assignment]
|
||||
SQLiteGitHubRepository = _missing_repo # type: ignore[assignment]
|
||||
SQLiteJobRepository = _missing_repo # type: ignore[assignment]
|
||||
SQLiteRefreshTokenRepository = _missing_repo # type: ignore[assignment]
|
||||
else:
|
||||
__all__ += [
|
||||
"SQLiteDeviceRepository",
|
||||
"SQLiteRefreshTokenRepository",
|
||||
"SQLiteJobRepository",
|
||||
"SQLiteEnrollmentRepository",
|
||||
"SQLiteGitHubRepository",
|
||||
]
|
||||
|
||||
1
Data/Engine/tests/__init__.py
Normal file
1
Data/Engine/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for the Borealis Engine."""
|
||||
74
Data/Engine/tests/test_builders_device_auth.py
Normal file
74
Data/Engine/tests/test_builders_device_auth.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
from Data.Engine.builders.device_auth import (
|
||||
DeviceAuthRequestBuilder,
|
||||
RefreshTokenRequestBuilder,
|
||||
)
|
||||
from Data.Engine.domain.device_auth import DeviceAuthErrorCode, DeviceAuthFailure
|
||||
|
||||
|
||||
class DeviceAuthRequestBuilderTests(unittest.TestCase):
|
||||
def test_build_successful_request(self) -> None:
|
||||
request = (
|
||||
DeviceAuthRequestBuilder()
|
||||
.with_authorization("Bearer abc123")
|
||||
.with_http_method("post")
|
||||
.with_htu("https://example.test/api")
|
||||
.with_service_context("currentUser")
|
||||
.with_dpop_proof("proof")
|
||||
.build()
|
||||
)
|
||||
|
||||
self.assertEqual(request.access_token, "abc123")
|
||||
self.assertEqual(request.http_method, "POST")
|
||||
self.assertEqual(request.htu, "https://example.test/api")
|
||||
self.assertEqual(request.service_context, "CURRENTUSER")
|
||||
self.assertEqual(request.dpop_proof, "proof")
|
||||
|
||||
def test_missing_authorization_raises_failure(self) -> None:
|
||||
builder = (
|
||||
DeviceAuthRequestBuilder()
|
||||
.with_http_method("GET")
|
||||
.with_htu("/health")
|
||||
)
|
||||
|
||||
with self.assertRaises(DeviceAuthFailure) as ctx:
|
||||
builder.build()
|
||||
|
||||
self.assertEqual(ctx.exception.code, DeviceAuthErrorCode.MISSING_AUTHORIZATION)
|
||||
|
||||
|
||||
class RefreshTokenRequestBuilderTests(unittest.TestCase):
|
||||
def test_refresh_request_requires_all_fields(self) -> None:
|
||||
request = (
|
||||
RefreshTokenRequestBuilder()
|
||||
.with_payload({"guid": "de305d54-75b4-431b-adb2-eb6b9e546014", "refresh_token": "tok"})
|
||||
.with_http_method("post")
|
||||
.with_htu("https://example.test/api")
|
||||
.with_dpop_proof("proof")
|
||||
.build()
|
||||
)
|
||||
|
||||
self.assertEqual(request.guid.value, "DE305D54-75B4-431B-ADB2-EB6B9E546014")
|
||||
self.assertEqual(request.refresh_token, "tok")
|
||||
self.assertEqual(request.http_method, "POST")
|
||||
self.assertEqual(request.htu, "https://example.test/api")
|
||||
self.assertEqual(request.dpop_proof, "proof")
|
||||
|
||||
def test_refresh_request_missing_guid_raises_failure(self) -> None:
|
||||
builder = (
|
||||
RefreshTokenRequestBuilder()
|
||||
.with_payload({"refresh_token": "tok"})
|
||||
.with_http_method("POST")
|
||||
.with_htu("https://example.test/api")
|
||||
)
|
||||
|
||||
with self.assertRaises(DeviceAuthFailure) as ctx:
|
||||
builder.build()
|
||||
|
||||
self.assertEqual(ctx.exception.code, DeviceAuthErrorCode.INVALID_CLAIMS)
|
||||
self.assertIn("missing guid", ctx.exception.detail)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - convenience for local runs
|
||||
unittest.main()
|
||||
59
Data/Engine/tests/test_domain_device_auth.py
Normal file
59
Data/Engine/tests/test_domain_device_auth.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
|
||||
from Data.Engine.domain.device_auth import (
|
||||
DeviceAuthErrorCode,
|
||||
DeviceAuthFailure,
|
||||
DeviceFingerprint,
|
||||
DeviceGuid,
|
||||
sanitize_service_context,
|
||||
)
|
||||
|
||||
|
||||
class DeviceGuidTests(unittest.TestCase):
|
||||
def test_guid_normalization_accepts_braces_and_lowercase(self) -> None:
|
||||
guid = DeviceGuid("{de305d54-75b4-431b-adb2-eb6b9e546014}")
|
||||
self.assertEqual(guid.value, "DE305D54-75B4-431B-ADB2-EB6B9E546014")
|
||||
|
||||
def test_guid_rejects_empty_string(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
DeviceGuid("")
|
||||
|
||||
|
||||
class DeviceFingerprintTests(unittest.TestCase):
|
||||
def test_fingerprint_normalization_trims_and_lowercases(self) -> None:
|
||||
fingerprint = DeviceFingerprint(" AA:BB:CC ")
|
||||
self.assertEqual(fingerprint.value, "aa:bb:cc")
|
||||
|
||||
def test_fingerprint_rejects_blank_input(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
DeviceFingerprint(" ")
|
||||
|
||||
|
||||
class ServiceContextTests(unittest.TestCase):
|
||||
def test_sanitize_service_context_returns_uppercase_only(self) -> None:
|
||||
self.assertEqual(sanitize_service_context("system"), "SYSTEM")
|
||||
|
||||
def test_sanitize_service_context_filters_invalid_chars(self) -> None:
|
||||
self.assertEqual(sanitize_service_context("sys tem!"), "SYSTEM")
|
||||
|
||||
def test_sanitize_service_context_returns_none_for_empty_result(self) -> None:
|
||||
self.assertIsNone(sanitize_service_context("@@@"))
|
||||
|
||||
|
||||
class DeviceAuthFailureTests(unittest.TestCase):
|
||||
def test_to_dict_includes_retry_after_and_detail(self) -> None:
|
||||
failure = DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.RATE_LIMITED,
|
||||
http_status=429,
|
||||
retry_after=30,
|
||||
detail="too many attempts",
|
||||
)
|
||||
payload = failure.to_dict()
|
||||
self.assertEqual(
|
||||
payload,
|
||||
{"error": "rate_limited", "retry_after": 30.0, "detail": "too many attempts"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - convenience for local runs
|
||||
unittest.main()
|
||||
32
Data/Engine/tests/test_sqlite_migrations.py
Normal file
32
Data/Engine/tests/test_sqlite_migrations.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from Data.Engine.repositories.sqlite import migrations
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_apply_all_creates_expected_tables(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
migrations.apply_all(conn)
|
||||
cursor = conn.cursor()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
|
||||
self.assertIn("devices", tables)
|
||||
self.assertIn("refresh_tokens", tables)
|
||||
self.assertIn("enrollment_install_codes", tables)
|
||||
self.assertIn("device_approvals", tables)
|
||||
self.assertIn("scheduled_jobs", tables)
|
||||
self.assertIn("scheduled_job_runs", tables)
|
||||
self.assertIn("github_token", tables)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - convenience for local runs
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user