"""§v2-A1.2 — org creation wizard + app profile (1 org = 1 app). Covers the catalog, draft, git/deploy checks, wizard completion, the 1-app guard, SSRF prevention on git URLs, and the post-wizard profile editing endpoints. """ from __future__ import annotations import pytest import pytest_asyncio from httpx import AsyncClient from app.domain.identity.models import PlatformRole from app.domain.projects.catalog import APP_TYPES from tests.conftest import auth_header, signup @pytest_asyncio.fixture(autouse=True) async def _seeded(seeded): return seeded async def _manager(client: AsyncClient) -> tuple[str, dict]: token = (await signup(client, "wiz-manager@example.com"))["access_token"] headers = auth_header(token) return token, headers async def _user(client: AsyncClient) -> tuple[str, dict]: token = ( await signup( client, "wiz-user@example.com", platform_role=PlatformRole.USER ) )["access_token"] headers = auth_header(token) return token, headers async def _complete( client: AsyncClient, headers: dict, overrides: dict | None = None ) -> dict: payload = { "org_name": "سازمان تست", "slug": "test-org", "app_name": "اپ تست", "app_type": "web_app", "language": "node", "framework": "next", "git_source": "platform", } if overrides: payload.update(overrides) response = await client.post("/api/setup/complete", json=payload, headers=headers) assert response.status_code == 201, response.text return response.json() # --------------------------------------------------------------------------- # Catalog # --------------------------------------------------------------------------- async def test_catalog_returns_app_types_and_languages(client: AsyncClient): token, headers = await _manager(client) response = await client.get("/api/setup/catalog", headers=headers) assert response.status_code == 200 data = response.json() assert set(data["app_types"]) == set(APP_TYPES) assert "languages" in data lang_keys = {lang["key"] for lang in data["languages"]} assert "node" in lang_keys assert "python" in lang_keys # next.js should carry defaults node = next(lang for lang in data["languages"] if lang["key"] == "node") next_fw = next(fw for fw in node["frameworks"] if fw["key"] == "next") assert next_fw["defaults"]["app_port"] == 3000 # --------------------------------------------------------------------------- # Draft # --------------------------------------------------------------------------- async def test_draft_save_and_resume(client: AsyncClient): token, headers = await _manager(client) await client.put( "/api/setup/draft", json={"step": 3, "data": {"org_name": "X"}}, headers=headers ) response = await client.get("/api/setup/draft", headers=headers) assert response.status_code == 200 data = response.json() assert data["step"] == 3 assert data["data"]["org_name"] == "X" # --------------------------------------------------------------------------- # Wizard completion — happy paths # --------------------------------------------------------------------------- async def test_complete_platform_git_creates_org_and_project(client: AsyncClient): token, headers = await _manager(client) result = await _complete(client, headers) assert result["organization"]["name"] == "سازمان تست" assert result["project"]["name"] == "اپ تست" assert result["deploy_target_id"] is None # Profile is readable via the org-scoped endpoint org_id = result["organization"]["id"] resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers) assert resp.status_code == 200 profile = resp.json() assert profile["app_type"] == "web_app" assert profile["language"] == "node" assert profile["framework"] == "next" assert profile["wizard_completed"] is True async def test_complete_with_external_git(client: AsyncClient): token, headers = await _manager(client) result = await _complete( client, headers, overrides={ "git_source": "external", "external_repo_url": "https://git.example.invalid/repo.git", "external_git_auth_type": "token", "external_git_username": "user", "external_git_secret": "my-secret-token", }, ) org_id = result["organization"]["id"] resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers) assert resp.status_code == 200 assert resp.json()["git_source"] == "external" assert resp.json()["external_repo_url"] == "https://git.example.invalid/repo.git" async def test_complete_with_deploy_target(client: AsyncClient): token, headers = await _manager(client) result = await _complete( client, headers, overrides={ "deploy_target_kind": "ssh", "deploy_target_host": "deploy.example.invalid", "deploy_target_port": 22, "deploy_target_username": "root", "deploy_target_secret": "deploy-secret", }, ) assert result["deploy_target_id"] is not None async def test_complete_does_not_block_on_failing_deploy_test(client: AsyncClient): token, headers = await _manager(client) result = await _complete( client, headers, overrides={ "deploy_target_kind": "ssh", "deploy_target_host": "fail.example.invalid", "deploy_target_port": 22, "deploy_target_username": "root", "deploy_target_secret": "deploy-secret", }, ) # The fake deploy tester returns ok=False for hosts containing "fail", # but the wizard stores the target regardless (last_test_ok=False). assert result["deploy_target_id"] is not None # --------------------------------------------------------------------------- # Guards # --------------------------------------------------------------------------- async def test_plain_user_cannot_complete_wizard(client: AsyncClient): token, headers = await _user(client) payload = { "org_name": "سازمان تست", "slug": "test-org-user", "app_name": "اپ تست", "app_type": "web_app", "language": "node", "framework": "next", "git_source": "platform", } response = await client.post("/api/setup/complete", json=payload, headers=headers) assert response.status_code == 403 async def test_second_app_for_same_org_returns_409(client: AsyncClient, session_factory): token, headers = await _manager(client) result = await _complete(client, headers, overrides={"slug": "test-409"}) org_id = result["organization"]["id"] from app.application.services.org_setup_service import OrgSetupService, WizardPayload from app.core.errors import ConflictError from app.domain.projects.catalog import ProfileDefaults async with session_factory() as session: svc = OrgSetupService(session) defaults = ProfileDefaults( app_type="api", language="python", framework="fastapi", runtime_version="3.12", package_manager="pip", setup_command="", build_command=None, test_command=None, start_command="", app_port=8000, healthcheck_path="/", docker_image="python:3.12-slim", ) with pytest.raises(ConflictError, match="این سازمان قبلاً"): await svc._create_single_app( org_id=org_id, user_id="ignored", payload=WizardPayload( org_name="X", slug="x", app_name="Y", app_type="api", language="python", framework="fastapi", ), defaults=defaults, ) # --------------------------------------------------------------------------- # Secrets never leak # --------------------------------------------------------------------------- async def test_secrets_never_appear_in_wizard_responses(client: AsyncClient): token, headers = await _manager(client) result = await _complete( client, headers, overrides={ "slug": "secret-check", "git_source": "external", "external_repo_url": "https://git.example.invalid/repo.git", "external_git_secret": "SUPER-SECRET-123", "deploy_target_kind": "ssh", "deploy_target_host": "deploy.example.invalid", "deploy_target_secret": "DEPLOY-SECRET-456", }, ) # Audit endpoint must not contain the secret org_id = result["organization"]["id"] audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers) assert audit.status_code == 200 for event in audit.json(): raw = str(event) assert "SUPER-SECRET-123" not in raw assert "DEPLOY-SECRET-456" not in raw # Draft must not contain the secret draft = await client.get("/api/setup/draft", headers=headers) assert draft.status_code == 200 raw_draft = str(draft.json()) assert "SUPER-SECRET-123" not in raw_draft assert "DEPLOY-SECRET-456" not in raw_draft # --------------------------------------------------------------------------- # SSRF on git URL # --------------------------------------------------------------------------- async def test_check_git_rejects_localhost(client: AsyncClient): token, headers = await _manager(client) response = await client.post( "/api/setup/check-git", json={"url": "https://localhost/repo.git"}, headers=headers, ) assert response.status_code == 422 or response.status_code == 400 async def test_check_git_rejects_private_ip(client: AsyncClient): token, headers = await _manager(client) response = await client.post( "/api/setup/check-git", json={"url": "https://192.168.1.1/repo.git"}, headers=headers, ) assert response.status_code == 422 or response.status_code == 400 async def test_check_git_rejects_credentials_in_url(client: AsyncClient): token, headers = await _manager(client) response = await client.post( "/api/setup/check-git", json={"url": "https://user:pass@github.com/repo.git"}, headers=headers, ) assert response.status_code == 422 or response.status_code == 400 # --------------------------------------------------------------------------- # Profile editing # --------------------------------------------------------------------------- async def test_patch_profile_updates_fields(client: AsyncClient): token, headers = await _manager(client) result = await _complete(client, headers, overrides={"slug": "patch-test"}) org_id = result["organization"]["id"] resp = await client.patch( f"/api/organizations/{org_id}/app/profile", json={"app_port": 9000, "healthcheck_path": "/ready"}, headers=headers, ) assert resp.status_code == 200 profile = resp.json() assert profile["app_port"] == 9000 assert profile["healthcheck_path"] == "/ready" async def test_put_deploy_target_rotates_secret(client: AsyncClient): token, headers = await _manager(client) result = await _complete(client, headers, overrides={"slug": "deploy-test"}) org_id = result["organization"]["id"] resp = await client.put( f"/api/organizations/{org_id}/app/deploy-target", json={ "kind": "sftp", "host": "sftp.example.invalid", "port": 22, "username": "admin", "secret": "new-secret", "remote_path": "/var/www", }, headers=headers, ) assert resp.status_code == 200 data = resp.json() assert data["kind"] == "sftp" assert data["has_secret"] is True async def test_put_git_credentials_rotates_secret(client: AsyncClient): token, headers = await _manager(client) result = await _complete( client, headers, overrides={ "slug": "git-cred-test", "git_source": "external", "external_repo_url": "https://git.example.invalid/repo.git", }, ) org_id = result["organization"]["id"] resp = await client.put( f"/api/organizations/{org_id}/app/git", json={"auth_type": "token", "username": "newuser", "secret": "new-token"}, headers=headers, ) assert resp.status_code == 200 data = resp.json() assert data["git_source"] == "external" assert data["has_secret"] is True # --------------------------------------------------------------------------- # Hook recording # --------------------------------------------------------------------------- async def test_hooks_called_after_wizard_complete(client: AsyncClient, monkeypatch): from app.application.services.org_setup_service import OrgSetupService hook_calls: list[dict] = [] async def _record_hooks(self, *, organization_id: str, project_id: str) -> None: hook_calls.append({"organization_id": organization_id, "project_id": project_id}) monkeypatch.setattr(OrgSetupService, "_call_hooks", _record_hooks) token, headers = await _manager(client) result = await _complete(client, headers, overrides={"slug": "hook-test"}) org_id = result["organization"]["id"] project_id = result["project"]["id"] assert any( c["organization_id"] == org_id and c["project_id"] == project_id for c in hook_calls )