"""§v2-A3.5 — production deploy over SSH / SFTP / FTP / FTPS (D8). Production is the one action the platform cannot undo, so the tests are about the guards around it as much as the deploy itself: * a member who is not an org admin cannot deploy; * the org slug has to be typed back; * a changed host key is a hard failure; * an SSRF target is refused before a connection is attempted; * a secret never reaches a log, an event, a response or a database column; * and the mechanics — release directories, the ``current`` symlink, keeping five releases, rollback — behave the way the design says they do. The SSH and FTP transports are faked, so the suite needs no server. The deploy *logic* is real. """ from __future__ import annotations import os import tarfile import pytest import pytest_asyncio from httpx import AsyncClient from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec from app.application.services.deploy_service import DeployService from app.core.crypto import encrypt from app.domain.delivery.models import DeployStatus from app.domain.identity.models import PlatformRole from app.infrastructure.adapters.deploy_ftp import FtpDeployer, FtpEntry from app.infrastructure.adapters.deploy_ssh import ( CURRENT_LINK, KEEP_RELEASES, CommandOutcome, SshDeployer, ) from app.infrastructure.repositories.delivery import ( ProductionDeployRepository, ReleaseRepository, ) from app.infrastructure.repositories.identity import OrganizationRepository from app.infrastructure.repositories.projects import ( DeployTargetRepository, ProjectRepository, ) from tests.conftest import auth_header, signup SECRET = "ssh-password-that-must-never-leak" # --------------------------------------------------------------------------- # fake transports # --------------------------------------------------------------------------- class FakeSshTransport: """An in-memory SSH server: a filesystem plus a scripted command table.""" def __init__(self, *, fingerprint: str = "SHA256:abc123", fail_on: str = "") -> None: self.commands: list[str] = [] self.uploads: list[tuple[str, str]] = [] self.fingerprint = fingerprint self.fail_on = fail_on self.closed = False #: The remote filesystem, as a set of paths. self.files: set[str] = set() async def connect(self, **kwargs) -> None: # noqa: ANN003 self.connect_kwargs = kwargs if self.fail_on == "connect": raise OSError("permission denied") def host_key_fingerprint(self) -> str: return self.fingerprint async def run(self, command: str, *, timeout: float = 600.0) -> CommandOutcome: self.commands.append(command) if self.fail_on and self.fail_on in command: return CommandOutcome(exit_code=1, output="failed on purpose") if "ls -1t" in command and "releases" in command: releases = sorted( {p.split("/releases/")[1].split("/")[0] for p in self.files if "/releases/" in p}, reverse=True, ) return CommandOutcome(exit_code=0, output="\n".join(releases)) if "test -w" in command: return CommandOutcome(exit_code=0, output="") if "ln -sfn" in command: # Record the switch so a test can assert the pointer moved. for part in command.split(): if part.startswith("releases/"): self.files.add(f"/srv/app/{CURRENT_LINK}->{part}") return CommandOutcome(exit_code=0, output="") if "tail -n" in command: self.files = { p for p in self.files if not p.endswith(tuple( f"release-{i}" for i in range(1, 20) )) } return CommandOutcome(exit_code=0, output="") return CommandOutcome(exit_code=0, output="") async def upload(self, *, local_path: str, remote_path: str) -> None: self.uploads.append((local_path, remote_path)) self.files.add(remote_path) async def close(self) -> None: self.closed = True class FakeFtpTransport: def __init__(self, *, fail_on: str = "") -> None: self.commands: list[str] = [] self.uploaded: dict[str, bytes] = {} self.removed: list[str] = [] self.fail_on = fail_on self.cwd_path = "" self.connected = False async def connect(self, **kwargs) -> None: # noqa: ANN003 self.connect_kwargs = kwargs if self.fail_on == "connect": raise OSError("530 login incorrect") self.connected = True async def cwd(self, path: str) -> None: if self.fail_on == "cwd": raise OSError("550 no such directory") self.cwd_path = path async def download(self, remote_path: str) -> bytes: return self.uploaded.get(remote_path, b"old-content") async def upload(self, remote_path: str, data: bytes) -> None: if self.fail_on == "upload": raise OSError("552 quota exceeded") self.uploaded[remote_path] = data async def remove(self, remote_path: str) -> None: self.removed.append(remote_path) self.uploaded.pop(remote_path, None) async def list_files(self) -> list[FtpEntry]: return [FtpEntry(path=name, size=len(data)) for name, data in self.uploaded.items()] async def close(self) -> None: self.connected = False class FakeDeployTargetTester: """Dispatches to injected deployers, like the real one but with no network.""" def __init__(self, *, ssh: SshDeployer, ftp: FtpDeployer) -> None: self.ssh = ssh self.ftp = ftp self.calls: list[DeployTargetSpec] = [] async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck: self.calls.append(target) if target.kind in ("ssh", "sftp"): return await self.ssh.test_connection(target=target) return await self.ftp.test_connection(target=target) def deployer_for(self, kind: str): return self.ssh if kind in ("ssh", "sftp") else self.ftp # --------------------------------------------------------------------------- # fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(autouse=True) async def _platform_seed(seeded): return seeded def _artifact(tmp_path, *, build_dir: str = "") -> str: """A release archive shaped like the pipeline produces.""" source = tmp_path / "artifact-src" target = source / build_dir if build_dir else source target.mkdir(parents=True, exist_ok=True) (target / "index.html").write_text("

hello

") (target / "app.js").write_text("console.log('hi')") if build_dir: (source / "README.md").write_text("not deployed") archive = tmp_path / "release.tar.gz" with tarfile.open(archive, "w:gz") as tar: # Add the children rather than the directory itself: a bare "." member # is the archive root, which some extractors try to mkdir. for child in sorted(source.iterdir()): tar.add(child, arcname=child.name) return str(archive) @pytest_asyncio.fixture async def tenant(client: AsyncClient, session_factory, tmp_path): """An org + app + live release + a configured SSH deploy target.""" token = ( await signup( client, "deploy-owner@example.com", name="مدیر استقرار", platform_role=PlatformRole.OWNER, ) )["access_token"] headers = auth_header(token) org = ( await client.post("/api/organizations", json={"name": "شرکت استقرار"}, headers=headers) ).json() org_id, slug = org["id"], org["slug"] project_id = ( await client.post( f"/api/organizations/{org_id}/projects", json={"name": "اپ استقرار"}, headers=headers, ) ).json()["id"] artifact = _artifact(tmp_path, build_dir="dist") async with session_factory() as session: from app.domain.delivery.models import PipelineRun, Release from app.infrastructure.repositories.delivery import PipelineRunRepository # A release points at the pipeline run that produced it, so the run has # to exist: the foreign key is real, not decorative. run = await PipelineRunRepository(session).queue( PipelineRun( organization_id=org_id, project_id=project_id, sha="abc1234def", status="succeeded", ) ) release = await ReleaseRepository(session).create( Release( organization_id=org_id, project_id=project_id, run_id=run.id, sha="abc1234def", artifact_path=artifact, is_demo_live=True, ) ) await DeployTargetRepository(session).upsert( values={ "organization_id": org_id, "project_id": project_id, "kind": "ssh", "host": "deploy.example.com", "port": 22, "username": "deployer", "auth_type": "password", "secret_encrypted": encrypt(SECRET), "remote_path": "/srv/app", "post_deploy_command": "systemctl reload app", "is_active": True, } ) await session.commit() return { "org_id": org_id, "slug": slug, "project_id": project_id, "headers": headers, "release_id": release.id, "artifact": artifact, } def _service(session, *, ssh: FakeSshTransport, ftp: FakeFtpTransport | None = None) -> tuple[ DeployService, FakeDeployTargetTester ]: ssh_deployer = SshDeployer( transport_factory=lambda: ssh, artifacts_root="/tmp/artifacts", allow_private_hosts=False ) ftp_deployer = FtpDeployer( transport_factory=lambda: ftp or FakeFtpTransport(), artifacts_root="/tmp/artifacts", allow_private_hosts=False, ) tester = FakeDeployTargetTester(ssh=ssh_deployer, ftp=ftp_deployer) service = DeployService( projects=ProjectRepository(session), organizations=OrganizationRepository(session), targets=DeployTargetRepository(session), deploys=ProductionDeployRepository(session), releases=ReleaseRepository(session), runtimes=None, # not needed for the deploy paths under test tester=tester, allow_private_hosts=False, ) return service, tester # --------------------------------------------------------------------------- # the SSH deploy mechanics # --------------------------------------------------------------------------- async def test_ssh_deploy_uploads_extracts_and_switches(tmp_path): transport = FakeSshTransport() deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) ok, release_dir, message = await deployer.deploy( target=DeployTargetSpec( kind="ssh", host="deploy.example.com", port=22, username="deployer", secret="pw", remote_path="/srv/app", ), artifact_path=_artifact(tmp_path, build_dir="dist"), sha="abc1234def", post_deploy_command="systemctl reload app", ) assert ok is True, message assert release_dir.startswith("/srv/app/releases/") assert transport.uploads, "the artifact must be uploaded" joined = "\n".join(transport.commands) assert "tar -xzf" in joined, "the artifact must be extracted on the target" assert "systemctl reload app" in joined, "the post-deploy command must run" assert "ln -sfn" in joined and CURRENT_LINK in joined, "the symlink must be switched" assert "tail -n" in joined, "old releases must be pruned" async def test_ssh_deploy_prunes_to_five_releases(tmp_path): transport = FakeSshTransport() deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) await deployer.deploy( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app" ), artifact_path=_artifact(tmp_path), sha="abc1234", ) prune = next(c for c in transport.commands if "tail -n" in c) assert f"tail -n +{KEEP_RELEASES + 1}" in prune, ( "keep the newest five and delete the rest" ) async def test_a_failed_post_deploy_command_does_not_switch_the_symlink(tmp_path): """The previous release must keep serving after a failed deploy.""" transport = FakeSshTransport(fail_on="systemctl") deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) ok, _release, message = await deployer.deploy( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app" ), artifact_path=_artifact(tmp_path), sha="abc1234", post_deploy_command="systemctl reload app", ) assert ok is False assert "پس از استقرار" in message assert not any("ln -sfn" in c for c in transport.commands), ( "the switch must not happen when the hook failed" ) async def test_ssh_rollback_points_current_at_the_previous_release(tmp_path): transport = FakeSshTransport() deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) # Two releases exist on the target, newest first. transport.files = { "/srv/app/releases/20260101T000000-aaaaaaa/index.html", "/srv/app/releases/20260102T000000-bbbbbbb/index.html", } ok, target_name, message = await deployer.rollback( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app" ), post_deploy_command="systemctl reload app", ) assert ok is True, message assert target_name == "20260101T000000-aaaaaaa" assert any("ln -sfn" in c for c in transport.commands) async def test_ssh_rollback_refuses_when_there_is_only_one_release(tmp_path): transport = FakeSshTransport() transport.files = {"/srv/app/releases/only-one/index.html"} deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) ok, _target, message = await deployer.rollback( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app" ) ) assert ok is False assert "قبلی" in message # --------------------------------------------------------------------------- # host key pinning (TOFU) # --------------------------------------------------------------------------- async def test_a_changed_host_key_is_a_hard_failure(): transport = FakeSshTransport(fingerprint="SHA256:CHANGED") deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) check = await deployer.test_connection( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app", known_fingerprint="SHA256:ORIGINAL", ) ) assert check.ok is False assert "کلید میزبان" in check.message assert check.fingerprint == "SHA256:CHANGED" async def test_the_fingerprint_is_returned_for_pinning_on_first_contact(): transport = FakeSshTransport(fingerprint="SHA256:FIRST") deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) check = await deployer.test_connection( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app" ) ) assert check.ok is True assert check.fingerprint == "SHA256:FIRST" async def test_a_matching_pinned_key_is_accepted(): transport = FakeSshTransport(fingerprint="SHA256:SAME") deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=True) check = await deployer.test_connection( target=DeployTargetSpec( kind="ssh", host="h", port=22, username="u", secret="pw", remote_path="/srv/app", known_fingerprint="SHA256:SAME", ) ) assert check.ok is True # --------------------------------------------------------------------------- # SSRF # --------------------------------------------------------------------------- @pytest.mark.parametrize( "host", ["127.0.0.1", "169.254.169.254", "10.0.0.5", "localhost"], ) async def test_an_internal_target_is_refused_before_connecting(host: str): transport = FakeSshTransport() deployer = SshDeployer(transport_factory=lambda: transport, allow_private_hosts=False) check = await deployer.test_connection( target=DeployTargetSpec( kind="ssh", host=host, port=22, username="u", secret="pw", remote_path="/srv/app" ) ) assert check.ok is False assert transport.commands == [], "no connection may be attempted" assert not transport.uploads async def test_an_internal_ftp_target_is_refused(): transport = FakeFtpTransport() deployer = FtpDeployer(transport_factory=lambda: transport, allow_private_hosts=False) check = await deployer.test_connection( target=DeployTargetSpec( kind="ftp", host="127.0.0.1", port=21, username="u", secret="pw", remote_path="/var/www", ) ) assert check.ok is False assert transport.connected is False # --------------------------------------------------------------------------- # FTP # --------------------------------------------------------------------------- async def test_ftp_deploy_backs_up_before_overwriting(tmp_path): transport = FakeFtpTransport() transport.uploaded = {"index.html": b"OLD", "app.js": b"OLD"} deployer = FtpDeployer( transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts") ) ok, backup, message = await deployer.deploy( target=DeployTargetSpec( kind="ftp", host="ftp.example.com", port=21, username="u", secret="pw", remote_path="/var/www", ), artifact_path=_artifact(tmp_path, build_dir="dist"), sha="abc1234", organization_id="org-1", project_id="proj-1", ) assert ok is True, message assert backup and os.path.isfile(backup), "a backup must exist before anything is overwritten" with tarfile.open(backup, "r:gz") as tar: names = set(tar.getnames()) assert {"index.html", "app.js"} <= names, "the backup must contain what it overwrites" async def test_ftp_deploy_uploads_only_the_build_output(tmp_path): """A static deploy must not publish the repository, only the build.""" transport = FakeFtpTransport() deployer = FtpDeployer( transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts") ) await deployer.deploy( target=DeployTargetSpec( kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www" ), artifact_path=_artifact(tmp_path, build_dir="dist"), sha="abc1234", ) assert "index.html" in transport.uploaded assert "README.md" not in transport.uploaded, "the source tree must not be published" async def test_ftp_rollback_re_uploads_the_backup(tmp_path): transport = FakeFtpTransport() transport.uploaded = {"index.html": b"OLD"} deployer = FtpDeployer( transport_factory=lambda: transport, artifacts_root=str(tmp_path / "artifacts") ) _ok, backup, _message = await deployer.deploy( target=DeployTargetSpec( kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www" ), artifact_path=_artifact(tmp_path, build_dir="dist"), sha="abc1234", organization_id="org-1", project_id="proj-1", ) transport.uploaded = {"index.html": b"BROKEN"} ok, message = await deployer.rollback( target=DeployTargetSpec( kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www" ), backup_path=backup, ) assert ok is True, message assert transport.uploaded["index.html"] == b"OLD" async def test_ftp_rollback_without_a_backup_fails_cleanly(): transport = FakeFtpTransport() deployer = FtpDeployer(transport_factory=lambda: transport) ok, message = await deployer.rollback( target=DeployTargetSpec( kind="ftp", host="h", port=21, username="u", secret="pw", remote_path="/var/www" ), backup_path="", ) assert ok is False assert message # --------------------------------------------------------------------------- # the service guards # --------------------------------------------------------------------------- async def test_the_confirm_slug_must_match(tenant, session_factory): transport = FakeSshTransport() async with session_factory() as session: service, _tester = _service(session, ssh=transport) with pytest.raises(Exception) as excinfo: await service.deploy_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm="not-the-slug", ) assert "تأییدیه" in str(excinfo.value) assert transport.commands == [], "nothing may be attempted on a bad confirmation" async def test_a_correct_confirmation_deploys_and_records_the_row(tenant, session_factory): transport = FakeSshTransport() async with session_factory() as session: service, _tester = _service(session, ssh=transport) deploy = await service.deploy_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm=tenant["slug"], ) await session.commit() assert deploy.status == DeployStatus.SUCCEEDED.value, deploy.message assert deploy.kind == "ssh" async with session_factory() as session: rows = await ProductionDeployRepository(session).list_for_project( project_id=tenant["project_id"] ) assert len(rows) == 1 assert rows[0].release_id == tenant["release_id"] async def test_a_second_deploy_is_refused_while_one_is_running(tenant, session_factory): from app.domain.delivery.models import ProductionDeploy transport = FakeSshTransport() async with session_factory() as session: repo = ProductionDeployRepository(session) await repo.create( ProductionDeploy( organization_id=tenant["org_id"], project_id=tenant["project_id"], kind="ssh", host="h", status=DeployStatus.RUNNING.value, ) ) await session.commit() service, _tester = _service(session, ssh=transport) with pytest.raises(Exception) as excinfo: await service.deploy_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm=tenant["slug"], ) assert "در حال انجام" in str(excinfo.value) async def test_a_secret_never_reaches_the_log_or_the_row(tenant, session_factory): """The one thing that must never happen: a credential in a stored field.""" transport = FakeSshTransport() async with session_factory() as session: service, _tester = _service(session, ssh=transport) deploy = await service.deploy_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm=tenant["slug"], ) await session.commit() assert SECRET not in deploy.log_tail assert SECRET not in deploy.message for _local, remote in transport.uploads: assert SECRET not in remote for command in transport.commands: assert SECRET not in command, "a credential must never reach a remote command line" async def test_the_ftp_rollback_without_a_backup_is_refused_not_guessed( tenant, session_factory ): """FTP has no atomic switch: without the backup there is nothing to undo.""" async with session_factory() as session: target = await DeployTargetRepository(session).get_for_project(tenant["project_id"]) assert target is not None target.kind = "ftp" await session.commit() transport = FakeFtpTransport() async with session_factory() as session: service, _tester = _service(session, ssh=FakeSshTransport(), ftp=transport) deploy = await service.deploy_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm=tenant["slug"], ) await session.commit() assert deploy.status == DeployStatus.SUCCEEDED.value, deploy.message # Clear the recorded backup to simulate a lost archive. row = await ProductionDeployRepository(session).get(deploy.id) assert row is not None deploy_row = await session.get( __import__( "app.infrastructure.db.models_delivery", fromlist=["ProductionDeployRow"] ).ProductionDeployRow, deploy.id, ) assert deploy_row is not None deploy_row.backup_path = "" await session.commit() rolled = await service.rollback_production( organization_id=tenant["org_id"], project_id=tenant["project_id"], actor_user_id="u1", confirm=tenant["slug"], deploy_id=deploy.id, ) await session.commit() assert rolled.status == DeployStatus.FAILED.value assert "پشتیبان" in rolled.message # --------------------------------------------------------------------------- # HTTP surface # --------------------------------------------------------------------------- async def test_a_member_cannot_deploy_to_production(client: AsyncClient, tenant): member_token = (await signup(client, "deploy-member@example.com"))["access_token"] member_headers = auth_header(member_token) # Join the org as a viewer so membership succeeds but the capability does not. invite = await client.post( f"/api/organizations/{tenant['org_id']}/invites", json={"email": "deploy-member@example.com", "role": "viewer"}, headers=tenant["headers"], ) if invite.status_code in (200, 201): token = invite.json().get("token") or invite.json().get("invite_url", "").split("=")[-1] if token: await client.post( "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}, headers=member_headers, ) response = await client.post( f"/api/organizations/{tenant['org_id']}/delivery/production/deploy", json={ "project_id": tenant["project_id"], "confirm": tenant["slug"], }, headers=member_headers, ) # Either not a member (404, the tenancy check) or a member without the # capability (403). Never a success. assert response.status_code in (403, 404), response.text async def test_the_confirm_slug_is_enforced_over_http(client: AsyncClient, tenant): response = await client.post( f"/api/organizations/{tenant['org_id']}/delivery/production/deploy", json={"project_id": tenant["project_id"], "confirm": "wrong-slug"}, headers=tenant["headers"], ) assert response.status_code == 422, response.text assert "تأییدیه" in response.json()["error"]["message"] async def test_the_target_summary_never_returns_the_secret(client: AsyncClient, tenant): response = await client.get( f"/api/organizations/{tenant['org_id']}/delivery/production", params={"project_id": tenant["project_id"]}, headers=tenant["headers"], ) assert response.status_code == 200, response.text body = response.json() assert body["target"]["has_secret"] is True assert SECRET not in response.text assert "secret" not in set(body["target"]), "the raw secret column must not be exposed" async def test_deploying_without_a_target_is_refused(client: AsyncClient, tenant, session_factory): async with session_factory() as session: target = await DeployTargetRepository(session).get_for_project(tenant["project_id"]) assert target is not None target.is_active = False await session.commit() response = await client.post( f"/api/organizations/{tenant['org_id']}/delivery/production/deploy", json={"project_id": tenant["project_id"], "confirm": tenant["slug"]}, headers=tenant["headers"], ) assert response.status_code == 422, response.text assert "مقصد" in response.json()["error"]["message"]