fix(github-app): PAT fallback covers every mint failure; video/motion/x cleanups

mint_installation_token now wraps JWT build + HTTP + parsing so raw
httpx/jwt failures surface as GitHubAppError and the existing PAT
fallback catches them all (a GitHub outage no longer crashes git
operations for App-bound projects). The PEM is validated at
credential-set time instead of first mint, and the installation-token
cache is cleared when credentials are deleted. Also: the video
preview root resolves symlinks like its sibling route (frames under a
symlinked workspaces_root no longer false-404), the tracked dangling
motion/node_modules symlink is removed and the gitignore gains a
slash-less entry that actually matches symlinks, changelog caption
input strips GHSA refs like PR refs, and the edit-project dialog hides
the GitHub App section for auto-detected gitlab.com projects and warns
before a save that would clear both auth sources.
This commit is contained in:
Renn F
2026-07-22 03:31:12 +02:00
parent 0296ec6fde
commit 5ca8a9c4a6
14 changed files with 369 additions and 38 deletions
+4
View File
@@ -107,6 +107,10 @@ panel/.env.*.local
# motion/ (HyperFrames compositions) + video-renderer (its render sidecar)
motion/node_modules/
# the trailing-slash form above only matches a directory — a tracked
# symlink at this path (e.g. a container-absolute pnpm link) needs its own,
# slash-less entry or it slips through `git add`/`git status` untracked.
motion/node_modules
video-renderer/node_modules/
# hyperframes lint/preview generates index.html in each composition dir —
# regenerated locally as needed, not tracked (the sidecar renders
@@ -204,6 +204,38 @@ describe("EditProjectDialog — GitHub App binding", () => {
).not.toBeInTheDocument();
});
it("hides the picker for an auto-detected gitlab.com URL (git_provider stored as null)", async () => {
renderDialog(
makeProject({
git_provider: null,
git_url: "https://gitlab.com/acme/widgets.git",
}),
);
expect(
await screen.findByText(/App auth is GitHub-only/i),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Select repo/i }),
).not.toBeInTheDocument();
});
it("warns when saving would clear both the App binding and the PAT", async () => {
renderDialog(
makeProject({ github_installation_id: null, has_git_token: true }),
);
expect(
screen.queryByText(/no git credentials at all/i),
).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("switch", { name: /clear token/i }));
expect(
await screen.findByText(/no git credentials at all/i),
).toBeInTheDocument();
});
it("binding via the repo picker sets github_installation_id in the submitted payload", async () => {
renderDialog(makeProject());
@@ -25,7 +25,7 @@ import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConventionsTab } from "@/components/conventions/conventions-tab";
import { Key, KeyRound } from "lucide-react";
import { Key, KeyRound, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { githubAppApi } from "@/lib/api";
@@ -103,6 +103,28 @@ const SANDBOX_EXTENSIONS: Record<
],
};
// Auto-detect from the git URL host, mirroring
// roboco/foundation/policy/forge.py's detect_provider: only gitlab.com is
// auto-detected as non-GitHub for App-binding purposes — github.com and any
// unrecognized/self-hosted host stay github-ish (a self-hosted forge must
// set the Forge select explicitly to change this).
function isAutoDetectedGitlab(gitUrl: string): boolean {
const url = gitUrl.trim();
let host: string | null = null;
if (url.includes("://")) {
try {
host = new URL(url).hostname.toLowerCase() || null;
} catch {
host = null;
}
} else {
// scp-like SSH syntax: [user@]host:path
const match = /^(?:[^@/]+@)?([^/:]+):/.exec(url);
host = match ? match[1].toLowerCase() : null;
}
return host === "gitlab.com";
}
const SANDBOX_SERVICE_HINTS: Record<string, string> = {
postgres:
"Ephemeral PostgreSQL container for this project's agent spawns — random creds, tmpfs storage, torn down at end of engagement.",
@@ -218,8 +240,20 @@ function EditProjectForm({
const appConfigured = !!credStatus?.has_credentials;
// App auth is GitHub-only; a self-hosted Gitea/GitLab project keeps using
// its own token below regardless of any installation id already stored.
// An "auto" provider still needs a host check — an auto-detected
// gitlab.com project stores git_provider=None, so the explicit-string
// check alone would wrongly show the App section for it.
const isNonGithubProvider =
gitProvider === "gitea" || gitProvider === "gitlab";
gitProvider === "gitea" ||
gitProvider === "gitlab" ||
(gitProvider === "auto" && isAutoDetectedGitlab(gitUrl));
// True when saving now would leave this project with no way to
// authenticate git operations at all: no App binding AND no PAT (either
// explicitly cleared, or never set and no replacement entered).
const willHaveNoToken =
clearToken || (!project.has_git_token && !newToken.trim());
const bothAuthSourcesEmpty = githubInstallationId === null && willHaveNoToken;
const handleRepoSelected = (repo: SelectedRepo) => {
setGitUrl(repo.git_url);
@@ -487,6 +521,17 @@ function EditProjectForm({
)}
</div>
{bothAuthSourcesEmpty && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/20 p-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Saving now leaves this project with no git credentials at
all no GitHub App binding and no personal access token.
Clone, push, and PR operations will fail until one is set.
</span>
</div>
)}
{/* Assigned Cell */}
<div className="grid gap-2">
<HelpTip label="Which cell owns this project — only that cell's agents can claim its tasks (enforced server-side, not just a UI filter).">
+2
View File
@@ -22,6 +22,7 @@ from roboco.security import guard_deco
from roboco.services.github_app_auth import (
GitHubAppAPIError,
GitHubAppNotConfiguredError,
clear_token_cache,
list_installation_repositories,
list_installations,
)
@@ -82,6 +83,7 @@ async def clear_github_app_credentials(
app_id="", private_key=""
)
await db.commit()
clear_token_cache()
return GitHubAppCredentialsStatus(has_credentials=has_creds)
+8 -2
View File
@@ -307,8 +307,14 @@ _FRAME_NAME_RE = re.compile(r"^frame-(\d+)-of-\d+-at-([\d.]+)s\.png$")
def _previews_root(project_slug: str, task_id: UUID) -> Path:
"""The container-shared dir request_render extracts frames to — same
path every agent container mounts (content_actions._render_extract_frames),
so this resolves identically regardless of who rendered."""
return Path(settings.workspaces_root) / project_slug / ".previews" / task_id.hex[:8]
so this resolves identically regardless of who rendered. Resolved (like
the sibling composition-preview route resolves its workspace root before
calling ``_resolve_preview_path``) — otherwise a symlinked
``workspaces_root`` makes ``candidate.is_relative_to(root)`` mismatch and
every legit frame 404s."""
return (
Path(settings.workspaces_root) / project_slug / ".previews" / task_id.hex[:8]
).resolve()
def _list_orientation_frames(dir_path: Path) -> list[PreviewFrameResponse]:
+30 -9
View File
@@ -125,25 +125,46 @@ def _raise_for_status(resp: httpx.Response, action: str) -> None:
async def mint_installation_token(session: AsyncSession, installation_id: int) -> str:
"""Return a live installation token, minting (and caching) as needed."""
"""Return a live installation token, minting (and caching) as needed.
The JWT build, HTTP call, and response parsing all run under one
try/except so a raw ``httpx`` exception (network hiccup) or a corrupted-
PEM ``jwt.encode`` failure surfaces as a ``GitHubAppError`` instead of an
unexpected exception type — ``ProjectService._resolve_token``'s
``except GitHubAppError`` then falls back to the PAT for every failure
mode, not just a bad HTTP status.
"""
cached = _token_cache.get(installation_id)
if cached is not None:
token, expires_at = cached
if expires_at - _TOKEN_REFRESH_MARGIN_SECONDS > time.time():
return token
app_jwt = await _app_jwt(session)
url = f"{_api_base()}/app/installations/{installation_id}/access_tokens"
async with httpx.AsyncClient(timeout=_timeout()) as client:
resp = await client.post(url, headers=_headers(app_jwt))
_raise_for_status(resp, "Minting installation token")
try:
app_jwt = await _app_jwt(session)
url = f"{_api_base()}/app/installations/{installation_id}/access_tokens"
async with httpx.AsyncClient(timeout=_timeout()) as client:
resp = await client.post(url, headers=_headers(app_jwt))
_raise_for_status(resp, "Minting installation token")
data = resp.json()
token = cast("str", data["token"])
expires_at = _parse_expiry(data.get("expires_at"))
except GitHubAppError:
raise
except Exception as e:
raise GitHubAppError(f"Minting installation token failed: {e}") from e
data = resp.json()
token = cast("str", data["token"])
_token_cache[installation_id] = (token, _parse_expiry(data.get("expires_at")))
_token_cache[installation_id] = (token, expires_at)
return token
def clear_token_cache() -> None:
"""Drop every cached installation token — called when the App's
credentials are cleared so a stale token can't be served for a binding
that no longer has a signing key behind it."""
_token_cache.clear()
async def list_installations(session: AsyncSession) -> list[Installation]:
"""List every installation of the configured App (JWT-authenticated)."""
app_jwt = await _app_jwt(session)
+16 -1
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from sqlalchemy import select
from roboco.db.tables import GitHubAppCredentialsTable
@@ -25,7 +26,19 @@ if TYPE_CHECKING:
class GitHubAppCredentialsValidationError(ValueError):
"""Raised when a partial (not all-or-nothing) credential set is given."""
"""Raised when a partial (not all-or-nothing) credential set is given,
or the private key isn't a loadable PEM key."""
def _validate_private_key_pem(private_key: str) -> None:
"""Reject an unloadable PEM before it's ever stored, so a fat-fingered
paste fails at set time instead of at the first JWT mint."""
try:
load_pem_private_key(private_key.encode(), password=None)
except Exception as e:
raise GitHubAppCredentialsValidationError(
f"private_key is not a valid PEM private key: {e}"
) from e
@dataclass(frozen=True)
@@ -75,6 +88,8 @@ class GitHubAppCredentialsService(BaseService):
self.log.info("GitHub App credentials cleared")
return False
_validate_private_key_pem(private_key)
try:
encrypted_key = encrypt_token(private_key)
except EncryptionError as e:
+7 -2
View File
@@ -152,7 +152,12 @@ def _fallback_release_body(
# Bold feature leads in a Keep-a-Changelog release body: "- **Headline (#N).**"
_CHANGELOG_LEAD_RE = re.compile(r"^- \*\*(?P<lead>.+?)\*\*", re.MULTILINE)
_CHANGELOG_PR_REF_RE = re.compile(r"\s*\(#\d+(?:,\s*#\d+)*\)")
# A trailing parenthesized ref list: PR numbers ("#123") and/or GHSA
# advisory ids ("GHSA-xxxx-xxxx-xxxx"), comma-separated in any mix.
_CHANGELOG_REF_TOKEN = r"(?:#\d+|GHSA-\w{4}-\w{4}-\w{4})"
_CHANGELOG_REF_RE = re.compile(
rf"\s*\({_CHANGELOG_REF_TOKEN}(?:,\s*{_CHANGELOG_REF_TOKEN})*\)"
)
def changelog_highlights(entry: str, *, limit: int = 8) -> list[str]:
@@ -167,7 +172,7 @@ def changelog_highlights(entry: str, *, limit: int = 8) -> list[str]:
"""
out: list[str] = []
for m in _CHANGELOG_LEAD_RE.finditer(entry):
lead = _CHANGELOG_PR_REF_RE.sub("", m.group("lead")).strip().rstrip(".").strip()
lead = _CHANGELOG_REF_RE.sub("", m.group("lead")).strip().rstrip(".").strip()
if lead:
out.append(lead)
if len(out) >= limit:
@@ -10,6 +10,8 @@ from typing import TYPE_CHECKING
import pytest
import pytest_asyncio
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from roboco.db.tables import GitHubAppCredentialsTable
from roboco.services.github_app_credentials import (
GitHubAppCredentialsService,
@@ -23,9 +25,19 @@ if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _generate_rsa_pem() -> str:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
_CREDS = {
"app_id": "123456",
"private_key": "-----BEGIN PRIVATE KEY-----\nfake-pem\n-----END PRIVATE KEY-----",
"private_key": _generate_rsa_pem(),
}
@@ -88,9 +100,16 @@ async def test_rotate_overwrites_previous_values(
svc: GitHubAppCredentialsService,
) -> None:
await svc.set_credentials(**_CREDS)
rotated = {"app_id": "654321", "private_key": _CREDS["private_key"] + "-rotated"}
rotated = {"app_id": "654321", "private_key": _generate_rsa_pem()}
await svc.set_credentials(**rotated)
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.app_id == rotated["app_id"]
assert decrypted.private_key == rotated["private_key"]
@pytest.mark.asyncio
async def test_malformed_pem_is_rejected(svc: GitHubAppCredentialsService) -> None:
with pytest.raises(GitHubAppCredentialsValidationError):
await svc.set_credentials(app_id="123456", private_key="not-a-pem-at-all")
assert await svc.has_credentials() is False
+45 -6
View File
@@ -15,6 +15,8 @@ from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
@@ -34,6 +36,18 @@ if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _generate_rsa_pem() -> str:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
_VALID_PEM = _generate_rsa_pem()
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(github_app_router, prefix="/api/github-app")
@@ -71,14 +85,11 @@ async def test_set_credentials_reports_status_never_plaintext(
) -> None:
resp = await ceo_client.put(
"/api/github-app/credentials",
json={
"app_id": "123456",
"private_key": "-----BEGIN KEY-----\nsecretpem\n-----END KEY-----",
},
json={"app_id": "123456", "private_key": _VALID_PEM},
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"has_credentials": True}
assert "secretpem" not in resp.text
assert _VALID_PEM.splitlines()[1] not in resp.text
status_resp = await ceo_client.get("/api/github-app/credentials")
assert status_resp.json()["has_credentials"] is True
@@ -93,11 +104,28 @@ async def test_partial_credentials_is_400(ceo_client: AsyncClient) -> None:
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_malformed_pem_is_400(ceo_client: AsyncClient) -> None:
# Compares before/after rather than asserting an absolute False: routes
# commit directly (unlike the service-layer tests' rolled-back flush),
# so an earlier test in this module may have already left credentials
# set — the contract under test is "a rejected PUT changes nothing".
before = await ceo_client.get("/api/github-app/credentials")
resp = await ceo_client.put(
"/api/github-app/credentials",
json={"app_id": "123456", "private_key": "not-a-real-pem"},
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
after = await ceo_client.get("/api/github-app/credentials")
assert after.json() == before.json()
@pytest.mark.asyncio
async def test_clear_credentials(ceo_client: AsyncClient) -> None:
await ceo_client.put(
"/api/github-app/credentials",
json={"app_id": "123456", "private_key": "pem-body"},
json={"app_id": "123456", "private_key": _VALID_PEM},
)
resp = await ceo_client.delete("/api/github-app/credentials")
assert resp.status_code == HTTPStatus.OK
@@ -107,6 +135,17 @@ async def test_clear_credentials(ceo_client: AsyncClient) -> None:
assert status_resp.json()["has_credentials"] is False
@pytest.mark.asyncio
async def test_clear_credentials_clears_token_cache(ceo_client: AsyncClient) -> None:
"""Deleting the App credentials must drop any cached installation
token — otherwise a revoked/replaced App keeps serving a stale token
until the cache entry's own expiry."""
with patch("roboco.api.routes.github_app.clear_token_cache") as mock_clear:
resp = await ceo_client.delete("/api/github-app/credentials")
assert resp.status_code == HTTPStatus.OK
mock_clear.assert_called_once()
@pytest.mark.asyncio
async def test_installations_not_configured_is_409(ceo_client: AsyncClient) -> None:
with patch(
+23 -8
View File
@@ -1456,18 +1456,33 @@ async def test_preview_frame_non_ceo_is_forbidden(db_session: AsyncSession) -> N
app.dependency_overrides.clear()
def test_resolve_preview_path_confines_frame_orientation_traversal(
tmp_path: Path,
def test_previews_root_confines_frame_orientation_traversal_through_symlink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The preview-frame route composes ``f"{orientation}/{filename}"`` before
calling ``_resolve_preview_path`` — same confinement the composition-HTML
proxy uses, applied to a task's .previews/ dir instead of its read-clone."""
root = (tmp_path / "roboco-x" / ".previews" / "abcd1234").resolve()
(root / "vertical").mkdir(parents=True)
frame = root / "vertical" / "frame-01-of-1-at-0.5s.png"
"""Drives the REAL ``_previews_root`` -> ``_resolve_preview_path`` chain
(the preview-frame route composes ``f"{orientation}/{filename}"`` before
calling ``_resolve_preview_path``) with an UNRESOLVED, symlinked
``workspaces_root`` — the container-mount shape. Before ``_previews_root``
resolved its own path, the ``is_relative_to`` confinement check compared a
resolved candidate against an unresolved root and 404'd every legit
frame; this proves a real frame under the symlink still serves while
traversal is still rejected."""
real_root = tmp_path / "real-workspaces"
real_root.mkdir()
symlinked_root = tmp_path / "workspaces-symlink"
symlinked_root.symlink_to(real_root)
monkeypatch.setattr(cfg, "workspaces_root", str(symlinked_root))
task_id = uuid4()
project_slug = "roboco-x"
frame_dir = real_root / project_slug / ".previews" / task_id.hex[:8] / "vertical"
frame_dir.mkdir(parents=True)
frame = frame_dir / "frame-01-of-1-at-0.5s.png"
frame.write_bytes(b"png")
secret = tmp_path / "secret.png"
secret.write_bytes(b"nope")
root = video_module._previews_root(project_slug, task_id)
assert (
video_module._resolve_preview_path(root, "vertical/frame-01-of-1-at-0.5s.png")
== frame.resolve()
+55 -1
View File
@@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
@@ -20,7 +21,9 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from roboco.services import github_app_auth
from roboco.services.github_app_auth import (
GitHubAppAPIError,
GitHubAppError,
GitHubAppNotConfiguredError,
clear_token_cache,
list_installation_repositories,
list_installations,
mint_installation_token,
@@ -62,7 +65,7 @@ def _patch_creds(*, configured: bool = True) -> Any:
def _client(
*, get: list[MagicMock] | None = None, post: list[MagicMock] | None = None
*, get: list[Any] | None = None, post: list[Any] | None = None
) -> MagicMock:
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
@@ -163,6 +166,57 @@ async def test_mint_failure_raises_api_error() -> None:
await mint_installation_token(MagicMock(), 5)
@pytest.mark.asyncio
async def test_mint_network_error_wrapped_as_github_app_error() -> None:
"""A raw httpx failure (connection refused, DNS, timeout) must not escape
as an unexpected exception type ProjectService._resolve_token only
catches GitHubAppError to fall back to the PAT."""
client = _client(post=[httpx.ConnectError("connection refused")])
with (
_patch_creds(),
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
pytest.raises(GitHubAppError) as exc_info,
):
await mint_installation_token(MagicMock(), 55)
assert not isinstance(exc_info.value, GitHubAppAPIError)
@pytest.mark.asyncio
async def test_mint_corrupted_pem_wrapped_as_github_app_error() -> None:
"""A corrupted stored private key makes ``jwt.encode`` raise
``InvalidKeyError`` must also surface as GitHubAppError, not the raw
PyJWT exception, so the PAT fallback still triggers."""
fake_service = MagicMock()
fake_service.get_decrypted = AsyncMock(
return_value=GitHubAppCredentialsData(app_id=_APP_ID, private_key="not-a-pem")
)
with (
patch(
"roboco.services.github_app_auth.get_github_app_credentials_service",
return_value=fake_service,
),
pytest.raises(GitHubAppError) as exc_info,
):
await mint_installation_token(MagicMock(), 66)
assert not isinstance(exc_info.value, GitHubAppAPIError)
@pytest.mark.asyncio
async def test_clear_token_cache_forces_a_fresh_mint() -> None:
client = _client(post=[_token_resp("tok-first"), _token_resp("tok-second")])
with (
_patch_creds(),
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
):
first = await mint_installation_token(MagicMock(), 77)
clear_token_cache()
second = await mint_installation_token(MagicMock(), 77)
assert first == "tok-first"
assert second == "tok-second"
assert client.post.call_count == _SECOND_CALL_COUNT
@pytest.mark.asyncio
async def test_list_installations_maps_account_login() -> None:
payload = [
@@ -13,12 +13,28 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from roboco.services.github_app_auth import GitHubAppAPIError
from roboco.services.github_app_credentials import GitHubAppCredentialsData
from roboco.services.project import ProjectService
from roboco.utils.crypto import encrypt_token
def _generate_rsa_pem() -> str:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
_VALID_PEM = _generate_rsa_pem()
def _project(*, installation_id: int | None, pat: str | None) -> MagicMock:
p = MagicMock()
p.id = uuid4()
@@ -99,6 +115,62 @@ async def test_mint_failure_falls_back_to_pat() -> None:
assert token == "ghp_fallback"
@pytest.mark.asyncio
async def test_mint_network_error_falls_back_to_pat() -> None:
"""A raw httpx failure inside the REAL mint path (not mocked away) must
still fall back to the PAT github_app_auth wraps it as GitHubAppError
at its own chokepoint, which _resolve_token's existing except clause
already catches."""
svc = _svc_with_get(_project(installation_id=42, pat="ghp_fallback"))
fake_creds_svc = MagicMock()
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
fake_creds_svc.get_decrypted = AsyncMock(
return_value=GitHubAppCredentialsData(app_id="1", private_key=_VALID_PEM)
)
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.post = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
with (
patch(
"roboco.services.project.get_github_app_credentials_service",
return_value=fake_creds_svc,
),
patch(
"roboco.services.github_app_auth.get_github_app_credentials_service",
return_value=fake_creds_svc,
),
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
):
token = await svc.get_decrypted_token(uuid4())
assert token == "ghp_fallback"
@pytest.mark.asyncio
async def test_mint_corrupted_pem_falls_back_to_pat() -> None:
"""A corrupted stored private key makes the REAL mint path's
``jwt.encode`` raise ``InvalidKeyError`` before any network call must
also fall back to the PAT."""
svc = _svc_with_get(_project(installation_id=42, pat="ghp_fallback"))
fake_creds_svc = MagicMock()
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
fake_creds_svc.get_decrypted = AsyncMock(
return_value=GitHubAppCredentialsData(app_id="1", private_key="not-a-pem")
)
with (
patch(
"roboco.services.project.get_github_app_credentials_service",
return_value=fake_creds_svc,
),
patch(
"roboco.services.github_app_auth.get_github_app_credentials_service",
return_value=fake_creds_svc,
),
):
token = await svc.get_decrypted_token(uuid4())
assert token == "ghp_fallback"
@pytest.mark.asyncio
async def test_mint_failure_with_no_pat_returns_none() -> None:
svc = _svc_with_get(_project(installation_id=42, pat=None))
+7 -5
View File
@@ -1425,19 +1425,21 @@ def test_changelog_highlights_extracts_feature_headlines() -> None:
entry = (
"## [0.26.0] - 2026-07-20\n\n"
"### Security\n\n"
"- **Orchestrator API is off the public internet (GHSA-4f7g).** Both "
"composes published :8000 on 0.0.0.0.\n\n"
"- **Orchestrator API is off the public internet (GHSA-4f7g-w95g-5q2c).** "
"Both composes published :8000 on 0.0.0.0.\n\n"
"### Added\n\n"
"- **Telegram Mini App V5 — brand voice and an operations ring (#583).** "
"Share Tech Mono becomes the display face.\n"
"- **Forge program: GitHub, Gitea, and GitLab (#575, #581).** One API.\n"
)
hl = x_engine_module.changelog_highlights(entry)
assert hl[0] == "Orchestrator API is off the public internet (GHSA-4f7g)"
# A GHSA advisory ref is stripped exactly like a PR ref — neither belongs
# in a caption prompt's feature headline.
assert hl[0] == "Orchestrator API is off the public internet"
assert hl[1] == "Telegram Mini App V5 — brand voice and an operations ring"
assert hl[2] == "Forge program: GitHub, Gitea, and GitLab"
# No raw commit-subject noise, no trailing PR refs or periods.
assert all("#" not in h.split("(GHSA")[0] for h in hl)
# No raw commit-subject noise, no trailing PR/GHSA refs or periods.
assert all("#" not in h and "GHSA" not in h for h in hl)
def test_changelog_highlights_empty_on_no_leads() -> None: