|
1 | | -import asyncio |
| 1 | +"""Integration tests for Phase 4: Platform Identity (Keycloak + Supabase). |
| 2 | +
|
| 3 | +Covers execute() behaviour with mocked infrastructure — the interface contract |
| 4 | +(should_skip / healthcheck) is already exercised in test_m3_bootstrap.py. |
| 5 | +""" |
| 6 | + |
| 7 | +from unittest.mock import AsyncMock, MagicMock, mock_open, patch |
2 | 8 |
|
3 | 9 | import pytest |
4 | 10 |
|
5 | | -pytestmark = pytest.mark.skip(reason="Phase 4 handlers not yet implemented (M3+)") |
6 | | - |
7 | | - |
8 | | -@pytest.mark.asyncio |
9 | | -async def test_phase_4(): |
10 | | - # Load spec, run phases 0-3 (stubbed) and then phase 4 |
11 | | - spec = load_spec("examples/minimal.yaml") |
12 | | - orch = Orchestrator(spec) |
13 | | - # Assume phases 0-3 are already run (or call them) |
14 | | - await orch.phase_3_pki() # if not run |
15 | | - # Now run Phase 4 handler directly |
16 | | - from netengine.phases.phase_platform_identity import PlatformIdentityPhaseHandler |
17 | | - |
18 | | - handler = PlatformIdentityPhaseHandler() |
19 | | - await handler.execute(orch.context) |
20 | | - |
21 | | - # Verify: |
22 | | - state = RuntimeState.load() |
23 | | - assert state.keycloak_platform_container_id is not None |
24 | | - assert state.platform_realm_id == "platform" |
25 | | - # Check DNS: dig auth.platform.internal |
26 | | - # Check Keycloak reachable: curl -k https://auth.platform.internal/health/ready |
27 | | - # Check admin user can get token |
| 11 | +from netengine.phases.phase_platform_identity import PlatformIdentityPhaseHandler |
| 12 | + |
| 13 | + |
| 14 | +@pytest.fixture |
| 15 | +def patched_platform_deps(phase_context): |
| 16 | + """Phase context with all Phase 4 external dependencies mocked out.""" |
| 17 | + mock_pki = MagicMock() |
| 18 | + mock_pki.issue_cert = AsyncMock(return_value=("CERT-DATA", "KEY-DATA")) |
| 19 | + |
| 20 | + mock_docker = MagicMock() |
| 21 | + mock_docker.start_container = AsyncMock(return_value="keycloak-ctr-abc") |
| 22 | + |
| 23 | + mock_oidc = MagicMock() |
| 24 | + mock_oidc.create_platform_realm = AsyncMock(return_value="platform-realm-id") |
| 25 | + mock_oidc.create_admin_user = AsyncMock(return_value="admin-user-id") |
| 26 | + mock_oidc.create_client = AsyncMock(return_value="platform-api-client-id") |
| 27 | + mock_oidc.add_token_mapper = AsyncMock() |
| 28 | + |
| 29 | + mock_dns = MagicMock() |
| 30 | + mock_dns.add_zone_record = AsyncMock() |
| 31 | + |
| 32 | + patches = [ |
| 33 | + patch("netengine.phases.phase_platform_identity.apply_migrations", AsyncMock()), |
| 34 | + patch( |
| 35 | + "netengine.phases.phase_platform_identity.PKIHandler", |
| 36 | + MagicMock(return_value=mock_pki), |
| 37 | + ), |
| 38 | + patch( |
| 39 | + "netengine.phases.phase_platform_identity.DockerHandler", |
| 40 | + MagicMock(return_value=mock_docker), |
| 41 | + ), |
| 42 | + patch( |
| 43 | + "netengine.phases.phase_platform_identity.OIDCHandler", |
| 44 | + MagicMock(return_value=mock_oidc), |
| 45 | + ), |
| 46 | + patch( |
| 47 | + "netengine.phases.phase_platform_identity.DNSHandler", |
| 48 | + MagicMock(return_value=mock_dns), |
| 49 | + ), |
| 50 | + patch("netengine.phases.phase_platform_identity.os"), |
| 51 | + patch("netengine.phases.phase_platform_identity.open", mock_open()), |
| 52 | + patch.object(PlatformIdentityPhaseHandler, "_wait_for_keycloak", AsyncMock()), |
| 53 | + ] |
| 54 | + |
| 55 | + for p in patches: |
| 56 | + p.start() |
| 57 | + |
| 58 | + yield { |
| 59 | + "context": phase_context, |
| 60 | + "pki": mock_pki, |
| 61 | + "docker": mock_docker, |
| 62 | + "oidc": mock_oidc, |
| 63 | + "dns": mock_dns, |
| 64 | + } |
| 65 | + |
| 66 | + for p in patches: |
| 67 | + p.stop() |
| 68 | + |
| 69 | + |
| 70 | +class TestPlatformIdentityPhaseHandlerExecute: |
| 71 | + """Tests for Phase 4 execute() with mocked external dependencies.""" |
| 72 | + |
| 73 | + @pytest.mark.asyncio |
| 74 | + async def test_execute_populates_identity_platform_output(self, patched_platform_deps): |
| 75 | + """Phase 4 execute should set identity_platform_output on runtime_state.""" |
| 76 | + ctx = patched_platform_deps["context"] |
| 77 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 78 | + |
| 79 | + assert ctx.runtime_state.identity_platform_output is not None |
| 80 | + |
| 81 | + @pytest.mark.asyncio |
| 82 | + async def test_execute_output_has_required_keys(self, patched_platform_deps): |
| 83 | + """Phase 4 output must include all required identity_platform_output keys.""" |
| 84 | + ctx = patched_platform_deps["context"] |
| 85 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 86 | + |
| 87 | + output = ctx.runtime_state.identity_platform_output |
| 88 | + for key in ( |
| 89 | + "keycloak_container_id", |
| 90 | + "platform_realm_id", |
| 91 | + "admin_user_id", |
| 92 | + "platform_client_id", |
| 93 | + "deployed_at", |
| 94 | + ): |
| 95 | + assert key in output, f"Missing required key '{key}' in identity_platform_output" |
| 96 | + |
| 97 | + @pytest.mark.asyncio |
| 98 | + async def test_execute_marks_phase_completed(self, patched_platform_deps): |
| 99 | + """Phase 4 execute should set phase_completed['4'] = True.""" |
| 100 | + ctx = patched_platform_deps["context"] |
| 101 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 102 | + |
| 103 | + assert ctx.runtime_state.phase_completed.get("4") is True |
| 104 | + |
| 105 | + @pytest.mark.asyncio |
| 106 | + async def test_execute_sets_container_id_on_runtime_state(self, patched_platform_deps): |
| 107 | + """Phase 4 should persist the Keycloak container ID to runtime_state.""" |
| 108 | + ctx = patched_platform_deps["context"] |
| 109 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 110 | + |
| 111 | + assert ctx.runtime_state.keycloak_platform_container_id == "keycloak-ctr-abc" |
| 112 | + |
| 113 | + @pytest.mark.asyncio |
| 114 | + async def test_execute_registers_auth_dns_record(self, patched_platform_deps): |
| 115 | + """Phase 4 should register an A record for auth.<zone> pointing to the listen IP.""" |
| 116 | + ctx = patched_platform_deps["context"] |
| 117 | + mock_dns = patched_platform_deps["dns"] |
| 118 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 119 | + |
| 120 | + # spec.identity_platform.listen_ip == "10.0.0.7" in minimal.yaml |
| 121 | + mock_dns.add_zone_record.assert_called_once_with( |
| 122 | + ctx, "platform.internal", "A", "auth", "10.0.0.7", 300 |
| 123 | + ) |
| 124 | + |
| 125 | + @pytest.mark.asyncio |
| 126 | + async def test_execute_generates_bootstrap_admin_password(self, patched_platform_deps): |
| 127 | + """Phase 4 should generate a bootstrap admin password when none exists.""" |
| 128 | + ctx = patched_platform_deps["context"] |
| 129 | + assert ctx.runtime_state.bootstrap_admin_password is None |
| 130 | + |
| 131 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 132 | + |
| 133 | + assert ctx.runtime_state.bootstrap_admin_password is not None |
| 134 | + assert len(ctx.runtime_state.bootstrap_admin_password) > 0 |
| 135 | + |
| 136 | + @pytest.mark.asyncio |
| 137 | + async def test_execute_reuses_existing_admin_password(self, patched_platform_deps): |
| 138 | + """Phase 4 should not overwrite a bootstrap admin password already in state.""" |
| 139 | + ctx = patched_platform_deps["context"] |
| 140 | + ctx.runtime_state.bootstrap_admin_password = "already-set-password" |
| 141 | + |
| 142 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 143 | + |
| 144 | + assert ctx.runtime_state.bootstrap_admin_password == "already-set-password" |
| 145 | + |
| 146 | + @pytest.mark.asyncio |
| 147 | + async def test_execute_issues_tls_cert_for_auth_hostname(self, patched_platform_deps): |
| 148 | + """Phase 4 should request a TLS cert for auth.platform.internal via PKIHandler.""" |
| 149 | + ctx = patched_platform_deps["context"] |
| 150 | + mock_pki = patched_platform_deps["pki"] |
| 151 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 152 | + |
| 153 | + mock_pki.issue_cert.assert_awaited_once_with("auth.platform.internal", []) |
| 154 | + |
| 155 | + @pytest.mark.asyncio |
| 156 | + async def test_execute_creates_platform_realm(self, patched_platform_deps): |
| 157 | + """Phase 4 should create the platform OIDC realm via OIDCHandler.""" |
| 158 | + ctx = patched_platform_deps["context"] |
| 159 | + mock_oidc = patched_platform_deps["oidc"] |
| 160 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 161 | + |
| 162 | + mock_oidc.create_platform_realm.assert_awaited_once_with("platform") |
| 163 | + |
| 164 | + @pytest.mark.asyncio |
| 165 | + async def test_execute_persists_realm_and_user_ids(self, patched_platform_deps): |
| 166 | + """Phase 4 should write realm and user IDs to runtime_state.""" |
| 167 | + ctx = patched_platform_deps["context"] |
| 168 | + await PlatformIdentityPhaseHandler().execute(ctx) |
| 169 | + |
| 170 | + assert ctx.runtime_state.platform_realm_id == "platform-realm-id" |
| 171 | + assert ctx.runtime_state.admin_user_id == "admin-user-id" |
| 172 | + assert ctx.runtime_state.platform_client_id == "platform-api-client-id" |
0 commit comments