- db.py: migrations + DAOs for tokens / projects / jobs / findings (SQLite WAL)
- auth.py: SHA-256 bearer hashing + LAN-CIDR allowlist + admin/app token tiers
- models.py: Pydantic shapes (Project, Subproject, Schedule, Notify, Job, CreateJobRequest)
- server.py: FastAPI on port 8810; /healthz, /admin/tokens/*, /projects/*, /jobs, /jobs/{id}, /jobs/{id}/log, /jobs/{id}/findings
- runner.py: bounded asyncio pool, per-job timeout with process-group SIGTERM→SIGKILL escalation, orphaned-job recovery on boot
- workspace.py: bare-clone + worktree materialization, gc
- config.py: env-driven
- 62 tests across db / auth / projects / jobs / runner / e2e — all green
Cross-token project access returns 404 (not 403) — existence-leak guard.
Bearer tokens hashed at rest; admin token bootstrapped on first boot.
Recipe subprocess uses start_new_session=True so killpg targets the
whole process tree on timeout — child processes can't escape SIGKILL.
Pump task guarded with wait_for(2s) + cancel fallback against any
orphan that survives the group kill.
Wave 2 (parsers + findings extraction + MCP + email digest) pending.
Spec: memory/spec-crafting-table.md
122 lines
4.1 KiB
Python
122 lines
4.1 KiB
Python
"""E2E — register a project, kick off a job, poll until terminal, assert.
|
|
|
|
Uses a local file-protocol git URL (we init a tiny repo in tmp_path) so the
|
|
real workspace materialization path runs end-to-end without network. Recipe
|
|
is a no-op echo so we exit 0.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import sample_project_payload
|
|
|
|
|
|
def _make_local_git_repo(root: Path) -> str:
|
|
"""Init a fresh repo with one commit and return the on-disk path that can
|
|
be passed as a git_url (git clone supports plain paths)."""
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git binary not present in test environment")
|
|
repo = root / "fixture-repo"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
|
|
subprocess.run(["git", "config", "user.email", "test@example"], cwd=repo, check=True)
|
|
subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True)
|
|
subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo, check=True)
|
|
(repo / "README.md").write_text("hello\n")
|
|
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
|
|
subprocess.run(["git", "commit", "-q", "-m", "initial"], cwd=repo, check=True)
|
|
return str(repo)
|
|
|
|
|
|
def test_register_run_poll_succeeds(client, tmp_workspace, tmp_path):
|
|
tc, ctx = client
|
|
|
|
# Make a real local repo
|
|
git_url = _make_local_git_repo(tmp_path)
|
|
|
|
payload = sample_project_payload(name="e2e-proj")
|
|
payload["git_url"] = git_url
|
|
payload["subprojects"][0]["test"] = "echo running-test && exit 0"
|
|
payload["subprojects"][0]["audit"] = "echo running-audit && exit 0"
|
|
payload["subprojects"][0]["timeout_secs"] = 30
|
|
|
|
r = tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=payload,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
r2 = tc.post(
|
|
"/projects/e2e-proj/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
assert r2.status_code == 200, r2.text
|
|
job_id = r2.json()["job_id"]
|
|
|
|
# Poll for terminal status
|
|
deadline = time.monotonic() + 30
|
|
final = None
|
|
while time.monotonic() < deadline:
|
|
r3 = tc.get(
|
|
f"/jobs/{job_id}",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r3.status_code == 200
|
|
body = r3.json()
|
|
status = body["job"]["status"]
|
|
if status in ("succeeded", "failed", "timed_out", "cancelled"):
|
|
final = body
|
|
break
|
|
time.sleep(0.2)
|
|
|
|
assert final is not None, "job never reached terminal state"
|
|
assert final["job"]["status"] == "succeeded", final
|
|
assert final["job"]["exit_code"] == 0
|
|
# Log tail should contain our echoed line
|
|
assert any("running-test" in line for line in final["log_tail"]), final["log_tail"]
|
|
|
|
|
|
def test_register_run_failing_recipe(client, tmp_workspace, tmp_path):
|
|
tc, ctx = client
|
|
git_url = _make_local_git_repo(tmp_path)
|
|
|
|
payload = sample_project_payload(name="e2e-fail")
|
|
payload["git_url"] = git_url
|
|
payload["subprojects"][0]["test"] = "exit 7"
|
|
payload["subprojects"][0]["timeout_secs"] = 20
|
|
|
|
r = tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=payload,
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
r2 = tc.post(
|
|
"/projects/e2e-fail/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
job_id = r2.json()["job_id"]
|
|
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
r3 = tc.get(
|
|
f"/jobs/{job_id}",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
body = r3.json()
|
|
if body["job"]["status"] in ("succeeded", "failed", "timed_out", "cancelled"):
|
|
assert body["job"]["status"] == "failed"
|
|
assert body["job"]["exit_code"] == 7
|
|
return
|
|
time.sleep(0.2)
|
|
raise AssertionError("job never finished")
|