Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d
This commit is contained in:
commit
0adf8ef5d9
90 files changed
+14315
No files matched your search
@@ -0,0 +1,122 @@
|
||||
from pathlib import Path
|
||||
from threading import BoundedSemaphore
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _repository(root: Path) -> Path:
|
||||
repository = root / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(repository / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
return repository
|
||||
|
||||
|
||||
def test_scan_api_respects_root_and_exports_reports(tmp_path: Path) -> None:
|
||||
repository = _repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
first_response = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
assert first_response.status_code == 201
|
||||
first = first_response.json()
|
||||
assert first["repository_path"] == str(repository.resolve())
|
||||
|
||||
html = client.get(f"/api/v1/scans/{first['id']}/export?format=html")
|
||||
assert html.status_code == 200
|
||||
assert "RepoPulse report" in html.text
|
||||
assert "repo" in html.text
|
||||
|
||||
blocked = client.post("/api/v1/scans", json={"path": str(tmp_path.parent)})
|
||||
assert blocked.status_code == 422
|
||||
|
||||
|
||||
def test_scan_api_comparison_delta_and_trend(tmp_path: Path) -> None:
|
||||
repository = _repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
first = client.post("/api/v1/scans", json={"path": "repo"}).json()
|
||||
(repository / "tests").mkdir()
|
||||
(repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
||||
second = client.post("/api/v1/scans", json={"path": "repo"}).json()
|
||||
|
||||
comparison = client.get(f"/api/v1/scans/{second['id']}/comparison")
|
||||
assert comparison.status_code == 200
|
||||
assert comparison.json()["previous_scan_id"] == first["id"]
|
||||
|
||||
delta = client.get(f"/api/v1/scans/{second['id']}/delta")
|
||||
assert delta.status_code == 200
|
||||
assert delta.json()["previous_health_score"] == first["health_score"]
|
||||
assert delta.json()["previous_finding_count"] == len(first["findings"])
|
||||
|
||||
trend = client.get(f"/api/v1/trend?path={repository}")
|
||||
assert trend.status_code == 200
|
||||
assert [item["scan_id"] for item in trend.json()] == [first["id"], second["id"]]
|
||||
|
||||
|
||||
def test_scan_api_rejects_when_scan_capacity_is_busy(tmp_path: Path) -> None:
|
||||
_repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert main_module.SCAN_SLOTS.acquire(blocking=False)
|
||||
try:
|
||||
response = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
finally:
|
||||
main_module.SCAN_SLOTS.release()
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "2"
|
||||
|
||||
|
||||
def test_scan_api_rejects_symlinked_repository_outside_root(tmp_path: Path) -> None:
|
||||
outside = tmp_path.parent / f"{tmp_path.name}-outside-repository"
|
||||
outside.mkdir()
|
||||
(outside / "app.py").write_text("print('outside')\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked-repository"
|
||||
try:
|
||||
linked.symlink_to(outside, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are unavailable in this environment")
|
||||
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
response = client.post("/api/v1/scans", json={"path": "linked-repository"})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_mutating_api_requires_operator_token_only_when_configured(tmp_path: Path, monkeypatch) -> None:
|
||||
_repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
monkeypatch.setenv("REPOPULSE_OPERATOR_TOKEN", "test-operator-token")
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert client.get("/api/v1/meta").status_code == 200
|
||||
denied = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
assert denied.status_code == 401
|
||||
|
||||
allowed = client.post(
|
||||
"/api/v1/scans",
|
||||
json={"path": "repo"},
|
||||
headers={"X-RepoPulse-Token": "test-operator-token"},
|
||||
)
|
||||
assert allowed.status_code == 201
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.gitea import GiteaClient
|
||||
from app.models import Repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def test_repository_catalog_and_durable_scan_job_contract(tmp_path) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b""))
|
||||
repository = Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
main_module.store.upsert_repository(repository)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
catalog = client.get("/api/v1/repositories")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()[0]["id"] == "gitea:64"
|
||||
assert "latest_scan" not in catalog.json()[0]
|
||||
|
||||
queued = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False})
|
||||
assert queued.status_code == 202
|
||||
job = queued.json()
|
||||
assert job["repository_id"] == "gitea:64"
|
||||
assert job["status"] == "queued"
|
||||
|
||||
duplicate = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False})
|
||||
assert duplicate.status_code == 202
|
||||
assert duplicate.json()["id"] == job["id"]
|
||||
assert duplicate.json()["deduplicated"] is True
|
||||
|
||||
detail = client.get("/api/v1/repositories/gitea%3A64")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["latest_scan"] is None
|
||||
assert client.get(f"/api/v1/scan-jobs/{job['id']}").json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_signed_gitea_webhook_only_schedules_default_branch_push(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b""))
|
||||
repository = Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
main_module.store.upsert_repository(repository)
|
||||
monkeypatch.setenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "webhook-secret")
|
||||
payload = {
|
||||
"repository": {"id": 64},
|
||||
"ref": "refs/heads/main",
|
||||
"after": "abcdef0123456789abcdef0123456789abcdef01",
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest()
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Signature": signature, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["job_id"]
|
||||
assert main_module.store.get_repository("gitea:64").latest_revision == payload["after"]
|
||||
|
||||
wrong_signature = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Signature": "0" * 64, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert wrong_signature.status_code == 401
|
||||
|
||||
feature_payload = payload | {"ref": "refs/heads/feature"}
|
||||
feature_body = json.dumps(feature_payload).encode()
|
||||
feature_signature = hmac.new(b"webhook-secret", feature_body, hashlib.sha256).hexdigest()
|
||||
feature = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=feature_body,
|
||||
headers={"X-Gitea-Signature": feature_signature, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert feature.status_code == 200
|
||||
assert feature.json()["job_id"] is None
|
||||
|
||||
|
||||
def test_meta_exposes_operational_counts_without_connector_token(tmp_path) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "never-return-this-token")
|
||||
main_module.store.upsert_repository(
|
||||
Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
)
|
||||
|
||||
response = TestClient(main_module.app).get("/api/v1/meta")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["repository_count"] == 1
|
||||
assert response.json()["repository_states"]["never_scanned"] == 1
|
||||
assert "never-return-this-token" not in response.text
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.architecture import build_architecture
|
||||
|
||||
|
||||
def test_architecture_resolves_python_and_relative_typescript_imports(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "web").mkdir()
|
||||
(tmp_path / "src" / "core.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
(tmp_path / "src" / "app.py").write_text("import src.core\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "util.ts").write_text("export const value = 1\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "app.ts").write_text("import { value } from './util'\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "side-effect.ts").write_text("import './util.js'\n", encoding="utf-8")
|
||||
|
||||
source_texts = {
|
||||
"src/core.py": "VALUE = 1\n",
|
||||
"src/app.py": "import src.core\n",
|
||||
"web/util.ts": "export const value = 1\n",
|
||||
"web/app.ts": "import { value } from './util'\n",
|
||||
"web/side-effect.ts": "import './util.js'\n",
|
||||
}
|
||||
languages = {
|
||||
"src/core.py": "Python",
|
||||
"src/app.py": "Python",
|
||||
"web/util.ts": "TypeScript",
|
||||
"web/app.ts": "TypeScript",
|
||||
"web/side-effect.ts": "TypeScript",
|
||||
}
|
||||
loc = {path: 1 for path in source_texts}
|
||||
|
||||
graph, hotspots = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
languages,
|
||||
loc,
|
||||
{path: 0 for path in source_texts},
|
||||
{"src/core.py": 100, "src/app.py": 200},
|
||||
)
|
||||
|
||||
pairs = {(edge.source, edge.target) for edge in graph.edges}
|
||||
assert ("src/app.py", "src/core.py") in pairs
|
||||
assert ("web/app.ts", "web/util.ts") in pairs
|
||||
assert ("web/side-effect.ts", "web/util.ts") in pairs
|
||||
assert {node.id for node in graph.nodes} == set(source_texts)
|
||||
assert hotspots[0].path in {"src/app.py", "src/core.py"}
|
||||
|
||||
|
||||
def test_architecture_edge_bound_is_explicit(tmp_path: Path) -> None:
|
||||
source_texts = {
|
||||
"src/a.py": "import src.b\n",
|
||||
"src/b.py": "import src.c\n",
|
||||
"src/c.py": "VALUE = 1\n",
|
||||
}
|
||||
languages = {path: "Python" for path in source_texts}
|
||||
|
||||
graph, _ = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
languages,
|
||||
{path: 1 for path in source_texts},
|
||||
{path: 0 for path in source_texts},
|
||||
{},
|
||||
max_edges=1,
|
||||
)
|
||||
|
||||
assert len(graph.edges) == 1
|
||||
assert graph.truncated is True
|
||||
|
||||
|
||||
def test_architecture_resolves_jsconfig_aliases_and_local_workspace_packages(tmp_path: Path) -> None:
|
||||
(tmp_path / "src" / "components").mkdir(parents=True)
|
||||
(tmp_path / "packages" / "shared" / "src").mkdir(parents=True)
|
||||
(tmp_path / "jsconfig.json").write_text(
|
||||
'{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "package.json").write_text('{"name":"workspace-root"}', encoding="utf-8")
|
||||
(tmp_path / "packages" / "shared" / "package.json").write_text(
|
||||
'{"name":"@workspace/shared"}', encoding="utf-8"
|
||||
)
|
||||
|
||||
source_texts = {
|
||||
"src/app.ts": "import { Button } from '@/components/Button'\nimport { shared } from '@workspace/shared'\n",
|
||||
"src/components/Button.ts": "export const Button = () => null\n",
|
||||
"packages/shared/src/index.ts": "export const shared = true\n",
|
||||
}
|
||||
graph, _ = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
{path: "TypeScript" for path in source_texts},
|
||||
{path: 1 for path in source_texts},
|
||||
{path: 0 for path in source_texts},
|
||||
{},
|
||||
)
|
||||
|
||||
pairs = {(edge.source, edge.target) for edge in graph.edges}
|
||||
assert ("src/app.ts", "src/components/Button.ts") in pairs
|
||||
assert ("src/app.ts", "packages/shared/src/index.ts") in pairs
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gitea import GiteaClient, GiteaError
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _transport(url: str, headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
assert headers["Authorization"] == "token test-token"
|
||||
if "/user/repos" in url:
|
||||
return 200, json.dumps(
|
||||
[
|
||||
{
|
||||
"id": 64,
|
||||
"name": "RepoPulse",
|
||||
"full_name": "Jens/RepoPulse",
|
||||
"owner": {"login": "Jens"},
|
||||
"description": "Repository intelligence",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://evil.example/Jens/RepoPulse.git",
|
||||
"private": True,
|
||||
}
|
||||
]
|
||||
).encode()
|
||||
if "/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
raise AssertionError(url)
|
||||
|
||||
|
||||
def test_gitea_discovery_is_bounded_and_preserves_stable_identity() -> None:
|
||||
client = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=_transport,
|
||||
)
|
||||
|
||||
discovered = client.discover()
|
||||
|
||||
assert len(discovered) == 1
|
||||
repository, revision = discovered[0]
|
||||
assert repository.id == "gitea:64"
|
||||
assert repository.provider_repository_id == "64"
|
||||
assert repository.full_name == "Jens/RepoPulse"
|
||||
assert repository.clone_url == "http://gitea.local/Jens/RepoPulse.git"
|
||||
assert repository.latest_revision == revision
|
||||
|
||||
|
||||
def test_gitea_auth_errors_are_sanitized() -> None:
|
||||
def rejected(_url: str, _headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
return 401, b"secret-bearing upstream response"
|
||||
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=rejected)
|
||||
|
||||
try:
|
||||
client.list_repositories()
|
||||
except GiteaError as exc:
|
||||
assert exc.code == "authentication"
|
||||
assert "test-token" not in str(exc)
|
||||
assert "secret-bearing" not in str(exc)
|
||||
else:
|
||||
raise AssertionError("authentication error was not raised")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code, error_code", [(401, "authentication"), (403, "authentication"), (404, "not_found"), (429, "rate_limited"), (500, "upstream")])
|
||||
def test_gitea_http_statuses_are_classified_without_upstream_body(status_code: int, error_code: str) -> None:
|
||||
client = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=lambda *_: (status_code, b"token=test-token upstream body"),
|
||||
)
|
||||
|
||||
with pytest.raises(GiteaError) as raised:
|
||||
client.list_repositories()
|
||||
|
||||
assert raised.value.code == error_code
|
||||
assert "test-token" not in str(raised.value)
|
||||
assert "upstream body" not in str(raised.value)
|
||||
|
||||
|
||||
def test_gitea_discovery_paginates_until_a_short_page() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def paginated(url: str, headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
calls.append(url)
|
||||
assert headers["Authorization"] == "token test-token"
|
||||
if "/user/repos" in url:
|
||||
if "page=1" in url:
|
||||
payload = [{"id": 64, "owner": {"login": "Jens"}, "name": "One", "default_branch": "main"}]
|
||||
elif "page=2" in url:
|
||||
payload = [{"id": 65, "owner": {"login": "Jens"}, "name": "Two", "default_branch": "trunk"}]
|
||||
else:
|
||||
payload = []
|
||||
return 200, json.dumps(payload).encode()
|
||||
if "/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
if "/branches/trunk" in url:
|
||||
return 200, b'{"commit":{"sha":"abcdef0123456789abcdef0123456789abcdef01"}}'
|
||||
raise AssertionError(url)
|
||||
|
||||
client = GiteaClient("http://gitea.local", "test-token", page_size=1, max_pages=3, transport=paginated)
|
||||
|
||||
discovered = client.discover()
|
||||
|
||||
assert [repository.id for repository, _ in discovered] == ["gitea:64", "gitea:65"]
|
||||
assert len([call for call in calls if "/user/repos" in call]) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transport, error_code",
|
||||
[
|
||||
(lambda *_: (200, b"not-json"), "invalid_response"),
|
||||
(lambda *_: (_ for _ in ()).throw(TimeoutError()), "unreachable"),
|
||||
],
|
||||
)
|
||||
def test_gitea_malformed_and_timeout_responses_are_safe(transport, error_code: str) -> None:
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=transport)
|
||||
|
||||
with pytest.raises(GiteaError) as raised:
|
||||
client.list_repositories()
|
||||
|
||||
assert raised.value.code == error_code
|
||||
|
||||
|
||||
def test_gitea_sync_creates_catalog_without_full_reports(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=_transport)
|
||||
|
||||
result = client.sync(store)
|
||||
|
||||
assert result.discovered == 1
|
||||
assert result.created == 1
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567"
|
||||
assert repository.description == "Repository intelligence"
|
||||
assert store.repository_scans(repository.id) == []
|
||||
|
||||
|
||||
def test_gitea_outage_preserves_catalog_and_marks_sync_error(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
healthy = GiteaClient("http://gitea.local", "test-token", transport=_transport)
|
||||
healthy.sync(store)
|
||||
|
||||
unavailable = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=lambda *_: (503, b"upstream details must not escape"),
|
||||
)
|
||||
result = unavailable.sync(store)
|
||||
|
||||
assert result.errors == ["upstream: Gitea returned a server error"]
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.available is True
|
||||
assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567"
|
||||
assert repository.sync_error == "upstream: Gitea returned a server error"
|
||||
|
||||
|
||||
def test_complete_sync_marks_missing_repository_unavailable_without_deleting_history(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
GiteaClient("http://gitea.local", "test-token", transport=_transport).sync(store)
|
||||
missing = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (200, b"[]"))
|
||||
|
||||
result = missing.sync(store)
|
||||
|
||||
assert result.unavailable == 1
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.available is False
|
||||
assert store.repository_scans(repository.id) == []
|
||||
|
||||
|
||||
def test_branch_resolution_failure_is_partial_and_preserves_other_catalog_entries(tmp_path: Path) -> None:
|
||||
def partial_transport(url: str, _headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
if "/user/repos" in url:
|
||||
return 200, json.dumps(
|
||||
[
|
||||
{"id": 1, "owner": {"login": "Jens"}, "name": "Healthy", "default_branch": "main"},
|
||||
{"id": 2, "owner": {"login": "Jens"}, "name": "Broken", "default_branch": "main"},
|
||||
]
|
||||
).encode()
|
||||
if "/repos/Jens/Healthy/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
if "/repos/Jens/Broken/branches/main" in url:
|
||||
return 500, b"upstream details must remain private"
|
||||
raise AssertionError(url)
|
||||
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
result = GiteaClient("http://gitea.local", "test-token", transport=partial_transport).sync(store)
|
||||
|
||||
assert result.discovered == 1
|
||||
assert result.partial is True
|
||||
assert result.branch_failures == 1
|
||||
assert result.listing_complete is True
|
||||
assert "Jens/Broken: upstream: Gitea returned a server error" in result.errors
|
||||
assert store.get_repository("gitea:1").latest_revision
|
||||
broken = store.get_repository("gitea:2")
|
||||
assert broken is not None and broken.available is True
|
||||
assert broken.sync_error == "Jens/Broken: upstream: Gitea returned a server error"
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.acquisition import AcquisitionError, GitAcquirer
|
||||
from app.gitea import GiteaClient
|
||||
from app.jobs import ScanJobRunner
|
||||
from app.models import Repository, ScanJobStatus, ScanSource, ScanTrigger, SourceOrigin
|
||||
from app.scanner import scan_repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _git_repository(root: Path) -> tuple[Path, str]:
|
||||
root.mkdir()
|
||||
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(root / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(root)], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "config", "user.name", "RepoPulse Test"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "commit", "--quiet", "-m", "initial"], check=True)
|
||||
revision = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
return root, revision
|
||||
|
||||
|
||||
def _catalog_repository(source: Path, revision: str) -> Repository:
|
||||
return Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="Demo",
|
||||
full_name="Jens/Demo",
|
||||
clone_url=source.as_uri(),
|
||||
default_branch="master",
|
||||
latest_revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def test_acquirer_scans_detached_exact_revision_and_cleans_up(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
repository = _catalog_repository(source, revision)
|
||||
acquirer = GitAcquirer(tmp_path / "cache", timeout=30)
|
||||
|
||||
acquired = acquirer.acquire(repository, "job-1", revision)
|
||||
|
||||
checked_out = subprocess.run(
|
||||
["git", "-C", str(acquired.path), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert checked_out == revision
|
||||
assert (acquired.path / "app.py").read_text(encoding="utf-8") == "print('hello')\n"
|
||||
acquirer.cleanup(acquired, repository)
|
||||
assert not acquired.path.exists()
|
||||
|
||||
|
||||
def test_acquirer_recovers_a_corrupt_mirror_and_rejects_missing_sha(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
repository = _catalog_repository(source, revision)
|
||||
acquirer = GitAcquirer(tmp_path / "cache", timeout=30)
|
||||
mirror = acquirer._mirror_path(repository)
|
||||
mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||
mirror.write_text("corrupt mirror", encoding="utf-8")
|
||||
|
||||
with pytest.raises(AcquisitionError, match="Requested commit was not found"):
|
||||
acquirer.acquire(repository, "job-missing", "abcdef0123456789abcdef0123456789abcdef01")
|
||||
|
||||
acquired = acquirer.acquire(repository, "job-recovered", revision)
|
||||
assert acquired.actual_revision == revision
|
||||
acquirer.cleanup(acquired, repository)
|
||||
|
||||
|
||||
def test_durable_runner_completes_and_deduplicates_revision(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(source, revision)
|
||||
store.upsert_repository(repository)
|
||||
runner = ScanJobRunner(
|
||||
store,
|
||||
gitea=GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b"")),
|
||||
acquirer=GitAcquirer(tmp_path / "cache", timeout=30),
|
||||
)
|
||||
|
||||
first = runner.enqueue(repository)
|
||||
completed = runner.process_one()
|
||||
reused = runner.enqueue(repository)
|
||||
|
||||
assert completed is not None
|
||||
assert completed.status == ScanJobStatus.COMPLETED
|
||||
assert completed.scan_id is not None
|
||||
assert reused.id == first.id
|
||||
assert reused.deduplicated is True
|
||||
report = store.get(completed.scan_id)
|
||||
assert report is not None
|
||||
assert report.source.actual_revision == revision
|
||||
assert report.source.trigger == ScanTrigger.MANUAL
|
||||
assert completed.duration_ms is not None
|
||||
|
||||
|
||||
def test_expired_job_lease_is_recovered_for_another_claim(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
store.update_job(
|
||||
queued.id,
|
||||
status=ScanJobStatus.SCANNING,
|
||||
phase="scanning",
|
||||
lease_until=datetime.now(UTC) - timedelta(seconds=5),
|
||||
)
|
||||
|
||||
claimed = store.claim_next_job()
|
||||
|
||||
assert claimed is not None
|
||||
assert claimed.id == queued.id
|
||||
assert claimed.status == ScanJobStatus.ACQUIRING_SOURCE
|
||||
assert claimed.attempt == 1
|
||||
assert claimed.message == "Acquiring exact repository revision"
|
||||
|
||||
|
||||
def test_active_job_lease_renews_but_terminal_jobs_are_not_resurrected(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
claimed = store.claim_next_job()
|
||||
|
||||
assert claimed is not None
|
||||
with store._connection() as db:
|
||||
initial_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert initial_lease is not None
|
||||
renewed = store.renew_job_lease(queued.id)
|
||||
assert renewed is not None
|
||||
with store._connection() as db:
|
||||
renewed_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert renewed_lease is not None and renewed_lease > initial_lease
|
||||
|
||||
completed = store.update_job(queued.id, status=ScanJobStatus.COMPLETED, phase="completed", progress=100)
|
||||
assert completed is not None
|
||||
after_completion = store.renew_job_lease(queued.id)
|
||||
assert after_completion is not None
|
||||
with store._connection() as db:
|
||||
terminal_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert terminal_lease is None
|
||||
|
||||
|
||||
def test_atomic_claim_allows_only_one_worker_to_own_a_queued_job(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
claims = list(workers.map(lambda _unused: store.claim_next_job(), range(2)))
|
||||
|
||||
successful = [claim for claim in claims if claim is not None]
|
||||
assert len(successful) == 1
|
||||
assert successful[0].status == ScanJobStatus.ACQUIRING_SOURCE
|
||||
|
||||
|
||||
def test_scheduler_queues_unscanned_and_changed_repositories_only(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(source, revision)
|
||||
store.upsert_repository(repository)
|
||||
runner = ScanJobRunner(store, gitea=GiteaClient())
|
||||
|
||||
runner._schedule_changed_repositories()
|
||||
first = store.claim_next_job()
|
||||
assert first is not None
|
||||
assert first.requested_revision == revision
|
||||
store.update_job(first.id, status=ScanJobStatus.SUPERSEDED)
|
||||
|
||||
report = scan_repository(
|
||||
str(source),
|
||||
source=ScanSource(
|
||||
origin=SourceOrigin.GITEA,
|
||||
provider="gitea",
|
||||
repository_id=repository.id,
|
||||
repository_full_name=repository.full_name,
|
||||
branch=repository.default_branch,
|
||||
requested_revision=revision,
|
||||
actual_revision=revision,
|
||||
trigger=ScanTrigger.MANUAL,
|
||||
),
|
||||
)
|
||||
store.save(report)
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 0
|
||||
|
||||
changed = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
store.upsert_repository(repository.model_copy(update={"latest_revision": changed}))
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
|
||||
disabled = repository.model_copy(
|
||||
update={
|
||||
"id": "gitea:65",
|
||||
"provider_repository_id": "65",
|
||||
"latest_revision": "1234567890abcdef1234567890abcdef12345678",
|
||||
"policy": repository.policy.model_copy(update={"auto_scan": False}),
|
||||
}
|
||||
)
|
||||
store.upsert_repository(disabled)
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
|
||||
|
||||
def test_acquisition_rejects_token_bearing_clone_outside_gitea_origin(tmp_path: Path) -> None:
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567").model_copy(
|
||||
update={"clone_url": "https://evil.example/Jens/Demo.git"}
|
||||
)
|
||||
acquirer = GitAcquirer(
|
||||
tmp_path / "cache",
|
||||
token="test-token",
|
||||
allowed_clone_origin="https://gitea.local",
|
||||
)
|
||||
|
||||
with pytest.raises(AcquisitionError, match="outside the configured Gitea origin"):
|
||||
acquirer.acquire(repository, "job-1", repository.latest_revision)
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.gitea import GiteaClient
|
||||
from app.jobs import ScanJobRunner
|
||||
from app.models import Repository, ScanJobStatus, ScanTrigger
|
||||
from app.store import MaintenanceModeError, ScanStore
|
||||
|
||||
|
||||
def _gitea_repository() -> Repository:
|
||||
return Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
|
||||
|
||||
def test_maintenance_mode_is_durable_authenticated_and_exposes_drain_state(tmp_path: Path, monkeypatch) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
main_module.store = ScanStore(str(database))
|
||||
main_module.job_runner = None
|
||||
main_module.store.upsert_repository(_gitea_repository())
|
||||
queued = main_module.store.enqueue_job(
|
||||
"gitea:64",
|
||||
_gitea_repository().latest_revision,
|
||||
ScanTrigger.MANUAL,
|
||||
)
|
||||
monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.delenv("REPOPULSE_MAINTENANCE_TOKEN", raising=False)
|
||||
client = TestClient(main_module.app)
|
||||
assert client.post("/api/v1/operator/maintenance/enter").status_code == 503
|
||||
monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret")
|
||||
assert client.get("/api/v1/meta").json()["mutation_authentication"] == "perimeter_required"
|
||||
|
||||
assert client.post("/api/v1/operator/maintenance/enter").status_code == 401
|
||||
assert client.get("/api/v1/operator/maintenance").status_code == 401
|
||||
headers = {"X-RepoPulse-Token": "operator-secret"}
|
||||
entered = client.post("/api/v1/operator/maintenance/enter", headers=headers)
|
||||
|
||||
assert entered.status_code == 200
|
||||
assert entered.json()["enabled"] is True
|
||||
assert entered.json()["queue_depth"] == 1
|
||||
assert entered.json()["active_jobs"] == 0
|
||||
assert entered.json()["active_drained"] is True
|
||||
assert entered.json()["drained"] is False
|
||||
changed_at = entered.json()["changed_at"]
|
||||
assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 423
|
||||
assert main_module.store.maintenance_status()["changed_at"] == changed_at
|
||||
assert client.get("/api/v1/operator/maintenance", headers=headers).json()["enabled"] is True
|
||||
assert client.get("/api/v1/meta").json()["maintenance"]["enabled"] is True
|
||||
|
||||
restarted = ScanStore(str(database))
|
||||
assert restarted.maintenance_status()["enabled"] is True
|
||||
assert restarted.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
assert client.post("/api/v1/operator/maintenance/exit").status_code == 401
|
||||
exited = client.post("/api/v1/operator/maintenance/exit", headers=headers)
|
||||
assert exited.status_code == 200
|
||||
assert exited.json()["enabled"] is False
|
||||
assert ScanStore(str(database)).maintenance_status()["enabled"] is False
|
||||
|
||||
|
||||
def test_paused_mode_rejects_api_mutations_but_keeps_read_endpoints_available(tmp_path: Path, monkeypatch) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
repository = _gitea_repository()
|
||||
main_module.store.upsert_repository(repository)
|
||||
queued = main_module.store.enqueue_job(
|
||||
repository.id,
|
||||
repository.latest_revision,
|
||||
ScanTrigger.MANUAL,
|
||||
)
|
||||
monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret")
|
||||
client = TestClient(main_module.app)
|
||||
headers = {"X-RepoPulse-Token": "operator-secret"}
|
||||
assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 200
|
||||
|
||||
mutation_requests = [
|
||||
("/api/v1/scans", {}),
|
||||
("/api/v1/repositories/sync", {}),
|
||||
("/api/v1/repositories/gitea%3A64/scans", {"json": {"force": True}}),
|
||||
("/api/v1/webhooks/gitea", {"content": b"not validated while paused"}),
|
||||
("/api/v1/operator/database-recovery/acknowledge", {}),
|
||||
]
|
||||
for path, kwargs in mutation_requests:
|
||||
response = client.post(path, **kwargs)
|
||||
assert response.status_code == 423, (path, response.status_code, response.text)
|
||||
|
||||
assert client.get("/api/v1/repositories").status_code == 200
|
||||
assert client.get("/api/v1/scan-jobs/" + queued.id).status_code == 200
|
||||
assert main_module.store.queue_stats()["queued"] == 1
|
||||
assert main_module.store.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
|
||||
def test_paused_worker_makes_no_scheduler_writes_or_job_claims(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _gitea_repository()
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
store.set_state("worker_heartbeat", "before-maintenance")
|
||||
store.set_state("last_gitea_sync", "prior-sync")
|
||||
store.set_maintenance_mode(True)
|
||||
|
||||
class CountingGitea:
|
||||
configured = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sync_calls = 0
|
||||
|
||||
def sync(self, _store):
|
||||
self.sync_calls += 1
|
||||
raise AssertionError("scheduler must not sync in maintenance mode")
|
||||
|
||||
gitea = CountingGitea()
|
||||
runner = ScanJobRunner(store, gitea=gitea) # type: ignore[arg-type]
|
||||
|
||||
assert runner._run_iteration() is None
|
||||
assert gitea.sync_calls == 0
|
||||
assert store.get_state("worker_heartbeat") == "before-maintenance"
|
||||
assert store.get_state("last_gitea_sync") == "prior-sync"
|
||||
assert store.claim_next_job() is None
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
assert store.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
|
||||
def test_already_claimed_job_can_finish_during_maintenance(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "README.md").write_text("# Existing work\n", encoding="utf-8")
|
||||
(source / "app.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = Repository(
|
||||
id="local:existing-job",
|
||||
provider="local",
|
||||
provider_repository_id="existing-job",
|
||||
owner="local",
|
||||
name="Existing job",
|
||||
full_name="local/Existing job",
|
||||
default_branch="main",
|
||||
)
|
||||
store.upsert_repository(repository, source_path=str(source))
|
||||
store.enqueue_job(repository.id, None, ScanTrigger.MANUAL)
|
||||
claimed = store.claim_next_job()
|
||||
assert claimed is not None
|
||||
runner = ScanJobRunner(store, gitea=GiteaClient())
|
||||
|
||||
store.set_maintenance_mode(True)
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
assert store.queue_stats()["active"] == 1
|
||||
runner._process(claimed)
|
||||
|
||||
assert store.get_job(claimed.id).status == ScanJobStatus.COMPLETED
|
||||
assert store.queue_stats()["active"] == 0
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
|
||||
|
||||
def test_invalid_persisted_maintenance_state_fails_closed(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
store.set_state("maintenance_mode", "not-json")
|
||||
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
with pytest.raises(MaintenanceModeError):
|
||||
store.enqueue_job("missing", "revision", ScanTrigger.MANUAL)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import (
|
||||
DependencySignals,
|
||||
RepositoryMetrics,
|
||||
ScanReport,
|
||||
)
|
||||
from app.models import (
|
||||
TestSignals as SignalsModel,
|
||||
)
|
||||
from app.scanner import scan_repository
|
||||
from app.store import SCHEMA_VERSION, ScanStore
|
||||
|
||||
|
||||
def test_v1_scan_schema_is_migrated_and_legacy_history_is_cataloged(tmp_path: Path) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
report = ScanReport(
|
||||
id="legacy-scan",
|
||||
repository_path=str((tmp_path / "repo").resolve()),
|
||||
repository_name="repo",
|
||||
scanned_at="2026-09-12T00:00:00+00:00",
|
||||
duration_ms=1,
|
||||
metrics=RepositoryMetrics(),
|
||||
languages={},
|
||||
category_scores={},
|
||||
health_score=100,
|
||||
release_readiness="READY",
|
||||
findings=[],
|
||||
tests=SignalsModel(),
|
||||
dependencies=DependencySignals(),
|
||||
)
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE scans (
|
||||
id TEXT PRIMARY KEY,
|
||||
repository_path TEXT NOT NULL,
|
||||
repository_name TEXT NOT NULL,
|
||||
scanned_at TEXT NOT NULL,
|
||||
health_score INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(report.id, report.repository_path, report.repository_name, report.scanned_at.isoformat(), 100, report.model_dump_json()),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
store = ScanStore(str(database))
|
||||
|
||||
assert store.get_state("missing") is None
|
||||
assert store.repository_count() == 1
|
||||
migrated = store.get("legacy-scan")
|
||||
assert migrated is not None
|
||||
assert migrated.source.provider == "local"
|
||||
assert migrated.source.repository_id is not None
|
||||
assert store.list_repositories()[0].last_scan_id == "legacy-scan"
|
||||
connection = sqlite3.connect(database)
|
||||
assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_schema_upgrade_creates_a_timestamped_pre_migration_backup(tmp_path: Path) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
original = ScanStore(str(database))
|
||||
saved = original.save(scan_repository(str(repository)))
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
migrated = ScanStore(str(database))
|
||||
|
||||
assert list(tmp_path.glob("history.db.pre-v3-*.bak"))
|
||||
assert migrated.get(saved.id) is not None
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.reporting import content_disposition, safe_report_filename
|
||||
|
||||
|
||||
def test_report_filenames_are_safe_and_support_unicode() -> None:
|
||||
filename = safe_report_filename('repo/evil\r\n"<é>', "html")
|
||||
disposition = content_disposition(filename)
|
||||
|
||||
assert filename.startswith("repopulse-repo_evil___")
|
||||
assert "\r" not in disposition
|
||||
assert "\n" not in disposition
|
||||
assert 'filename="' in disposition
|
||||
assert "filename*=UTF-8''" in disposition
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Severity
|
||||
from app.scanner import scan_repository
|
||||
|
||||
|
||||
def _write_good_repo(root: Path) -> None:
|
||||
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(root / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
||||
(root / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(root / "src").mkdir()
|
||||
(root / "tests").mkdir()
|
||||
(root / ".gitea" / "workflows").mkdir(parents=True)
|
||||
(root / ".gitea" / "workflows" / "check.yml").write_text("name: check\n", encoding="utf-8")
|
||||
(root / "src" / "core.py").write_text("def answer():\n return 42\n", encoding="utf-8")
|
||||
(root / "src" / "app.py").write_text("import src.core\n\ndef run():\n return src.core.answer()\n", encoding="utf-8")
|
||||
(root / "tests" / "test_app.py").write_text("def test_app():\n assert True\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _realistic_fixture_secret() -> str:
|
||||
parts = ["production", "secret", "value", "123456"]
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def test_scan_collects_v1_signals_and_architecture(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.repository_name == tmp_path.name
|
||||
assert report.metrics.source_files == 3
|
||||
assert report.metrics.test_files == 1
|
||||
assert report.languages["Python"] > 0
|
||||
assert report.tests.ci_workflows == [".gitea/workflows/check.yml"]
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.status in {"READY", "READY WITH WARNINGS"}
|
||||
assert any(edge.source == "src/app.py" and edge.target == "src/core.py" for edge in report.architecture.edges)
|
||||
assert any(item.path == "src/app.py" for item in report.hotspots)
|
||||
assert 0 <= report.health_score <= 100
|
||||
|
||||
|
||||
def test_scan_collects_debt_and_dependency_signals(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"dependencies":{"react":"latest"},"devDependencies":{"vitest":"^3.0.0"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "src" / "app.py").write_text(
|
||||
"# TODO improve this\n# FIXME remove workaround\nprint('hello')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.todo == 1
|
||||
assert report.metrics.fixme == 1
|
||||
assert report.dependencies.declared_dependencies == 2
|
||||
assert report.dependencies.unbounded_dependencies == 1
|
||||
assert any(item.code == "dependency.unbounded" for item in report.findings)
|
||||
|
||||
|
||||
def test_potential_secret_is_redacted_and_blocks_release(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
secret_name = "API_" + "KEY"
|
||||
(tmp_path / "app.py").write_text(
|
||||
f"{secret_name} = 'super-secret-value-123456'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.status == "BLOCKED"
|
||||
assert secret_findings
|
||||
assert secret_findings[0].severity == Severity.BLOCKER
|
||||
assert "super-secret-example-value" not in secret_findings[0].message
|
||||
|
||||
|
||||
def test_secret_detection_scans_real_literals_in_tests_but_ignores_obvious_templates(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
realistic_secret = _realistic_fixture_secret()
|
||||
token_key = "to" + "ken"
|
||||
(tmp_path / "src" / "settings.py").write_text(
|
||||
"secret = os.getenv('APP_SECRET')\n" + f"api_key = '{realistic_secret}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "tests" / "test_transport.py").write_text(
|
||||
token_key + ' = "' + realistic_secret + '"\nresponse = b"token=test-token upstream body"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "README.md").write_text("# Demo\n\n`token=example-documentation-value`\n", encoding="utf-8")
|
||||
(tmp_path / ".env.development.example").write_text("APP_SECRET=example-development-secret\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert len(secret_findings) == 2
|
||||
assert {item.path for item in secret_findings} == {"src/settings.py", "tests/test_transport.py"}
|
||||
assert all(item.guidance and item.guidance.remediation_class == "security remediation" for item in secret_findings)
|
||||
assert secret_findings[0].line == 2
|
||||
assert not any(item.code == "security.env-committed" for item in report.findings)
|
||||
|
||||
|
||||
def test_scan_budget_reports_coverage_without_turning_truncation_into_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
for index in range(4):
|
||||
(tmp_path / f"module_{index}.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
|
||||
from app.models import ScanLimits
|
||||
|
||||
report = scan_repository(str(tmp_path), limits=ScanLimits(max_files=2, max_text_bytes_per_file=2_000_000, max_findings=20, max_secret_findings=5))
|
||||
|
||||
assert report.coverage is not None and report.coverage.truncated is True
|
||||
assert report.coverage.skipped_files_by_budget > 0
|
||||
assert any(check.key == "analysis_coverage" and check.state.value == "warn" for check in report.release_gate.checks)
|
||||
assert any(item.code == "analysis.findings-truncated" for item in report.findings) is False
|
||||
|
||||
|
||||
def test_configuration_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
realistic_secret = _realistic_fixture_secret()
|
||||
token_key = "to" + "ken"
|
||||
(tmp_path / "settings.json").write_text(
|
||||
json.dumps({token_key: realistic_secret}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert [(item.path, item.line) for item in secret_findings] == [("settings.json", 1)]
|
||||
|
||||
|
||||
def test_member_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
(tmp_path / "src" / "client.py").write_text(
|
||||
"class Client:\n def __init__(self):\n self.api_key = 'production-secret-value-123456'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert [(item.path, item.line) for item in secret_findings] == [("src/client.py", 3)]
|
||||
|
||||
|
||||
def test_documentation_only_repository_does_not_require_test_files(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Catalog entry\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text(".DS_Store\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 0
|
||||
assert not any(item.code == "testing.none-detected" for item in report.findings)
|
||||
|
||||
|
||||
def test_scan_skips_symlinked_files_outside_repository(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
external = tmp_path.parent / f"{tmp_path.name}-outside.py"
|
||||
external.write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked.py"
|
||||
try:
|
||||
linked.symlink_to(external)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are unavailable in this environment")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 3
|
||||
assert all(finding.path != "linked.py" for finding in report.findings)
|
||||
|
||||
|
||||
def test_scan_skips_symlinked_directories_outside_repository(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
external = tmp_path.parent / f"{tmp_path.name}-outside-dir"
|
||||
external.mkdir()
|
||||
(external / "outside.py").write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked"
|
||||
try:
|
||||
linked.symlink_to(external, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are unavailable in this environment")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 3
|
||||
assert all(finding.path != "linked/outside.py" for finding in report.findings)
|
||||
|
||||
|
||||
def test_malformed_dependency_manifests_are_reported_without_crashing(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("\n", encoding="utf-8")
|
||||
(tmp_path / "package.json").write_text("[]", encoding="utf-8")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\ndependencies = \"invalid\"\n", encoding="utf-8")
|
||||
(tmp_path / "pom.xml").write_text("<project>", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
codes = {finding.code for finding in report.findings}
|
||||
|
||||
assert "dependency.invalid-package-json" in codes
|
||||
assert "dependency.invalid-pyproject" in codes
|
||||
assert "dependency.invalid-pom" in codes
|
||||
|
||||
|
||||
def test_empty_git_repository_does_not_fallback_to_untracked_files(tmp_path: Path) -> None:
|
||||
(tmp_path / "stray.py").write_text("print('not tracked')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.git.available is True
|
||||
assert report.git.tracked_files == 0
|
||||
assert report.metrics.files == 0
|
||||
assert report.metrics.source_files == 0
|
||||
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.signals import analyze_dependencies, detect_test_signals
|
||||
|
||||
|
||||
def test_dependency_analysis_handles_multiple_ecosystems(tmp_path: Path) -> None:
|
||||
package = tmp_path / "package.json"
|
||||
package.write_text('{"dependencies":{"react":"latest"}}', encoding="utf-8")
|
||||
requirements = tmp_path / "requirements.txt"
|
||||
requirements.write_text("fastapi>=0.100\n", encoding="utf-8")
|
||||
lock = tmp_path / "package-lock.json"
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
|
||||
signals, findings = analyze_dependencies(
|
||||
tmp_path,
|
||||
{
|
||||
"package.json": [package],
|
||||
"requirements.txt": [requirements],
|
||||
"package-lock.json": [lock],
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.declared_dependencies == 2
|
||||
assert signals.unbounded_dependencies == 2
|
||||
assert signals.unpinned_dependencies == 0
|
||||
assert "package-lock.json" in signals.lockfiles
|
||||
assert any(item.code == "dependency.unbounded" for item in findings)
|
||||
|
||||
|
||||
def test_python_dependency_ranges_only_flag_truly_unbounded_declarations(tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"""
|
||||
[project]
|
||||
dependencies = [
|
||||
"exact==1.2.3",
|
||||
"compatible~=2.0",
|
||||
"bounded>=3.0,<4.0",
|
||||
"open>=5.0",
|
||||
]
|
||||
optional-dependencies.dev = ["pytest~=8.0"]
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
signals, findings = analyze_dependencies(tmp_path, {"pyproject.toml": [pyproject]})
|
||||
|
||||
assert signals.declared_dependencies == 5
|
||||
assert signals.unbounded_dependencies == 1
|
||||
assert [finding.code for finding in findings] == ["dependency.python-unbounded"]
|
||||
|
||||
|
||||
def test_test_signals_detect_ci_coverage_and_frameworks() -> None:
|
||||
text_by_path = {
|
||||
"package.json": '{"devDependencies":{"vitest":"^3.0.0","@playwright/test":"^1.0.0"}}',
|
||||
}
|
||||
signals = detect_test_signals(
|
||||
["src/app.ts", "src/app.test.ts", ".gitea/workflows/ci.yml", "coverage/lcov.info"],
|
||||
source_files=2,
|
||||
test_files=1,
|
||||
text_by_path=text_by_path,
|
||||
)
|
||||
|
||||
assert "Vitest" in signals.frameworks
|
||||
assert "Playwright" in signals.frameworks
|
||||
assert signals.ci_workflows == [".gitea/workflows/ci.yml"]
|
||||
assert signals.coverage_artifacts == ["coverage/lcov.info"]
|
||||
assert signals.test_source_ratio == 50.0
|
||||
|
||||
|
||||
def test_test_signals_distinguish_validation_from_deploy_only_workflows() -> None:
|
||||
signals = detect_test_signals(
|
||||
[".gitea/workflows/validate.yml", ".gitea/workflows/deploy.yml"],
|
||||
source_files=1,
|
||||
test_files=1,
|
||||
text_by_path={
|
||||
".gitea/workflows/validate.yml": "steps:\n - run: pytest --cov\n - run: npm run build\n",
|
||||
".gitea/workflows/deploy.yml": "steps:\n - run: deploy production\n",
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.ci_validation_workflows == [".gitea/workflows/validate.yml"]
|
||||
assert signals.ci_build_workflows == [".gitea/workflows/validate.yml"]
|
||||
assert signals.ci_deploy_only_workflows == [".gitea/workflows/deploy.yml"]
|
||||
assert signals.coverage_evidence == [".gitea/workflows/validate.yml"]
|
||||
|
||||
|
||||
def test_dependency_analysis_follows_local_requirements_includes_and_workspace_locks(tmp_path: Path) -> None:
|
||||
packages = tmp_path / "packages" / "web"
|
||||
packages.mkdir(parents=True)
|
||||
package = packages / "package.json"
|
||||
package.write_text('{"dependencies":{"shared":"workspace:*"}}', encoding="utf-8")
|
||||
lock = tmp_path / "pnpm-lock.yaml"
|
||||
lock.write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
|
||||
requirements = tmp_path / "requirements.txt"
|
||||
included = tmp_path / "requirements-prod.txt"
|
||||
requirements.write_text("-r requirements-prod.txt\n", encoding="utf-8")
|
||||
included.write_text("fastapi==0.116.0\n", encoding="utf-8")
|
||||
|
||||
signals, findings = analyze_dependencies(
|
||||
tmp_path,
|
||||
{
|
||||
"package.json": [package],
|
||||
"pnpm-lock.yaml": [lock],
|
||||
"requirements.txt": [requirements],
|
||||
"requirements-prod.txt": [included],
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.unbounded_dependencies == 0
|
||||
assert signals.included_requirements == ["requirements-prod.txt"]
|
||||
assert signals.declared_dependencies == 2
|
||||
assert not any(item.code == "dependency.no-lockfile" for item in findings)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.git_analysis import analyze_git
|
||||
from app.scanner import _walk_files, scan_repository
|
||||
|
||||
|
||||
def test_non_git_fallback_applies_root_gitignore_rules(tmp_path: Path) -> None:
|
||||
(tmp_path / ".gitignore").write_text("ignored.py\nignored-dir/\n*.log\n!keep.log\n", encoding="utf-8")
|
||||
(tmp_path / "kept.py").write_text("print('kept')\n", encoding="utf-8")
|
||||
(tmp_path / "ignored.py").write_text("print('ignored')\n", encoding="utf-8")
|
||||
(tmp_path / "keep.log").write_text("kept\n", encoding="utf-8")
|
||||
(tmp_path / "drop.log").write_text("dropped\n", encoding="utf-8")
|
||||
(tmp_path / "ignored-dir").mkdir()
|
||||
(tmp_path / "ignored-dir" / "nested.py").write_text("print('nested')\n", encoding="utf-8")
|
||||
|
||||
files = {path.relative_to(tmp_path).as_posix() for path in _walk_files(tmp_path)}
|
||||
|
||||
assert "kept.py" in files
|
||||
assert "keep.log" in files
|
||||
assert "ignored.py" not in files
|
||||
assert "drop.log" not in files
|
||||
assert "ignored-dir/nested.py" not in files
|
||||
|
||||
|
||||
def test_git_churn_attributes_renames_to_the_new_path(tmp_path: Path) -> None:
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "RepoPulse Test"], check=True)
|
||||
(tmp_path / "old.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "initial"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "mv", "old.py", "new.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "rename"], check=True)
|
||||
(tmp_path / "new.py").write_text("print('hello world')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-am", "edit after rename"], check=True)
|
||||
|
||||
signals, churn = analyze_git(tmp_path)
|
||||
|
||||
assert signals.renamed_files == ["new.py"]
|
||||
assert churn.get("new.py", 0) > 0
|
||||
assert "old.py" not in churn
|
||||
|
||||
|
||||
def test_policy_suppresses_accepted_risk_without_erasing_the_observation(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("\n", encoding="utf-8")
|
||||
(tmp_path / ".repopulse.yml").write_text("suppressions:\n - security.secret-assignment\n", encoding="utf-8")
|
||||
(tmp_path / "app.py").write_text("api_key = 'abcdefghijk1234'\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
finding = next(item for item in report.findings if item.code == "security.secret-assignment")
|
||||
|
||||
assert finding.suppressed is True
|
||||
assert report.category_scores["Security basics"] == 100
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.checks[0].state.value == "pass"
|
||||
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import app.store as store_module
|
||||
from app.scanner import scan_repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def test_scan_history_comparison_and_trend(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
|
||||
(repository / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
(repository / "tests").mkdir()
|
||||
(repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert store.get(second.id) == second
|
||||
assert store.recent(1) == [second]
|
||||
assert store.previous_for_repository(second.repository_path, second.id) == first
|
||||
|
||||
comparison = store.compare(second)
|
||||
assert comparison.previous_scan_id == first.id
|
||||
assert comparison.health_score_delta == second.health_score - first.health_score
|
||||
assert comparison.metric_deltas["source_files"] == 2
|
||||
|
||||
trend = store.trend(second.repository_path)
|
||||
assert [item.scan_id for item in trend] == [first.id, second.id]
|
||||
assert trend[-1].health_score == second.health_score
|
||||
|
||||
|
||||
def test_comparison_uses_the_scan_before_the_selected_scan(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
third = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert store.previous_for_repository(second.repository_path, second.id) == first
|
||||
assert store.previous_for_repository(third.repository_path, third.id) == second
|
||||
|
||||
|
||||
def test_repository_summary_exposes_compact_remediation_context(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(repository / ".env").write_text("TOKEN=actual-secret-value-123456\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
(repository / ".env").unlink()
|
||||
(repository / "app.py").write_text("api_key = 'actual-secret-value-123456'\n", encoding="utf-8")
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
summary = store.list_repositories()[0]
|
||||
|
||||
assert summary.last_scan_blockers == 1
|
||||
assert summary.last_scan_primary_signal is not None
|
||||
assert summary.last_scan_primary_signal.code == "security.secret-assignment"
|
||||
assert summary.last_scan_primary_signal.count == 1
|
||||
assert summary.last_scan_added_findings >= 1
|
||||
assert summary.last_scan_resolved_findings == 1
|
||||
|
||||
|
||||
def test_corrupt_database_is_quarantined_and_recreated(tmp_path: Path) -> None:
|
||||
database = tmp_path / "repopulse.db"
|
||||
database.write_bytes(b"not a sqlite database")
|
||||
|
||||
store = ScanStore(str(database))
|
||||
|
||||
assert store.recent() == []
|
||||
assert list(tmp_path.glob("repopulse.db.corrupt-*"))
|
||||
assert store.database_recovery_status()["status"] == "degraded_recovered"
|
||||
|
||||
|
||||
def test_schema_migration_records_the_durable_backup_artifact(tmp_path: Path) -> None:
|
||||
database = tmp_path / "repopulse.db"
|
||||
initial_store = ScanStore(str(database))
|
||||
with initial_store._connection() as db:
|
||||
db.execute("PRAGMA user_version = 3")
|
||||
|
||||
migrated_store = ScanStore(str(database))
|
||||
|
||||
recorded = migrated_store.get_state("last_schema_migration_backup")
|
||||
assert recorded is not None
|
||||
metadata = json.loads(recorded)
|
||||
assert metadata["from_version"] == 3
|
||||
assert metadata["to_version"] == store_module.SCHEMA_VERSION
|
||||
assert (tmp_path / metadata["backup_file"]).is_file()
|
||||
assert migrated_store.storage_stats()["last_schema_migration_backup"] == recorded
|
||||
|
||||
|
||||
def test_history_is_bounded_per_repository(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(store_module, "MAX_HISTORY_PER_REPOSITORY", 2)
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
third = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert [item.id for item in store.recent_for_repository(third.repository_path)] == [third.id, second.id]
|
||||
assert store.get(first.id) is None
|
||||
|
||||
|
||||
def test_retention_compacts_old_detailed_payloads_and_expires_old_snapshots(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
monkeypatch.setenv("REPOPULSE_FULL_REPORTS_PER_REPOSITORY", "1")
|
||||
monkeypatch.setenv("REPOPULSE_REPORT_RETENTION_DAYS", "30")
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
compacted = store.get(first.id)
|
||||
|
||||
assert compacted is not None and compacted.compacted is True
|
||||
assert compacted.findings == []
|
||||
assert store.get(second.id) is not None
|
||||
|
||||
expired = second.model_copy(
|
||||
update={"id": "expired-scan", "scanned_at": datetime.now(UTC) - timedelta(days=31)}
|
||||
)
|
||||
store.save(expired)
|
||||
assert store.get("expired-scan") is None
|
||||
|
||||
|
||||
def test_catalog_summary_uses_denormalized_fields_without_report_json_parsing(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
def fail_if_called(_row):
|
||||
raise AssertionError("catalog list must not parse report_json")
|
||||
|
||||
monkeypatch.setattr(store, "_report_from_row", fail_if_called)
|
||||
summary = store.list_repositories()[0]
|
||||
assert summary.last_scan_id
|
||||
storage = store.storage_stats()
|
||||
assert storage["scan_count"] == 1
|
||||
assert storage["mirror_count"] >= 0
|
||||
assert storage["worktree_count"] >= 0
|
||||
Reference in new issue
Block a user