from __future__ import annotations import pytest from sqlmodel import Session, SQLModel, create_engine from backend.app.db.models import Policy from backend.app.schemas.deployment import ( DeploymentPlanV1, validate_compose_runtime_contract, ) from backend.app.services.deployment_control_plane import DeploymentControlPlane from backend.app.services.secret_store import set_secret def _plan(**overrides): payload = { "application_id": "wsbc-portal", "repository": "Jens/Windows-Support-Bundle-Collector", "revision": "a" * 40, "release_version": "0.23.0", "image_repository": "wsbc-portal", "image_tag": "0.23.0", "topology": { "compose_project": "wsbc-portal", "containers": ["wsbc-portal"], "proxy_network": "itworx-npm", "internal_port": 8080, }, "proxy": {"hostname": "support.example.com"}, "oidc": { "application_slug": "wsbc-portal", "role_claim": "roles", "client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET", "roles": { "administrator_group": "Administrator", "support_agent_group": "SupportAgent", "read_only_group": "ReadOnly", }, }, "broker_secret_ref": "WSBC_PORTAL_DEPLOYMENT_BROKER", } payload.update(overrides) return DeploymentPlanV1.model_validate(payload) class _Broker: def preflight(self, plan, *, secret): assert secret == "a" * 32 return {"status": "preflight_ok"} def apply(self, plan, *, secret, idempotency_key): assert secret == "a" * 32 return {"status": "accepted", "request": "b" * 64} def rollback(self, plan, *, secret, idempotency_key): return {"status": "accepted", "request": "c" * 64} def status(self, request_id, *, secret): return {"status": "deployed", "request": request_id} class _Portainer: def list_containers(self): return {"status": "ok", "containers": []} class _Npm: def proxy_hosts(self): return {"success": True, "items": []} def ensure_managed_proxy_host(self, **kwargs): assert kwargs["forward_host"] == "wsbc-portal" return {"success": True, "status": "created", "hostId": 42} class _Authentik: def configure_deployment_oidc_template(self, config, *, issuer_origin=None): self.template_config = config self.template_issuer = issuer_origin def validate_deployment_oidc_template(self, *, role_claim, role_groups): assert role_claim == "roles" assert role_groups == ("Administrator", "SupportAgent", "ReadOnly") return {"status": "ready"} def groups(self): return {"success": True, "items": [{"name": name} for name in ("Administrator", "SupportAgent", "ReadOnly")]} def test_connection(self): return {"status": "connected"} def applications(self): return {"success": True, "items": []} def ensure_deployment_oidc_application(self, **kwargs): assert kwargs["application_slug"] == "wsbc-portal" assert kwargs["client_secret"] == "b" * 32 return {"success": True, "status": "created", "applicationId": "app-1"} class _Unraid: def get_share_summary(self): return {"status": "ok"} def _session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'deployment.db'}") SQLModel.metadata.create_all(engine) return Session(engine) def _plane(): return DeploymentControlPlane( broker=_Broker(), portainer=_Portainer(), npm=_Npm(), authentik=_Authentik(), unraid=_Unraid(), ) def test_plan_rejects_latest_tag_and_raw_secret_like_fields(): raw = _plan().model_dump() raw["image_tag"] = "latest" try: DeploymentPlanV1.model_validate(raw) except ValueError as exc: assert "non-latest" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("latest tag must be rejected") raw = _plan().model_dump() raw["deployment_secret"] = "not-allowed" try: DeploymentPlanV1.model_validate(raw) except ValueError as exc: assert "Extra inputs" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("raw secret fields must be rejected") raw = _plan().model_dump() raw["proxy"]["force_ssl"] = False try: DeploymentPlanV1.model_validate(raw) except ValueError as exc: assert "force HTTPS" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("managed proxy must force HTTPS") def test_preflight_fails_closed_without_exact_target_policy(tmp_path): plan = _plan(oidc=None) with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) result = _plane().execute( session, plan=plan, idempotency_key="preflight-without-policy-0001", operation="preflight", ) assert result["status"] == "preflight_failed" assert "deployment_policy" in result["preflight"]["blockers"] def test_preflight_rejects_wildcard_deployment_policy(tmp_path): plan = _plan(oidc=None) with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) session.add(Policy( id="wildcard-deploy", action="deployment.apply", target="*", voice="blocked", manual="allowed", risk="high", reason="Wildcard must not authorize a production deployment.", )) session.commit() result = _plane().execute( session, plan=plan, idempotency_key="preflight-wildcard-policy-0001", operation="preflight", ) assert result["status"] == "preflight_failed" assert "deployment_policy" in result["preflight"]["blockers"] def test_compose_contract_rejects_docker_socket_and_unowned_mounts(): plan = _plan(oidc=None) unsafe = """ services: wsbc-portal: image: wsbc-portal:0.23.0 restart: unless-stopped read_only: true cap_drop: [ALL] volumes: - /var/run/docker.sock:/var/run/docker.sock networks: [itworx-npm] networks: itworx-npm: external: true """ try: validate_compose_runtime_contract(plan, unsafe) except ValueError as exc: assert "docker socket" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("docker socket mount must be rejected") def test_compose_contract_rejects_path_traversal_outside_owned_appdata(): plan = _plan(oidc=None) unsafe = """ services: wsbc-portal: image: wsbc-portal:0.23.0 restart: unless-stopped read_only: true cap_drop: [ALL] volumes: - /srv/example/appdata/wsbc-portal/../../other-app:/data networks: [itworx-npm] networks: itworx-npm: external: true """ try: validate_compose_runtime_contract(plan, unsafe) except ValueError as exc: assert "normalized" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("path traversal mount must be rejected") def test_compose_contract_requires_an_owned_persistent_bind_mount(): plan = _plan(oidc=None) unsafe = """ services: wsbc-portal: image: wsbc-portal:0.23.0 restart: unless-stopped read_only: true cap_drop: [ALL] networks: [itworx-npm] networks: itworx-npm: external: true """ try: validate_compose_runtime_contract(plan, unsafe) except ValueError as exc: assert "persistent" in str(exc) else: # pragma: no cover - assertion clarity raise AssertionError("persistent bind mount must be required") def test_preflight_and_apply_are_durable_and_idempotent(tmp_path): plan = _plan() with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) session.add(Policy( id="allow-wsbc-deploy", action="deployment.apply", target="wsbc-portal", voice="blocked", manual="allowed", risk="high", reason="Test exact-target deployment policy.", )) session.commit() plane = _plane() result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply") assert not result["preflight"]["blockers"], result["preflight"] assert result["status"] == "applying", result assert result["brokerRequestId"] == "b" * 64 replay = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply") assert replay["idempotentReplay"] is True assert replay["id"] == result["id"] def test_verified_controller_apply_completes_only_after_typed_oidc_and_proxy(tmp_path): class DirectBroker(_Broker): def apply(self, plan, *, secret, idempotency_key): return {"status": "deployed", "request": "b" * 64} plan = _plan() with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) session.add(Policy( id="allow-wsbc-deploy-direct", action="deployment.apply", target="wsbc-portal", voice="blocked", manual="allowed", risk="high", reason="Test exact-target deployment policy.", )) session.commit() plane = DeploymentControlPlane( broker=DirectBroker(), portainer=_Portainer(), npm=_Npm(), authentik=_Authentik(), unraid=_Unraid(), ) result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-direct-0001", operation="apply") assert result["status"] == "succeeded" assert result["stage"] == "controller_and_edge_verified" assert result["evidence"]["externalIntegration"]["oidc"]["status"] == "created" assert result["evidence"]["externalIntegration"]["proxy"]["status"] == "created" def test_deployment_evidence_uses_allowlists_and_never_persists_connector_secrets(tmp_path): class DirectBroker(_Broker): def apply(self, plan, *, secret, idempotency_key): return {"status": "deployed", "request": "b" * 64, "token": "broker-secret"} class SecretEchoNpm(_Npm): def ensure_managed_proxy_host(self, **kwargs): return {"success": True, "status": "created", "hostId": 42, "meta": {"token": "npm-secret"}} class SecretEchoAuthentik(_Authentik): def ensure_deployment_oidc_application(self, **kwargs): return {"success": True, "status": "created", "applicationId": "app-1", "client_secret_value": "oidc-secret"} plan = _plan() with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) session.add(Policy( id="allow-wsbc-deploy-evidence", action="deployment.apply", target="wsbc-portal", voice="blocked", manual="allowed", risk="high", reason="Test exact-target deployment policy.", )) session.commit() result = DeploymentControlPlane( broker=DirectBroker(), portainer=_Portainer(), npm=SecretEchoNpm(), authentik=SecretEchoAuthentik(), unraid=_Unraid(), ).execute(session, plan=plan, idempotency_key="apply-wsbc-evidence-0001", operation="apply") assert result["status"] == "succeeded" assert "broker-secret" not in str(result) assert "npm-secret" not in str(result) assert "oidc-secret" not in str(result) @pytest.mark.parametrize("swapped_roles", [False, True]) def test_saved_root_template_is_used_by_existing_deployment_plan(tmp_path, monkeypatch, swapped_roles): from uuid import uuid4 from backend.app.core.config import settings from backend.app.db.models import AppSetting from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1, TEMPLATE_FIELDS monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example.com/application/o/appops-users/") profile = EnrollmentOidcProfileV1(issuer="https://auth.example.com", **{field: uuid4() for field in TEMPLATE_FIELDS}) class SavedTemplateAuthentik(_Authentik): def validate_deployment_oidc_template(self, **kwargs): assert self.template_config == profile.template_config() # Authentik emits the same filtered set for any permutation. The # orchestrator must separately preserve named root role meanings. assert kwargs["role_claim"] == "roles" assert set(kwargs["role_groups"]) == {"Administrator", "SupportAgent", "ReadOnly"} return {"status": "ready"} def ensure_deployment_oidc_application(self, **kwargs): assert self.template_config == profile.template_config() return super().ensure_deployment_oidc_application(**kwargs) with _session(tmp_path) as session: trust = EnrollmentTrustConfigV1( controller_identity="unraid-controller", bootstrap_secret_ref="ENROLLMENT_BOOTSTRAP", approved_networks=("itworx-npm",), allowed_domain_suffixes=("example.com",), oidc_issuer=profile.issuer, administrator_group="Administrator", support_agent_group="SupportAgent", read_only_group="ReadOnly", ) session.add(AppSetting(key="deployment_enrollment:trust", value_json=trust.model_dump(mode="json"))) session.add(AppSetting(key="deployment_enrollment:oidc_template", value_json=profile.model_dump(mode="json"))) session.commit() plane = _plane() plane.authentik = SavedTemplateAuthentik() plan = _plan() if swapped_roles: oidc = plan.oidc.model_dump() oidc["roles"].update(administrator_group="ReadOnly", read_only_group="Administrator") plan = _plan(oidc=oidc) # Exercise both consumers; the integration can also run on an async # controller completion where this instance never performed preflight. preflight = plane.preflight(session, plan) if swapped_roles: assert "oidc_template" in preflight["blockers"] plane.authentik = SavedTemplateAuthentik() set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) success, evidence, _ = plane._complete_external_integrations(session, plan) assert success is not swapped_roles if swapped_roles: assert evidence == {} else: assert evidence["oidc"]["status"] == "created" def test_invalid_oidc_template_prevents_controller_apply(tmp_path): class UnavailableAuthentik(_Authentik): def validate_deployment_oidc_template(self, **kwargs): return {"status": "unavailable", "reason": "remote-secret-sentinel"} class NoApplyBroker(_Broker): def apply(self, *args, **kwargs): raise AssertionError("Invalid template must fail before controller apply") with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) session.add(Policy(id="allow-invalid-template-test", action="deployment.apply", target="wsbc-portal", manual="allowed", voice="blocked", risk="high", reason="Test")) session.commit() plane = _plane() plane.authentik = UnavailableAuthentik() plane.broker = NoApplyBroker() result = plane.execute(session, plan=_plan(), idempotency_key="invalid-template-0001", operation="apply") assert result["status"] == "preflight_failed" assert "oidc_template" in result["preflight"]["blockers"] assert "remote-secret-sentinel" not in str(result) def test_oidc_template_drift_after_preflight_prevents_provider_write(tmp_path): class ChangedAuthentik(_Authentik): def validate_deployment_oidc_template(self, **kwargs): return {"status": "unavailable", "reason": "secret-sentinel"} def ensure_deployment_oidc_application(self, **kwargs): raise AssertionError("Unverified template must never create a provider") with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) plane = _plane() plane.authentik = ChangedAuthentik() success, evidence, message = plane._complete_external_integrations(session, _plan()) assert not success assert evidence == {} assert "secret-sentinel" not in message @pytest.mark.parametrize("claim,administrator", [("groups", "Administrator"), ("roles", "OtherAdmin")]) def test_preflight_verifies_requested_claim_and_groups(tmp_path, claim, administrator): class RequestedContractAuthentik(_Authentik): def validate_deployment_oidc_template(self, *, role_claim, role_groups): assert role_claim == claim assert role_groups == (administrator, "SupportAgent", "ReadOnly") return {"status": "unavailable", "reason": "private-template-diagnostic"} oidc = _plan().oidc.model_dump() oidc["role_claim"] = claim oidc["roles"]["administrator_group"] = administrator with _session(tmp_path) as session: plane = _plane() plane.authentik = RequestedContractAuthentik() result = plane.preflight(session, _plan(oidc=oidc)) assert "oidc_template" in result["blockers"] assert "private-template-diagnostic" not in str(result) def test_missing_role_group_blocks_provider_write(tmp_path): class MissingGroupAuthentik(_Authentik): def groups(self): return {"success": True, "items": [{"name": "Administrator"}, {"name": "SupportAgent"}]} def ensure_deployment_oidc_application(self, **kwargs): raise AssertionError("Missing group must fail before provider write") with _session(tmp_path) as session: set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) plane = _plane() plane.authentik = MissingGroupAuthentik() result = plane.preflight(session, _plan()) assert "oidc_template" in result["blockers"] success, evidence, _ = plane._complete_external_integrations(session, _plan()) assert not success assert evidence == {}