126 lines
4.8 KiB
Python
126 lines
4.8 KiB
Python
"""WP-230E: the session-store production decision is enforced, not just documented.
|
|
|
|
See docs/02-security/SESSION_STORE_DECISION.md. Two things are tested:
|
|
|
|
1. scripts/compose_release_check.py actually fails the release gate if
|
|
control-api is ever scaled beyond one replica (the concrete enforcement
|
|
mechanism the decision doc points to), not just that the doc says so.
|
|
2. The documented single-instance consequence -- a restart invalidates
|
|
every session -- is a real, observable property of SessionStore, not an
|
|
assumption.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import yaml # type: ignore[import-untyped]
|
|
|
|
from scripts.compose_release_check import check_compose
|
|
from services.control_api.auth.bff import SessionStore
|
|
from services.control_api.auth.errors import SessionExpired
|
|
from services.control_api.auth.models import (
|
|
AuthenticatedPrincipal,
|
|
ManagementRole,
|
|
PrincipalKind,
|
|
TokenSet,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REAL_COMPOSE = ROOT / "infrastructure" / "compose" / "docker-compose.yml"
|
|
|
|
|
|
def _minimal_compose(extra_control_api: dict[str, object] | None = None) -> dict[str, object]:
|
|
control_api: dict[str, object] = {"image": "local/itworx-mcp-hub-control:dev"}
|
|
if extra_control_api:
|
|
control_api.update(extra_control_api)
|
|
return {
|
|
"name": "itworx-mcp-hub",
|
|
"services": {
|
|
"control-api": control_api,
|
|
"docker-read-broker": {
|
|
"image": "local/itworx-mcp-hub-broker:dev",
|
|
"volumes": ["/var/run/docker.sock:/var/run/docker.sock:ro"],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class ComposeSingleInstanceEnforcementTests(unittest.TestCase):
|
|
def _check(self, document: dict[str, object]) -> dict[str, object]:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "docker-compose.yml"
|
|
path.write_text(yaml.safe_dump(document), encoding="utf-8")
|
|
return check_compose(path)
|
|
|
|
def test_control_api_scaled_to_two_replicas_fails_the_gate(self) -> None:
|
|
result = self._check(_minimal_compose({"deploy": {"replicas": 2}}))
|
|
self.assertIn("control-api", result["singleInstanceViolations"])
|
|
self.assertFalse(result["pass"])
|
|
|
|
def test_control_api_with_no_deploy_block_passes(self) -> None:
|
|
result = self._check(_minimal_compose())
|
|
self.assertEqual(result["singleInstanceViolations"], [])
|
|
self.assertTrue(result["pass"])
|
|
|
|
def test_control_api_explicitly_pinned_to_one_replica_passes(self) -> None:
|
|
result = self._check(_minimal_compose({"deploy": {"replicas": 1}}))
|
|
self.assertEqual(result["singleInstanceViolations"], [])
|
|
self.assertTrue(result["pass"])
|
|
|
|
def test_real_compose_file_does_not_scale_control_api(self) -> None:
|
|
result = check_compose(REAL_COMPOSE)
|
|
self.assertEqual(result["singleInstanceViolations"], [])
|
|
|
|
|
|
class SessionStoreRestartBehaviorTests(unittest.TestCase):
|
|
"""Proves the documented consequence of the in-memory decision is real:
|
|
the store has no persistence, so a process restart (modeled here as
|
|
constructing a fresh SessionStore -- the same thing a real restart does,
|
|
a brand-new empty dict) invalidates every session that existed before
|
|
it, rather than silently reusing stale state."""
|
|
|
|
def _principal(self) -> AuthenticatedPrincipal:
|
|
return AuthenticatedPrincipal(
|
|
kind=PrincipalKind.MANAGEMENT_USER,
|
|
subject="user-1",
|
|
issuer="https://fake.oidc.test",
|
|
audience=("itworx-mcp-hub",),
|
|
client_id=None,
|
|
roles=frozenset({ManagementRole.VIEWER}),
|
|
issued_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
|
|
)
|
|
|
|
def _store(self) -> SessionStore:
|
|
return SessionStore(
|
|
ttl_seconds=3600, cookie_name="hub_session", same_site="lax", secure=True
|
|
)
|
|
|
|
def test_session_does_not_survive_a_simulated_restart(self) -> None:
|
|
first_process = self._store()
|
|
cookie = first_process.create(
|
|
self._principal(),
|
|
TokenSet(access_token="access", id_token=None, refresh_token=None), # noqa: S106
|
|
)
|
|
|
|
second_process = self._store()
|
|
with self.assertRaises(SessionExpired):
|
|
second_process.get(cookie.value)
|
|
|
|
def test_the_same_process_still_recognizes_its_own_session(self) -> None:
|
|
store = self._store()
|
|
cookie = store.create(
|
|
self._principal(),
|
|
TokenSet(access_token="access", id_token=None, refresh_token=None), # noqa: S106
|
|
)
|
|
record = store.get(cookie.value)
|
|
self.assertEqual(record.csrf_token, cookie.csrf_token)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|