- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt, minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract - docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code) - docker-compose.enterprise-clean.yml: same env var fix - docker/.env.agent.example: same env var fix - build.sh: same env var fix - chore: semantics-testing SKILL.md, backend tests, pyproject.toml
348 lines
14 KiB
Python
348 lines
14 KiB
Python
# #region Test.Git.Url [C:3] [TYPE Module] [SEMANTICS test,git,url,parse,remote]
|
|
# @BRIEF Tests for GitServiceUrlMixin — host extraction, credential stripping, host replacement, origin alignment, remote identity parsing, server URL derivation, URL normalization.
|
|
# @RELATION BINDS_TO -> [GitServiceUrlMixin]
|
|
# @TEST_EDGE: empty_url -> returns None or empty
|
|
# @TEST_EDGE: non_http_scheme -> returns None (ssh/git protocol)
|
|
# @TEST_EDGE: url_with_port -> host:port preserved
|
|
# @TEST_EDGE: url_with_credentials -> credentials stripped/replaced correctly
|
|
# @TEST_EDGE: ssh_url -> parsed via git@ split
|
|
# @TEST_EDGE: missing_owner_repo -> raises HTTPException 400
|
|
# @TEST_EDGE: empty_server_url -> raises HTTPException 400
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from src.services.git._url import GitServiceUrlMixin
|
|
|
|
|
|
class TestableGitUrl(GitServiceUrlMixin):
|
|
"""Minimal concrete subclass for testing URL mixin methods."""
|
|
pass
|
|
|
|
|
|
# ── _extract_http_host ──
|
|
|
|
class TestExtractHttpHost:
|
|
"""_extract_http_host — host[:port] extraction from HTTP(S) URLs."""
|
|
|
|
# #region test_extract_host_https [C:2] [TYPE Function]
|
|
def test_extract_host_https(self):
|
|
"""Standard HTTPS URL returns lowercase host."""
|
|
svc = TestableGitUrl()
|
|
result = svc._extract_http_host("https://Git.Example.COM/org/repo.git")
|
|
assert result == "git.example.com"
|
|
# #endregion test_extract_host_https
|
|
|
|
# #region test_extract_host_with_port [C:2] [TYPE Function]
|
|
def test_extract_host_with_port(self):
|
|
"""URL with port returns host:port."""
|
|
svc = TestableGitUrl()
|
|
result = svc._extract_http_host("http://gitea.local:3000/repo")
|
|
assert result == "gitea.local:3000"
|
|
# #endregion test_extract_host_with_port
|
|
|
|
# #region test_extract_host_empty [C:2] [TYPE Function]
|
|
def test_extract_host_empty(self):
|
|
"""Empty/None input returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._extract_http_host(None) is None
|
|
assert svc._extract_http_host("") is None
|
|
assert svc._extract_http_host(" ") is None
|
|
# #endregion test_extract_host_empty
|
|
|
|
# #region test_extract_host_non_http [C:2] [TYPE Function]
|
|
def test_extract_host_non_http(self):
|
|
"""SSH or FTP scheme returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._extract_http_host("git@github.com:org/repo.git") is None
|
|
assert svc._extract_http_host("ftp://files.example.com/repo") is None
|
|
# #endregion test_extract_host_non_http
|
|
|
|
# #region test_extract_host_no_hostname [C:2] [TYPE Function]
|
|
def test_extract_host_no_hostname(self):
|
|
"""Malformed URL with no hostname returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._extract_http_host("http://") is None
|
|
# #endregion test_extract_host_no_hostname
|
|
|
|
|
|
# ── _strip_url_credentials ──
|
|
|
|
class TestStripUrlCredentials:
|
|
"""_strip_url_credentials — remove user:pass from URL."""
|
|
|
|
# #region test_strip_credentials [C:2] [TYPE Function]
|
|
def test_strip_credentials(self):
|
|
"""Credentials removed, scheme/host/path preserved."""
|
|
svc = TestableGitUrl()
|
|
result = svc._strip_url_credentials("https://oauth2:token123@git.example.com/org/repo.git")
|
|
assert result == "https://git.example.com/org/repo.git"
|
|
# #endregion test_strip_credentials
|
|
|
|
# #region test_strip_no_credentials [C:2] [TYPE Function]
|
|
def test_strip_no_credentials(self):
|
|
"""URL without credentials returned unchanged."""
|
|
svc = TestableGitUrl()
|
|
result = svc._strip_url_credentials("https://git.example.com/org/repo.git")
|
|
assert result == "https://git.example.com/org/repo.git"
|
|
# #endregion test_strip_no_credentials
|
|
|
|
# #region test_strip_empty [C:2] [TYPE Function]
|
|
def test_strip_empty(self):
|
|
"""Empty string returned as-is."""
|
|
svc = TestableGitUrl()
|
|
assert svc._strip_url_credentials("") == ""
|
|
# #endregion test_strip_empty
|
|
|
|
# #region test_strip_non_http_passthrough [C:2] [TYPE Function]
|
|
def test_strip_non_http_passthrough(self):
|
|
"""Non-HTTP URL returned unchanged."""
|
|
svc = TestableGitUrl()
|
|
url = "git@github.com:org/repo.git"
|
|
assert svc._strip_url_credentials(url) == url
|
|
# #endregion test_strip_non_http_passthrough
|
|
|
|
# #region test_strip_with_port [C:2] [TYPE Function]
|
|
def test_strip_with_port(self):
|
|
"""Port preserved after credential stripping."""
|
|
svc = TestableGitUrl()
|
|
result = svc._strip_url_credentials("https://user:pass@gitea.local:3000/repo")
|
|
assert result == "https://gitea.local:3000/repo"
|
|
# #endregion test_strip_with_port
|
|
|
|
|
|
# ── _replace_host_in_url ──
|
|
|
|
class TestReplaceHostInUrl:
|
|
"""_replace_host_in_url — swap origin host with config host."""
|
|
|
|
# #region test_replace_host_basic [C:2] [TYPE Function]
|
|
def test_replace_host_basic(self):
|
|
"""Source host replaced with config host, credentials preserved."""
|
|
svc = TestableGitUrl()
|
|
result = svc._replace_host_in_url(
|
|
"https://old-host.com/org/repo.git",
|
|
"https://new-host.com",
|
|
)
|
|
assert result == "https://new-host.com/org/repo.git"
|
|
# #endregion test_replace_host_basic
|
|
|
|
# #region test_replace_host_preserves_auth [C:2] [TYPE Function]
|
|
def test_replace_host_preserves_auth(self):
|
|
"""Credentials from source URL preserved on new host."""
|
|
svc = TestableGitUrl()
|
|
result = svc._replace_host_in_url(
|
|
"https://oauth2:tok123@old-host.com/org/repo.git",
|
|
"https://new-host.com:3000",
|
|
)
|
|
assert "oauth2:tok123@new-host.com:3000" in result
|
|
assert "/org/repo.git" in result
|
|
# #endregion test_replace_host_preserves_auth
|
|
|
|
# #region test_replace_host_empty_inputs [C:2] [TYPE Function]
|
|
def test_replace_host_empty_inputs(self):
|
|
"""Empty source or config returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._replace_host_in_url(None, "https://x.com") is None
|
|
assert svc._replace_host_in_url("https://x.com", None) is None
|
|
assert svc._replace_host_in_url("", "") is None
|
|
# #endregion test_replace_host_empty_inputs
|
|
|
|
# #region test_replace_host_non_http [C:2] [TYPE Function]
|
|
def test_replace_host_non_http(self):
|
|
"""Non-HTTP source or config returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._replace_host_in_url("git@old:x.git", "https://new.com") is None
|
|
assert svc._replace_host_in_url("https://old.com/x", "ftp://new.com") is None
|
|
# #endregion test_replace_host_non_http
|
|
|
|
|
|
# ── _parse_remote_repo_identity ──
|
|
|
|
class TestParseRemoteRepoIdentity:
|
|
"""_parse_remote_repo_identity — extract owner/repo from remote URL."""
|
|
|
|
# #region test_parse_https_url [C:2] [TYPE Function]
|
|
def test_parse_https_url(self):
|
|
"""HTTPS URL parsed into owner, repo, namespace."""
|
|
svc = TestableGitUrl()
|
|
result = svc._parse_remote_repo_identity("https://gitea.example.com/org/repo.git")
|
|
assert result["owner"] == "org"
|
|
assert result["repo"] == "repo"
|
|
assert result["namespace"] == "org"
|
|
assert result["full_name"] == "org/repo"
|
|
# #endregion test_parse_https_url
|
|
|
|
# #region test_parse_ssh_url [C:2] [TYPE Function]
|
|
def test_parse_ssh_url(self):
|
|
"""SSH git@ URL parsed via colon split."""
|
|
svc = TestableGitUrl()
|
|
result = svc._parse_remote_repo_identity("git@github.com:myorg/myrepo.git")
|
|
assert result["owner"] == "myorg"
|
|
assert result["repo"] == "myrepo"
|
|
assert result["namespace"] == "myorg"
|
|
# #endregion test_parse_ssh_url
|
|
|
|
# #region test_parse_nested_namespace [C:2] [TYPE Function]
|
|
def test_parse_nested_namespace(self):
|
|
"""Nested namespace (subgroups) preserved."""
|
|
svc = TestableGitUrl()
|
|
result = svc._parse_remote_repo_identity("https://gitlab.com/group/subgroup/project.git")
|
|
assert result["owner"] == "group"
|
|
assert result["repo"] == "project"
|
|
assert result["namespace"] == "group/subgroup"
|
|
assert result["full_name"] == "group/subgroup/project"
|
|
# #endregion test_parse_nested_namespace
|
|
|
|
# #region test_parse_empty_url_raises [C:2] [TYPE Function]
|
|
def test_parse_empty_url_raises(self):
|
|
"""Empty URL raises HTTPException 400."""
|
|
svc = TestableGitUrl()
|
|
with pytest.raises(HTTPException, match="empty"):
|
|
svc._parse_remote_repo_identity("")
|
|
# #endregion test_parse_empty_url_raises
|
|
|
|
# #region test_parse_single_segment_raises [C:2] [TYPE Function]
|
|
def test_parse_single_segment_raises(self):
|
|
"""URL with <2 path segments raises HTTPException 400."""
|
|
svc = TestableGitUrl()
|
|
with pytest.raises(HTTPException, match="Cannot parse"):
|
|
svc._parse_remote_repo_identity("https://host.com/onlyone")
|
|
# #endregion test_parse_single_segment_raises
|
|
|
|
# #region test_parse_no_git_suffix [C:2] [TYPE Function]
|
|
def test_parse_no_git_suffix(self):
|
|
"""URL without .git suffix parsed correctly."""
|
|
svc = TestableGitUrl()
|
|
result = svc._parse_remote_repo_identity("https://host.com/owner/repo")
|
|
assert result["repo"] == "repo"
|
|
# #endregion test_parse_no_git_suffix
|
|
|
|
|
|
# ── _derive_server_url_from_remote ──
|
|
|
|
class TestDeriveServerUrlFromRemote:
|
|
"""_derive_server_url_from_remote — build API base URL."""
|
|
|
|
# #region test_derive_https [C:2] [TYPE Function]
|
|
def test_derive_https(self):
|
|
"""HTTPS URL → scheme://host base."""
|
|
svc = TestableGitUrl()
|
|
result = svc._derive_server_url_from_remote("https://gitea.example.com/org/repo.git")
|
|
assert result == "https://gitea.example.com"
|
|
# #endregion test_derive_https
|
|
|
|
# #region test_derive_with_port [C:2] [TYPE Function]
|
|
def test_derive_with_port(self):
|
|
"""Port preserved in derived URL."""
|
|
svc = TestableGitUrl()
|
|
result = svc._derive_server_url_from_remote("http://gitea.local:3000/repo")
|
|
assert result == "http://gitea.local:3000"
|
|
# #endregion test_derive_with_port
|
|
|
|
# #region test_derive_ssh_returns_none [C:2] [TYPE Function]
|
|
def test_derive_ssh_returns_none(self):
|
|
"""SSH URL returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._derive_server_url_from_remote("git@host:org/repo.git") is None
|
|
# #endregion test_derive_ssh_returns_none
|
|
|
|
# #region test_derive_empty_returns_none [C:2] [TYPE Function]
|
|
def test_derive_empty_returns_none(self):
|
|
"""Empty input returns None."""
|
|
svc = TestableGitUrl()
|
|
assert svc._derive_server_url_from_remote("") is None
|
|
assert svc._derive_server_url_from_remote(None) is None
|
|
# #endregion test_derive_empty_returns_none
|
|
|
|
|
|
# ── _normalize_git_server_url ──
|
|
|
|
class TestNormalizeGitServerUrl:
|
|
"""_normalize_git_server_url — strip trailing slash."""
|
|
|
|
# #region test_normalize_strips_slash [C:2] [TYPE Function]
|
|
def test_normalize_strips_slash(self):
|
|
"""Trailing slash removed."""
|
|
svc = TestableGitUrl()
|
|
assert svc._normalize_git_server_url("https://gitea.com/") == "https://gitea.com"
|
|
# #endregion test_normalize_strips_slash
|
|
|
|
# #region test_normalize_no_change [C:2] [TYPE Function]
|
|
def test_normalize_no_change(self):
|
|
"""URL without trailing slash unchanged."""
|
|
svc = TestableGitUrl()
|
|
assert svc._normalize_git_server_url("https://gitea.com") == "https://gitea.com"
|
|
# #endregion test_normalize_no_change
|
|
|
|
# #region test_normalize_empty_raises [C:2] [TYPE Function]
|
|
def test_normalize_empty_raises(self):
|
|
"""Empty URL raises HTTPException 400."""
|
|
svc = TestableGitUrl()
|
|
with pytest.raises(HTTPException, match="required"):
|
|
svc._normalize_git_server_url("")
|
|
# #endregion test_normalize_empty_raises
|
|
|
|
|
|
# ── _align_origin_host_with_config ──
|
|
|
|
class TestAlignOriginHostWithConfig:
|
|
"""_align_origin_host_with_config — auto-align origin host drift."""
|
|
|
|
# #region test_align_no_drift [C:2] [TYPE Function]
|
|
def test_align_no_drift(self):
|
|
"""Matching hosts → no action, returns None."""
|
|
svc = TestableGitUrl()
|
|
origin = MagicMock()
|
|
result = svc._align_origin_host_with_config(
|
|
dashboard_id=1,
|
|
origin=origin,
|
|
config_url="https://gitea.com",
|
|
current_origin_url="https://gitea.com/org/repo.git",
|
|
binding_remote_url=None,
|
|
)
|
|
assert result is None
|
|
origin.set_url.assert_not_called()
|
|
# #endregion test_align_no_drift
|
|
|
|
# #region test_align_host_mismatch [C:2] [TYPE Function]
|
|
@patch("src.services.git._url.SessionLocal")
|
|
def test_align_host_mismatch(self, mock_session_cls):
|
|
"""Mismatched hosts → origin.set_url called, DB updated."""
|
|
mock_session = MagicMock()
|
|
mock_session_cls.return_value = mock_session
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
svc = TestableGitUrl()
|
|
origin = MagicMock()
|
|
result = svc._align_origin_host_with_config(
|
|
dashboard_id=1,
|
|
origin=origin,
|
|
config_url="https://new-host.com",
|
|
current_origin_url="https://old-host.com/org/repo.git",
|
|
binding_remote_url=None,
|
|
)
|
|
assert result is not None
|
|
assert "new-host.com" in result
|
|
origin.set_url.assert_called_once()
|
|
# #endregion test_align_host_mismatch
|
|
|
|
# #region test_align_missing_config [C:2] [TYPE Function]
|
|
def test_align_missing_config(self):
|
|
"""No config URL → returns None, no action."""
|
|
svc = TestableGitUrl()
|
|
origin = MagicMock()
|
|
result = svc._align_origin_host_with_config(
|
|
dashboard_id=1, origin=origin, config_url=None,
|
|
current_origin_url="https://host.com/repo", binding_remote_url=None,
|
|
)
|
|
assert result is None
|
|
# #endregion test_align_missing_config
|
|
# #endregion Test.Git.Url
|