387 lines
13 KiB
Python
387 lines
13 KiB
Python
"""§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
|
|
)
|