mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(forge): Phase 0 — git_provider column + registration-time forge validation (#569)
* feat(forge): Phase 0 — git_provider column + registration-time forge validation Pointing a project at a GitLab/Gitea git_url used to fail silently, several steps deep, at first PR. New pure policy module (foundation/policy/forge.py) detects the provider from the git_url host and validates at the ProjectService create/update chokepoint: github auto-detects and auto-stamps, explicit git_provider=github is the GitHub Enterprise escape hatch, gitlab/gitea are recognized-but-not-yet-supported, unknown hosts get a loud rejection with guidance. An update changing git_url does NOT inherit a stored auto-stamped provider (restating the override is required), so a host swap can't smuggle the escape hatch past validation. Migration 075 adds the nullable projects.git_provider column; the panel project dialogs show the detected forge. Phase 0 of the forge-providers spec. * fix(panel): mock-mode forge detection extracts the real host CodeQL js/incomplete-url-substring-sanitization: the substring check matched github.com anywhere in the URL. Extract the hostname (URL parse or scp-form regex, mirroring forge.py) and require an exact match. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -92,6 +92,144 @@ async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None
|
||||
await svc.create(payload, project_setup["creator_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_auto_stamps_github(project_setup: dict) -> None:
|
||||
"""A github.com git_url with no explicit git_provider auto-stamps 'github'."""
|
||||
svc = project_setup["svc"]
|
||||
project = await svc.create(
|
||||
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
|
||||
)
|
||||
assert project.git_provider == "github"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_explicit_github_provider_preserved(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_provider"] = "github"
|
||||
project = await svc.create(
|
||||
ProjectCreate(**payload_dict), project_setup["creator_id"]
|
||||
)
|
||||
assert project.git_provider == "github"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_github_enterprise_escape_hatch(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
"""An explicit git_provider='github' is accepted even on a non-github.com
|
||||
host (the GitHub Enterprise escape hatch)."""
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_url"] = "https://ghe.example.com/owner/repo.git"
|
||||
payload_dict["git_provider"] = "github"
|
||||
project = await svc.create(
|
||||
ProjectCreate(**payload_dict), project_setup["creator_id"]
|
||||
)
|
||||
assert project.git_provider == "github"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_gitlab_url(project_setup: dict) -> None:
|
||||
"""A gitlab.com git_url with no explicit git_provider is rejected loud and
|
||||
early — Phase 0's whole point (was a silent multi-step-deep GitError)."""
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_url"] = "https://gitlab.com/group/project.git"
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_explicit_gitlab_provider(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_url"] = "https://gitlab.com/group/project.git"
|
||||
payload_dict["git_provider"] = "gitlab"
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_unknown_host(project_setup: dict) -> None:
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_url"] = "https://git.internal.example/owner/repo.git"
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_unknown_provider_string(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_provider"] = "bitbucket"
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_git_url_changed_to_gitlab(project_setup: dict) -> None:
|
||||
svc = project_setup["svc"]
|
||||
project = await svc.create(
|
||||
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.update(
|
||||
project.id,
|
||||
ProjectUpdate(git_url="https://gitlab.com/group/project.git"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_git_url_unrelated_field_does_not_reraise_forge(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
"""An update that touches neither git_url nor git_provider never
|
||||
re-validates the forge, so a project's existing (grandfathered) combo
|
||||
can't retroactively block an unrelated rename."""
|
||||
svc = project_setup["svc"]
|
||||
project = await svc.create(
|
||||
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
|
||||
)
|
||||
updated = await svc.update(project.id, ProjectUpdate(name="renamed"))
|
||||
assert updated is not None
|
||||
assert updated.name == "renamed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_git_provider_to_gitlab_rejected(project_setup: dict) -> None:
|
||||
svc = project_setup["svc"]
|
||||
project = await svc.create(
|
||||
_project_payload(uuid4().hex[:6]), project_setup["creator_id"]
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.update(project.id, ProjectUpdate(git_provider="gitlab"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_git_provider_explicit_none_reverts_to_auto_detect(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
"""Explicit None clears the override (#197); the still-github.com git_url
|
||||
keeps the update valid via auto-detect."""
|
||||
svc = project_setup["svc"]
|
||||
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
|
||||
payload_dict["git_provider"] = "github"
|
||||
project = await svc.create(
|
||||
ProjectCreate(**payload_dict), project_setup["creator_id"]
|
||||
)
|
||||
updated = await svc.update(project.id, ProjectUpdate(git_provider=None))
|
||||
assert updated is not None
|
||||
assert updated.git_provider is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_protected_git_url(
|
||||
project_setup: dict, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -521,7 +659,6 @@ async def test_update_sync_state_success(project_setup: dict) -> None:
|
||||
async def test_get_decrypted_token_decryption_error(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
|
||||
svc = project_setup["svc"]
|
||||
payload = _project_payload(uuid4().hex[:6])
|
||||
pd = payload.model_dump()
|
||||
@@ -549,7 +686,6 @@ async def test_get_decrypted_token_returns_none_when_project_missing(
|
||||
async def test_get_decrypted_token_by_slug_decryption_error(
|
||||
project_setup: dict,
|
||||
) -> None:
|
||||
|
||||
svc = project_setup["svc"]
|
||||
payload = _project_payload(uuid4().hex[:6])
|
||||
pd = payload.model_dump()
|
||||
@@ -691,6 +827,5 @@ async def test_check_agent_access_with_allowed_list_membership(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_service_factory(db_session: AsyncSession) -> None:
|
||||
|
||||
svc = get_project_service(db_session)
|
||||
assert isinstance(svc, ProjectService)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Forge provider detection + registration-time validation — pure, no DB/IO."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.foundation.policy.forge import (
|
||||
KNOWN_PROVIDERS,
|
||||
detect_provider,
|
||||
validate_project_forge,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_provider — host extraction across https/ssh/.git/subgroup shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_https_github() -> None:
|
||||
assert detect_provider("https://github.com/owner/repo.git") == "github"
|
||||
|
||||
|
||||
def test_detect_https_github_no_dot_git_suffix() -> None:
|
||||
assert detect_provider("https://github.com/owner/repo") == "github"
|
||||
|
||||
|
||||
def test_detect_https_github_with_token_userinfo() -> None:
|
||||
url = "https://x-access-token:ghp_abc123@github.com/owner/repo.git"
|
||||
assert detect_provider(url) == "github"
|
||||
|
||||
|
||||
def test_detect_ssh_scp_syntax_github() -> None:
|
||||
assert detect_provider("git@github.com:owner/repo.git") == "github"
|
||||
|
||||
|
||||
def test_detect_ssh_url_scheme_github() -> None:
|
||||
assert detect_provider("ssh://git@github.com/owner/repo.git") == "github"
|
||||
|
||||
|
||||
def test_detect_https_gitlab_com() -> None:
|
||||
assert detect_provider("https://gitlab.com/group/project.git") == "gitlab"
|
||||
|
||||
|
||||
def test_detect_ssh_scp_syntax_gitlab() -> None:
|
||||
assert detect_provider("git@gitlab.com:group/project.git") == "gitlab"
|
||||
|
||||
|
||||
def test_detect_gitlab_subgroup_path_still_detects_host() -> None:
|
||||
# Subgroup paths (3+ segments) don't change host resolution — detect_provider
|
||||
# only looks at the host, never the path shape.
|
||||
url = "https://gitlab.com/group/subgroup/project.git"
|
||||
assert detect_provider(url) == "gitlab"
|
||||
|
||||
|
||||
def test_detect_self_hosted_gitlab_host_is_unresolvable() -> None:
|
||||
# A self-hosted host can't be told apart from GHE/Gitea by host alone.
|
||||
assert detect_provider("https://gitlab.example.com/group/project.git") is None
|
||||
|
||||
|
||||
def test_detect_self_hosted_https_unknown_host() -> None:
|
||||
assert detect_provider("https://git.internal.example/owner/repo.git") is None
|
||||
|
||||
|
||||
def test_detect_bitbucket_host_unresolvable() -> None:
|
||||
assert detect_provider("https://bitbucket.org/owner/repo.git") is None
|
||||
|
||||
|
||||
def test_detect_empty_string() -> None:
|
||||
assert detect_provider("") is None
|
||||
|
||||
|
||||
def test_detect_unparseable_garbage() -> None:
|
||||
assert detect_provider("not a url at all") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_project_forge — the registration-time gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_git_url_is_ok() -> None:
|
||||
assert validate_project_forge(None, None) is None
|
||||
assert validate_project_forge("", None) is None
|
||||
|
||||
|
||||
def test_detected_github_no_explicit_provider_is_ok() -> None:
|
||||
assert validate_project_forge("https://github.com/owner/repo.git", None) is None
|
||||
|
||||
|
||||
def test_detected_github_ssh_scp_no_explicit_provider_is_ok() -> None:
|
||||
assert validate_project_forge("git@github.com:owner/repo.git", None) is None
|
||||
|
||||
|
||||
def test_explicit_github_provider_is_ok_regardless_of_host() -> None:
|
||||
# The GitHub Enterprise escape hatch — a non-github.com host is accepted
|
||||
# once the operator explicitly names the provider.
|
||||
url = "https://ghe.internal.example/owner/repo.git"
|
||||
assert validate_project_forge(url, "github") is None
|
||||
|
||||
|
||||
def test_explicit_gitlab_provider_rejected_as_not_yet_supported() -> None:
|
||||
error = validate_project_forge("https://gitlab.com/group/project.git", "gitlab")
|
||||
assert error is not None
|
||||
assert "not yet" in error.lower()
|
||||
assert "gitlab" in error.lower()
|
||||
|
||||
|
||||
def test_explicit_gitea_provider_rejected_as_not_yet_supported() -> None:
|
||||
error = validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
|
||||
assert error is not None
|
||||
assert "not yet" in error.lower()
|
||||
assert "gitea" in error.lower()
|
||||
|
||||
|
||||
def test_unknown_host_no_explicit_provider_rejected() -> None:
|
||||
error = validate_project_forge("https://git.internal.example/owner/repo.git", None)
|
||||
assert error is not None
|
||||
assert "github" in error.lower()
|
||||
|
||||
|
||||
def test_detected_gitlab_no_explicit_provider_rejected() -> None:
|
||||
error = validate_project_forge("https://gitlab.com/group/project.git", None)
|
||||
assert error is not None
|
||||
assert "github" in error.lower()
|
||||
|
||||
|
||||
def test_unknown_provider_string_rejected_naming_known_providers() -> None:
|
||||
error = validate_project_forge("https://github.com/owner/repo.git", "bitbucket")
|
||||
assert error is not None
|
||||
for provider in KNOWN_PROVIDERS:
|
||||
assert provider in error
|
||||
|
||||
|
||||
def test_known_providers_tuple_is_the_documented_set() -> None:
|
||||
assert KNOWN_PROVIDERS == ("github", "gitlab", "gitea")
|
||||
Reference in New Issue
Block a user