feat(org): org creation wizard + app profile (v2 phase A1-2)

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
+386
View File
@@ -0,0 +1,386 @@
"""§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
)