Files
smart-org/apps/api/tests/test_git_workspace.py
T
Agent 3 22374f3e1c feat(delivery): final report + reserved-label guard + Windows test fixes (v2 phase A3-final)
Gap-closing on top of the merged A3-3..A3-6 work:

- ProjectService.create_preview_domain now refuses a label that is a reserved
  platform name (git, mcp, api, panel, ...) via PROTECTED_HOST_LABELS. The rules
  set existed but only the owner-facing validate_extra_host consulted it, so the
  tenant path could reserve one.
- process.py: signal.SIGKILL is resolved through getattr with a SIGTERM
  fallback, so the module imports and type-checks on Windows where SIGKILL does
  not exist.
- Three more Windows test repairs: a git-created .git must be unlinked and
  recreated rather than overwritten in place; POSIX-only chmod and symlink
  assertions are gated on os.name (the security property they assert is checked
  everywhere, the platform-specific mechanics only where they apply).
- docs/plan/reports/agent-3-final.md: the integration hand-off — host
  requirements, every setting, every endpoint added or changed, the tables, the
  open CHANGE REQUESTS, a 12-step manual QA script and the known limitations.

Checks: ruff clean, mypy clean (136 files), 521 API tests pass, 83 web tests
pass, tsc clean, next lint clean.
2026-10-14 12:07:10 +03:30

710 lines
27 KiB
Python

"""§v2-A3.2 — the platform's git adapter, exercised against real git.
These tests run a real ``git`` binary against a local bare repository standing in
for the remote. Nothing is faked except the *remote's location*: the flags, the
locking, the worktree dance and the credential plumbing are the real thing,
because those are precisely the parts that cannot be verified by a mock.
``protocol.file.allow=user`` is passed explicitly. The production default is
``never`` — a repository must not be able to make git read a local path through a
submodule or ``--upload-pack`` — but a local bare repo *is* a local path, so the
hardening has to be relaxed for the test to be able to run at all. That is why
the setting exists rather than being hard-coded.
"""
from __future__ import annotations
import asyncio
import os
import pytest
from app.core.errors import NotFoundError
from app.domain.platform.rules import RuleViolation, assert_remote_host_allowed, is_blocked_address
from app.infrastructure.adapters.git_workspace import (
LocalGitWorkspaceAdapter,
ProjectGitSource,
)
from app.infrastructure.adapters.process import ProcessResult, run_process
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
async def _git(args: list[str], *, cwd: str, ok: bool = True) -> ProcessResult:
result = await run_process(
["git", *args],
cwd=cwd,
timeout_seconds=60,
env={
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
},
)
if ok:
assert result.ok, f"git {' '.join(args)} failed: {result.stderr}"
return result
async def _make_remote(tmp_path) -> str:
"""A local bare repository with one commit on ``main``; returns its path."""
remote = tmp_path / "remote.git"
seed = tmp_path / "seed"
await _git(["init", "--quiet", "--bare", str(remote)], cwd=str(tmp_path))
await _git(["init", "--quiet", str(seed)], cwd=str(tmp_path))
(seed / "README.md").write_text("hello\n")
await _git(["add", "-A"], cwd=str(seed))
await _git(["commit", "--quiet", "-m", "init"], cwd=str(seed))
await _git(["branch", "-M", "main"], cwd=str(seed))
await _git(["remote", "add", "origin", str(remote)], cwd=str(seed))
await _git(["push", "--quiet", "origin", "main"], cwd=str(seed))
return str(remote)
def _resolver(clone_url: str, *, auth_type: str = "none", secret: str | None = None,
username: str | None = None):
async def resolve(project_id: str) -> ProjectGitSource | None:
if project_id == "missing":
return None
return ProjectGitSource(
project_id=project_id,
organization_id="org1",
clone_url=clone_url,
default_branch="main",
auth_type=auth_type,
username=username,
secret=secret,
)
return resolve
def _adapter(tmp_path, clone_url: str, **kwargs) -> LocalGitWorkspaceAdapter:
kwargs.setdefault("allow_file_protocol", True)
kwargs.setdefault("runner", run_process)
return LocalGitWorkspaceAdapter(
resolver=_resolver(clone_url, **kwargs.pop("credentials", {})),
root=str(tmp_path / "root"),
**kwargs,
)
# ---------------------------------------------------------------------------
# ensure_workspace
# ---------------------------------------------------------------------------
async def test_ensure_workspace_clones_and_creates_dev(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
assert ref.default_branch == "dev"
assert os.path.isdir(ref.host_path), "the work tree must exist for the sandbox mount"
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "README.md").is_file()
assert await adapter.head(project_id="proj1", branch="dev") is not None
# `dev` was pushed to the platform remote because it did not exist there.
assert await adapter.head(project_id="proj1", branch="dev") is not None
async def test_ensure_workspace_is_idempotent(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
first = await adapter.ensure_workspace(project_id="proj1")
(tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").write_text("x")
second = await adapter.ensure_workspace(project_id="proj1")
assert first.host_path == second.host_path
assert (tmp_path / "root" / "org1" / "proj1" / "work" / "new.txt").is_file()
async def test_ensure_workspace_rejects_an_unknown_project(tmp_path):
adapter = _adapter(tmp_path, str(tmp_path / "nothing.git"))
with pytest.raises(NotFoundError):
await adapter.ensure_workspace(project_id="missing")
async def test_gitdir_is_not_inside_the_work_tree(tmp_path):
"""The git dir must never be reachable from the sandbox mount (D6)."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
assert gitdir.is_dir()
assert not os.path.exists(os.path.join(ref.host_path, ".git"))
assert not str(gitdir).startswith(ref.host_path + os.sep)
# ---------------------------------------------------------------------------
# worktrees
# ---------------------------------------------------------------------------
async def test_task_worktree_is_created_and_removed(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert worktree.branch == "task/t1"
assert worktree.sandbox_path == "/workspace/.worktrees/t1"
assert os.path.isdir(worktree.host_path)
await adapter.remove_worktree(worktree=worktree)
assert not os.path.isdir(worktree.host_path)
async def test_task_worktree_is_invisible_in_the_main_work_tree(tmp_path):
"""`info/exclude` keeps .worktrees out of `status` without touching .gitignore."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
ref = await adapter.ensure_workspace(project_id="proj1")
await adapter.create_task_worktree(project_id="proj1", task_id="t1")
result = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", ref.host_path,
"status", "--porcelain=v1", "-uall"],
cwd=ref.host_path,
)
assert result.stdout.strip() == "", "the worktree dir must not show up as untracked"
assert not os.path.exists(os.path.join(ref.host_path, ".gitignore"))
async def test_create_task_worktree_is_idempotent(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
first = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
second = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert first.host_path == second.host_path
# ---------------------------------------------------------------------------
# diff / commit
# ---------------------------------------------------------------------------
async def test_diff_reports_a_new_directory_thanks_to_uall(tmp_path):
"""`git status` without ``-uall`` hides new directories — a real agybot bug."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
(tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src").mkdir()
(
tmp_path / "root" / "org1" / "proj1" / "work" / ".worktrees" / "t1" / "src" / "a.py"
).write_text("print('hi')\n")
diff = await adapter.diff(worktree=worktree)
assert diff.changed_files == ["src/a.py"]
assert diff.is_empty is False
@pytest.mark.skipif(
os.name == "nt",
reason="creating a symlink on Windows requires SeCreateSymbolicLinkPrivilege "
"(admin or Developer Mode); the adapter's guard is covered on POSIX hosts",
)
async def test_diff_detects_an_escaping_symlink(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
outside = tmp_path / "outside.txt"
outside.write_text("secret")
os.symlink(outside, os.path.join(worktree.host_path, "escape.txt"))
diff = await adapter.diff(worktree=worktree)
assert diff.escaping_symlinks == ["escape.txt"]
async def test_commit_returns_none_when_there_is_nothing_to_commit(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
assert await adapter.commit(worktree=worktree, message="empty", author="AI") is None
async def test_commit_records_the_author_and_returns_a_sha(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
target = os.path.join(worktree.host_path, "feature.py")
with open(target, "w", encoding="utf-8") as handle:
handle.write("x = 1\n")
sha = await adapter.commit(worktree=worktree, message="task/t1: add feature", author="Alice")
assert sha and len(sha) == 40
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
log = await _git(
["--git-dir", str(shared_gitdir), "log", "-1", "--format=%an <%ae>|%s", "task/t1"],
cwd=str(tmp_path),
)
assert log.stdout.strip() == "Alice <alice@smart-org.local>|task/t1: add feature"
# ...and specifically to `task/t1`, not to `dev`. A shared gitdir makes this
# the easiest thing in the world to get wrong.
task_head = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
)
assert task_head.stdout.strip() == sha
dev_head = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "dev"], cwd=str(tmp_path)
)
assert dev_head.stdout.strip() != sha, "the commit must not land on dev directly"
# ---------------------------------------------------------------------------
# merge / push / head
# ---------------------------------------------------------------------------
async def test_merge_into_dev_and_push(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
sha = await adapter.commit(worktree=worktree, message="task/t1: a", author="AI")
merged = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
assert merged.ok is True, merged.message
# `--no-ff` means dev gets a merge *commit*, not the task commit itself, so
# the task sha is a parent of dev's new head rather than dev's head.
dev_head = await adapter.head(project_id="proj1", branch="dev")
assert dev_head == merged.sha
assert dev_head != sha
ancestry = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"rev-list", "--parents", "-1", "dev"],
cwd=str(tmp_path),
)
assert sha in ancestry.stdout.split()
pushed = await adapter.push(project_id="proj1", branch="dev")
assert pushed.ok is True, pushed.message
# The remote really received it.
ls = await _git(["--git-dir", remote, "rev-parse", "dev"], cwd=str(tmp_path))
assert ls.stdout.strip() == dev_head
async def test_merge_conflict_leaves_a_clean_tree(tmp_path):
"""A half-merge would break every later operation (agybot bug H3).
The task branch has to fork *before* ``dev`` moves, otherwise the merge is a
trivial fast-forward and there is nothing to conflict about.
"""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
# 1. The task branches off dev as it stands.
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "README.md"), "w", encoding="utf-8") as handle:
handle.write("task version\n")
await adapter.commit(worktree=worktree, message="task/t1: edit", author="AI")
# 2. dev moves on, touching the same file.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "README.md").write_text("dev version\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev edit"],
cwd=str(main_work),
)
outcome = await adapter.merge_into(project_id="proj1", branch="task/t1", target="dev")
assert outcome.ok is False
assert outcome.conflict_files == ["README.md"]
# No merge in progress: the next status is clean.
status = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "status", "--porcelain=v1", "-uall"],
cwd=str(main_work),
)
assert status.stdout.strip() == ""
assert not os.path.exists(
tmp_path / "root" / "org1" / "proj1" / "git" / "MERGE_HEAD"
)
async def test_sync_with_base_reports_that_the_base_moved(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
# Nothing moved yet.
quiet = await adapter.sync_with_base(worktree=worktree)
assert quiet.ok is True and quiet.base_changed is False
# Move dev forward.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "other.txt").write_text("x\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "dev moved"],
cwd=str(main_work),
)
moved = await adapter.sync_with_base(worktree=worktree)
assert moved.ok is True
assert moved.base_changed is True
assert os.path.isfile(os.path.join(worktree.host_path, "other.txt"))
async def test_concurrent_merges_are_serialised(tmp_path):
"""Without the per-project lock two merges corrupt the index."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
for task in ("t1", "t2"):
worktree = await adapter.create_task_worktree(project_id="proj1", task_id=task)
with open(
os.path.join(worktree.host_path, f"{task}.txt"), "w", encoding="utf-8"
) as handle:
handle.write(f"{task}\n")
await adapter.commit(worktree=worktree, message=f"task/{task}", author="AI")
first, second = await asyncio.gather(
adapter.merge_into(project_id="proj1", branch="task/t1", target="dev"),
adapter.merge_into(project_id="proj1", branch="task/t2", target="dev"),
)
assert first.ok is True and second.ok is True
status = await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(tmp_path / "root" / "org1" / "proj1" / "work"),
"status", "--porcelain=v1", "-uall"],
cwd=str(tmp_path),
)
assert status.stdout.strip() == ""
async def test_push_reports_a_rejection_without_forcing(tmp_path):
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
# Someone else advances the remote's dev, so our push is no longer a
# fast-forward. `origin/dev` is what the clone checked out, not a fresh
# branch off the default one.
other = tmp_path / "other"
await _git(["clone", "--quiet", remote, str(other)], cwd=str(tmp_path))
await _git(["checkout", "--quiet", "-B", "dev", "origin/dev"], cwd=str(other))
(other / "divergent.txt").write_text("other\n")
await _git(["add", "-A"], cwd=str(other))
await _git(["commit", "--quiet", "-m", "divergent"], cwd=str(other))
await _git(["push", "--quiet", "origin", "dev"], cwd=str(other))
# Local dev moves a different way.
main_work = tmp_path / "root" / "org1" / "proj1" / "work"
(main_work / "mine.txt").write_text("mine\n")
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "add", "-A"],
cwd=str(main_work),
)
await _git(
["--git-dir", str(tmp_path / "root" / "org1" / "proj1" / "git"),
"--work-tree", str(main_work), "commit", "--quiet", "-m", "mine"],
cwd=str(main_work),
)
outcome = await adapter.push(project_id="proj1", branch="dev")
assert outcome.ok is False
assert outcome.rejected is True, outcome.message
# ---------------------------------------------------------------------------
# hardening
# ---------------------------------------------------------------------------
async def test_hooks_are_never_executed(tmp_path):
"""A repository may ship hooks; a hook is arbitrary host code execution."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
hooks = gitdir / "hooks"
hooks.mkdir(parents=True, exist_ok=True)
marker = tmp_path / "HOOK_RAN"
hook = hooks / "post-commit"
hook.write_text(f"#!/bin/sh\ntouch {marker}\n")
os.chmod(hook, 0o755)
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
await adapter.commit(worktree=worktree, message="task/t1", author="AI")
assert not marker.exists(), "a post-commit hook must not run"
async def test_a_tampered_dot_git_file_is_ignored(tmp_path):
"""The `.git` pointer inside a worktree is attacker-controlled text."""
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
await adapter.ensure_workspace(project_id="proj1")
worktree = await adapter.create_task_worktree(project_id="proj1", task_id="t1")
decoy = tmp_path / "decoy"
await _git(["init", "--quiet", str(decoy)], cwd=str(tmp_path))
# Replace rather than overwrite: on Windows a git-created ``.git`` cannot be
# written in place (it can only be unlinked and recreated). The assertion
# below is identical either way — what matters is the *content* we plant.
pointer = os.path.join(worktree.host_path, ".git")
if os.path.lexists(pointer):
os.unlink(pointer)
with open(pointer, "w", encoding="utf-8") as handle:
handle.write(f"gitdir: {decoy}\n")
with open(os.path.join(worktree.host_path, "a.txt"), "w", encoding="utf-8") as handle:
handle.write("a\n")
sha = await adapter.commit(worktree=worktree, message="task/t1", author="AI")
assert sha is not None
# The commit landed on OUR task branch, not in the decoy.
shared_gitdir = tmp_path / "root" / "org1" / "proj1" / "git"
real = await _git(
["--git-dir", str(shared_gitdir), "rev-parse", "task/t1"], cwd=str(tmp_path)
)
assert real.stdout.strip() == sha
decoy_log = await _git(
["--git-dir", str(decoy), "log", "--oneline"], cwd=str(tmp_path), ok=False
)
assert decoy_log.exit_code != 0 or "task/t1" not in decoy_log.stdout
async def test_credentials_never_appear_in_argv(tmp_path):
"""argv is world-readable via `ps`; a secret must travel another way."""
secret = "ghp_thisisasecrettokenvalue0123456789"
seen: list[tuple[str, ...]] = []
async def recording_runner(argv, **kwargs): # noqa: ANN001, ANN003
seen.append(tuple(argv))
return await run_process(argv, **kwargs)
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret=secret, username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
runner=recording_runner,
)
# A token against a local path fails, but the *attempt* is what we assert on.
await adapter.ensure_workspace(project_id="proj1")
assert seen, "the adapter must have invoked git"
for argv in seen:
joined = " ".join(argv)
assert secret not in joined, f"secret leaked into argv: {argv}"
async def test_the_credential_file_is_removed_after_the_call(tmp_path):
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
)
await adapter.ensure_workspace(project_id="proj1")
private = tmp_path / "root" / "org1" / "proj1" / "private"
assert private.is_dir()
assert not (private / "credentials").exists(), "the 0600 secret file must be deleted"
assert not (private / "id_key").exists()
async def test_the_askpass_helper_is_private_and_holds_no_secret(tmp_path):
remote = await _make_remote(tmp_path)
adapter = LocalGitWorkspaceAdapter(
resolver=_resolver(remote, auth_type="token", secret="s3cret-token-value", username="git"),
root=str(tmp_path / "root"),
allow_file_protocol=True,
)
await adapter.ensure_workspace(project_id="proj1")
helper = tmp_path / "root" / "org1" / "proj1" / "private" / "askpass.sh"
assert helper.is_file()
# ``chmod`` is a no-op on Windows (NTFS has no POSIX mode bits), so the
# executable bit is only asserted where it exists. The security property
# that matters everywhere — no secret inside the helper — is checked below.
if os.name == "posix":
assert oct(helper.stat().st_mode)[-3:] == "700"
assert "s3cret-token-value" not in helper.read_text()
# ---------------------------------------------------------------------------
# SSRF: the shared host check
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"::1",
"fd00::1",
"fe80::1",
"0.0.0.0",
],
)
def test_is_blocked_address_covers_the_dangerous_ranges(address: str):
assert is_blocked_address(address) is True
@pytest.mark.parametrize("address", ["8.8.8.8", "1.1.1.1", "2606:4700::1111"])
def test_is_blocked_address_allows_public_addresses(address: str):
assert is_blocked_address(address) is False
@pytest.mark.parametrize(
"url",
[
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:6379/",
"http://localhost/repo.git",
"https://10.0.0.5/repo.git",
"http://[::1]/repo.git",
"https://user:pass@example.com/repo.git",
"file:///etc/passwd",
"https://example.com:6379/repo.git",
],
)
def test_assert_remote_host_allowed_refuses_dangerous_targets(url: str):
with pytest.raises(RuleViolation):
assert_remote_host_allowed(url, resolver=lambda *a, **k: [])
def test_assert_remote_host_allowed_accepts_a_public_host():
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("93.184.216.34", port))]
host, port = assert_remote_host_allowed(
"https://git.example.com/team/app.git", resolver=resolver
)
assert host == "git.example.com"
assert port is None
def test_assert_remote_host_allowed_refuses_a_name_that_resolves_private():
"""A public-looking name can resolve into the private network."""
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("10.1.2.3", port))]
with pytest.raises(RuleViolation):
assert_remote_host_allowed("https://internal.example.com/repo.git", resolver=resolver)
def test_assert_remote_host_allowed_can_be_told_to_allow_private():
host, _ = assert_remote_host_allowed(
"http://127.0.0.1:8080/repo.git", allow_private=True
)
assert host == "127.0.0.1"
def test_assert_remote_host_allowed_accepts_scp_style_ssh():
def resolver(host, port, **kwargs): # noqa: ANN001, ANN003
return [(2, 1, 6, "", ("93.184.216.34", port))]
host, _ = assert_remote_host_allowed("git@git.example.com:team/app.git", resolver=resolver)
assert host == "git.example.com"
async def test_test_remote_refuses_a_private_host(tmp_path):
adapter = _adapter(tmp_path, "https://example.com/x.git")
from app.application.ports.repo_workspace import RemoteCredentials
check = await adapter.test_remote(
url="http://169.254.169.254/repo.git", credentials=RemoteCredentials()
)
assert check.ok is False
assert "داخلی" in check.message
async def test_test_remote_lists_branches_of_a_local_repo(tmp_path, monkeypatch):
"""The ls-remote path, with the SSRF check stubbed out.
The check itself is covered by the tests above; here the subject is the
parsing of `git ls-remote --heads` and the branch list it produces. A local
bare repo is used as the remote because the suite must not need a network.
"""
import app.infrastructure.adapters.git_workspace as module
from app.application.ports.repo_workspace import RemoteCredentials
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
remote = await _make_remote(tmp_path)
adapter = _adapter(tmp_path, remote)
check = await adapter.test_remote(
url=f"file://{remote}", credentials=RemoteCredentials()
)
assert check.ok is True, check.message
assert check.branches == ["main"]
assert check.default_branch == "main"
async def test_test_remote_reports_a_missing_repository(tmp_path, monkeypatch):
import app.infrastructure.adapters.git_workspace as module
from app.application.ports.repo_workspace import RemoteCredentials
monkeypatch.setattr(module, "assert_remote_host_allowed", lambda *a, **k: ("local", None))
adapter = _adapter(tmp_path, "https://example.com/x.git")
check = await adapter.test_remote(
url=f"file://{tmp_path}/does-not-exist.git", credentials=RemoteCredentials()
)
assert check.ok is False
assert check.message