- 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
248 lines
7.2 KiB
Python
248 lines
7.2 KiB
Python
"""HTTP-level tests for /projects/{name}/jobs and /jobs surfaces.
|
|
|
|
These tests stub out the runner so we don't actually clone/run subprocesses
|
|
inside the API tests — see test_runner.py for the exec-side coverage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import sample_project_payload
|
|
|
|
|
|
def _stub_runner(server):
|
|
"""Replace runner.enqueue with a no-op so we can exercise the API surface
|
|
without driving the dispatcher."""
|
|
server.runner.enqueue = _aiono_op # type: ignore[assignment]
|
|
|
|
|
|
async def _aiono_op(*_a, **_k):
|
|
return None
|
|
|
|
|
|
def test_create_job_returns_id(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="job-proj"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/job-proj/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "audit"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert isinstance(body["job_id"], str) and len(body["job_id"]) >= 16
|
|
assert body["status"] == "queued"
|
|
|
|
|
|
def test_create_job_invalid_recipe(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="invrec"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/invrec/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "not-a-real-recipe"},
|
|
)
|
|
# Pydantic Literal validation -> 422
|
|
assert r.status_code in (400, 422)
|
|
|
|
|
|
def test_create_job_subproject_without_recipe_command(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
payload = sample_project_payload(name="missing-cmd")
|
|
# Drop the audit command
|
|
payload["subprojects"][0]["audit"] = None
|
|
payload["subprojects"][0]["build"] = None
|
|
payload["subprojects"][0]["test"] = None
|
|
payload["subprojects"][0]["lint"] = "echo lint"
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=payload,
|
|
)
|
|
r = tc.post(
|
|
"/projects/missing-cmd/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "audit"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_list_jobs_filters_by_project_and_token(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="pa"),
|
|
)
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
json=sample_project_payload(name="pb"),
|
|
)
|
|
tc.post(
|
|
"/projects/pa/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
tc.post(
|
|
"/projects/pb/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
|
|
r = tc.get(
|
|
"/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r.status_code == 200
|
|
jobs = r.json()["jobs"]
|
|
assert all(j["project_name"] == "pa" for j in jobs)
|
|
|
|
|
|
def test_list_jobs_filter_status(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="ps"),
|
|
)
|
|
tc.post(
|
|
"/projects/ps/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
r = tc.get(
|
|
"/jobs?status=queued",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert all(j["status"] == "queued" for j in r.json()["jobs"])
|
|
r2 = tc.get(
|
|
"/jobs?status=succeeded",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r2.json()["jobs"] == []
|
|
|
|
|
|
def test_get_job_includes_log_tail(client, tmp_workspace):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="logged"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/logged/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
job_id = r.json()["job_id"]
|
|
|
|
# Plant a fake log file
|
|
log_path = tmp_workspace["log_dir"] / f"{job_id}.log"
|
|
log_path.write_text("\n".join(f"line-{i}" for i in range(300)) + "\n")
|
|
|
|
r2 = tc.get(
|
|
f"/jobs/{job_id}",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r2.status_code == 200
|
|
body = r2.json()
|
|
assert "log_tail" in body
|
|
assert len(body["log_tail"]) == 200
|
|
assert body["log_tail"][-1] == "line-299"
|
|
|
|
|
|
def test_get_job_other_token_404(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="cross-job"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/cross-job/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
job_id = r.json()["job_id"]
|
|
r2 = tc.get(
|
|
f"/jobs/{job_id}",
|
|
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
|
)
|
|
assert r2.status_code == 404
|
|
|
|
|
|
def test_get_findings_empty_in_wave1(client):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="findings-test"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/findings-test/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "audit"},
|
|
)
|
|
job_id = r.json()["job_id"]
|
|
r2 = tc.get(
|
|
f"/jobs/{job_id}/findings",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r2.status_code == 200
|
|
assert r2.json()["findings"] == []
|
|
|
|
|
|
def test_get_log_streams_file(client, tmp_workspace):
|
|
tc, ctx = client
|
|
_stub_runner(ctx["server"])
|
|
tc.post(
|
|
"/projects",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json=sample_project_payload(name="logstream"),
|
|
)
|
|
r = tc.post(
|
|
"/projects/logstream/jobs",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
json={"recipe": "test"},
|
|
)
|
|
job_id = r.json()["job_id"]
|
|
log_path = tmp_workspace["log_dir"] / f"{job_id}.log"
|
|
log_path.write_text("hello world\nmore lines\n")
|
|
|
|
r2 = tc.get(
|
|
f"/jobs/{job_id}/log",
|
|
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
|
)
|
|
assert r2.status_code == 200
|
|
assert "hello world" in r2.text
|
|
|
|
|
|
def test_healthz_open_to_lan(client):
|
|
tc, _ = client
|
|
r = tc.get("/healthz")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["db"] == "ok"
|
|
assert "runner" in body and "max" in body["runner"]
|