"""§v2-A3.4 — domains belong to the platform owner (D9). A tenant gets one automatic demo host and nothing else. Attaching another host — especially a custom FQDN — means writing a routing rule and a TLS certificate on a host shared by every tenant, and that is infrastructure. The tests below are mostly about *refusals*, because that is where the security lives: a wildcard, a protected label, a host that belongs to someone else, an unconfirmed detach, and a non-owner calling any of it. The re-sync test asserts the property that makes the button safe to press: when nothing changed, nothing is reloaded. """ from __future__ import annotations import pytest import pytest_asyncio from httpx import AsyncClient from app.domain.identity.models import PlatformRole from app.domain.platform.rules import ( PROTECTED_HOST_LABELS, RuleViolation, validate_extra_host, ) from app.infrastructure.adapters.mock import MockDomainRouterAdapter from tests.conftest import auth_header, signup ROOT = "msa.artacloud.ir" # --------------------------------------------------------------------------- # the rule itself # --------------------------------------------------------------------------- @pytest.mark.parametrize( "host", [ "shop.msa.artacloud.ir", "app-2.msa.artacloud.ir", ], ) def test_a_single_label_under_the_root_is_always_allowed(host: str): assert validate_extra_host(host, root_domain=ROOT) == host @pytest.mark.parametrize( "host", [ "*", "*.example.com", "shop.*.msa.artacloud.ir", "no-dot", "-leading.msa.artacloud.ir", "trailing-.msa.artacloud.ir", "under_score.msa.artacloud.ir", "shop .msa.artacloud.ir", "", ], ) def test_malformed_hosts_are_refused(host: str): with pytest.raises(RuleViolation): validate_extra_host(host, root_domain=ROOT) @pytest.mark.parametrize("label", sorted(PROTECTED_HOST_LABELS)) def test_protected_platform_labels_are_refused(label: str): with pytest.raises(RuleViolation): validate_extra_host(f"{label}.{ROOT}", root_domain=ROOT) with pytest.raises(RuleViolation): validate_extra_host(f"{label}.example.com", root_domain=ROOT, allow_custom=True) def test_a_custom_fqdn_needs_the_operator_switch(): with pytest.raises(RuleViolation): validate_extra_host("shop.customer.example", root_domain=ROOT, allow_custom=False) assert ( validate_extra_host("shop.customer.example", root_domain=ROOT, allow_custom=True) == "shop.customer.example" ) def test_the_root_domain_itself_is_never_attachable(): with pytest.raises(RuleViolation): validate_extra_host(ROOT, root_domain=ROOT, allow_custom=True) def test_hosts_are_normalised(): assert ( validate_extra_host(" SHOP.MSA.ARTACLOUD.IR. ", root_domain=ROOT) == "shop.msa.artacloud.ir" ) # --------------------------------------------------------------------------- # re-sync is idempotent # --------------------------------------------------------------------------- async def test_sync_does_nothing_when_nothing_changed(): router = MockDomainRouterAdapter(root_domain=ROOT) await router.sync_routes([("alpha.msa.artacloud.ir", 8100)]) before = router.routes_fingerprint() result = await router.sync_routes([("alpha.msa.artacloud.ir", 8100)]) assert result.ok is True assert "از قبل همگام" in result.message assert router.routes_fingerprint() == before async def test_sync_converges_on_the_desired_state(): router = MockDomainRouterAdapter(root_domain=ROOT) await router.sync_routes([("stale.msa.artacloud.ir", 8100)]) result = await router.sync_routes( [("alpha.msa.artacloud.ir", 8101), ("beta.msa.artacloud.ir", 8102)] ) assert result.ok is True hosts = {r.host for r in await router.list_all_routes()} assert hosts == {"alpha.msa.artacloud.ir", "beta.msa.artacloud.ir"} async def test_sync_refuses_an_unmanaged_host(): router = MockDomainRouterAdapter(root_domain=ROOT) result = await router.sync_routes([("not-ours.example.com", 8100)]) assert result.ok is False # --------------------------------------------------------------------------- # HTTP surface # --------------------------------------------------------------------------- @pytest_asyncio.fixture(autouse=True) async def _platform_seed(seeded): return seeded async def _owner(client: AsyncClient) -> dict: token = ( await signup( client, "domains-owner@example.com", name="مالک سامانه", platform_role=PlatformRole.OWNER, ) )["access_token"] headers = auth_header(token) org_id = ( await client.post("/api/organizations", json={"name": "سازمان دامنه"}, headers=headers) ).json()["id"] project_id = ( await client.post( f"/api/organizations/{org_id}/projects", json={"name": "اپ دامنه"}, headers=headers, ) ).json()["id"] return {"org_id": org_id, "project_id": project_id, "headers": headers} async def _seed_domain(session_factory, owner: dict, host: str, port: int) -> str: from app.infrastructure.repositories.projects import PreviewDomainRepository async with session_factory() as session: record = await PreviewDomainRepository(session).create( organization_id=owner["org_id"], project_id=owner["project_id"], host=host, port=port, created_by_user_id=None, ) await session.commit() return record["id"] async def test_a_non_owner_cannot_list_routes(client: AsyncClient): token = (await signup(client, "not-owner@example.com"))["access_token"] response = await client.get("/api/admin/domains", headers=auth_header(token)) assert response.status_code == 403, response.text async def test_a_non_owner_cannot_attach_a_host(client: AsyncClient, owner=None): token = (await signup(client, "not-owner-2@example.com"))["access_token"] response = await client.post( "/api/admin/domains", json={"organization_id": "x", "project_id": "y", "host": "a.msa.artacloud.ir"}, headers=auth_header(token), ) assert response.status_code == 403, response.text async def test_the_owner_sees_every_route(client: AsyncClient, session_factory): owner = await _owner(client) await _seed_domain(session_factory, owner, "shown.msa.artacloud.ir", 8100) response = await client.get("/api/admin/domains", headers=owner["headers"]) assert response.status_code == 200, response.text hosts = [r["host"] for r in response.json()] assert "shown.msa.artacloud.ir" in hosts assert response.json()[0]["project_name"] == "اپ دامنه" async def test_attaching_a_protected_host_is_refused(client: AsyncClient): owner = await _owner(client) response = await client.post( "/api/admin/domains", json={ "organization_id": owner["org_id"], "project_id": owner["project_id"], "host": f"git.{ROOT}", }, headers=owner["headers"], ) assert response.status_code == 422, response.text assert "رزرو" in response.json()["error"]["message"] async def test_attaching_a_wildcard_is_refused(client: AsyncClient): owner = await _owner(client) response = await client.post( "/api/admin/domains", json={ "organization_id": owner["org_id"], "project_id": owner["project_id"], "host": f"*.{ROOT}", }, headers=owner["headers"], ) assert response.status_code == 422, response.text async def test_attaching_a_custom_domain_needs_the_switch(client: AsyncClient): owner = await _owner(client) response = await client.post( "/api/admin/domains", json={ "organization_id": owner["org_id"], "project_id": owner["project_id"], "host": "shop.customer.example", }, headers=owner["headers"], ) assert response.status_code == 422, response.text assert "سفارشی" in response.json()["error"]["message"] async def test_attaching_a_host_to_a_foreign_app_is_not_found(client: AsyncClient): owner = await _owner(client) other = (await signup(client, "domains-other@example.com"))["access_token"] other_headers = auth_header(other) other_org = ( await client.post( "/api/organizations", json={"name": "سازمان دیگر"}, headers=other_headers ) ).json()["id"] other_project = ( await client.post( f"/api/organizations/{other_org}/projects", json={"name": "اپ دیگر"}, headers=other_headers, ) ).json()["id"] response = await client.post( "/api/admin/domains", json={ "organization_id": owner["org_id"], "project_id": other_project, "host": f"extra-host.{ROOT}", }, headers=owner["headers"], ) assert response.status_code == 404, response.text async def test_detach_requires_the_host_typed_back(client: AsyncClient, session_factory): owner = await _owner(client) domain_id = await _seed_domain(session_factory, owner, "detach.msa.artacloud.ir", 8100) wrong = await client.request( "DELETE", f"/api/admin/domains/{domain_id}", json={"confirm": "not-the-host"}, headers=owner["headers"], ) assert wrong.status_code == 422, wrong.text right = await client.request( "DELETE", f"/api/admin/domains/{domain_id}", json={"confirm": "detach.msa.artacloud.ir"}, headers=owner["headers"], ) assert right.status_code == 204, right.text listed = await client.get("/api/admin/domains", headers=owner["headers"]) assert "detach.msa.artacloud.ir" not in [r["host"] for r in listed.json()] async def test_detach_is_audited(client: AsyncClient, session_factory): owner = await _owner(client) domain_id = await _seed_domain(session_factory, owner, "audited.msa.artacloud.ir", 8100) await client.request( "DELETE", f"/api/admin/domains/{domain_id}", json={"confirm": "audited.msa.artacloud.ir"}, headers=owner["headers"], ) audit = await client.get( f"/api/organizations/{owner['org_id']}/audit", headers=owner["headers"] ) actions = {e["action"] for e in audit.json()} assert "domain.detach" in actions async def test_the_mcp_gateway_is_offered_as_a_suggested_route(client: AsyncClient): owner = await _owner(client) response = await client.get("/api/admin/domains/suggested", headers=owner["headers"]) assert response.status_code == 200, response.text body = response.json() assert body[0]["label"] == "mcp" assert body[0]["host"] == f"mcp.{ROOT}" assert body[0]["enabled"] is False async def test_sync_is_idempotent_over_http(client: AsyncClient, session_factory): owner = await _owner(client) await _seed_domain(session_factory, owner, "sync.msa.artacloud.ir", 8100) first = await client.post("/api/admin/domains/sync", headers=owner["headers"]) second = await client.post("/api/admin/domains/sync", headers=owner["headers"]) assert first.status_code == 200, first.text assert second.status_code == 200, second.text assert second.json()["routes"] == 1 # The mock router reports "already in sync" on the second pass; the real # Caddy adapter skips the reload entirely (asserted at the adapter level). assert second.json()["changed"] is False async def test_the_member_facing_domain_route_is_owner_only(client: AsyncClient): """A tenant can no longer attach a host through the project route (D9).""" token = (await signup(client, "domains-tenant@example.com"))["access_token"] headers = auth_header(token) org_id = ( await client.post("/api/organizations", json={"name": "سازمان مستأجر"}, headers=headers) ).json()["id"] project_id = ( await client.post( f"/api/organizations/{org_id}/projects", json={"name": "اپ مستأجر"}, headers=headers, ) ).json()["id"] response = await client.post( f"/api/organizations/{org_id}/projects/{project_id}/preview/domains", json={"subdomain": "tenant-app"}, headers=headers, ) assert response.status_code == 403, response.text